diff --git a/.githooks/pre-push b/.githooks/pre-push index 931c05cd..e01b96ed 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,5 +4,11 @@ set -euo pipefail repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" +# Git hands hooks the repository-locating environment (GIT_DIR, GIT_INDEX_FILE, +# ...). Tests create throwaway repositories; inheriting these variables would +# point their commits at THIS repository, so clear them before the gate. +for name in $(git rev-parse --local-env-vars); do + unset "$name" +done echo "==> [pre-push] make pre-ci" exec make pre-ci diff --git a/Cargo.lock b/Cargo.lock index 71231295..61d82ab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,7 @@ dependencies = [ "anyhow", "codegraph-core", "codegraph-mcp", + "codegraph-store", "codegraph-watch", "interprocess", "notify", @@ -564,6 +565,7 @@ dependencies = [ "codegraph-mcp", "codegraph-resolve", "codegraph-store", + "codegraph-watch", "regex", "reqwest 0.12.28", "rmcp", @@ -3716,9 +3718,9 @@ dependencies = [ [[package]] name = "tree-sitter-scala" -version = "0.26.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de5a4a7ff23a55474ce6a741d52aaeca7a82fe9421bb982b86e98c6ac8629397" +checksum = "24e0ab4505990bfe30051761d40a7bf4033ce5a81c9eda9e20e987a5cdc84826" dependencies = [ "cc", "tree-sitter-language", diff --git a/README.md b/README.md index 3c88d0f4..374cd64d 100644 --- a/README.md +++ b/README.md @@ -182,10 +182,20 @@ codegraph install --yes --init codegraph install --target=codex,claude,kiro --yes ``` +Global profile overrides are honored: Claude Code follows +`CLAUDE_CONFIG_DIR`, Codex follows `CODEX_HOME`, and OpenCode 2 receives its +native `mcp.servers.codegraph` entry with `codemode: false`. Claude entries set +`alwaysLoad: true`; Copilot CLI entries set `deferTools: "never"` so Explore is +available from the first prompt. + A server launched without `--path` can serve an existing index selected by an explicit per-call `projectPath`, client roots, or deterministic workspace -adoption. Pin `--path` when a project should be the default rather than -supplied per call. +adoption. First explicit access to an existing index waits for catch-up and +retains that project's shared daemon/watcher; multiple child projects remain +separate and multiple MCP sessions share one writer per project. Pin `--path` +when a project should be the default rather than supplied per call. Explicit +direct mode (`CODEGRAPH_NO_DAEMON=1`) opts out of lazy cross-project daemon +services, permits one direct writer, and rejects a second. The default visible MCP surface emphasizes exploration, file/symbol reads, search, and callers. Additional known tools can be enabled with diff --git a/crates/codegraph-bench/fixtures/cpp/constructor_resolution.cpp b/crates/codegraph-bench/fixtures/cpp/constructor_resolution.cpp new file mode 100644 index 00000000..20c911d3 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/constructor_resolution.cpp @@ -0,0 +1,55 @@ +struct AggregateOnly { + int value; +}; + +class WithConstructor { +public: + WithConstructor() {} + explicit WithConstructor(int value) {} +}; + +class Defaults { +public: + Defaults() {} + explicit Defaults(int value = 0) {} +}; + +namespace left { +struct Widget { + Widget() {} +}; +} + +namespace right { +struct Widget { + Widget() {} +}; +} + +void aggregate_only() { + AggregateOnly value{}; +} + +void constructor_default() { + WithConstructor value; +} + +void constructor_braced() { + WithConstructor value{}; +} + +void constructor_value() { + WithConstructor value(1); +} + +void constructor_ambiguous_overload() { + Defaults value; +} + +void constructor_ambiguous_owner() { + Widget value; +} + +void constructor_explicit_owner() { + left::Widget value; +} diff --git a/crates/codegraph-bench/fixtures/cpp/designated_macro.c b/crates/codegraph-bench/fixtures/cpp/designated_macro.c new file mode 100644 index 00000000..1c182e4d --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/designated_macro.c @@ -0,0 +1,16 @@ +void reset_profile(void) +{ + RESET_CONFIG(profile_t, profile, + .pid = { [0] = 10, [1] = { 50, 75 } }, + .limit = 500, + ); +} + +void after_reset(void) +{ +} + +int final_value(void) +{ + return 1; +} diff --git a/crates/codegraph-bench/fixtures/cpp/pure_virtual.cpp b/crates/codegraph-bench/fixtures/cpp/pure_virtual.cpp new file mode 100644 index 00000000..5f98fb08 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/pure_virtual.cpp @@ -0,0 +1,21 @@ +class AbstractStore { +public: + virtual int read(int key) = 0; + virtual AbstractStore *clone() = 0; + virtual AbstractStore &operator=(const AbstractStore &) = 0; + int inherited_pure() = 0; + int declaration_only(int key = 0); + int (*callback)(int) = 0; + int data = 0; +}; + +class DiskStore : public AbstractStore { +public: + int read(int key) override { return key; } + AbstractStore *clone() override { return this; } +}; + +int fetch_store(AbstractStore *store, int key) +{ + return store->read(key); +} diff --git a/crates/codegraph-bench/fixtures/cpp/raw_string.cpp b/crates/codegraph-bench/fixtures/cpp/raw_string.cpp new file mode 100644 index 00000000..a5eb421a --- /dev/null +++ b/crates/codegraph-bench/fixtures/cpp/raw_string.cpp @@ -0,0 +1,21 @@ +namespace fixture { +const char *source_template = u8R"MARKUP( +DECLARE_THING( +struct Ghost { int value; }; +UE_DEPRECATED( +class GHOST_API IgnoredApi {}; +float4 position [[position]]; +__global__ void ignored_kernel() {} +ignored_kernel<<<1, 1>>>(); +)MARKUP"; +} + +int after_raw_string(int value) +{ + return value; +} + +int final_after_raw_string(void) +{ + return 1; +} diff --git a/crates/codegraph-bench/fixtures/dart/extension_type.dart b/crates/codegraph-bench/fixtures/dart/extension_type.dart new file mode 100644 index 00000000..fd553dd7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/dart/extension_type.dart @@ -0,0 +1,11 @@ +extension type MetersT(double value) { + double get km => value / 1000; + + void report() { + print(km); + } +} + +class Widget { + double get half => 1.0; +} diff --git a/crates/codegraph-bench/fixtures/rust/self_field.rs b/crates/codegraph-bench/fixtures/rust/self_field.rs index b7db6036..2b38d661 100644 --- a/crates/codegraph-bench/fixtures/rust/self_field.rs +++ b/crates/codegraph-bench/fixtures/rust/self_field.rs @@ -90,3 +90,46 @@ impl Countdown { } } } + +pub struct SplitTarget; + +impl SplitTarget { + pub fn reset(&mut self) {} +} + +pub struct SplitDecoy; + +impl SplitDecoy { + pub fn reset(&mut self) {} + + pub fn missing(&mut self) {} +} + +impl SplitTarget { + pub fn drive(&mut self) { + self.reset(); + self.missing(); + } +} + +pub trait Drive { + fn go(&mut self); +} + +pub struct StepDecoy; + +impl StepDecoy { + pub fn step(&mut self) {} +} + +pub struct Doer; + +impl Doer { + pub fn step(&mut self) {} +} + +impl Drive for Doer { + fn go(&mut self) { + self.step(); + } +} diff --git a/crates/codegraph-bench/fixtures/scala/agreements.scala b/crates/codegraph-bench/fixtures/scala/agreements.scala new file mode 100644 index 00000000..40d6ab40 --- /dev/null +++ b/crates/codegraph-bench/fixtures/scala/agreements.scala @@ -0,0 +1,9 @@ +object ExtAgreement {} + +trait ExtAgreement { + def extId: String = "x" +} + +trait OtherAgreement + +class MAgreement(agreement: String)(implicit queryService: Int) diff --git a/crates/codegraph-bench/fixtures/scala/m_ext_agreement.scala b/crates/codegraph-bench/fixtures/scala/m_ext_agreement.scala new file mode 100644 index 00000000..b1222012 --- /dev/null +++ b/crates/codegraph-bench/fixtures/scala/m_ext_agreement.scala @@ -0,0 +1,4 @@ +class MExtAgreement(agreement: String, ext: String)(implicit queryService: Int) + extends MAgreement(agreement)(queryService) with ExtAgreement with OtherAgreement { + def render(): String = extId +} diff --git a/crates/codegraph-bench/fixtures/typescript/callable_semantics.ts b/crates/codegraph-bench/fixtures/typescript/callable_semantics.ts new file mode 100644 index 00000000..277e0e42 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/callable_semantics.ts @@ -0,0 +1,28 @@ +export interface PlatformApi { + fetchPage(id: string): Promise; + version: string; +} + +export type Handle = { stop(): void; label: string }; + +function callableSemanticsUniqueHelper(): number { + return 1; +} + +export function* generatorTask() { + yield callableSemanticsUniqueHelper(); +} + +export function Screen() { + const plainHandler = () => callableSemanticsUniqueHelper(); + const callbackHandler = useCallback(() => callableSemanticsUniqueHelper(), []); + const eventHandler = useEffectEvent(function () { + return callableSemanticsUniqueHelper(); + }); + const wrappedHandler = Effect.fn("Screen.wrapped")(function* () { + yield callableSemanticsUniqueHelper(); + }); + const memoValue = useMemo(() => callableSemanticsUniqueHelper(), []); + const mappedValue = [1].map(() => callableSemanticsUniqueHelper()); + return plainHandler() + callbackHandler() + eventHandler() + memoValue + mappedValue.length; +} diff --git a/crates/codegraph-bench/fixtures/typescript/commonjs_handlers.js b/crates/codegraph-bench/fixtures/typescript/commonjs_handlers.js new file mode 100644 index 00000000..0e214d98 --- /dev/null +++ b/crates/codegraph-bench/fixtures/typescript/commonjs_handlers.js @@ -0,0 +1,16 @@ +function findItems() {} +function removeItem() {} + +exports.getItems = async () => { + findItems(); +}; + +module.exports.deleteItem = function () { + removeItem(); +}; + +exports.plain = 42; +module.exports = { legacy: 1 }; + +const handlers = {}; +handlers.onSave = () => findItems(); diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs index 11b9492c..79e01872 100644 --- a/crates/codegraph-bench/tests/equivalence.rs +++ b/crates/codegraph-bench/tests/equivalence.rs @@ -201,6 +201,43 @@ fn kotlin_db_is_self_equivalent_to_kotlin_golden() { assert_equivalent(&kotlin_db(), &kotlin_golden_dir()).unwrap(); } +#[test] +fn generated_golden_matches_committed_scala_fixture() { + // Guards Scala grammar 0.26.2's multi-parameter-list inheritance shape, + // every `extends`/`with` parent, companion object -> Module classification, + // inheritance preference for the real type, and eta-expansion method values. + let tempdir = TestDir::new("generated-golden-scala"); + write_golden(&scala_db(), tempdir.path()).unwrap(); + + let expected = load_golden(&scala_golden_dir()).unwrap(); + let actual = load_golden(tempdir.path()).unwrap(); + + diff_canonical(&expected, &actual, None).unwrap(); +} + +#[test] +fn scala_db_is_self_equivalent_to_scala_golden() { + assert_equivalent(&scala_db(), &scala_golden_dir()).unwrap(); +} + +#[test] +fn generated_golden_matches_committed_dart_fixture() { + // Guards Dart 3 `extension type`: the type is class-like and owns getter / + // method nodes rather than leaving top-level functions or dropping members. + let tempdir = TestDir::new("generated-golden-dart"); + write_golden(&dart_db(), tempdir.path()).unwrap(); + + let expected = load_golden(&dart_golden_dir()).unwrap(); + let actual = load_golden(tempdir.path()).unwrap(); + + diff_canonical(&expected, &actual, None).unwrap(); +} + +#[test] +fn dart_db_is_self_equivalent_to_dart_golden() { + assert_equivalent(&dart_db(), &dart_golden_dir()).unwrap(); +} + #[test] fn generated_golden_matches_committed_typescript_fixture() { let tempdir = TestDir::new("generated-golden-typescript"); @@ -511,6 +548,22 @@ fn kotlin_golden_dir() -> PathBuf { workspace_root().join("reference/golden/kotlin") } +fn scala_db() -> PathBuf { + workspace_root().join("reference/golden/scala/colby.db") +} + +fn scala_golden_dir() -> PathBuf { + workspace_root().join("reference/golden/scala") +} + +fn dart_db() -> PathBuf { + workspace_root().join("reference/golden/dart/colby.db") +} + +fn dart_golden_dir() -> PathBuf { + workspace_root().join("reference/golden/dart") +} + fn typescript_db() -> PathBuf { workspace_root().join("reference/golden/typescript/colby.db") } diff --git a/crates/codegraph-cli/src/installer/mod.rs b/crates/codegraph-cli/src/installer/mod.rs index e9840f13..5595570b 100644 --- a/crates/codegraph-cli/src/installer/mod.rs +++ b/crates/codegraph-cli/src/installer/mod.rs @@ -34,12 +34,19 @@ fn context_from_env() -> Result { .map(PathBuf::from) .ok_or_else(|| anyhow::anyhow!("could not resolve home directory (HOME/USERPROFILE)"))?; let cwd = std::env::current_dir()?; + let non_blank_path = |name: &str| { + std::env::var_os(name) + .filter(|value| !value.to_string_lossy().trim().is_empty()) + .map(PathBuf::from) + }; Ok(InstallContext { home, cwd, app_data: std::env::var_os("APPDATA").map(PathBuf::from), xdg_config_home: std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), hermes_home: std::env::var_os("HERMES_HOME").map(PathBuf::from), + claude_config_dir: non_blank_path("CLAUDE_CONFIG_DIR"), + codex_home: non_blank_path("CODEX_HOME"), }) } @@ -747,6 +754,8 @@ mod tests { app_data: None, xdg_config_home: Some(base.join("xdg")), hermes_home: Some(base.join("hermes")), + claude_config_dir: None, + codex_home: None, }; (ctx, base) } diff --git a/crates/codegraph-cli/src/installer/registry.rs b/crates/codegraph-cli/src/installer/registry.rs index 1fefaf89..fe5e447b 100644 --- a/crates/codegraph-cli/src/installer/registry.rs +++ b/crates/codegraph-cli/src/installer/registry.rs @@ -188,6 +188,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } diff --git a/crates/codegraph-cli/src/installer/shared.rs b/crates/codegraph-cli/src/installer/shared.rs index 598e4cf1..a9c046bb 100644 --- a/crates/codegraph-cli/src/installer/shared.rs +++ b/crates/codegraph-cli/src/installer/shared.rs @@ -547,6 +547,122 @@ pub fn remove_nested_key_jsonc( Ok(FileAction::Removed) } +/// Surgically upsert `.. = value` in JSONC while preserving +/// comments, sibling servers, key order, and formatting. This is the native +/// OpenCode 2 `mcp.servers.codegraph` shape; keeping the primitive generic stops +/// targets from replacing the whole `servers` object and dropping user comments. +pub fn upsert_three_level_key_jsonc( + path: &Path, + root_key: &str, + middle_key: &str, + leaf_key: &str, + value: &Value, + schema_url: Option<&str>, +) -> std::io::Result { + let text = fs::read_to_string(path)?; + let existed = !text.trim().is_empty(); + let parsed = parse_json_object(&text); + if let Some(map) = &parsed { + let current = map + .get(root_key) + .and_then(|root| root.get(middle_key)) + .and_then(|middle| middle.get(leaf_key)); + if current == Some(value) && (schema_url.is_none() || map.contains_key("$schema")) { + return Ok(FileAction::Unchanged); + } + } + + let root = CstRootNode::parse(&text, &ParseOptions::default()) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()))?; + let root_object = root.object_value_or_set(); + let first = root_object + .object_value_or_create(root_key) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("`{root_key}` exists but is not an object"), + ) + })?; + let second = first.object_value_or_create(middle_key).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("`{root_key}.{middle_key}` exists but is not an object"), + ) + })?; + match second.get(leaf_key) { + Some(property) => property.set_value(to_cst_input(value)), + None => { + second.append(leaf_key, to_cst_input(value)); + } + } + if let Some(schema) = schema_url + && root_object.get("$schema").is_none() + { + root_object.insert(0, "$schema", CstInputValue::String(schema.to_string())); + } + let mut output = root.to_string(); + if text.ends_with('\n') && !output.ends_with('\n') { + output.push('\n'); + } + atomic_write_file(path, &output)?; + Ok(if existed { + FileAction::Updated + } else { + FileAction::Created + }) +} + +/// Remove `..` from JSONC, dropping only wrappers that become +/// empty. Comments and unrelated entries at every level remain untouched. +pub fn remove_three_level_key_jsonc( + path: &Path, + root_key: &str, + middle_key: &str, + leaf_key: &str, +) -> std::io::Result { + let Ok(text) = fs::read_to_string(path) else { + return Ok(FileAction::NotFound); + }; + let present = parse_json_object(&text) + .and_then(|map| { + map.get(root_key) + .and_then(|root| root.get(middle_key)) + .and_then(|middle| middle.get(leaf_key)) + .cloned() + }) + .is_some(); + if !present { + return Ok(FileAction::NotFound); + } + + let root = CstRootNode::parse(&text, &ParseOptions::default()) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()))?; + if let Some(root_object) = root.object_value() + && let Some(first) = root_object.object_value(root_key) + && let Some(second) = first.object_value(middle_key) + { + if let Some(property) = second.get(leaf_key) { + property.remove(); + } + if second.properties().is_empty() + && let Some(property) = first.get(middle_key) + { + property.remove(); + } + if first.properties().is_empty() + && let Some(property) = root_object.get(root_key) + { + property.remove(); + } + } + let mut output = root.to_string(); + if text.ends_with('\n') && !output.ends_with('\n') { + output.push('\n'); + } + atomic_write_file(path, &output)?; + Ok(FileAction::Removed) +} + /// Replace or append a marker-delimited section. Ports /// `replaceOrAppendMarkedSection` (shared.ts:141). pub fn replace_or_append_marked_section( diff --git a/crates/codegraph-cli/src/installer/targets/antigravity.rs b/crates/codegraph-cli/src/installer/targets/antigravity.rs index 538e9107..27481be3 100644 --- a/crates/codegraph-cli/src/installer/targets/antigravity.rs +++ b/crates/codegraph-cli/src/installer/targets/antigravity.rs @@ -272,6 +272,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -297,6 +299,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; Self { base, ctx } } diff --git a/crates/codegraph-cli/src/installer/targets/claude.rs b/crates/codegraph-cli/src/installer/targets/claude.rs index e85b1c7c..131b7501 100644 --- a/crates/codegraph-cli/src/installer/targets/claude.rs +++ b/crates/codegraph-cli/src/installer/targets/claude.rs @@ -2,7 +2,9 @@ //! //! Writes the MCP entry to `~/.claude.json` (global) or `./.mcp.json` (local), //! permissions to `/.claude/settings.json` (gated on `auto_allow`), and the -//! marker-fenced instructions block to `/.claude/CLAUDE.md`. +//! marker-fenced instructions block to `/.claude/CLAUDE.md`. A global +//! `$CLAUDE_CONFIG_DIR` moves all three profile files (and the skill directory) +//! into that directory; local installs remain project-scoped (#1627). use std::fs; use std::path::PathBuf; @@ -21,15 +23,39 @@ use super::super::types::{ pub struct ClaudeCodeTarget; +fn claude_mcp_server_config() -> Value { + let mut config = mcp_server_config(); + if let Value::Object(map) = &mut config { + map.insert("alwaysLoad".to_string(), Value::Bool(true)); + } + config +} + +fn resolved_profile_path(ctx: &InstallContext, path: &PathBuf) -> PathBuf { + if path.is_absolute() { + path.clone() + } else { + ctx.cwd.join(path) + } +} + fn config_dir(ctx: &InstallContext, loc: Location) -> PathBuf { match loc { - Location::Global => ctx.home.join(".claude"), + Location::Global => ctx + .claude_config_dir + .as_ref() + .map(|path| resolved_profile_path(ctx, path)) + .unwrap_or_else(|| ctx.home.join(".claude")), Location::Local => ctx.cwd.join(".claude"), } } fn mcp_json_path(ctx: &InstallContext, loc: Location) -> PathBuf { - // global → ~/.claude.json; local → ./.mcp.json (claude.ts:49-56). + // Default global → ~/.claude.json; custom profile → + // $CLAUDE_CONFIG_DIR/.claude.json; local → ./.mcp.json. match loc { + Location::Global if ctx.claude_config_dir.is_some() => { + config_dir(ctx, loc).join(".claude.json") + } Location::Global => ctx.home.join(".claude.json"), Location::Local => ctx.cwd.join(".mcp.json"), } @@ -129,7 +155,7 @@ impl AgentTarget for ClaudeCodeTarget { fn print_config(&self, ctx: &InstallContext, loc: Location) -> String { let target = mcp_json_path(ctx, loc); let snippet = - to_upstream_json(&json!({ "mcpServers": { "codegraph": mcp_server_config() } })); + to_upstream_json(&json!({ "mcpServers": { "codegraph": claude_mcp_server_config() } })); format!("# Add to {}\n\n{snippet}\n", target.display()) } @@ -149,7 +175,7 @@ impl AgentTarget for ClaudeCodeTarget { // Ports writeMcpEntry (claude.ts:214). fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { let file = mcp_json_path(ctx, loc); - let after = mcp_server_config(); + let after = claude_mcp_server_config(); match read_config_file(&file) { ConfigRead::Unparseable => FileWrite { path: file, @@ -431,6 +457,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -479,6 +507,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; fs::create_dir_all(&ctx.home).unwrap(); fs::create_dir_all(&ctx.cwd).unwrap(); @@ -601,6 +631,7 @@ mod tests { assert!(out.contains("mcpServers")); assert!(out.contains("codegraph")); assert!(out.contains(".claude.json")); + assert!(out.contains("\"alwaysLoad\": true")); } #[test] @@ -619,4 +650,62 @@ mod tests { assert!(fx.read(&settings).get("permissions").is_none()); } } + + #[test] + fn global_profile_override_moves_every_claude_surface_and_round_trips() { + let fx = TempClaude::new("profile-override"); + let mut ctx = fx.ctx.clone(); + ctx.claude_config_dir = Some(PathBuf::from("profiles/claude custom")); + let profile = ctx.cwd.join("profiles/claude custom"); + let target = ClaudeCodeTarget; + + let installed = target.install(&ctx, Location::Global, opts(true)); + let expected = [ + profile.join(".claude.json"), + profile.join("settings.json"), + profile.join("CLAUDE.md"), + ]; + assert_eq!( + installed + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(), + expected + ); + assert!(target.detect(&ctx, Location::Global).already_configured); + assert_eq!( + target.skill_dir(&ctx, Location::Global).unwrap(), + profile.join("skills") + ); + assert!( + target + .print_config(&ctx, Location::Global) + .contains(&profile.join(".claude.json").display().to_string()) + ); + assert!(!ctx.home.join(".claude").exists()); + assert!(!ctx.home.join(".claude.json").exists()); + + target.uninstall(&ctx, Location::Global); + assert!(!target.detect(&ctx, Location::Global).already_configured); + assert!(!profile.join("CLAUDE.md").exists()); + } + + #[test] + fn claude_profile_override_never_moves_local_project_files() { + let fx = TempClaude::new("profile-local"); + let mut ctx = fx.ctx.clone(); + ctx.claude_config_dir = Some(fx.base.join("custom-profile")); + let target = ClaudeCodeTarget; + target.install(&ctx, Location::Local, opts(false)); + assert_eq!( + mcp_json_path(&ctx, Location::Local), + ctx.cwd.join(".mcp.json") + ); + assert_eq!( + instructions_path(&ctx, Location::Local), + ctx.cwd.join(".claude/CLAUDE.md") + ); + assert!(!fx.base.join("custom-profile").exists()); + } } diff --git a/crates/codegraph-cli/src/installer/targets/codex.rs b/crates/codegraph-cli/src/installer/targets/codex.rs index abaaf27a..ea01dc74 100644 --- a/crates/codegraph-cli/src/installer/targets/codex.rs +++ b/crates/codegraph-cli/src/installer/targets/codex.rs @@ -2,7 +2,8 @@ //! //! Writes the MCP entry as the dotted-key table `[mcp_servers.codegraph]` and //! installs the managed instructions block: -//! - global: `~/.codex/config.toml` + `~/.codex/AGENTS.md` +//! - global: `$CODEX_HOME/config.toml` + `$CODEX_HOME/AGENTS.md`, falling back +//! to `~/.codex` when the override is absent //! - local: `/.codex/config.toml` + `/AGENTS.md` use std::fs; @@ -24,7 +25,17 @@ const TOML_HEADER: &str = "mcp_servers.codegraph"; fn config_dir(ctx: &InstallContext, loc: Location) -> PathBuf { match loc { - Location::Global => ctx.home.join(".codex"), + Location::Global => ctx + .codex_home + .as_ref() + .map(|path| { + if path.is_absolute() { + path.clone() + } else { + ctx.cwd.join(path) + } + }) + .unwrap_or_else(|| ctx.home.join(".codex")), Location::Local => ctx.cwd.join(".codex"), } } @@ -219,6 +230,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -267,6 +280,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; Self { base, ctx } } @@ -507,4 +522,45 @@ mod tests { assert!(out.contains("[mcp_servers.codegraph]")); assert!(out.contains("config.toml")); } + + #[test] + fn global_codex_home_override_moves_user_layer_and_round_trips() { + let fx = TempCodex::new("profile-override"); + let mut ctx = fx.ctx.clone(); + ctx.codex_home = Some(PathBuf::from("profiles/codex custom")); + let profile = ctx.cwd.join("profiles/codex custom"); + let target = CodexTarget; + + target.install(&ctx, Location::Global, opts()); + assert!(profile.join("config.toml").is_file()); + assert!(profile.join("AGENTS.md").is_file()); + assert!(target.detect(&ctx, Location::Global).already_configured); + assert!( + target + .print_config(&ctx, Location::Global) + .contains(&profile.join("config.toml").display().to_string()) + ); + assert!(!ctx.home.join(".codex").exists()); + + target.uninstall(&ctx, Location::Global); + assert!(!target.detect(&ctx, Location::Global).already_configured); + } + + #[test] + fn codex_home_override_never_moves_the_project_layer() { + let fx = TempCodex::new("profile-local"); + let mut ctx = fx.ctx.clone(); + ctx.codex_home = Some(fx.base.join("custom-profile")); + let target = CodexTarget; + target.install(&ctx, Location::Local, opts()); + assert_eq!( + toml_config_path(&ctx, Location::Local), + ctx.cwd.join(".codex/config.toml") + ); + assert_eq!( + instructions_path(&ctx, Location::Local), + ctx.cwd.join("AGENTS.md") + ); + assert!(!fx.base.join("custom-profile").exists()); + } } diff --git a/crates/codegraph-cli/src/installer/targets/copilot.rs b/crates/codegraph-cli/src/installer/targets/copilot.rs index 2dc1e5f2..ea4055a4 100644 --- a/crates/codegraph-cli/src/installer/targets/copilot.rs +++ b/crates/codegraph-cli/src/installer/targets/copilot.rs @@ -20,8 +20,9 @@ //! named `${workspaceFolder}`. The global entry is therefore BARE (read-only //! off any existing index, like the Kiro/Qoder global entries) and the LOCAL //! entry pins an absolute `--path`. -//! * The Copilot CLI entry carries `"tools": ["*"]`. Without it the CLI -//! registers the server but exposes none of its tools. +//! * The Copilot CLI entry carries `"tools": ["*"]` plus +//! `"deferTools": "never"`. Without the first it exposes no tools; without +//! the second a large connected tool set defers Explore behind tool search. //! //! CLI-only and additive: no extraction, resolution, or golden surface is //! touched. The upsert is JSONC-surgical and only ever writes the `codegraph` @@ -102,6 +103,7 @@ fn copilot_cli_entry() -> Value { let mut base = mcp_server_config(); if let Value::Object(map) = &mut base { map.insert("tools".to_string(), json!(["*"])); + map.insert("deferTools".to_string(), json!("never")); } base } @@ -427,6 +429,8 @@ mod tests { app_data: Some(home.join("AppData").join("Roaming")), xdg_config_home: Some(home.join(".config")), hermes_home: None, + claude_config_dir: None, + codex_home: None, }; (ctx, base) } @@ -626,8 +630,8 @@ mod tests { #[test] fn copilot_cli_entry_declares_all_tools() { - // Without `"tools": ["*"]` the CLI registers the server but exposes none - // of its tools. + // Without `tools` the CLI exposes nothing; without `deferTools: never` + // it can hide Explore behind tool search on large tool sets (#1696). let (ctx, base) = temp_ctx("cli-tools"); CopilotCliTarget.install(&ctx, Location::Global, opts()); let config = read_json_file(&copilot_cli_mcp_json(&ctx)); @@ -636,6 +640,11 @@ mod tests { json!(["*"]), "the CLI entry must declare tools: [\"*\"]" ); + assert_eq!( + config["mcpServers"]["codegraph"]["deferTools"], + json!("never"), + "Explore must remain on Copilot CLI's native tool list" + ); let _ = fs::remove_dir_all(base); } diff --git a/crates/codegraph-cli/src/installer/targets/cursor.rs b/crates/codegraph-cli/src/installer/targets/cursor.rs index ebd78383..e613c6bc 100644 --- a/crates/codegraph-cli/src/installer/targets/cursor.rs +++ b/crates/codegraph-cli/src/installer/targets/cursor.rs @@ -237,6 +237,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -262,6 +264,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; fs::create_dir_all(&ctx.cwd).unwrap(); fs::create_dir_all(&ctx.home).unwrap(); diff --git a/crates/codegraph-cli/src/installer/targets/gemini.rs b/crates/codegraph-cli/src/installer/targets/gemini.rs index f4636f1e..3ed20cd0 100644 --- a/crates/codegraph-cli/src/installer/targets/gemini.rs +++ b/crates/codegraph-cli/src/installer/targets/gemini.rs @@ -170,6 +170,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -195,6 +197,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; fs::create_dir_all(&ctx.cwd).unwrap(); Self { base, ctx } diff --git a/crates/codegraph-cli/src/installer/targets/hermes.rs b/crates/codegraph-cli/src/installer/targets/hermes.rs index a6ceb391..57582402 100644 --- a/crates/codegraph-cli/src/installer/targets/hermes.rs +++ b/crates/codegraph-cli/src/installer/targets/hermes.rs @@ -490,6 +490,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -515,6 +517,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: Some(base.join("hermes")), + claude_config_dir: None, + codex_home: None, }; Self { base, ctx } } diff --git a/crates/codegraph-cli/src/installer/targets/kiro.rs b/crates/codegraph-cli/src/installer/targets/kiro.rs index 7b4d9972..7cef1969 100644 --- a/crates/codegraph-cli/src/installer/targets/kiro.rs +++ b/crates/codegraph-cli/src/installer/targets/kiro.rs @@ -232,6 +232,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -287,6 +289,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; // When a global install runs @@ -352,6 +356,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; fs::create_dir_all(&ctx.cwd).unwrap(); Self { base, ctx } diff --git a/crates/codegraph-cli/src/installer/targets/opencode.rs b/crates/codegraph-cli/src/installer/targets/opencode.rs index 3f4146af..38733026 100644 --- a/crates/codegraph-cli/src/installer/targets/opencode.rs +++ b/crates/codegraph-cli/src/installer/targets/opencode.rs @@ -2,9 +2,9 @@ //! //! Writes the MCP entry to `$XDG_CONFIG_HOME/opencode/opencode.jsonc` (global, //! XDG on every platform) or `./opencode.jsonc` (local), falling back to an -//! existing `.json`. Instructions go to `/AGENTS.md`. opencode uses the -//! `mcp.` wrapper with a string-array `command` and an `enabled` flag — -//! not `mcpServers`. +//! existing `.json`. Instructions go to `/AGENTS.md`. OpenCode 2 uses the +//! native `mcp.servers.` wrapper with a string-array `command`, +//! `disabled`, and `codemode` flags — not `mcpServers` (#1698). //! //! Existing configs are edited surgically via `jsonc-parser` (see //! `shared::upsert_nested_key_jsonc`), preserving the user's comments, key @@ -18,8 +18,8 @@ use serde_json::{Map, Value, json}; use super::super::shared::{ self, CODEGRAPH_SECTION_END, CODEGRAPH_SECTION_START, ConfigRead, parse_json_object, - read_config_file, remove_nested_key_jsonc, to_upstream_json, upsert_instructions_entry, - upsert_nested_key_jsonc, write_json_file, + read_config_file, remove_nested_key_jsonc, remove_three_level_key_jsonc, to_upstream_json, + upsert_instructions_entry, upsert_three_level_key_jsonc, write_json_file, }; use super::super::types::{ AgentTarget, DetectionResult, FileAction, FileWrite, InstallContext, InstallOptions, Location, @@ -85,7 +85,8 @@ fn opencode_server_entry() -> Value { json!({ "type": "local", "command": ["codegraph", "serve", "--mcp"], - "enabled": true, + "disabled": false, + "codemode": false, }) } @@ -107,7 +108,9 @@ impl AgentTarget for OpencodeTarget { fn detect(&self, ctx: &InstallContext, loc: Location) -> DetectionResult { let file = config_path(ctx, loc); let config = parse_config(&fs::read_to_string(&file).unwrap_or_default()); - let already_configured = config.get("mcp").and_then(|m| m.get("codegraph")).is_some(); + let already_configured = config.get("mcp").is_some_and(|mcp| { + mcp.get("codegraph").is_some() || mcp["servers"].get("codegraph").is_some() + }); let installed = match loc { Location::Global => { let legacy = legacy_windows_config_dir(ctx); @@ -153,7 +156,7 @@ impl AgentTarget for OpencodeTarget { let target = config_path(ctx, loc); let snippet = to_upstream_json(&json!({ "$schema": SCHEMA_URL, - "mcp": { "codegraph": opencode_server_entry() }, + "mcp": { "servers": { "codegraph": opencode_server_entry() } }, })); format!("# Add to {}\n\n{snippet}\n", target.display()) } @@ -187,8 +190,10 @@ fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { ConfigRead::Missing => { let mut config = Map::new(); config.insert("$schema".to_string(), json!(SCHEMA_URL)); + let mut servers = Map::new(); + servers.insert("codegraph".to_string(), after); let mut mcp = Map::new(); - mcp.insert("codegraph".to_string(), after); + mcp.insert("servers".to_string(), Value::Object(servers)); config.insert("mcp".to_string(), Value::Object(mcp)); let _ = write_json_file(&file, &config); FileWrite { @@ -196,10 +201,33 @@ fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { action: FileAction::Created, } } - ConfigRead::Parsed(_) => { - let action = - upsert_nested_key_jsonc(&file, "mcp", "codegraph", &after, Some(SCHEMA_URL)) - .unwrap_or(FileAction::Skipped); + ConfigRead::Parsed(config) => { + let had_legacy = config + .get("mcp") + .and_then(|mcp| mcp.get("codegraph")) + .is_some(); + if had_legacy && remove_nested_key_jsonc(&file, "mcp", "codegraph").is_err() { + return FileWrite { + path: file, + action: FileAction::Skipped, + }; + } + let action = upsert_three_level_key_jsonc( + &file, + "mcp", + "servers", + "codegraph", + &after, + Some(SCHEMA_URL), + ) + .map(|action| { + if had_legacy && action == FileAction::Unchanged { + FileAction::Updated + } else { + action + } + }) + .unwrap_or(FileAction::Skipped); FileWrite { path: file, action } } } @@ -207,7 +235,14 @@ fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { // Ports removeMcpEntryAt (opencode.ts:233). fn remove_mcp_entry_at(file: &Path) -> FileWrite { - let action = remove_nested_key_jsonc(file, "mcp", "codegraph").unwrap_or(FileAction::NotFound); + let native = remove_three_level_key_jsonc(file, "mcp", "servers", "codegraph") + .unwrap_or(FileAction::NotFound); + let legacy = remove_nested_key_jsonc(file, "mcp", "codegraph").unwrap_or(FileAction::NotFound); + let action = if native == FileAction::Removed || legacy == FileAction::Removed { + FileAction::Removed + } else { + FileAction::NotFound + }; FileWrite { path: file.to_path_buf(), action, @@ -262,6 +297,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, } } @@ -287,6 +324,8 @@ mod tests { app_data: None, xdg_config_home: Some(base.join("xdg")), hermes_home: None, + claude_config_dir: None, + codex_home: None, }; fs::create_dir_all(&ctx.cwd).unwrap(); Self { base, ctx } @@ -323,13 +362,15 @@ mod tests { assert!(result.files.len() >= 2); let json = fx.read(&cfg); assert_eq!(json["$schema"], SCHEMA_URL); - let entry = &json["mcp"]["codegraph"]; + let entry = &json["mcp"]["servers"]["codegraph"]; assert_eq!(entry["type"], "local"); assert_eq!( entry["command"], serde_json::json!(["codegraph", "serve", "--mcp"]) ); - assert_eq!(entry["enabled"], true); + assert_eq!(entry["disabled"], false); + assert_eq!(entry["codemode"], false); + assert!(json["mcp"].get("codegraph").is_none()); assert!(instructions_path(&fx.ctx, Location::Local).exists()); let detect = target.detect(&fx.ctx, Location::Local); @@ -344,7 +385,10 @@ mod tests { target.install(&fx.ctx, Location::Global, opts()); let cfg = config_path(&fx.ctx, Location::Global); assert!(cfg.starts_with(fx.ctx.xdg_config_home.as_ref().unwrap())); - assert!(fx.read(&cfg)["mcp"]["codegraph"].is_object()); + let entry = &fx.read(&cfg)["mcp"]["servers"]["codegraph"]; + assert!(entry.is_object()); + assert_eq!(entry["disabled"], false); + assert_eq!(entry["codemode"], false); } #[test] @@ -367,6 +411,49 @@ mod tests { assert_eq!(fs::read_to_string(&cfg).unwrap(), first, "no churn"); } + #[test] + fn reinstall_migrates_v1_shape_and_preserves_jsonc_siblings() { + let fx = TempOc::new("migrate-v1"); + let target = OpencodeTarget; + let cfg = fx.ctx.cwd.join("opencode.jsonc"); + fs::write( + &cfg, + concat!( + "{\n", + " // keep this comment\n", + " \"mcp\": {\n", + " \"codegraph\": { \"type\": \"local\", \"command\": [\"old\"], \"enabled\": true },\n", + " \"other\": { \"type\": \"local\", \"command\": [\"other\"] }\n", + " }\n", + "}\n", + ), + ) + .unwrap(); + + assert!(target.detect(&fx.ctx, Location::Local).already_configured); + let first = target.install(&fx.ctx, Location::Local, opts()); + assert_eq!(first.files[0].action, FileAction::Updated); + let text = fs::read_to_string(&cfg).unwrap(); + assert!(text.contains("// keep this comment")); + let parsed = fx.read(&cfg); + assert!(parsed["mcp"].get("codegraph").is_none()); + assert!(parsed["mcp"]["other"].is_object()); + assert_eq!(parsed["mcp"]["servers"]["codegraph"]["disabled"], false); + assert_eq!(parsed["mcp"]["servers"]["codegraph"]["codemode"], false); + + let before = fs::read_to_string(&cfg).unwrap(); + let second = target.install(&fx.ctx, Location::Local, opts()); + assert_eq!(second.files[0].action, FileAction::Unchanged); + assert_eq!(fs::read_to_string(&cfg).unwrap(), before); + + target.uninstall(&fx.ctx, Location::Local); + let text = fs::read_to_string(&cfg).unwrap(); + assert!(text.contains("// keep this comment")); + let parsed = fx.read(&cfg); + assert!(parsed["mcp"].get("servers").is_none()); + assert!(parsed["mcp"]["other"].is_object()); + } + #[test] fn config_path_prefers_existing_json_over_default_jsonc() { let fx = TempOc::new("prefer-json"); @@ -384,7 +471,11 @@ mod tests { let result = target.uninstall(&fx.ctx, Location::Local); assert_eq!(result.files[0].action, FileAction::Removed); let json = fx.read(&cfg); - assert!(json.get("mcp").is_none() || json["mcp"].get("codegraph").is_none()); + assert!( + json.get("mcp").is_none() + || json["mcp"].get("servers").is_none() + || json["mcp"]["servers"].get("codegraph").is_none() + ); } #[test] @@ -420,6 +511,8 @@ mod tests { app_data: Some(base.join("appdata")), xdg_config_home: Some(base.join("xdg")), hermes_home: None, + claude_config_dir: None, + codex_home: None, }; let legacy = legacy_windows_config_dir(&ctx).expect("distinct app_data yields legacy dir"); assert!(legacy.ends_with("opencode")); @@ -446,6 +539,8 @@ mod tests { app_data: Some(base.join("appdata")), xdg_config_home: Some(base.join("xdg")), hermes_home: None, + claude_config_dir: None, + codex_home: None, }; let legacy = legacy_windows_config_dir(&ctx).unwrap(); fs::create_dir_all(&legacy).unwrap(); diff --git a/crates/codegraph-cli/src/installer/targets/qoder.rs b/crates/codegraph-cli/src/installer/targets/qoder.rs index 221cb25c..3d6f3342 100644 --- a/crates/codegraph-cli/src/installer/targets/qoder.rs +++ b/crates/codegraph-cli/src/installer/targets/qoder.rs @@ -350,6 +350,8 @@ mod tests { app_data: Some(home.join("AppData").join("Roaming")), xdg_config_home: Some(home.join(".config")), hermes_home: None, + claude_config_dir: None, + codex_home: None, }; (ctx, base) } diff --git a/crates/codegraph-cli/src/installer/targets/trae.rs b/crates/codegraph-cli/src/installer/targets/trae.rs index 912d7989..96b2761d 100644 --- a/crates/codegraph-cli/src/installer/targets/trae.rs +++ b/crates/codegraph-cli/src/installer/targets/trae.rs @@ -239,6 +239,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; (ctx, base) } diff --git a/crates/codegraph-cli/src/installer/targets/zed.rs b/crates/codegraph-cli/src/installer/targets/zed.rs index 747d950b..a6a24477 100644 --- a/crates/codegraph-cli/src/installer/targets/zed.rs +++ b/crates/codegraph-cli/src/installer/targets/zed.rs @@ -214,6 +214,8 @@ mod tests { app_data: Some(home.join("AppData").join("Roaming")), xdg_config_home: Some(home.join(".config")), hermes_home: None, + claude_config_dir: None, + codex_home: None, }; (ctx, base) } diff --git a/crates/codegraph-cli/src/installer/targets/zuno.rs b/crates/codegraph-cli/src/installer/targets/zuno.rs index 3da32fd2..bf4e7e2d 100644 --- a/crates/codegraph-cli/src/installer/targets/zuno.rs +++ b/crates/codegraph-cli/src/installer/targets/zuno.rs @@ -230,6 +230,8 @@ mod tests { app_data: None, xdg_config_home: Some(base.join("xdg")), hermes_home: None, + claude_config_dir: None, + codex_home: None, }; fs::create_dir_all(&ctx.home).unwrap(); fs::create_dir_all(&ctx.cwd).unwrap(); diff --git a/crates/codegraph-cli/src/installer/types.rs b/crates/codegraph-cli/src/installer/types.rs index c54e7b9d..5d4012a2 100644 --- a/crates/codegraph-cli/src/installer/types.rs +++ b/crates/codegraph-cli/src/installer/types.rs @@ -140,6 +140,10 @@ pub struct InstallContext { pub xdg_config_home: Option, /// `$HERMES_HOME`, used only by the Hermes target. pub hermes_home: Option, + /// `$CLAUDE_CONFIG_DIR`, used only by global Claude Code profile paths. + pub claude_config_dir: Option, + /// `$CODEX_HOME`, used only by global Codex user-layer paths. + pub codex_home: Option, } /// Ports `AgentTarget` (types.ts:73). `&self` is the frozen registry singleton. @@ -317,6 +321,8 @@ mod tests { app_data: None, xdg_config_home: None, hermes_home: None, + claude_config_dir: None, + codex_home: None, }; (ctx, base) } diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index b69a016c..9e77d466 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -21,9 +21,9 @@ use codegraph_core::deprioritize::DeprioritizeMatcher; use codegraph_core::generated_header::detect_generated_file; use codegraph_core::logger::{LoggerConfig, init_logger}; use codegraph_core::node_id::hash_content; -use codegraph_core::types::{ExtractionResult, FileRecord, Language, Node, NodeKind}; +use codegraph_core::types::{Edge, ExtractionResult, FileRecord, Language, Node, NodeKind}; use codegraph_extract::{ExtractOptions, detect_language_with, extract_source_with_observer}; -use codegraph_graph::graph::{GodotReach, GraphTraverser}; +use codegraph_graph::graph::{GodotReach, GraphTraverser, group_definitions}; use codegraph_graph::query::{SearchOptions, search_nodes}; use codegraph_graph::{segment_match, segments}; use codegraph_mcp::{McpServer, RunUntilAdoption}; @@ -1244,9 +1244,39 @@ struct PromptHookPayload { cwd: Option, } -/// Cap on the HIGH-tier explore injection so a large-repo explore can't flood -/// the prompt (upstream `MAX = 16000`). -const PROMPT_HOOK_MAX_INJECT: usize = 16000; +/// Claude Code's documented inline hook-output limit (#1694). Larger output is +/// persisted to a file and the model sees only a short preview. +const CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT: usize = 10_000; + +/// Maximum UTF-8 bytes of HIGH-tier Explore text placed inside the hook wrapper. +/// 9,000 leaves headroom for the wrapper and truncation notice while staying +/// below [`CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT`]. +const PROMPT_HOOK_MAX_INJECT: usize = 9_000; +const _: () = assert!(PROMPT_HOOK_MAX_INJECT < CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT); + +fn cap_prompt_hook_injection(text: &str) -> std::borrow::Cow<'_, str> { + if text.len() <= PROMPT_HOOK_MAX_INJECT { + return std::borrow::Cow::Borrowed(text); + } + let mut cut = PROMPT_HOOK_MAX_INJECT; + while !text.is_char_boundary(cut) { + cut -= 1; + } + std::borrow::Cow::Owned(format!( + "{}\n\u{2026}(truncated; call codegraph_explore for the rest)", + &text[..cut] + )) +} + +/// Host-generated task completion messages arrive through the same hook channel +/// as typed prompts (#1832). The whole prompt must be that envelope — opened at +/// its first non-whitespace content and closed at its last — so a question +/// typed after the notification, or one that merely mentions the tag, is still +/// a prompt (upstream v1.6.1 `isTaskNotification`). +fn is_task_notification_wrapper(prompt: &str) -> bool { + let trimmed = prompt.trim_matches(char::is_whitespace); + trimmed.starts_with("") && trimmed.ends_with("") +} /// `codegraph prompt-hook` — the Claude `UserPromptSubmit` hook entry point, /// now a confidence-tiered gate (upstream #1126 + #1136, telemetry EXCLUDED): @@ -1288,6 +1318,9 @@ fn cmd_prompt_hook(path: Option, query: Option) -> Result<()> { if query.is_empty() { return Ok(()); } + if is_task_notification_wrapper(query) { + return Ok(()); + } // Gate BEFORE opening the index: a prompt that clears none of the three // tiers is a zero-cost no-op with no filesystem work. @@ -1341,21 +1374,12 @@ fn cmd_prompt_hook(path: Option, query: Option) -> Result<()> { if text.trim().is_empty() { return Ok(()); } - let body = if text.len() > PROMPT_HOOK_MAX_INJECT { - let mut cut = PROMPT_HOOK_MAX_INJECT; - while !text.is_char_boundary(cut) { - cut -= 1; - } - format!( - "{}\n\u{2026}(truncated; call codegraph_explore for the rest)", - &text[..cut] - ) - } else { - text - }; - println!( + let body = cap_prompt_hook_injection(&text); + let output = format!( "\n{body}\n" ); + debug_assert!(output.len() < CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT); + println!("{output}"); return Ok(()); } @@ -1419,7 +1443,7 @@ fn cmd_init( } fn cmd_uninit(path: Option, force: bool) -> Result<()> { - let project = resolve_required_rebuild_project(path)?; + let project = resolve_required_rebuild_project(path, true)?; if !force { bail!("refusing to delete .codegraph without --force"); } @@ -1593,7 +1617,10 @@ fn cmd_index( // interrupted Building rebuild. Resolve state slots as well as a DB artifact // so the crash window after deletion but before final writer creation remains // reachable; authorization is still decided later under the exclusive lease. - let project = resolve_required_rebuild_project(path)?; + // An explicit path is the project to rebuild, not a hint to search upward. + // A bare `codegraph index` keeps the convenient cwd-to-ancestor discovery. + let allow_ancestor = path.is_none(); + let project = resolve_required_rebuild_project(path, allow_ancestor)?; guard_indexable_root(&project)?; let paths = index_paths(&project)?; recover_dead_owner_sidecars(&paths, &project); @@ -1627,7 +1654,7 @@ fn cmd_sync(path: Option, quiet: bool, diagnostics: DiagnosticArgs) -> // can migrate it under one retained exclusive lease. Uninitialized remains // discoverable only to reach the typed under-lease rejection; it is never // authorized to sync or recreate residue. - let project = resolve_required_rebuild_project(path)?; + let project = resolve_required_rebuild_project(path, true)?; let paths = index_paths(&project)?; if let Some(reason) = owner_mismatch_only_reason(&paths) { bail!("{}", owner_mismatch_recovery_error(&project, &reason)); @@ -1716,6 +1743,19 @@ fn cmd_sync(path: Option, quiet: bool, diagnostics: DiagnosticArgs) -> ); } } + let mut sync_warnings = Vec::new(); + if let Ok(store) = open_store(&project) { + for relative in &outcome.changed_paths { + if let Some(record) = store.file_by_path(relative)? { + sync_warnings.extend( + record + .errors + .into_iter() + .filter(|error| codegraph_extract::is_extraction_warning(error)), + ); + } + } + } diagnostic_run.phase_end( "sync", sync_started.elapsed(), @@ -1734,6 +1774,9 @@ fn cmd_sync(path: Option, quiet: bool, diagnostics: DiagnosticArgs) -> format_number(outcome.files_removed as i64), format_duration(outcome.duration_ms as i64) ); + for warning in &sync_warnings { + eprintln!("warning: {warning}"); + } } diagnostic_run.finish_success(json!({ "durationMs": outcome.duration_ms, @@ -1741,6 +1784,7 @@ fn cmd_sync(path: Option, quiet: bool, diagnostics: DiagnosticArgs) -> "filesReindexed": outcome.files_reindexed, "filesSkipped": outcome.files_skipped_unchanged, "filesRemoved": outcome.files_removed, + "warnings": sync_warnings, })); Ok(()) } @@ -1998,6 +2042,7 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { && built_with_extraction_version .is_none_or(|v| v < codegraph_store::CURRENT_EXTRACTION_VERSION); let resolution_incomplete = store.is_resolution_incomplete()?; + let pending = codegraph_watch::pending_project_changes(&project, &store)?; if json_output { let mut index_obj = json!({ @@ -2025,7 +2070,14 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { "journalMode": journal_mode(&store)?, "nodesByKind": map_counts(nodes_by_kind.clone()), "languages": files_by_language.iter().filter(|(_, c)| *c > 0).map(|(l, _)| l).collect::>(), - "pendingChanges": { "added": 0, "modified": 0, "removed": 0 }, + "pendingChanges": { + "added": pending.added.len(), + "modified": pending.modified.len(), + "removed": pending.removed.len(), + "addedPaths": pending.added, + "modifiedPaths": pending.modified, + "removedPaths": pending.removed, + }, "worktreeMismatch": null, "index": index_obj, "dbPath": db, @@ -2069,10 +2121,32 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { for (language, count) in files_by_language { println!(" {language:15} {}", format_number(count)); } + if !pending.is_empty() { + println!( + "\nPending sync: {} added, {} modified, {} removed", + pending.added.len(), + pending.modified.len(), + pending.removed.len() + ); + for (label, paths) in [ + ("added", &pending.added), + ("modified", &pending.modified), + ("removed", &pending.removed), + ] { + for path in paths { + println!(" {label:8} {path}"); + } + } + } if resolution_incomplete { println!( "\n⚠ Index is PARTIAL: a resolution pass was interrupted, so some call\n edges are missing. Run `codegraph sync` to heal it.\n" ); + } else if !pending.is_empty() { + println!( + "\nIndex has {} pending source change(s). Run `codegraph sync`.\n", + pending.total() + ); } else { println!("\nIndex is up to date\n"); } @@ -2737,7 +2811,12 @@ fn resolve_http_addr(http_addr: &str) -> Result { /// Indirection to `codegraph_mcp::serve_http` (streamable-HTTP via rmcp, the /// sole HTTP transport). fn serve_http_impl(default_project: Option, addr: std::net::SocketAddr) -> Result<()> { - codegraph_mcp::serve_http(default_project, addr).context("serving MCP over streamable-HTTP") + codegraph_mcp::serve_http_with_project_services( + default_project, + addr, + explicit_project_services(false), + ) + .context("serving MCP over streamable-HTTP") } /// `codegraph http list`: prune dead entries, then print a table of the live @@ -3396,17 +3475,17 @@ mod normalize_lexical_tests { } } -/// Whether `serve --mcp` should start background services (live watcher + -/// catch-up sync) for `project_root`. They run when the path was EXPLICIT -/// (`--path X` — the user opted into X) or the cwd is ALREADY indexed. A bare -/// serve from an UNINDEXED cwd (the Zed case) returns false so catch-up never -/// self-indexes the cwd — keeping it unindexed and therefore adoptable when the -/// client reports its real workspace root via `roots/list`. -fn should_run_serve_services(explicit_path: bool, project_root: &Path) -> bool { - explicit_path - || codegraph_dir(project_root) - .map(|d| d.is_dir()) - .unwrap_or(false) +/// Whether `serve --mcp` should start background writer services (live watcher + +/// catch-up sync) for `project_root`. +/// +/// An explicit `--path` pins query resolution but is not permission to create an +/// index namespace. Services therefore start only for an existing index root; +/// both bare and explicit launches against an unindexed project remain read-only +/// and leave `codegraph init` as the sole creator of `.codegraph`. +fn should_run_serve_services(_explicit_path: bool, project_root: &Path) -> bool { + codegraph_dir(project_root) + .map(|directory| directory.is_dir()) + .unwrap_or(false) } fn serve_direct( @@ -3416,6 +3495,19 @@ fn serve_direct( explicit_path: bool, ) -> Result<()> { let run_services = should_run_serve_services(explicit_path, project_root); + let _writer_guard = if run_services && should_run_daemon_services(project_root) { + match codegraph_daemon::try_acquire_writer_lock(project_root, "direct")? { + codegraph_daemon::WriterAcquireResult::Acquired(guard) => Some(guard), + codegraph_daemon::WriterAcquireResult::Taken { pid_path, existing } => { + bail!( + "{}", + codegraph_daemon::writer_lock_held_message(existing.as_ref(), &pid_path) + ); + } + } + } else { + None + }; // Watcher startup stays here (pre-handshake). Layer A // (`watch_disabled_reason`) already refuses to walk HOME / the filesystem // root, so a home-rooted launch never exhausts inotify. Restarting the @@ -3437,7 +3529,7 @@ fn serve_direct( // would then be rejected as "already indexed cwd"). let _catch_up_done = (run_services && should_run_daemon_services(project_root)) .then(|| spawn_catch_up(project_root)); - serve_direct_stdio(project) + serve_direct_stdio(project, explicit_project_services(no_watch)) } /// Serve the direct (pinned) stdio path through the rmcp [`CodeGraphHandler`] @@ -3445,8 +3537,20 @@ fn serve_direct( /// adoption handoff keeps the hand-rolled path (`serve_direct_no_services` → /// [`McpServer::run_until_adoption`]), since rmcp owns its read loop and cannot /// hand the reader back for the daemon proxy. -fn serve_direct_stdio(project: Option) -> Result<()> { - codegraph_mcp::serve_stdio_rmcp(project).context("running rmcp MCP stdio server") +fn serve_direct_stdio( + project: Option, + project_services: Option, +) -> Result<()> { + codegraph_mcp::serve_stdio_rmcp_with_project_services(project, project_services) + .context("running rmcp MCP stdio server") +} + +/// Lazy services for repositories selected by an explicit per-call +/// `projectPath`. The shared daemon owns the watcher; the MCP handler retains a +/// passive connection and waits for catch-up once per project/session. Explicit +/// `CODEGRAPH_NO_DAEMON=1` remains an opt-out from this daemon-backed behavior. +fn explicit_project_services(no_watch: bool) -> Option { + (!daemon_opt_out()).then(|| codegraph_daemon::project_service_broker(no_watch)) } /// Serves MCP tools off any existing index WITHOUT starting the watcher, @@ -3463,7 +3567,8 @@ fn serve_direct_no_services( // borrowed `.lock()` guards (`!Send`) cannot. let reader = BufReader::new(io::stdin()); let stdout = io::stdout(); - let mut server = McpServer::new(project); + let mut server = + McpServer::new_with_project_services(project, explicit_project_services(no_watch)); match server .run_until_adoption(reader, &stdout) .context("running MCP stdio server until workspace adoption")? @@ -3487,22 +3592,37 @@ where W: Write + Send + 'static + Unpin, { let Some(socket_path) = start_daemon_for_adopted_root(&project_root, no_watch) else { - return codegraph_mcp::rmcp_session::serve_session_rmcp(reader, writer, project_root) - .context("running rmcp MCP stdio server for adopted project"); + return codegraph_mcp::rmcp_session::serve_session_rmcp_with_project_services( + reader, + writer, + project_root, + explicit_project_services(no_watch), + ) + .context("running rmcp MCP stdio server for adopted project"); }; match codegraph_daemon::attach_to_daemon(&socket_path) { Ok(client) if codegraph_daemon::verify_daemon_hello(&client.hello).is_none() => {} Ok(_) => { tracing::debug!("serve_adopted: daemon version mismatch; serving direct"); - return codegraph_mcp::rmcp_session::serve_session_rmcp(reader, writer, project_root) - .context("running rmcp MCP stdio server for adopted project"); + return codegraph_mcp::rmcp_session::serve_session_rmcp_with_project_services( + reader, + writer, + project_root, + explicit_project_services(no_watch), + ) + .context("running rmcp MCP stdio server for adopted project"); } Err(err) => { tracing::debug!(error = %err, "serve_adopted: daemon preflight failed; serving direct"); heal_stale_daemon_if_dead(&project_root); - return codegraph_mcp::rmcp_session::serve_session_rmcp(reader, writer, project_root) - .context("running rmcp MCP stdio server for adopted project"); + return codegraph_mcp::rmcp_session::serve_session_rmcp_with_project_services( + reader, + writer, + project_root, + explicit_project_services(no_watch), + ) + .context("running rmcp MCP stdio server for adopted project"); } } @@ -3714,7 +3834,10 @@ fn serve_spawn_or_proxy( if let Some(result) = proxy_to_running_daemon(project_root) { return result; } - serve_direct(project, project_root, no_watch, explicit_path) + // A failed transport attach does not grant a second watcher. Keep the + // request path available as a read-only direct session while the live + // daemon retains the sole background-writer capability. + serve_direct_stdio(project, explicit_project_services(no_watch)) } ColdStartAction::SpawnDaemonAndServeDirect => { // Cold: kick off the shared daemon for FUTURE sessions, then serve @@ -3724,8 +3847,18 @@ fn serve_spawn_or_proxy( // failed/lost race just means this session serves direct (which it // does anyway). We deliberately do NOT poll or proxy here: that // prelude is exactly what blew past opencode's handshake timeout. - spawn_shared_daemon_best_effort(project_root, no_watch); - serve_direct(project, project_root, no_watch, explicit_path) + if spawn_shared_daemon_best_effort(project_root, no_watch) { + // The detached daemon is the sole writer. This foreground process + // exists only to answer the first MCP handshake without waiting for + // socket readiness; starting another watcher/catch-up here recreated + // the exact dual-writer race #1740 guards against. + serve_direct_stdio(project, explicit_project_services(no_watch)) + } else { + // If the child could not even be spawned, preserve the established + // in-process fallback. It takes writer.pid itself, so two fallback + // processes still cannot run competing watchers. + serve_direct(project, project_root, no_watch, explicit_path) + } } } } @@ -3772,18 +3905,21 @@ fn proxy_to_running_daemon(project_root: &Path) -> Option> { /// session serves direct regardless), and the daemon's own pid lock guarantees /// N concurrent cold starts do not produce N daemons. Does NOT block on socket /// readiness — that would reintroduce the handshake stall this fix removes. -fn spawn_shared_daemon_best_effort(project_root: &Path, no_watch: bool) { +fn spawn_shared_daemon_best_effort(project_root: &Path, no_watch: bool) -> bool { match std::env::current_exe() { Ok(exe) => match codegraph_daemon::spawn_detached_daemon(&exe, project_root, no_watch) { Ok(()) => { tracing::debug!("serve_spawn_or_proxy: spawned shared daemon (fire-and-forget)"); + true } Err(err) => { tracing::debug!(error = %err, "serve_spawn_or_proxy: daemon spawn failed; serving direct only"); + false } }, Err(err) => { tracing::debug!(error = %err, "serve_spawn_or_proxy: current_exe unavailable; serving direct only"); + false } } } @@ -3953,8 +4089,8 @@ mod serve_mode_tests { std::fs::create_dir_all(indexed.join(".codegraph")).unwrap(); assert!( - should_run_serve_services(true, &unindexed), - "explicit --path must run services even on an unindexed root" + !should_run_serve_services(true, &unindexed), + "explicit --path pins queries but must not create an index or writer namespace" ); assert!( !should_run_serve_services(false, &unindexed), @@ -4039,6 +4175,52 @@ mod serve_mode_tests { } } +#[cfg(test)] +mod prompt_hook_output_tests { + use super::{ + CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, PROMPT_HOOK_MAX_INJECT, cap_prompt_hook_injection, + is_task_notification_wrapper, + }; + + #[test] + fn high_tier_body_cap_stays_inline_and_preserves_utf8_boundaries() { + assert_eq!(PROMPT_HOOK_MAX_INJECT, 9_000); + assert_eq!(cap_prompt_hook_injection("short"), "short"); + + let ascii = "a".repeat(PROMPT_HOOK_MAX_INJECT + 500); + let capped = cap_prompt_hook_injection(&ascii); + assert!(capped.starts_with(&"a".repeat(PROMPT_HOOK_MAX_INJECT))); + assert!(capped.contains("truncated; call codegraph_explore")); + assert!(capped.len() < CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT); + + let unicode = "界".repeat(4_000); + let capped = cap_prompt_hook_injection(&unicode); + assert!(capped.is_char_boundary(capped.len())); + assert!(capped.starts_with(&"界".repeat(PROMPT_HOOK_MAX_INJECT / 3))); + assert!(capped.len() < CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT); + } + + #[test] + fn task_notification_gate_is_anchored_after_leading_whitespace_only() { + assert!(is_task_notification_wrapper( + "\n finished calls Foo()" + )); + assert!(!is_task_notification_wrapper( + "Please explain and its call flow" + )); + assert!(!is_task_notification_wrapper("")); + assert!(!is_task_notification_wrapper( + "done\nNow explain how Foo() is called" + )); + assert!(!is_task_notification_wrapper( + "unterminated" + )); + assert!(is_task_notification_wrapper( + "done\n\n" + )); + } +} + fn cmd_unlock(path: Option) -> Result<()> { let start = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); index_paths(&start)?; @@ -4099,7 +4281,7 @@ fn cmd_callers( ) -> Result<()> { let project = resolve_required_project(path)?; let store = open_store(&project)?; - let nodes = related_nodes_for_symbol( + let report = related_report_for_symbol( &store, &project, &symbol, @@ -4107,23 +4289,39 @@ fn cmd_callers( Related::Callers, file.as_deref(), )?; + let summaries = report + .union + .iter() + .map(NodeSummary::from) + .collect::>(); let godot = godot_honesty_for_symbol(&store, &project, &symbol)?; if json_output { + let targets = report + .definitions + .iter() + .flat_map(|definition| definition.roots.iter()) + .map(|node| definition_json(std::slice::from_ref(node))["definition"].clone()) + .collect::>(); print_json_pretty(&json!({ "symbol": symbol, + "targets": targets, + "ambiguous": report.definitions.len() > 1, + "aggregation": if report.definitions.len() > 1 { "union" } else { "definition" }, "file": file, - "callers": nodes, + "filteredOut": report.filtered_out, + "note": report.note, + "definitions": related_definitions_json(&report, Related::Callers), + "callers": summaries, + "total": report.total, + "limit": report.limit, + "truncated": report.total > report.limit, "godotDynamic": godot.as_json(), }))?; } else { - print_related( - "Callers", - &describe_symbol(&symbol, file.as_deref()), - &nodes, - ); - godot.print_cli(nodes.is_empty()); + print_related_report("Callers", "callers", &symbol, &report); + godot.print_cli(report.union.is_empty()); } - if strict && nodes.is_empty() { + if strict && report.union.is_empty() { bail!("codegraph callers: no callers found for \"{symbol}\""); } Ok(()) @@ -4139,7 +4337,7 @@ fn cmd_callees( ) -> Result<()> { let project = resolve_required_project(path)?; let store = open_store(&project)?; - let nodes = related_nodes_for_symbol( + let report = related_report_for_symbol( &store, &project, &symbol, @@ -4147,16 +4345,36 @@ fn cmd_callees( Related::Callees, file.as_deref(), )?; + let summaries = report + .union + .iter() + .map(NodeSummary::from) + .collect::>(); if json_output { - print_json_pretty(&json!({ "symbol": symbol, "file": file, "callees": nodes }))?; + let targets = report + .definitions + .iter() + .flat_map(|definition| definition.roots.iter()) + .map(|node| definition_json(std::slice::from_ref(node))["definition"].clone()) + .collect::>(); + print_json_pretty(&json!({ + "symbol": symbol, + "targets": targets, + "ambiguous": report.definitions.len() > 1, + "aggregation": if report.definitions.len() > 1 { "union" } else { "definition" }, + "file": file, + "filteredOut": report.filtered_out, + "note": report.note, + "definitions": related_definitions_json(&report, Related::Callees), + "callees": summaries, + "total": report.total, + "limit": report.limit, + "truncated": report.total > report.limit, + }))?; } else { - print_related( - "Callees", - &describe_symbol(&symbol, file.as_deref()), - &nodes, - ); + print_related_report("Callees", "callees", &symbol, &report); } - if strict && nodes.is_empty() { + if strict && report.union.is_empty() { bail!("codegraph callees: no callees found for \"{symbol}\""); } Ok(()) @@ -4201,66 +4419,146 @@ fn cmd_impact( if exact_matches.is_empty() { bail!(lookup_symbol_not_found_message(&symbol)); } - let exact_matches = filter_matches_by_file(exact_matches, &symbol, file.as_deref())?; + let owned = exact_matches.into_iter().cloned().collect::>(); + let grouped = group_definitions(&owned, file.as_deref()); + let note = grouped.filtered_out.then(|| { + format!( + "no definition of \"{symbol}\" matches file \"{}\" - showing all definitions instead", + file.as_deref().unwrap_or_default() + ) + }); let traverser = GraphTraverser::new(&store); - let mut nodes = HashMap::new(); - let mut edge_keys = HashSet::new(); - let mut godot_files: Vec = Vec::new(); - for node in exact_matches { - let impact = traverser.get_impact_radius(&node.id, depth)?; - for (id, node) in impact.nodes { - nodes.insert(id, node); - } - for edge in impact.edges { - edge_keys.insert((edge.source, edge.target, edge.kind)); + let definitions = grouped + .groups + .into_iter() + .map(|roots| impact_definition_report(&store, &traverser, roots, depth)) + .collect::>>()?; + + let mut affected = Vec::new(); + let mut affected_seen = HashSet::new(); + let mut edge_seen = HashSet::new(); + let mut union_edges = Vec::new(); + let mut resource_referrers = BTreeSet::new(); + for definition in &definitions { + for node in &definition.affected { + let key = format!( + "{}\0{}\0{}\0{}", + node.kind.as_str(), + node.file_path, + node.name, + node.start_line + ); + if affected_seen.insert(key) { + affected.push(node.clone()); + } } - if is_godot_resource_target_node(node) && !godot_files.contains(&node.file_path) { - godot_files.push(node.file_path.clone()); + for edge in &definition.edges { + let key = format!("{}\0{}\0{}", edge.source, edge.target, edge.kind.as_str()); + if edge_seen.insert(key) { + union_edges.push(edge.clone()); + } } - } - let mut godot_referrers: Vec = Vec::new(); - for file in &godot_files { - godot_referrers.extend(godot_reverse_referrers(&store, file)?); - } - godot_referrers.sort(); - godot_referrers.dedup(); - // A loader-side referrer may already be represented by a graph edge (for - // example, a GDScript `preload`). Keep the path-keyed resource lane - // structurally disjoint from traversal nodes before counting or rendering. - let traversal_file_paths = nodes - .values() - .map(|node| node.file_path.as_str()) - .collect::>(); - godot_referrers.retain(|file| !traversal_file_paths.contains(file.as_str())); - let resource_edge_count = godot_referrers.len(); - let mut affected = nodes.values().map(NodeSummary::from).collect::>(); - for from_file in godot_referrers { - affected.push(NodeSummary { - name: from_file.clone(), - kind: NodeKind::File, - file_path: from_file, - start_line: 0, - }); + resource_referrers.extend(definition.resource_referrers.iter().cloned()); } let godot = godot_honesty_for_symbol(&store, &project, &symbol)?; if json_output { + let targets = definitions + .iter() + .flat_map(|definition| definition.roots.iter()) + .map(|node| definition_json(std::slice::from_ref(node))["definition"].clone()) + .collect::>(); + let definition_json = definitions + .iter() + .map(|definition| { + let mut value = crate::definition_json(&definition.roots); + let object = value.as_object_mut().expect("definition JSON is object"); + object.insert("nodeCount".to_string(), json!(definition.affected.len())); + object.insert( + "edgeCount".to_string(), + json!(definition.edges.len() + definition.resource_referrers.len()), + ); + object.insert( + "resourceEdgeCount".to_string(), + json!(definition.resource_referrers.len()), + ); + object.insert( + "affected".to_string(), + serde_json::Value::Array( + definition + .affected + .iter() + .zip(&definition.affected_ids) + .map(|(node, id)| { + let mut value = serde_json::to_value(node) + .expect("affected node summary serializes"); + if let Some(id) = id { + value + .as_object_mut() + .expect("affected node summary is object") + .insert("id".to_string(), json!(id)); + } + value + }) + .collect(), + ), + ); + object.insert( + "edges".to_string(), + serde_json::to_value(&definition.edges).expect("impact edges serialize"), + ); + value + }) + .collect::>(); print_json_pretty(&json!({ "symbol": symbol, - "file": file, "depth": depth, + "targets": targets, + "ambiguous": definitions.len() > 1, + "aggregation": if definitions.len() > 1 { "union" } else { "definition" }, + "file": file, + "filteredOut": grouped.filtered_out, + "note": note, + "definitions": definition_json, "nodeCount": affected.len(), - "edgeCount": edge_keys.len() + resource_edge_count, - "resourceEdgeCount": resource_edge_count, + "edgeCount": union_edges.len() + resource_referrers.len(), + "resourceEdgeCount": resource_referrers.len(), "affected": affected, "godotDynamic": godot.as_json(), }))?; } else { - println!( - "\nImpact of changing \"{}\" - {} affected symbols:\n", - describe_symbol(&symbol, file.as_deref()), - affected.len() - ); - print_by_file(&affected); + if let Some(note) = ¬e { + println!("Note: {note}"); + } + if definitions.len() > 1 { + println!( + "\nImpact of changing \"{symbol}\" - {} distinct definitions (each with its own blast radius; narrow with --file):", + definitions.len() + ); + } + for definition in &definitions { + let head = &definition.roots[0]; + if definitions.len() > 1 { + println!( + "\n{} ({}) - {}:{} - {} affected symbols:\n", + head.qualified_name, + head.kind, + head.file_path, + head.start_line, + definition.affected.len() + ); + } else { + println!( + "\nImpact of changing \"{}\" - {} affected symbols:\n", + describe_symbol(&symbol, file.as_deref()), + definition.affected.len() + ); + println!( + "{} ({}) - {}:{}\n", + head.qualified_name, head.kind, head.file_path, head.start_line + ); + } + print_by_file(&definition.affected); + } godot.print_cli(affected.is_empty()); } Ok(()) @@ -4313,14 +4611,7 @@ fn node_target_is_file(engine: &codegraph_mcp::CodeGraphEngine, target: &str) -> if target.contains(['/', '\\']) { return true; } - engine - .indexed_file_paths() - .map(|paths| { - paths - .iter() - .any(|p| p == target || p.rsplit('/').next() == Some(target)) - }) - .unwrap_or(false) + engine.node_target_is_indexed_file(target).unwrap_or(false) } /// Render an MCP-engine `ToolResult` for a CLI subcommand: the plain rendered @@ -4778,9 +5069,12 @@ struct IndexSummary { files_indexed: i64, files_skipped: i64, files_errored: i64, + files_skipped_unsupported: i64, + top_unsupported_extensions: Vec<(String, usize)>, nodes_created: i64, edges_created: i64, duration_ms: i64, + warnings: Vec, } // Progress is a pure side effect: it only counts/displays and never gates, @@ -4950,7 +5244,23 @@ fn index_project_inner( eprintln!("Scanning files…"); } let scan_started = std::time::Instant::now(); - let files = codegraph_extract::engine::scan_project(project, &options)?; + let scan = codegraph_extract::engine::scan_project_with_stats(project, &options)?; + let files_skipped_unsupported = scan + .unsupported_by_extension + .values() + .copied() + .sum::() as i64; + let mut top_unsupported_extensions = scan + .unsupported_by_extension + .into_iter() + .collect::>(); + top_unsupported_extensions.sort_by(|(left_ext, left_count), (right_ext, right_count)| { + right_count + .cmp(left_count) + .then_with(|| left_ext.cmp(right_ext)) + }); + top_unsupported_extensions.truncate(5); + let files = scan.files; let scan_duration = scan_started.elapsed(); let mut diagnostic_run = DiagnosticRun::start( project, @@ -5001,6 +5311,7 @@ fn index_project_inner( let mut files_indexed = 0; let mut files_skipped = 0; let mut files_errored = 0; + let mut warnings = Vec::new(); // Stream the graph to the store in capped batches instead of holding the whole // project in memory. Equivalence with the all-at-once path is byte-for-byte and @@ -5128,7 +5439,18 @@ fn index_project_inner( }, |buffered| buffer_tracker.buffered(buffered), |index, (_relative, file, mut result)| { - if file.errors.is_empty() { + let fatal_errors = file + .errors + .iter() + .filter(|error| !codegraph_extract::is_extraction_warning(error)) + .count(); + warnings.extend( + file.errors + .iter() + .filter(|error| codegraph_extract::is_extraction_warning(error)) + .cloned(), + ); + if fatal_errors == 0 { files_indexed += 1; } else if result.nodes.is_empty() { files_skipped += 1; @@ -5351,17 +5673,23 @@ fn index_project_inner( files_indexed, files_skipped, files_errored, + files_skipped_unsupported, + top_unsupported_extensions, nodes_created: after.node_count - before.node_count, edges_created: after.edge_count - before.edge_count, duration_ms: started.elapsed().as_millis() as i64, + warnings, }; diagnostic_run.finish_success(json!({ "durationMs": summary.duration_ms, "filesIndexed": summary.files_indexed, "filesSkipped": summary.files_skipped, "filesErrored": summary.files_errored, + "filesSkippedUnsupported": summary.files_skipped_unsupported, + "topUnsupportedExtensions": summary.top_unsupported_extensions, "nodesCreated": summary.nodes_created, "edgesCreated": summary.edges_created, + "warnings": summary.warnings, })); Ok(summary) } @@ -5670,44 +5998,269 @@ fn print_index_result(result: &IndexSummary) { "Indexing failed - all {} files had errors", result.files_errored ); + } else if result.files_skipped_unsupported > 0 { + let extensions = result + .top_unsupported_extensions + .iter() + .map(|(extension, count)| format!("{extension} ({})", format_number(*count as i64))) + .collect::>() + .join(", "); + println!( + "No supported source files found - {} file(s) present, none in a language CodeGraph indexes{}", + format_number(result.files_skipped_unsupported), + if extensions.is_empty() { + String::new() + } else { + format!(": {extensions}") + } + ); + println!( + "CodeGraph is inactive for this workspace - searches will return nothing. Use your own file tools here." + ); } else { println!("No files found to index"); } if result.files_skipped > 0 { println!("Skipped {} files", format_number(result.files_skipped)); } + for warning in &result.warnings { + eprintln!("warning: {warning}"); + } +} + +#[derive(Debug)] +struct RelatedDefinitionReport { + roots: Vec, + nodes: Vec, + edges: Vec, +} + +#[derive(Debug)] +struct RelatedReport { + definitions: Vec, + union: Vec, + total: usize, + limit: usize, + filtered_out: bool, + note: Option, +} + +struct ImpactDefinitionReport { + roots: Vec, + affected: Vec, + affected_ids: Vec>, + edges: Vec, + resource_referrers: Vec, +} + +fn impact_definition_report( + store: &Store, + traverser: &GraphTraverser<'_>, + roots: Vec, + depth: usize, +) -> Result { + let mut nodes = Vec::new(); + let mut node_seen = HashSet::new(); + let mut edges = Vec::new(); + let mut edge_seen = HashSet::new(); + let mut godot_files = Vec::new(); + for root in &roots { + let impact = traverser.get_impact_radius(&root.id, depth)?; + for id in impact.node_order { + if node_seen.insert(id.clone()) + && let Some(node) = impact.nodes.get(&id) + { + nodes.push(node.clone()); + } + } + for edge in impact.edges { + let key = format!("{}\0{}\0{}", edge.source, edge.target, edge.kind.as_str()); + if edge_seen.insert(key) { + edges.push(edge); + } + } + if is_godot_resource_target_node(root) && !godot_files.contains(&root.file_path) { + godot_files.push(root.file_path.clone()); + } + } + let mut godot_referrers = Vec::new(); + for file in &godot_files { + godot_referrers.extend(godot_reverse_referrers(store, file)?); + } + godot_referrers.sort(); + godot_referrers.dedup(); + let traversal_file_paths = nodes + .iter() + .map(|node| node.file_path.as_str()) + .collect::>(); + godot_referrers.retain(|file| !traversal_file_paths.contains(file.as_str())); + let resource_referrers = godot_referrers.clone(); + let mut affected = nodes.iter().map(NodeSummary::from).collect::>(); + let mut affected_ids = nodes + .iter() + .map(|node| Some(node.id.clone())) + .collect::>(); + affected.extend(godot_referrers.into_iter().map(|file_path| NodeSummary { + name: file_path.clone(), + kind: NodeKind::File, + file_path, + start_line: 0, + })); + affected_ids.resize(affected.len(), None); + Ok(ImpactDefinitionReport { + roots, + affected, + affected_ids, + edges, + resource_referrers, + }) } -fn related_nodes_for_symbol( +fn related_report_for_symbol( store: &Store, project: &Path, symbol: &str, limit: usize, related: Related, file: Option<&str>, -) -> Result> { +) -> Result { let matches = symbol_matches(store, project, symbol)?; let exact_matches = exact_or_top_matches(&matches, symbol); if exact_matches.is_empty() { bail!(lookup_symbol_not_found_message(symbol)); } - let exact_matches = filter_matches_by_file(exact_matches, symbol, file)?; + let owned = exact_matches.into_iter().cloned().collect::>(); + let grouped = group_definitions(&owned, file); + let note = grouped.filtered_out.then(|| { + format!( + "no definition of \"{symbol}\" matches file \"{}\" - showing all definitions instead", + file.unwrap_or_default() + ) + }); let traverser = GraphTraverser::new(store); - let mut seen = HashSet::new(); - let mut out = Vec::new(); - for node in exact_matches { - let edges = match related { - Related::Callers => traverser.get_callers(&node.id, 1)?, - Related::Callees => traverser.get_callees(&node.id, 1)?, - }; - for entry in edges { - if seen.insert(entry.node.id.clone()) { - out.push(NodeSummary::from(&entry.node)); + let mut definitions = Vec::new(); + let mut union = Vec::new(); + let mut union_seen = HashSet::new(); + for roots in grouped.groups { + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + let mut node_seen = HashSet::new(); + let mut edge_seen = HashSet::new(); + for root in &roots { + let related_edges = match related { + Related::Callers => traverser.get_callers(&root.id, 1)?, + Related::Callees => traverser.get_callees(&root.id, 1)?, + }; + for entry in related_edges { + if node_seen.insert(entry.node.id.clone()) { + if union_seen.insert(entry.node.id.clone()) { + union.push(entry.node.clone()); + } + nodes.push(entry.node); + } + let key = format!( + "{}\0{}\0{}", + entry.edge.source, + entry.edge.target, + entry.edge.kind.as_str() + ); + if edge_seen.insert(key) { + edges.push(entry.edge); + } } } + definitions.push(RelatedDefinitionReport { + roots, + nodes, + edges, + }); } - out.truncate(limit); - Ok(out) + let total = union.len(); + union.truncate(limit); + Ok(RelatedReport { + definitions, + union, + total, + limit, + filtered_out: grouped.filtered_out, + note, + }) +} + +fn definition_json(roots: &[Node]) -> serde_json::Value { + let head = &roots[0]; + json!({ + "definition": { + "id": head.id, + "name": head.name, + "qualifiedName": head.qualified_name, + "kind": head.kind, + "language": head.language, + "filePath": head.file_path, + "startLine": head.start_line, + }, + "roots": roots.iter().map(|node| node.id.as_str()).collect::>(), + }) +} + +fn related_definitions_json(report: &RelatedReport, related: Related) -> Vec { + report + .definitions + .iter() + .map(|definition| { + let shown = definition + .nodes + .iter() + .take(report.limit) + .collect::>(); + let shown_ids = shown + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let edges = definition + .edges + .iter() + .filter(|edge| match related { + Related::Callers => shown_ids.contains(edge.source.as_str()), + Related::Callees => shown_ids.contains(edge.target.as_str()), + }) + .collect::>(); + let mut value = definition_json(&definition.roots); + let object = value.as_object_mut().expect("definition JSON is object"); + let key = match related { + Related::Callers => "callers", + Related::Callees => "callees", + }; + object.insert( + key.to_string(), + serde_json::Value::Array( + shown + .into_iter() + .map(|node| { + let mut value = serde_json::to_value(NodeSummary::from(node)) + .expect("node summary serializes"); + value + .as_object_mut() + .expect("node summary is object") + .insert("id".to_string(), json!(node.id)); + value + }) + .collect(), + ), + ); + object.insert( + "edges".to_string(), + serde_json::to_value(edges).expect("edges serialize"), + ); + object.insert("total".to_string(), json!(definition.nodes.len())); + object.insert("limit".to_string(), json!(report.limit)); + object.insert( + "truncated".to_string(), + json!(definition.nodes.len() > report.limit), + ); + value + }) + .collect() } #[derive(Debug, Clone, Copy)] @@ -5908,53 +6461,6 @@ fn exact_or_top_matches<'a>(matches: &'a [Node], symbol: &str) -> Vec<&'a Node> .collect() } -/// Narrow same-named definitions to the one declared in `file` (#1512). -/// -/// `None` passes every match through, so the unfiltered output of `callers` / -/// `callees` / `impact` is byte-unchanged. A filter is matched against the whole -/// project-relative path OR any trailing path segment boundary, so both -/// `src/deep/mod.ts` and `deep/mod.ts` select the same definition. -/// -/// An unmatched filter is an ERROR listing the files that DO define the symbol. -/// Returning an empty relative-set instead would render as "no callers", which -/// reads as "this symbol is dead" — the exact false negative the flag exists to -/// prevent. -fn filter_matches_by_file<'a>( - matches: Vec<&'a Node>, - symbol: &str, - file: Option<&str>, -) -> Result> { - let Some(want) = file else { - return Ok(matches); - }; - let want = want.replace('\\', "/"); - let want = want.trim_start_matches("./"); - let kept: Vec<&'a Node> = matches - .iter() - .copied() - .filter(|node| path_matches_file_filter(&node.file_path, want)) - .collect(); - if kept.is_empty() { - let mut defined_in: Vec<&str> = matches.iter().map(|n| n.file_path.as_str()).collect(); - defined_in.sort_unstable(); - defined_in.dedup(); - bail!( - "no definition of \"{symbol}\" in \"{want}\"; it is defined in: {}", - defined_in.join(", ") - ); - } - Ok(kept) -} - -/// A file filter matches the whole path or a trailing SEGMENT-aligned suffix, so -/// `other.ts` never selects `my_other.ts`. -fn path_matches_file_filter(file_path: &str, want: &str) -> bool { - let normalized = file_path.replace('\\', "/"); - normalized == want || normalized.ends_with(&format!("/{want}")) -} - -/// Render the queried symbol for human output, naming the applied `--file` -/// filter so a narrowed list is never mistaken for the symbol's full set. fn describe_symbol(symbol: &str, file: Option<&str>) -> String { match file { Some(file) => format!("{symbol}\" in \"{file}"), @@ -6166,7 +6672,10 @@ fn lockless_missing_detail(project: &Path, paths: &codegraph_core::IndexPaths) - ) } -fn resolve_required_rebuild_project(path: Option) -> Result { +fn resolve_required_rebuild_project( + path: Option, + allow_ancestor: bool, +) -> Result { let start = absolute_path(path.unwrap_or_else(|| PathBuf::from("."))); index_paths(&start)?; if has_rebuild_namespace(&start) { @@ -6178,23 +6687,38 @@ fn resolve_required_rebuild_project(path: Option) -> Result { break; } if has_rebuild_namespace(parent) { + if !allow_ancestor { + bail!( + "CodeGraph not initialized in {}; the nearest initialized project is {}. Pass that path to rebuild it, or run `codegraph init {}` to give the requested directory its own index", + start.display(), + parent.display(), + start.display() + ); + } warn_ancestor_index_retarget(&start, parent); return Ok(parent.to_path_buf()); } current = parent; } + if !allow_ancestor { + bail!( + "CodeGraph not initialized in {}; run `codegraph init {}` to give the requested directory its own index", + start.display(), + start.display() + ); + } bail!("CodeGraph not initialized in {}", start.display()) } /// Announce that a MUTATING command is about to operate on an ANCESTOR's index /// rather than the directory the user named (#1524). /// -/// `index .` inside an unindexed `parent/child` rebuilt the PARENT and printed -/// only `Indexed N files` — a count including the parent's own files — while -/// creating no child index. The retarget is intended behaviour (it is how one -/// index serves a whole tree), but performing it silently is what made the -/// outcome unreadable, and for `uninit --force` it deletes an index the user -/// never named. +/// A bare `index` inside an unindexed `parent/child` rebuilds the PARENT, as do +/// the other lifecycle commands that intentionally discover upward. An explicit +/// `index ` is stricter and refuses the retarget before reaching here. +/// The warning makes every still-supported ancestor operation visible; this is +/// especially important for `uninit --force`, which deletes an index the user +/// did not name directly. /// /// STDERR only: these commands' stdout is machine-readable and pinned by /// `stdout_purity.rs`. Callers that already resolved the project themselves — @@ -6481,6 +7005,7 @@ impl From<&Node> for NodeSummary { } } +#[cfg(test)] fn print_related(label: &str, symbol: &str, nodes: &[NodeSummary]) { if nodes.is_empty() { println!("No {} found for \"{}\"", label.to_lowercase(), symbol); @@ -6493,6 +7018,57 @@ fn print_related(label: &str, symbol: &str, nodes: &[NodeSummary]) { } } +fn print_related_report(title: &str, label: &str, symbol: &str, report: &RelatedReport) { + if let Some(note) = &report.note { + println!("Note: {note}"); + } + if report.definitions.len() > 1 { + println!( + "\n{title} of \"{symbol}\" - {} distinct definitions (narrow with --file):", + report.definitions.len() + ); + } + for definition in &report.definitions { + let head = &definition.roots[0]; + let shown = definition.nodes.len().min(report.limit); + let count = if definition.nodes.len() > report.limit { + format!("{shown} of {}", definition.nodes.len()) + } else { + definition.nodes.len().to_string() + }; + if report.definitions.len() > 1 { + println!( + "\n{} ({}) - {}:{} ({count}):\n", + head.qualified_name, head.kind, head.file_path, head.start_line + ); + } else { + println!("\n{title} of \"{symbol}\" ({count}):\n"); + println!( + "{} ({}) - {}:{}\n", + head.qualified_name, head.kind, head.file_path, head.start_line + ); + } + if definition.nodes.is_empty() { + if report.definitions.len() > 1 { + println!(" (no {label})"); + } else { + println!("No {label} found for \"{symbol}\""); + } + continue; + } + for node in definition.nodes.iter().take(report.limit) { + println!("{:<12}{}", node.kind, node.name); + println!(" {}:{}\n", node.file_path, node.start_line); + } + if definition.nodes.len() > report.limit { + println!( + "Showing {shown} of {} {label}; pass --limit to widen.", + definition.nodes.len() + ); + } + } +} + fn print_by_file(nodes: &[NodeSummary]) { let mut by_file: HashMap<&str, Vec<&NodeSummary>> = HashMap::new(); for node in nodes { @@ -7125,76 +7701,6 @@ mod pure_helper_tests { assert_eq!(picked.len(), 2); } - fn node_in(name: &str, file: &str) -> Node { - let mut n = node(name); - n.file_path = file.to_string(); - n - } - - #[test] - fn file_filter_matches_whole_path_and_segment_aligned_suffix() { - assert!(path_matches_file_filter( - "src/deep/mod.ts", - "src/deep/mod.ts" - )); - assert!(path_matches_file_filter("src/deep/mod.ts", "deep/mod.ts")); - assert!(path_matches_file_filter("src/deep/mod.ts", "mod.ts")); - } - - #[test] - fn file_filter_rejects_a_suffix_that_splits_a_segment() { - // `other.ts` must not select `my_other.ts` — a plain `ends_with` would. - assert!(!path_matches_file_filter("src/my_other.ts", "other.ts")); - assert!(!path_matches_file_filter("src/deep/mod.ts", "eep/mod.ts")); - } - - #[test] - fn filter_matches_by_file_none_passes_everything_through() { - let alpha = node_in("target", "alpha.ts"); - let beta = node_in("target", "beta.ts"); - let matches = vec![&alpha, &beta]; - let kept = filter_matches_by_file(matches, "target", None).expect("no filter"); - assert_eq!(kept.len(), 2, "unfiltered behaviour must be unchanged"); - } - - #[test] - fn filter_matches_by_file_selects_one_definition() { - let alpha = node_in("target", "alpha.ts"); - let beta = node_in("target", "beta.ts"); - let kept = - filter_matches_by_file(vec![&alpha, &beta], "target", Some("beta.ts")).expect("filter"); - assert_eq!( - kept.iter() - .map(|n| n.file_path.as_str()) - .collect::>(), - vec!["beta.ts"] - ); - } - - #[test] - fn filter_matches_by_file_unmatched_errors_and_lists_the_real_files() { - let alpha = node_in("target", "alpha.ts"); - let beta = node_in("target", "beta.ts"); - let err = filter_matches_by_file(vec![&alpha, &beta], "target", Some("nope.ts")) - .expect_err("an unmatched filter must error, not return empty"); - let text = err.to_string(); - assert!(text.contains("nope.ts"), "must name the filter: {text}"); - assert!( - text.contains("alpha.ts") && text.contains("beta.ts"), - "must list the defining files: {text}" - ); - } - - #[test] - fn filter_matches_by_file_normalizes_windows_separators_and_dot_slash() { - let deep = node_in("target", "src/deep/mod.ts"); - for want in ["src\\deep\\mod.ts", "./src/deep/mod.ts"] { - let kept = - filter_matches_by_file(vec![&deep], "target", Some(want)).expect("normalized"); - assert_eq!(kept.len(), 1, "{want} must select the definition"); - } - } - #[test] fn describe_symbol_names_the_applied_filter() { assert_eq!(describe_symbol("target", None), "target"); @@ -7405,10 +7911,13 @@ mod pure_helper_tests { } #[test] - fn should_run_serve_services_true_when_explicit_false_when_bare_unindexed() { + fn should_run_serve_services_requires_an_existing_index_root_in_cli_tests() { let dir = tmp("serve-svc"); - assert!(should_run_serve_services(true, &dir)); + assert!(!should_run_serve_services(true, &dir)); assert!(!should_run_serve_services(false, &dir)); + fs::create_dir(dir.join(".codegraph")).unwrap(); + assert!(should_run_serve_services(true, &dir)); + assert!(should_run_serve_services(false, &dir)); let _ = fs::remove_dir_all(&dir); } @@ -7648,30 +8157,50 @@ mod formatter_and_env_tests { } #[test] - fn print_index_result_covers_all_three_branches() { + fn print_index_result_covers_all_four_branches() { print_index_result(&IndexSummary { files_indexed: 5, files_skipped: 2, files_errored: 0, + files_skipped_unsupported: 0, + top_unsupported_extensions: Vec::new(), nodes_created: 10, edges_created: 3, duration_ms: 1200, + warnings: vec!["a.cpp: parse produced no symbols (tree has errors)".to_string()], }); print_index_result(&IndexSummary { files_indexed: 0, files_skipped: 0, files_errored: 4, + files_skipped_unsupported: 0, + top_unsupported_extensions: Vec::new(), + nodes_created: 0, + edges_created: 0, + duration_ms: 5, + warnings: Vec::new(), + }); + print_index_result(&IndexSummary { + files_indexed: 0, + files_skipped: 0, + files_errored: 0, + files_skipped_unsupported: 3, + top_unsupported_extensions: vec![(".move".to_string(), 3)], nodes_created: 0, edges_created: 0, duration_ms: 5, + warnings: Vec::new(), }); print_index_result(&IndexSummary { files_indexed: 0, files_skipped: 0, files_errored: 0, + files_skipped_unsupported: 0, + top_unsupported_extensions: Vec::new(), nodes_created: 0, edges_created: 0, duration_ms: 5, + warnings: Vec::new(), }); } diff --git a/crates/codegraph-cli/tests/ancestor_retarget_warning.rs b/crates/codegraph-cli/tests/ancestor_retarget_warning.rs index 978d5b34..35347e17 100644 --- a/crates/codegraph-cli/tests/ancestor_retarget_warning.rs +++ b/crates/codegraph-cli/tests/ancestor_retarget_warning.rs @@ -1,9 +1,9 @@ //! A mutating command must not silently retarget an ANCESTOR index (#1524). //! -//! `index .` inside `parent/child`, with only `parent/` indexed, walks up and -//! rebuilds the PARENT — reporting a file count that includes the parent's own -//! files while creating no child index, and printing nothing about it. Upstream's -//! thread widens the diagnostic to `sync` / `uninit` / `unlock`. +//! An explicit `index .` inside `parent/child`, with only `parent/` indexed, +//! must refuse to rebuild the parent. A bare `index` retains upward discovery, +//! while `sync` / `uninit` / `unlock` also retain their documented ancestor +//! behavior; those accepted retargets must name both paths on stderr. //! //! Each test drives the REAL binary, because the defect is in the CLI's project //! resolution, and asserts on STDERR: a mutating command's stdout is parsed by @@ -98,13 +98,54 @@ fn assert_retarget_warning(stderr: &str, parent: &Path, child: &Path) { } #[test] -fn index_in_an_unindexed_child_warns_that_the_parent_is_the_target() { +fn index_with_an_explicit_unindexed_child_refuses_to_retarget_the_parent() { let dir = TestDir::new("index"); let (parent, child) = parent_indexed_child_not(&dir); + let parent_db = parent.join(".codegraph/codegraph.db"); + let parent_mtime_before = fs::metadata(&parent_db).unwrap().modified().unwrap(); let (stdout, stderr, ok) = cli_in(&child, &["index", "."]); + assert!( + !ok, + "explicit index must refuse ancestor retargeting: stdout={stdout} stderr={stderr}" + ); + assert!( + stderr.contains(&format!("not initialized in {}", child.display())), + "the error must name the requested directory: {stderr}" + ); + assert!( + stderr.contains(&format!( + "nearest initialized project is {}", + parent.display() + )), + "the error must name the ancestor without operating on it: {stderr}" + ); + assert!( + stderr.contains("codegraph init"), + "the error must explain how to create a child-local index: {stderr}" + ); + assert!( + parent_db.is_file(), + "the refused command must leave the parent index intact" + ); + assert_eq!( + fs::metadata(&parent_db).unwrap().modified().unwrap(), + parent_mtime_before, + "the refused command must not rewrite the parent database" + ); + assert!( + !child.join(".codegraph").exists(), + "the refused command must not initialize the child implicitly" + ); +} + +#[test] +fn bare_index_in_an_unindexed_child_still_resolves_to_the_parent() { + let dir = TestDir::new("index-bare"); + let (parent, child) = parent_indexed_child_not(&dir); + let (stdout, stderr, ok) = cli_in(&child, &["index"]); assert!( ok, - "index must still succeed: stdout={stdout} stderr={stderr}" + "bare index must still succeed: stdout={stdout} stderr={stderr}" ); assert_retarget_warning(&stderr, &parent, &child); } @@ -170,7 +211,7 @@ fn the_retarget_warning_never_reaches_stdout() { // warning printed there would corrupt any script parsing the output. let dir = TestDir::new("purity"); let (_parent, child) = parent_indexed_child_not(&dir); - let (stdout, stderr, ok) = cli_in(&child, &["index", "."]); + let (stdout, stderr, ok) = cli_in(&child, &["index"]); assert!(ok, "index failed: stderr={stderr}"); assert!( !stdout.contains("resolved to an ancestor"), diff --git a/crates/codegraph-cli/tests/definition_grouping.rs b/crates/codegraph-cli/tests/definition_grouping.rs new file mode 100644 index 00000000..1de58e07 --- /dev/null +++ b/crates/codegraph-cli/tests/definition_grouping.rs @@ -0,0 +1,191 @@ +//! Definition-aware CLI output for callers/callees/impact (#1512/#1674). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct TestDir(PathBuf); + +impl TestDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-cli-definition-groups-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&path).expect("create temp project"); + Self(path) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn run(project: &Path, args: &[&str]) -> (String, String, bool) { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .current_dir(project) + .output() + .expect("run codegraph"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + +fn json(project: &Path, args: &[&str]) -> serde_json::Value { + let (stdout, stderr, ok) = run(project, args); + assert!(ok, "command failed: stdout={stdout} stderr={stderr}"); + serde_json::from_str(&stdout) + .unwrap_or_else(|error| panic!("invalid JSON: {error}; stdout={stdout}; stderr={stderr}")) +} + +fn fixture() -> TestDir { + let dir = TestDir::new(); + fs::create_dir_all(dir.0.join("a")).unwrap(); + fs::create_dir_all(dir.0.join("b")).unwrap(); + fs::write( + dir.0.join("a/svc.js"), + "export function alpha() {}\nexport function handle() { alpha(); }\nexport function aMain() { handle(); }\n", + ) + .unwrap(); + fs::write( + dir.0.join("b/svc.js"), + "export function beta() {}\nexport function handle() { beta(); }\nexport function bMain() { handle(); }\n", + ) + .unwrap(); + fs::write( + dir.0.join("limit.js"), + "export function target() {}\nexport function first() { target(); }\nexport function second() { target(); }\n", + ) + .unwrap(); + let path = dir.0.to_str().unwrap(); + let (stdout, stderr, ok) = run(&dir.0, &["init", path]); + assert!(ok, "init failed: stdout={stdout} stderr={stderr}"); + dir +} + +#[test] +fn callers_and_callees_are_attributed_to_distinct_definitions() { + let dir = fixture(); + let path = dir.0.to_str().unwrap(); + + let callers = json(&dir.0, &["callers", "handle", "-p", path, "--json"]); + assert_eq!(callers["ambiguous"], true); + assert_eq!(callers["aggregation"], "union"); + assert_eq!(callers["definitions"].as_array().unwrap().len(), 2); + assert_eq!(callers["callers"].as_array().unwrap().len(), 2); + assert_eq!(callers["total"], 2); + assert_eq!(callers["truncated"], false); + + let definitions = callers["definitions"].as_array().unwrap(); + for definition in definitions { + let file = definition["definition"]["filePath"].as_str().unwrap(); + let names = definition["callers"] + .as_array() + .unwrap() + .iter() + .map(|node| node["name"].as_str().unwrap()) + .collect::>(); + assert!(definition["callers"][0]["id"].is_string()); + match file { + "a/svc.js" => assert_eq!(names, vec!["aMain"]), + "b/svc.js" => assert_eq!(names, vec!["bMain"]), + other => panic!("unexpected definition file {other}"), + } + } + + let callees = json(&dir.0, &["callees", "handle", "-p", path, "--json"]); + let definitions = callees["definitions"].as_array().unwrap(); + for definition in definitions { + let file = definition["definition"]["filePath"].as_str().unwrap(); + let names = definition["callees"] + .as_array() + .unwrap() + .iter() + .map(|node| node["name"].as_str().unwrap()) + .collect::>(); + assert!(definition["callees"][0]["id"].is_string()); + match file { + "a/svc.js" => assert_eq!(names, vec!["alpha"]), + "b/svc.js" => assert_eq!(names, vec!["beta"]), + other => panic!("unexpected definition file {other}"), + } + } +} + +#[test] +fn file_filter_narrows_and_a_miss_falls_back_visibly() { + let dir = fixture(); + let path = dir.0.to_str().unwrap(); + + let narrowed = json( + &dir.0, + &[ + "callers", "handle", "-p", path, "--file", "a/svc.js", "--json", + ], + ); + assert_eq!(narrowed["ambiguous"], false); + assert_eq!(narrowed["filteredOut"], false); + assert_eq!(narrowed["definitions"].as_array().unwrap().len(), 1); + assert_eq!(narrowed["callers"][0]["name"], "aMain"); + + let fallback = json( + &dir.0, + &[ + "callers", + "handle", + "-p", + path, + "--file", + "missing.js", + "--json", + ], + ); + assert_eq!(fallback["ambiguous"], true); + assert_eq!(fallback["filteredOut"], true); + assert!( + fallback["note"] + .as_str() + .unwrap() + .contains("showing all definitions") + ); + assert_eq!(fallback["definitions"].as_array().unwrap().len(), 2); +} + +#[test] +fn limits_and_impact_metadata_tell_the_truth() { + let dir = fixture(); + let path = dir.0.to_str().unwrap(); + + let limited = json( + &dir.0, + &["callers", "target", "-p", path, "--limit", "1", "--json"], + ); + assert_eq!(limited["callers"].as_array().unwrap().len(), 1); + assert_eq!(limited["total"], 2); + assert_eq!(limited["limit"], 1); + assert_eq!(limited["truncated"], true); + assert_eq!(limited["definitions"][0]["total"], 2); + assert_eq!(limited["definitions"][0]["truncated"], true); + + let impact = json(&dir.0, &["impact", "handle", "-p", path, "--json"]); + assert_eq!(impact["ambiguous"], true); + assert_eq!(impact["aggregation"], "union"); + assert_eq!(impact["definitions"].as_array().unwrap().len(), 2); + assert!(impact["nodeCount"].as_u64().unwrap() >= 2); + for definition in impact["definitions"].as_array().unwrap() { + assert!(definition["nodeCount"].as_u64().unwrap() >= 1); + assert!(definition["edges"].is_array()); + assert!(definition["affected"][0]["id"].is_string()); + } +} diff --git a/crates/codegraph-cli/tests/explore_node_cli.rs b/crates/codegraph-cli/tests/explore_node_cli.rs index 59da2d1d..6f1a97f4 100644 --- a/crates/codegraph-cli/tests/explore_node_cli.rs +++ b/crates/codegraph-cli/tests/explore_node_cli.rs @@ -291,3 +291,48 @@ fn node_file_returns_numbered_source() { run.stdout ); } + +#[test] +fn node_file_accepts_pasted_line_and_range_selectors() { + let dir = TestDir::new("node-file-ranges"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + // Full paths exercise file mode directly; basenames also prove the CLI does + // not misroute a line-suffixed indexed file to symbol lookup. + for target in [ + "src/math.ts:5-6", + "math.ts:5-6", + "src/math.ts#L5-L6", + "math.ts#L5-6", + ] { + let run = run_in(dir.path(), &["node", target, "-p", p]); + assert!(run.ok, "node {target} must succeed: {}", run.stderr); + assert!( + run.stdout.contains("5\texport class Counter {") + && run.stdout.contains("6\t private value = 0;"), + "node {target} must render the inclusive requested range: {}", + run.stdout + ); + assert!( + !run.stdout.contains("4\t") && !run.stdout.contains("7\t"), + "node {target} must not leak adjacent lines: {}", + run.stdout + ); + } + + let pointer = run_in(dir.path(), &["node", "math.ts:8", "-p", p]); + assert!( + pointer.ok, + "single-line pointer must succeed: {}", + pointer.stderr + ); + assert!( + pointer + .stdout + .contains("8\t increment(step: number = 1): number {") + && !pointer.stdout.contains("7\t"), + "a single-line suffix must start the read at that line: {}", + pointer.stdout + ); +} diff --git a/crates/codegraph-cli/tests/file_disambiguation.rs b/crates/codegraph-cli/tests/file_disambiguation.rs index 23190e09..d43a2980 100644 --- a/crates/codegraph-cli/tests/file_disambiguation.rs +++ b/crates/codegraph-cli/tests/file_disambiguation.rs @@ -205,9 +205,9 @@ fn file_filter_accepts_a_path_suffix() { } #[test] -fn file_filter_matching_nothing_is_an_explicit_error() { - // Silently returning "no callers" would read as "this symbol is dead" — - // the same false-negative the filter exists to prevent. +fn file_filter_matching_nothing_falls_back_to_all_definitions_visibly() { + // An empty result would read as "this symbol is dead". Keep the real + // definitions and disclose that the requested narrowing missed. let dir = TestDir::new("nomatch"); let project = two_definition_project(&dir); let p = project.to_str().unwrap(); @@ -220,19 +220,13 @@ fn file_filter_matching_nothing_is_an_explicit_error() { "nosuch.ts", "--json", ]); - assert!( - !ok, - "an unmatched --file must fail, not report an empty result: {stdout}" - ); - let combined = format!("{stdout}{stderr}"); - assert!( - combined.contains("nosuch.ts"), - "the error must name the rejected filter: {combined}" - ); - assert!( - combined.contains("alpha.ts") && combined.contains("beta.ts"), - "the error must list the files that DO define the symbol: {combined}" - ); + assert!(ok, "fallback must succeed: stdout={stdout} stderr={stderr}"); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("JSON output"); + assert_eq!(value["filteredOut"], true); + assert_eq!(value["ambiguous"], true); + assert!(value["note"].as_str().unwrap().contains("nosuch.ts")); + assert_eq!(names(&value, "callers").len(), 2); + assert_eq!(value["definitions"].as_array().unwrap().len(), 2); } #[test] diff --git a/crates/codegraph-cli/tests/install_init.rs b/crates/codegraph-cli/tests/install_init.rs index c089c674..558db637 100644 --- a/crates/codegraph-cli/tests/install_init.rs +++ b/crates/codegraph-cli/tests/install_init.rs @@ -46,6 +46,8 @@ impl Fixture { .env("USERPROFILE", &self.home) .env("XDG_CONFIG_HOME", self.root.join("xdg")) .env("HERMES_HOME", self.root.join("hermes")) + .env_remove("CLAUDE_CONFIG_DIR") + .env_remove("CODEX_HOME") .env_remove("APPDATA") .output() .expect("run codegraph") diff --git a/crates/codegraph-cli/tests/installer.rs b/crates/codegraph-cli/tests/installer.rs index fef6375a..e67daf3d 100644 --- a/crates/codegraph-cli/tests/installer.rs +++ b/crates/codegraph-cli/tests/installer.rs @@ -49,6 +49,8 @@ impl Fixture { // never reads the developer's real ~/.config or $HERMES_HOME. .env("XDG_CONFIG_HOME", self.root.join("xdg")) .env("HERMES_HOME", self.root.join("hermes")) + .env_remove("CLAUDE_CONFIG_DIR") + .env_remove("CODEX_HOME") .env_remove("APPDATA") .output() .expect("run codegraph"); @@ -91,6 +93,7 @@ fn claude_local_install_idempotent_then_uninstall() { assert_eq!(entry["command"], "codegraph"); assert_eq!(entry["args"], serde_json::json!(["serve", "--mcp"])); assert_eq!(entry["type"], "stdio"); + assert_eq!(entry["alwaysLoad"], true); assert!(settings.exists()); let allow = read_json(&settings)["permissions"]["allow"].clone(); assert!( @@ -253,23 +256,97 @@ fn codex_replaces_indented_table_then_uninstalls_it() { } #[test] -fn opencode_local_uses_mcp_wrapper() { +fn opencode_local_uses_native_v2_mcp_wrapper() { let fx = Fixture::new("opencode"); let cfg = fx.project.join("opencode.jsonc"); fx.run(&["install", "--target=opencode", "--local", "--yes"]); let json = read_json(&cfg); assert_eq!(json["$schema"], "https://opencode.ai/config.json"); - let entry = &json["mcp"]["codegraph"]; + let entry = &json["mcp"]["servers"]["codegraph"]; assert_eq!(entry["type"], "local"); assert_eq!( entry["command"], serde_json::json!(["codegraph", "serve", "--mcp"]) ); - assert_eq!(entry["enabled"], true); + assert_eq!(entry["disabled"], false); + assert_eq!(entry["codemode"], false); + assert!(json["mcp"].get("codegraph").is_none()); fx.run(&["install", "--target=opencode", "--local", "--yes"]); fx.run(&["uninstall", "--target=opencode", "--local"]); - assert!(read_json(&cfg)["mcp"].get("codegraph").is_none()); + assert!( + read_json(&cfg) + .get("mcp") + .is_none_or(|mcp| mcp.get("servers").is_none()) + ); +} + +#[test] +fn claude_global_honors_custom_profile_through_cli_round_trip() { + let fx = Fixture::new("claude-profile"); + let profile = fx.root.join("profiles/claude"); + let run = |args: &[&str]| { + let output = Command::new(bin()) + .args(args) + .current_dir(&fx.project) + .env("HOME", &fx.home) + .env("CLAUDE_CONFIG_DIR", &profile) + .env_remove("CODEX_HOME") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + }; + + run(&["install", "--target=claude", "--global", "--yes"]); + assert!(profile.join(".claude.json").is_file()); + assert!(profile.join("settings.json").is_file()); + assert!(profile.join("CLAUDE.md").is_file()); + assert!(!fx.home.join(".claude").exists()); + assert!(!fx.home.join(".claude.json").exists()); + let printed = run(&["install", "--print-config", "claude"]); + assert!(printed.contains(&profile.join(".claude.json").display().to_string())); + run(&["uninstall", "--target=claude", "--global"]); + assert!( + read_json(&profile.join(".claude.json"))["mcpServers"] + .get("codegraph") + .is_none() + ); +} + +#[test] +fn codex_global_honors_custom_profile_through_cli_round_trip() { + let fx = Fixture::new("codex-profile"); + let profile = fx.root.join("profiles/codex"); + let run = |args: &[&str]| { + let output = Command::new(bin()) + .args(args) + .current_dir(&fx.project) + .env("HOME", &fx.home) + .env("CODEX_HOME", &profile) + .env_remove("CLAUDE_CONFIG_DIR") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + }; + + run(&["install", "--target=codex", "--global", "--yes"]); + assert!(profile.join("config.toml").is_file()); + assert!(profile.join("AGENTS.md").is_file()); + assert!(!fx.home.join(".codex").exists()); + let printed = run(&["install", "--print-config", "codex"]); + assert!(printed.contains(&profile.join("config.toml").display().to_string())); + run(&["uninstall", "--target=codex", "--global"]); + assert!(!profile.join("config.toml").exists()); } #[test] @@ -590,6 +667,10 @@ fn copilot_cli_global_install_declares_all_tools() { serde_json::json!(["*"]), "without tools:[\"*\"] the CLI exposes no tools: {config}" ); + assert_eq!( + config["mcpServers"]["codegraph"]["deferTools"], "never", + "Explore must not be deferred behind Copilot CLI tool search: {config}" + ); fx.run(&["uninstall", "--target=copilot-cli", "--global"]); let after = read_json(&fx.home.join(".copilot/mcp-config.json")); diff --git a/crates/codegraph-cli/tests/lease_lifetime.rs b/crates/codegraph-cli/tests/lease_lifetime.rs index 382d0b96..83863b6e 100644 --- a/crates/codegraph-cli/tests/lease_lifetime.rs +++ b/crates/codegraph-cli/tests/lease_lifetime.rs @@ -851,6 +851,7 @@ fn reader_lease_spans_stamp_check_through_last_row() { .arg("--http-addr") .arg(address.to_string()) .env("CODEGRAPH_HTTP_REGISTRY_DIR", ®istry_dir) + .env("CODEGRAPH_NO_DAEMON", "1") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); diff --git a/crates/codegraph-cli/tests/mcp_writer_lock.rs b/crates/codegraph-cli/tests/mcp_writer_lock.rs new file mode 100644 index 00000000..50d0b12d --- /dev/null +++ b/crates/codegraph-cli/tests/mcp_writer_lock.rs @@ -0,0 +1,193 @@ +//! End-to-end writer ownership for long-lived direct MCP services (#1740). +//! +//! The unit tests pin lockfile mechanics. These tests drive the real binary so +//! CLI mode selection, RAII lifetime, and the no-implicit-index boundary cannot +//! drift independently of those mechanics. + +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-mcp-writer-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn initialized_project(label: &str) -> TestDir { + let dir = TestDir::new(label); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/a.ts"), + "export function alpha(): number { return 1; }\n", + ) + .unwrap(); + let output = Command::new(bin()) + .args(["init", dir.path().to_str().unwrap()]) + .output() + .expect("run codegraph init"); + assert!( + output.status.success(), + "init failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + dir +} + +fn spawn_direct(project: &Path) -> Child { + Command::new(bin()) + .args(["serve", "--mcp", "--path", project.to_str().unwrap()]) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env("CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS", "0") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn direct MCP server") +} + +fn initialize(child: &mut Child) { + let stdin = child.stdin.as_mut().expect("child stdin remains open"); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"writer-lock-test","version":"0"}}}}}}"# + ) + .unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"# + ) + .unwrap(); + stdin.flush().unwrap(); +} + +fn wait_for_path(path: &Path, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if path.exists() { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + path.exists() +} + +fn wait_for_exit(child: &mut Child, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if child.try_wait().expect("poll child").is_some() { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + child.try_wait().expect("final child poll").is_some() +} + +#[test] +fn second_explicit_direct_writer_fails_fast_and_the_owner_cleans_up() { + let project = initialized_project("exclusive"); + let writer_path = codegraph_daemon::writer_pid_path(project.path()).unwrap(); + let mut first = spawn_direct(project.path()); + initialize(&mut first); + assert!( + wait_for_path(&writer_path, Duration::from_secs(10)), + "first direct server never published {}", + writer_path.display() + ); + assert!( + first.try_wait().unwrap().is_none(), + "first writer exited before contention" + ); + + let mut second = spawn_direct(project.path()); + assert!( + wait_for_exit(&mut second, Duration::from_secs(10)), + "second direct writer did not fail fast" + ); + let status = second.wait().expect("collect second status"); + let mut stderr = String::new(); + second + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .unwrap(); + assert!(!status.success(), "second writer unexpectedly succeeded"); + assert!( + stderr.contains("writer lock held") && stderr.contains("CODEGRAPH_NO_DAEMON"), + "contention error must be actionable: {stderr}" + ); + assert!( + first.try_wait().unwrap().is_none(), + "contending process must not terminate the established writer" + ); + + drop(first.stdin.take()); + if !wait_for_exit(&mut first, Duration::from_secs(10)) { + let _ = first.kill(); + panic!("first direct server did not exit after stdin EOF"); + } + assert!(first.wait().unwrap().success()); + assert!( + writer_path.is_file(), + "writer.pid is a stable kernel-lock path" + ); + assert!( + codegraph_daemon::read_writer_lock(project.path()).is_none(), + "ordinary EOF shutdown must release the kernel lock and clear diagnostics" + ); + assert_eq!(fs::metadata(writer_path).unwrap().len(), 0); +} + +#[test] +fn explicit_unindexed_path_stays_state_free() { + let project = TestDir::new("unindexed"); + fs::write(project.path().join("a.ts"), "export const a = 1;\n").unwrap(); + let index_root = project.path().join(".codegraph"); + let mut server = spawn_direct(project.path()); + initialize(&mut server); + std::thread::sleep(Duration::from_millis(250)); + assert!( + server.try_wait().unwrap().is_none(), + "unindexed explicit server should stay available for guidance/query errors" + ); + assert!( + !index_root.exists(), + "serve --mcp --path must not create an index or writer namespace" + ); + drop(server.stdin.take()); + if !wait_for_exit(&mut server, Duration::from_secs(10)) { + let _ = server.kill(); + panic!("unindexed server did not exit after stdin EOF"); + } + assert!(server.wait().unwrap().success()); +} diff --git a/crates/codegraph-cli/tests/parse_warning.rs b/crates/codegraph-cli/tests/parse_warning.rs new file mode 100644 index 00000000..fb8b12a6 --- /dev/null +++ b/crates/codegraph-cli/tests/parse_warning.rs @@ -0,0 +1,89 @@ +//! Honest warning for a supported file whose parse tree has errors and produces no symbols (#1522). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct TestDir(PathBuf); +impl TestDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-cli-parse-warning-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn source(delimiter: &str) -> String { + format!( + "const char* kTemplate = R\"{delimiter}(\nstruct Ignored {{ int v; }};\n){delimiter}\";\n\nint after_the_raw_string(int x) {{\n return x + 1;\n}}\n" + ) +} + +fn run(cwd: &Path, args: &[&str]) -> (String, String, bool) { + let output = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(args) + .current_dir(cwd) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .output() + .expect("run codegraph"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) +} + +#[test] +fn collapsed_parse_warns_without_failing_and_healthy_reindex_clears_it() { + let dir = TestDir::new(); + fs::write(dir.0.join("min.cpp"), source("FILE_TEMPLATE_V1")).unwrap(); + let path = dir.0.to_str().unwrap(); + + let (stdout, stderr, ok) = run(&dir.0, &["init", path]); + assert!( + ok, + "warning-only parse collapse must not fail: {stdout} {stderr}" + ); + assert!(stdout.contains("Indexed 1 files"), "stdout={stdout}"); + assert!( + stderr.contains("min.cpp: parse produced no symbols (tree has errors)"), + "stderr={stderr}" + ); + + fs::write(dir.0.join("min.cpp"), source("FILE_TEMPLATE_V")).unwrap(); + let (stdout, stderr, ok) = run(&dir.0, &["index", "--force", path]); + assert!(ok, "healthy reindex failed: {stdout} {stderr}"); + assert!( + !stderr.contains("parse produced no symbols"), + "stderr={stderr}" + ); + + let (stdout, stderr, ok) = run( + &dir.0, + &["search", "after_the_raw_string", "-p", path, "--json"], + ); + assert!(ok, "search failed: {stdout} {stderr}"); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(value[0]["node"]["name"], "after_the_raw_string"); + + fs::write(dir.0.join("min.cpp"), source("FILE_TEMPLATE_V1")).unwrap(); + let (stdout, stderr, ok) = run(&dir.0, &["sync", path]); + assert!(ok, "warning-only sync must not fail: {stdout} {stderr}"); + assert!( + stderr.contains("min.cpp: parse produced no symbols (tree has errors)"), + "sync must surface the persisted warning: stderr={stderr}" + ); +} diff --git a/crates/codegraph-cli/tests/project_path_live_sync.rs b/crates/codegraph-cli/tests/project_path_live_sync.rs new file mode 100644 index 00000000..c8beb6cf --- /dev/null +++ b/crates/codegraph-cli/tests/project_path_live_sync.rs @@ -0,0 +1,319 @@ +//! End-to-end acceptance for upstream #1835: an unpinned MCP session may access +//! several existing indexes through explicit `projectPath` values. First access +//! waits for catch-up, each project gets one shared daemon/watcher owner, and a +//! second connected MCP session keeps that owner alive after the first closes. + +#![cfg(unix)] + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-project-path-live-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn initialized_child(workspace: &Path, name: &str) -> PathBuf { + let project = workspace.join(name); + fs::create_dir_all(project.join("src")).unwrap(); + fs::create_dir(project.join(".git")).unwrap(); + fs::write( + project.join("src/live.ts"), + format!("export function initial_{name}(): number {{ return 1; }}\n"), + ) + .unwrap(); + let output = Command::new(bin()) + .args(["init", project.to_str().unwrap()]) + .output() + .expect("run codegraph init"); + assert!( + output.status.success(), + "init {} failed: stdout={} stderr={}", + project.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + project +} + +struct McpClient { + child: Child, + stdin: Option, + stdout: BufReader, + next_id: i64, +} + +impl McpClient { + fn spawn(workspace: &Path, registry: &Path, name: &str) -> Self { + let mut child = Command::new(bin()) + .args(["serve", "--mcp"]) + .current_dir(workspace) + .env("CODEGRAPH_MCP_REGISTRY_DIR", registry) + .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "60000") + .env("CODEGRAPH_DAEMON_MAX_IDLE_MS", "60000") + .env("CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS", "60000") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn global MCP client process"); + let stdin = child.stdin.take().expect("MCP stdin"); + let stdout = BufReader::new(child.stdout.take().expect("MCP stdout")); + let mut client = Self { + child, + stdin: Some(stdin), + stdout, + next_id: 1, + }; + let init_id = client.next_id(); + client.write(json!({ + "jsonrpc": "2.0", + "id": init_id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": name, "version": "0" } + } + })); + let response = client.read_id(init_id); + assert_eq!(response["result"]["serverInfo"]["name"], "codegraph"); + client.write(json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + })); + client + } + + fn next_id(&mut self) -> i64 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn write(&mut self, value: Value) { + let stdin = self.stdin.as_mut().expect("MCP stdin remains open"); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + fn read_id(&mut self, want: i64) -> Value { + loop { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line).expect("read MCP response"); + assert!(read > 0, "MCP process closed before response id {want}"); + if let Ok(value) = serde_json::from_str::(line.trim()) + && value.get("id").and_then(Value::as_i64) == Some(want) + { + return value; + } + } + } + + fn search(&mut self, project: &Path, query: &str) -> String { + let id = self.next_id(); + self.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { + "name": "codegraph_search", + "arguments": { + "query": query, + "projectPath": project + } + } + })); + let response = self.read_id(id); + let text = response["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(); + assert_ne!( + response["result"]["isError"], + json!(true), + "explicit project search failed: {text}" + ); + text + } + + fn close(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if self.child.try_wait().unwrap().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = self.child.kill(); + panic!("MCP process did not exit after stdin EOF"); + } +} + +impl Drop for McpClient { + fn drop(&mut self) { + drop(self.stdin.take()); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn daemon_pid(project: &Path) -> Option { + let path = codegraph_daemon::daemon_pid_path(project).ok()?; + let raw = fs::read_to_string(path).ok()?; + codegraph_daemon::decode_lock_info(&raw).map(|info| info.pid) +} + +fn wait_for_daemon_pid(project: &Path) -> u32 { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(pid) = daemon_pid(project) + && codegraph_daemon::is_process_alive(pid) + { + return pid; + } + assert!( + Instant::now() < deadline, + "daemon did not start for {}", + project.display() + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn is_symbol_hit(text: &str, symbol: &str) -> bool { + text.contains(symbol) && text.contains("src/live.ts") && !text.contains("No results found") +} + +fn wait_for_watched_symbol(client: &mut McpClient, project: &Path, symbol: &str) -> String { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let text = client.search(project, symbol); + if is_symbol_hit(&text, symbol) { + return text; + } + assert!( + Instant::now() < deadline, + "watcher did not index {symbol} in {}: {text}", + project.display() + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn shutdown_daemon(project: &Path) { + let paths = codegraph_core::IndexPaths::resolve(project, None).unwrap(); + match codegraph_daemon::request_daemon_shutdown(project, paths.project_identity()) { + Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) + | Ok(codegraph_daemon::ShutdownOutcome::Drained { .. }) => {} + other => panic!("failed to stop daemon for {}: {other:?}", project.display()), + } +} + +#[test] +fn explicit_projects_catch_up_and_share_watcher_ownership_across_sessions() { + let workspace = TestDir::new("workspace"); + fs::create_dir(workspace.path().join(".git")).unwrap(); + let service_a = initialized_child(workspace.path(), "service-a"); + let service_b = initialized_child(workspace.path(), "service-b"); + let registry = workspace.path().join("mcp-registry"); + + let mut first = McpClient::spawn(workspace.path(), ®istry, "project-path-first"); + let mut second = McpClient::spawn(workspace.path(), ®istry, "project-path-second"); + + // The edit happens after initialization but before the first explicit access. + // The first response must therefore wait for catch-up rather than serve the + // stale index that was present when the MCP process started. + let catchup_a = "firstExplicitCatchupUniqueA"; + fs::write( + service_a.join("src/live.ts"), + format!("export function {catchup_a}(): number {{ return 10; }}\n"), + ) + .unwrap(); + let first_result = first.search(&service_a, catchup_a); + assert!(is_symbol_hit(&first_result, catchup_a), "{first_result}"); + let first_pid = wait_for_daemon_pid(&service_a); + + // A second MCP session attaches the same repository service. It must share + // the existing daemon/writer rather than launching a second watcher owner. + let second_result = second.search(&service_a, catchup_a); + assert!(is_symbol_hit(&second_result, catchup_a), "{second_result}"); + assert_eq!(wait_for_daemon_pid(&service_a), first_pid); + + // Subsequent edits are delivered by the retained daemon watcher. Repeated + // tool calls do not re-run catch-up because this session already holds A. + let watched_a = "subsequentWatcherUpdateUniqueA"; + fs::write( + service_a.join("src/live.ts"), + format!("export function {watched_a}(): number {{ return 200; }}\n"), + ) + .unwrap(); + wait_for_watched_symbol(&mut second, &service_a, watched_a); + + // Closing one MCP session drops only its daemon connection. The second + // session's retained connection keeps the same watcher owner alive. + first.close(); + assert!(codegraph_daemon::is_process_alive(first_pid)); + let after_close_a = "watchSurvivesOtherSessionCloseUniqueA"; + fs::write( + service_a.join("src/live.ts"), + format!("export function {after_close_a}(): number {{ return 3000; }}\n"), + ) + .unwrap(); + wait_for_watched_symbol(&mut second, &service_a, after_close_a); + assert_eq!(wait_for_daemon_pid(&service_a), first_pid); + + // A different explicit repository remains separate: it gets its own index, + // catch-up, and daemon, without becoming an arbitrary default. + let catchup_b = "firstExplicitCatchupUniqueB"; + fs::write( + service_b.join("src/live.ts"), + format!("export function {catchup_b}(): number {{ return 40; }}\n"), + ) + .unwrap(); + let b_result = second.search(&service_b, catchup_b); + assert!(is_symbol_hit(&b_result, catchup_b), "{b_result}"); + let second_pid = wait_for_daemon_pid(&service_b); + assert_ne!( + second_pid, first_pid, + "each repository owns a separate daemon" + ); + + second.close(); + shutdown_daemon(&service_a); + shutdown_daemon(&service_b); +} diff --git a/crates/codegraph-cli/tests/prompt_hook.rs b/crates/codegraph-cli/tests/prompt_hook.rs index 34d75da8..c3367fb6 100644 --- a/crates/codegraph-cli/tests/prompt_hook.rs +++ b/crates/codegraph-cli/tests/prompt_hook.rs @@ -454,3 +454,26 @@ fn prompt_hook_kill_switch_silences() { "kill-switch must silence output, got:\n{out}" ); } + +#[test] +fn host_task_notification_is_silent_only_when_it_is_the_first_content() { + let (_dir, project) = index_mini("task-notification"); + let wrapped = format!( + "{{\"prompt\":\"\\n what calls Counter()\",\"cwd\":{}}}", + serde_json::to_string(project.to_str().unwrap()).unwrap() + ); + let (out, err, ok) = cli_stdin(&["prompt-hook"], &wrapped); + assert!(ok, "wrapper run failed: stdout={out} stderr={err}"); + assert!(out.trim().is_empty(), "host wrapper must be silent: {out}"); + + let discussed = format!( + "{{\"prompt\":\"Please explain and what calls Counter()\",\"cwd\":{}}}", + serde_json::to_string(project.to_str().unwrap()).unwrap() + ); + let (out, err, ok) = cli_stdin(&["prompt-hook"], &discussed); + assert!(ok, "discussion run failed: stdout={out} stderr={err}"); + assert!( + out.contains(" PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct Project(PathBuf); + +impl Project { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "codegraph-status-committed-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join(".gitignore"), ".codegraph/\n").unwrap(); + fs::write( + root.join("src/modify.ts"), + "export function before(): number { return 1; }\n", + ) + .unwrap(); + fs::write( + root.join("src/remove.ts"), + "export function removeMe(): number { return 2; }\n", + ) + .unwrap(); + git(&root, &["init"]); + git( + &root, + &["config", "user.email", "codegraph@example.invalid"], + ); + git(&root, &["config", "user.name", "CodeGraph Test"]); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-m", "initial"]); + Self(root) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for Project { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +/// Repository-locating variables (`git rev-parse --local-env-vars`). Git +/// exports them to hooks, so a test run from `pre-push` would otherwise commit +/// its throwaway fixture into the repository being pushed. +const GIT_LOCAL_ENV: &[&str] = &[ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CONFIG", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_OBJECT_DIRECTORY", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_REPLACE_REF_BASE", + "GIT_PREFIX", + "GIT_SHALLOW_FILE", + "GIT_COMMON_DIR", +]; + +fn git(root: &Path, args: &[&str]) -> String { + let mut command = Command::new("git"); + for name in GIT_LOCAL_ENV { + command.env_remove(name); + } + let output = command + .args(args) + .current_dir(root) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn codegraph(root: &Path, args: &[&str]) -> String { + let output = Command::new(bin()) + .args(args) + .current_dir(root) + .output() + .expect("run codegraph"); + assert!( + output.status.success(), + "codegraph {args:?} failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +#[test] +fn cli_and_mcp_status_see_clean_committed_delta_without_mutating_the_index() { + let project = Project::new(); + let root = project.path(); + codegraph(root, &["init", "."]); + let db = root.join(".codegraph/codegraph.db"); + + fs::write( + root.join("src/modify.ts"), + "export function afterCommit(): number { return 1000; }\n", + ) + .unwrap(); + fs::remove_file(root.join("src/remove.ts")).unwrap(); + fs::write( + root.join("src/add.ts"), + "export function addedAfterIndex(): number { return 3; }\n", + ) + .unwrap(); + fs::create_dir_all(root.join("vendor")).unwrap(); + fs::write( + root.join("vendor/ignored.ts"), + "export function mustNotAppear(): number { return 4; }\n", + ) + .unwrap(); + git(root, &["add", "-A"]); + git(root, &["commit", "-m", "committed delta"]); + assert!( + git(root, &["status", "--porcelain"]).trim().is_empty(), + "the regression requires a clean working tree" + ); + + let before = fs::read(&db).unwrap(); + let value: Value = + serde_json::from_str(&codegraph(root, &["status", ".", "--json"])).expect("status JSON"); + assert_eq!( + value["pendingChanges"], + json!({ + "added": 1, + "modified": 1, + "removed": 1, + "addedPaths": ["src/add.ts"], + "modifiedPaths": ["src/modify.ts"], + "removedPaths": ["src/remove.ts"], + }) + ); + + let engine = codegraph_mcp::CodeGraphEngine::open(root).unwrap(); + let result = engine.execute("codegraph_status", &json!({})); + let text = &result.content[0].text; + assert!(text.contains("Pending sync: 1 added, 1 modified, 1 removed")); + assert!(text.contains("added: src/add.ts")); + assert!(text.contains("modified: src/modify.ts")); + assert!(text.contains("removed: src/remove.ts")); + assert!(!text.contains("vendor/ignored.ts")); + drop(engine); + assert_eq!( + fs::read(&db).unwrap(), + before, + "CLI/MCP status must not mutate database bytes" + ); + + codegraph(root, &["sync", ".", "--quiet"]); + let clean: Value = serde_json::from_str(&codegraph(root, &["status", ".", "--json"])) + .expect("clean status JSON"); + assert_eq!(clean["pendingChanges"]["added"], 0); + assert_eq!(clean["pendingChanges"]["modified"], 0); + assert_eq!(clean["pendingChanges"]["removed"], 0); + assert_eq!(clean["pendingChanges"]["addedPaths"], json!([])); + assert_eq!(clean["pendingChanges"]["modifiedPaths"], json!([])); + assert_eq!(clean["pendingChanges"]["removedPaths"], json!([])); +} diff --git a/crates/codegraph-cli/tests/unsupported_project.rs b/crates/codegraph-cli/tests/unsupported_project.rs new file mode 100644 index 00000000..68826bbc --- /dev/null +++ b/crates/codegraph-cli/tests/unsupported_project.rs @@ -0,0 +1,91 @@ +//! Runtime contract for projects containing only unsupported source languages. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_codegraph")) +} + +struct TestDir(PathBuf); + +impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "codegraph-unsupported-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn init(project: &Path) -> (bool, String, String) { + let output = Command::new(bin()) + .args(["init", project.to_str().unwrap()]) + .env("CODEGRAPH_NO_DAEMON", "1") + .output() + .expect("run codegraph init"); + ( + output.status.success(), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +#[test] +fn unsupported_only_project_reports_extensions_and_inactive_state() { + let root = TestDir::new("only"); + fs::write(root.0.join("a.move"), "module a {}\n").unwrap(); + fs::write(root.0.join("b.MOVE"), "module b {}\n").unwrap(); + fs::write(root.0.join("tool.pl"), "print 1;\n").unwrap(); + + let (ok, stdout, stderr) = init(&root.0); + assert!(ok, "init failed: stdout={stdout} stderr={stderr}"); + assert!( + stdout.contains("No supported source files found") + && stdout.contains("3 file(s) present") + && stdout.contains(".move (2)") + && stdout.contains(".pl (1)"), + "extension summary missing: {stdout}" + ); + assert!( + stdout.contains("CodeGraph is inactive for this workspace") + && stdout.contains("searches will return nothing"), + "inactive state missing: {stdout}" + ); +} + +#[test] +fn supported_or_empty_projects_keep_their_existing_messages() { + let supported = TestDir::new("mixed"); + fs::write(supported.0.join("main.rs"), "fn main() {}\n").unwrap(); + fs::write(supported.0.join("ignored.move"), "module ignored {}\n").unwrap(); + let (ok, stdout, stderr) = init(&supported.0); + assert!(ok, "init failed: stdout={stdout} stderr={stderr}"); + assert!(stdout.contains("Indexed 1 files"), "got: {stdout}"); + assert!( + !stdout.contains("inactive for this workspace"), + "got: {stdout}" + ); + + let empty = TestDir::new("empty"); + let (ok, stdout, stderr) = init(&empty.0); + assert!(ok, "init failed: stdout={stdout} stderr={stderr}"); + assert!(stdout.contains("No files found to index"), "got: {stdout}"); + assert!( + !stdout.contains("inactive for this workspace"), + "got: {stdout}" + ); +} diff --git a/crates/codegraph-daemon/Cargo.toml b/crates/codegraph-daemon/Cargo.toml index 645878f3..dfcbf407 100644 --- a/crates/codegraph-daemon/Cargo.toml +++ b/crates/codegraph-daemon/Cargo.toml @@ -24,6 +24,7 @@ time = { workspace = true } # Cross-platform IPC (local socket on unix, named pipe on windows) interprocess = { workspace = true } codegraph-core = { path = "../codegraph-core" } +codegraph-store = { path = "../codegraph-store" } codegraph-mcp = { path = "../codegraph-mcp" } codegraph-watch = { path = "../codegraph-watch" } tokio = { version = "1", features = [ diff --git a/crates/codegraph-daemon/src/lib.rs b/crates/codegraph-daemon/src/lib.rs index a965c1bc..3e563c3d 100644 --- a/crates/codegraph-daemon/src/lib.rs +++ b/crates/codegraph-daemon/src/lib.rs @@ -11,10 +11,12 @@ mod lock; pub mod mcp_registry; mod paths; mod process; +mod project_service; pub mod proxy; mod session; pub mod spawn; mod transport; +mod writer_lock; use std::fs; use std::path::{Path, PathBuf}; @@ -38,10 +40,19 @@ pub use process::{ SupervisionState, current_ppid, is_process_alive, is_session_leader, supervision_lost_reason, terminate_pid, }; +pub use project_service::{ProjectDaemonLease, project_service_broker, retain_project_daemon}; pub use proxy::{ProxyOutcome, run_proxy, verify_daemon_hello}; pub use session::{SessionRegistry, read_daemon_hello, run_session_recv}; -pub use spawn::{CODEGRAPH_HTTP_DETACH_INTERNAL, spawn_detached_daemon, spawn_detached_http}; +pub use spawn::{ + CODEGRAPH_HTTP_DETACH_INTERNAL, CODEGRAPH_SKIP_STARTUP_CATCHUP, spawn_detached_daemon, + spawn_detached_daemon_for_project_service, spawn_detached_http, +}; use tracing::{debug, info, warn}; +pub use writer_lock::{ + WriterAcquireResult, WriterLockGuard, WriterLockInfo, clear_stale_writer_lock, + decode_writer_lock_info, read_writer_lock, try_acquire_writer_lock, writer_lock_held_message, + writer_pid_path, +}; use crate::lock::{cleanup_owned_rendezvous, rewrite_lock_socket_path}; use crate::session::{ControlHandle, ShutdownRequest, serve_session_async}; @@ -373,6 +384,19 @@ fn start_with_lock( let rendezvous_dir = paths::rendezvous_dir(&project_root)?; fs::create_dir_all(&rendezvous_dir) .with_context(|| format!("creating {}", rendezvous_dir.display()))?; + let writer_guard = match try_acquire_writer_lock(&project_root, "daemon")? { + WriterAcquireResult::Acquired(guard) => guard, + WriterAcquireResult::Taken { + pid_path: writer_path, + existing, + } => { + cleanup_owned_rendezvous(&pid_path, &socket_path, std::process::id()); + bail!( + "{}", + writer_lock_held_message(existing.as_ref(), &writer_path) + ); + } + }; let project_identity = paths::index_paths(&project_root)? .project_identity() .to_string(); @@ -413,6 +437,7 @@ fn start_with_lock( thread_registry, thread_shutdown, options, + writer_guard, )) }); @@ -491,6 +516,7 @@ async fn run_accept_loop_async( registry: SessionRegistry, shutdown: Arc, options: DaemonOptions, + _writer_guard: WriterLockGuard, ) -> Result<()> { use interprocess::local_socket::traits::tokio::Listener as _; @@ -520,7 +546,8 @@ async fn run_accept_loop_async( // the session task: per-connection would spawn N watchers. let watcher = start_project_watcher(&project_root, &options, &lease_loops); - let _catch_up_done = spawn_catch_up(&project_root, &lease_loops); + let _catch_up_done = (std::env::var(CODEGRAPH_SKIP_STARTUP_CATCHUP).as_deref() != Ok("1")) + .then(|| spawn_catch_up(&project_root, &lease_loops)); // The control channel is separate from the request path by construction: a // session that reads a control frame never builds an engine and never takes a @@ -549,6 +576,9 @@ async fn run_accept_loop_async( let session_socket = socket_display.clone(); let session_registry = registry.clone(); let run_mcp = options.run_mcp; + let project_services = run_mcp.then(|| { + project_service_broker(!options.watch) + }); let session_control = control.clone(); tokio::spawn(async move { if let Err(err) = serve_session_async( @@ -557,6 +587,7 @@ async fn run_accept_loop_async( session_socket, session_registry, run_mcp, + project_services, Some(session_control), ) .await diff --git a/crates/codegraph-daemon/src/project_service.rs b/crates/codegraph-daemon/src/project_service.rs new file mode 100644 index 00000000..f9cb13ef --- /dev/null +++ b/crates/codegraph-daemon/src/project_service.rs @@ -0,0 +1,242 @@ +//! Lazy shared-daemon ownership for repositories reached through MCP +//! `projectPath`. +//! +//! The MCP crate owns only an injectable, session-bounded broker to avoid a +//! dependency cycle. This module is the concrete integration: for an existing +//! index it starts or attaches the per-project daemon, retains one passive +//! client connection for the caller's lifetime, then performs one synchronous +//! catch-up before the first tool result is allowed to return. The daemon owns +//! the sole long-lived watcher/writer capability. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use codegraph_core::IndexPaths; +use serde_json::json; + +use crate::proxy::verify_daemon_hello; +use crate::session::read_daemon_hello; +use crate::transport::{Rendezvous, Stream, connect}; + +const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(25); +const CONNECT_BUDGET: Duration = Duration::from_secs(8); +const CONNECT_RESULT_MARGIN: Duration = Duration::from_millis(250); + +/// A passive daemon client retained by one MCP session. The daemon registry +/// counts this connection, so one session closing cannot stop the watcher while +/// another session still retains its own lease. The mutex only supplies `Sync`; +/// after the initial client hello no further bytes are written through it. +#[derive(Debug)] +pub struct ProjectDaemonLease { + _stream: Mutex, + project_root: PathBuf, + /// The daemon owner recorded when the lease attached. + daemon_pid: Option, + /// The index database the lease attached to, and its identity then. + db_path: PathBuf, + db_identity: Option, +} + +impl ProjectDaemonLease { + #[must_use] + pub fn project_root(&self) -> &Path { + &self.project_root + } +} + +impl codegraph_mcp::ProjectService for ProjectDaemonLease { + /// Live while the attached daemon still owns the project and the index + /// database is the same file it was: a daemon crash, an `uninit`, or a + /// re-created index each make the retained connection meaningless. + fn is_live(&self) -> bool { + let owner_unchanged = self + .daemon_pid + .is_some_and(|pid| daemon_owner_pid(&self.project_root) == Some(pid)); + let index_unchanged = self.db_identity.is_some_and(|identity| { + codegraph_store::PathIdentity::of(&self.db_path).ok() == Some(identity) + }); + owner_unchanged && index_unchanged + } +} + +/// Build the concrete broker used by CLI, HTTP, and nested daemon sessions. +/// Each broker owns at most the MCP crate's bounded number of explicit projects. +#[must_use] +pub fn project_service_broker(no_watch: bool) -> codegraph_mcp::ProjectServiceBroker { + codegraph_mcp::ProjectServiceBroker::new(Arc::new(move |project_root| { + let lease = retain_project_daemon(project_root, no_watch)?; + Ok(Arc::new(lease) as codegraph_mcp::ProjectServiceHandle) + })) +} + +/// Start/attach the existing project's shared daemon, retain a session +/// connection, and wait for one whole-project catch-up before returning. +/// +/// The existing-index check precedes every process or sync action, so this path +/// never creates `.codegraph` and never turns an arbitrary `projectPath` into a +/// default project. The daemon starts first: its watcher is already subscribed +/// while the synchronous catch-up runs, closing the scan-to-watch gap. A +/// concurrent daemon startup catch-up is harmless because the permanent index +/// lease serializes writers; this final catch-up runs after whichever pass won +/// the lease and therefore observes its result before the tool query executes. +pub fn retain_project_daemon(project_root: &Path, no_watch: bool) -> Result { + let paths = IndexPaths::resolve(project_root, std::env::var("CODEGRAPH_DIR").ok().as_deref())?; + let project_root = paths.project().to_path_buf(); + if !paths.current_root().is_dir() || !paths.current_db().is_file() { + bail!( + "refusing live services for unindexed project {}; run `codegraph init {}` first", + project_root.display(), + project_root.display() + ); + } + if let Some(reason) = codegraph_watch::too_broad_root_reason(&project_root) { + bail!( + "refusing live services for {}: {reason}", + project_root.display() + ); + } + + if !daemon_owner_live(&project_root) { + // Liveness-gated cleanup only; a live owner's rendezvous is never + // removed. Several racing sessions may all spawn children, but the + // daemon pid lock and writer.pid converge them onto exactly one owner. + let _ = crate::clear_stale_daemon_socket(&project_root); + let executable = std::env::current_exe().context("resolving codegraph executable")?; + crate::spawn_detached_daemon_for_project_service(&executable, &project_root, no_watch) + .context("starting shared daemon for explicit projectPath")?; + } + + let lease = connect_project_daemon_bounded(project_root.clone())?; + codegraph_watch::sync_project_once(&project_root).with_context(|| { + format!( + "catching up explicit projectPath {} before its first result", + project_root.display() + ) + })?; + Ok(lease) +} + +fn daemon_owner_live(project_root: &Path) -> bool { + daemon_owner_pid(project_root).is_some() +} + +/// The live daemon owner's pid recorded in the project's `daemon.pid`. +fn daemon_owner_pid(project_root: &Path) -> Option { + let pid_path = crate::daemon_pid_path(project_root).ok()?; + let raw = std::fs::read_to_string(pid_path).ok()?; + crate::decode_lock_info(&raw) + .filter(|info| info.pid > 0 && crate::is_process_alive(info.pid)) + .map(|info| info.pid) +} + +/// Cross-platform bounded connect/hello. Named-pipe reads have no portable +/// socket timeout, so the whole attempt lives on a worker and the caller waits +/// on a monotonic channel deadline. A wedged peer can strand at most one worker +/// for this failed first-access attempt; it cannot wedge the MCP request thread. +fn connect_project_daemon_bounded(project_root: PathBuf) -> Result { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let result = connect_project_daemon_until(&project_root, CONNECT_BUDGET) + .map_err(|error| format!("{error:#}")); + let _ = tx.send(result); + }); + match rx.recv_timeout(CONNECT_BUDGET + CONNECT_RESULT_MARGIN) { + Ok(Ok(lease)) => Ok(lease), + Ok(Err(error)) => bail!("{error}"), + Err(mpsc::RecvTimeoutError::Timeout) => bail!( + "shared daemon did not become ready within {:?}", + CONNECT_BUDGET + ), + Err(mpsc::RecvTimeoutError::Disconnected) => { + bail!("shared daemon connection worker ended without a result") + } + } +} + +fn connect_project_daemon_until( + project_root: &Path, + budget: Duration, +) -> Result { + let deadline = Instant::now() + budget; + let mut last_error = None; + while Instant::now() < deadline { + let socket_path = match crate::recorded_socket_path(project_root) { + Ok(path) => path, + Err(error) => { + last_error = Some(format!("resolving daemon rendezvous: {error:#}")); + std::thread::sleep(CONNECT_RETRY_INTERVAL); + continue; + } + }; + let rendezvous = Rendezvous::from_socket_path(&socket_path); + match connect(&rendezvous) { + Ok(mut stream) => { + let hello = read_daemon_hello(&mut stream).with_context(|| { + format!("reading shared daemon hello at {}", socket_path.display()) + })?; + if verify_daemon_hello(&hello).is_some() { + bail!( + "shared daemon at {} has an incompatible version/protocol: {}", + socket_path.display(), + hello + ); + } + let client_hello = json!({ "hostPid": std::process::id() }).to_string(); + writeln!(&mut stream, "{client_hello}") + .context("sending retained project-service client hello")?; + stream + .flush() + .context("flushing retained project-service client hello")?; + let db_path = IndexPaths::resolve( + project_root, + std::env::var("CODEGRAPH_DIR").ok().as_deref(), + ) + .map(|paths| paths.current_db()) + .unwrap_or_default(); + return Ok(ProjectDaemonLease { + _stream: Mutex::new(stream), + project_root: project_root.to_path_buf(), + daemon_pid: daemon_owner_pid(project_root), + db_identity: codegraph_store::PathIdentity::of(&db_path).ok(), + db_path, + }); + } + Err(error) => { + last_error = Some(format!( + "connecting to daemon socket {}: {error:#}", + socket_path.display() + )); + std::thread::sleep(CONNECT_RETRY_INTERVAL); + } + } + } + bail!( + "shared daemon was not reachable within {budget:?}: {}", + last_error.unwrap_or_else(|| "no rendezvous became available".to_string()) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unindexed_project_is_rejected_without_creating_state() { + let root = std::env::temp_dir().join(format!( + "codegraph-project-service-unindexed-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let error = retain_project_daemon(&root, false).expect_err("unindexed must fail"); + assert!(error.to_string().contains("unindexed project")); + assert!(!root.join(".codegraph").exists()); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/crates/codegraph-daemon/src/session.rs b/crates/codegraph-daemon/src/session.rs index f4707a5a..00d2559e 100644 --- a/crates/codegraph-daemon/src/session.rs +++ b/crates/codegraph-daemon/src/session.rs @@ -357,6 +357,7 @@ pub(crate) async fn serve_session_async( socket_path: String, registry: SessionRegistry, run_mcp: bool, + project_services: Option, control: Option, ) -> Result<()> { let guard = registry.start_session(); @@ -432,7 +433,11 @@ pub(crate) async fn serve_session_async( // control frame ACTIVELY ends this session (dropping the transport closes the // socket) instead of waiting for the client to disconnect. tokio::select! { - served = codegraph_mcp::rmcp_session::serve_session_rmcp_async(transport, project_root) => { + served = codegraph_mcp::rmcp_session::serve_session_rmcp_async_with_project_services( + transport, + project_root, + project_services, + ) => { served?; } _ = closing.changed() => { diff --git a/crates/codegraph-daemon/src/spawn.rs b/crates/codegraph-daemon/src/spawn.rs index 1312e660..f60d3c24 100644 --- a/crates/codegraph-daemon/src/spawn.rs +++ b/crates/codegraph-daemon/src/spawn.rs @@ -17,6 +17,12 @@ pub const CODEGRAPH_HTTP_DETACH_INTERNAL: &str = "CODEGRAPH_HTTP_DETACH_INTERNAL /// daemon crate does not need to depend on codegraph-watch just for a string. const CODEGRAPH_NO_WATCH: &str = "CODEGRAPH_NO_WATCH"; +/// Internal marker used only when a lazy explicit-project service starts a +/// daemon. That caller performs the mandatory first catch-up synchronously, so +/// the daemon must subscribe its watcher but skip its usual duplicate startup +/// catch-up pass. +pub const CODEGRAPH_SKIP_STARTUP_CATCHUP: &str = "CODEGRAPH_SKIP_STARTUP_CATCHUP"; + #[cfg(windows)] const DETACHED_PROCESS: u32 = 0x0000_0008; #[cfg(windows)] @@ -36,6 +42,26 @@ const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; /// file watcher, exactly as if the flag had been inherited — but without any /// global-env mutation in the parent. pub fn spawn_detached_daemon(exe: &Path, root: &Path, no_watch: bool) -> Result<()> { + spawn_detached_daemon_inner(exe, root, no_watch, false) +} + +/// Spawn a daemon for lazy explicit-`projectPath` services. The daemon owns the +/// watcher immediately but skips its background catch-up because the caller +/// waits for one synchronous catch-up before returning the first tool result. +pub fn spawn_detached_daemon_for_project_service( + exe: &Path, + root: &Path, + no_watch: bool, +) -> Result<()> { + spawn_detached_daemon_inner(exe, root, no_watch, true) +} + +fn spawn_detached_daemon_inner( + exe: &Path, + root: &Path, + no_watch: bool, + skip_startup_catch_up: bool, +) -> Result<()> { // Resolved once, fail-closed: the log target must be the project's own v2 // rendezvous log, never a reconstructed path. let log_path = daemon_log_path(root)?; @@ -52,6 +78,9 @@ pub fn spawn_detached_daemon(exe: &Path, root: &Path, no_watch: bool) -> Result< if no_watch { command.env(CODEGRAPH_NO_WATCH, "1"); } + if skip_startup_catch_up { + command.env(CODEGRAPH_SKIP_STARTUP_CATCHUP, "1"); + } detach(&mut command); diff --git a/crates/codegraph-daemon/src/writer_lock.rs b/crates/codegraph-daemon/src/writer_lock.rs new file mode 100644 index 00000000..6b971bc7 --- /dev/null +++ b/crates/codegraph-daemon/src/writer_lock.rs @@ -0,0 +1,374 @@ +//! Long-lived per-project writer ownership for MCP watcher/catch-up services. +//! +//! The permanent `index.lock` serializes one mutation at a time. It does not stop +//! two long-lived direct MCP processes from alternating watcher syncs forever. +//! `writer.pid` closes that lifecycle gap: one shared daemon, or one explicit +//! direct process, holds an OS-level exclusive lock for the lifetime of its +//! background writes. The kernel lock, not PID liveness, is authority. +//! +//! Diagnostics (holder pid, mode, start time) live in the sibling +//! `writer.json`, never in the locked file: Windows `LockFileEx` locks are +//! mandatory, so no other handle — not even one in the same process — could +//! read a record kept inside the locked file while its owner is alive. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use codegraph_core::IndexPaths; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WriterLockInfo { + pub pid: u32, + pub mode: String, + pub started_at: u128, +} + +#[derive(Debug)] +pub enum WriterAcquireResult { + Acquired(WriterLockGuard), + Taken { + pid_path: PathBuf, + existing: Option, + }, +} + +/// RAII ownership of the kernel-locked `writer.pid` handle. +/// +/// The path is deliberately persistent. Drop clears the diagnostic bytes while +/// still holding the kernel lock, then unlocks and closes the handle. Never +/// unlinking the authority path avoids the classic unlock/unlink/recreate ABA +/// race and lets process death release ownership automatically on every OS. +#[derive(Debug)] +pub struct WriterLockGuard { + file: File, + pid_path: PathBuf, + record_path: PathBuf, + info: WriterLockInfo, +} + +impl WriterLockGuard { + #[must_use] + pub fn path(&self) -> &Path { + &self.pid_path + } + + #[must_use] + pub fn info(&self) -> &WriterLockInfo { + &self.info + } +} + +impl Drop for WriterLockGuard { + fn drop(&mut self) { + // Remove diagnostics before releasing authority. Failure is + // best-effort: closing the file still releases the kernel lock, a + // reader only trusts the record while the lock is held, and the next + // owner replaces the record after acquiring it. + let _ = fs::remove_file(&self.record_path); + let _ = self.file.unlock(); + } +} + +pub fn writer_pid_path(project_root: &Path) -> Result { + let paths = IndexPaths::resolve(project_root, std::env::var("CODEGRAPH_DIR").ok().as_deref())?; + Ok(paths.current_root().join("writer.pid")) +} + +/// The diagnostic record beside [`writer_pid_path`]. +fn writer_record_path(pid_path: &Path) -> PathBuf { + pid_path.with_file_name("writer.json") +} + +pub fn decode_writer_lock_info(raw: &str) -> Option { + let info = serde_json::from_str::(raw.trim()).ok()?; + (info.pid > 0 && !info.mode.trim().is_empty()).then_some(info) +} + +/// Return diagnostics only when another process currently holds the kernel lock. +/// An idle persistent `writer.pid` therefore reads as `None` even if stale bytes +/// survived an unclean shutdown. +pub fn read_writer_lock(project_root: &Path) -> Option { + let path = writer_pid_path(project_root).ok()?; + let file = OpenOptions::new().read(true).write(true).open(&path).ok()?; + match file.try_lock() { + Ok(()) => { + let _ = file.unlock(); + None + } + Err(std::fs::TryLockError::WouldBlock) => read_record(&writer_record_path(&path)), + Err(std::fs::TryLockError::Error(_)) => None, + } +} + +/// Try once to claim long-lived writer ownership. The current index root must +/// already exist; this API never initializes a project or creates `.codegraph`. +pub fn try_acquire_writer_lock( + project_root: &Path, + mode: impl Into, +) -> Result { + let pid_path = writer_pid_path(project_root)?; + let parent = pid_path + .parent() + .expect("writer.pid is always inside the resolved index root"); + let parent_metadata = fs::symlink_metadata(parent) + .with_context(|| format!("opening existing index root {}", parent.display()))?; + if !parent_metadata.is_dir() || parent_metadata.file_type().is_symlink() { + bail!( + "writer lock parent is not a physical directory: {}", + parent.display() + ); + } + + // Reject a pre-existing alias/non-file before opening it. IndexPaths already + // rejects aliased parent components; this closes the final-entry case. + if let Ok(metadata) = fs::symlink_metadata(&pid_path) + && (!metadata.is_file() || metadata.file_type().is_symlink()) + { + bail!( + "writer lock path is not a regular physical file: {}", + pid_path.display() + ); + } + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&pid_path) + .with_context(|| format!("opening {}", pid_path.display()))?; + if !file + .metadata() + .with_context(|| format!("inspecting {}", pid_path.display()))? + .is_file() + { + bail!( + "writer lock handle is not a regular file: {}", + pid_path.display() + ); + } + + match file.try_lock() { + Ok(()) => { + if !codegraph_store::path_still_names_file(&pid_path, &file) + .with_context(|| format!("corroborating {}", pid_path.display()))? + { + let _ = file.unlock(); + bail!( + "writer lock path changed during acquisition: {}", + pid_path.display() + ); + } + let info = WriterLockInfo { + pid: process::id(), + mode: mode.into(), + started_at: now_millis(), + }; + let record_path = writer_record_path(&pid_path); + if let Err(error) = write_record(&record_path, &info) { + let _ = file.unlock(); + return Err(error).with_context(|| format!("publishing {}", record_path.display())); + } + Ok(WriterAcquireResult::Acquired(WriterLockGuard { + file, + pid_path, + record_path, + info, + })) + } + Err(std::fs::TryLockError::WouldBlock) => Ok(WriterAcquireResult::Taken { + existing: read_record(&writer_record_path(&pid_path)), + pid_path, + }), + Err(std::fs::TryLockError::Error(source)) => { + Err(source).with_context(|| format!("locking {}", pid_path.display())) + } + } +} + +#[must_use] +pub fn writer_lock_held_message(existing: Option<&WriterLockInfo>, pid_path: &Path) -> String { + let holder = existing.map_or_else( + || "another process".to_string(), + |info| format!("PID {} ({} mode)", info.pid, info.mode), + ); + format!( + "CodeGraph writer lock held by {holder}. Only one live MCP writer may own auto-sync for a project. Stop the other server, or unset CODEGRAPH_NO_DAEMON so additional clients proxy to the shared daemon. The kernel lock at {} releases automatically when its owner exits.", + pid_path.display() + ) +} + +/// Clear stale diagnostic bytes only when no live writer holds the kernel lock. +/// The persistent authority file itself is never removed. +pub fn clear_stale_writer_lock(project_root: &Path) -> bool { + let Ok(path) = writer_pid_path(project_root) else { + return false; + }; + let Ok(mut file) = OpenOptions::new().read(true).write(true).open(&path) else { + return false; + }; + if file.try_lock().is_err() { + return false; + } + let record_path = writer_record_path(&path); + let had_record = fs::symlink_metadata(&record_path).is_ok(); + let removed = had_record && fs::remove_file(&record_path).is_ok(); + // An older build kept the record inside the lock file itself. + let had_bytes = file.metadata().is_ok_and(|metadata| metadata.len() > 0); + let cleared = had_bytes && clear_file(&mut file).is_ok(); + let _ = file.unlock(); + removed || cleared +} + +fn read_record(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if !metadata.is_file() { + return None; + } + decode_writer_lock_info(&fs::read_to_string(path).ok()?) +} + +/// Publish the record atomically: write a fresh sibling, then rename it over +/// `writer.json` (a rename replaces a planted symlink rather than following it). +fn write_record(path: &Path, info: &WriterLockInfo) -> std::io::Result<()> { + let staging = path.with_file_name(format!( + "writer.json.{}.{}.tmp", + process::id(), + now_millis() + )); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&staging)?; + serde_json::to_writer_pretty(&mut file, info).map_err(std::io::Error::other)?; + file.write_all(b"\n")?; + file.sync_data()?; + drop(file); + fs::rename(&staging, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&staging); + } + result +} + +fn clear_file(file: &mut File) -> std::io::Result<()> { + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.sync_data() +} + +fn now_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT: AtomicU64 = AtomicU64::new(1); + + fn project(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "codegraph-writer-{label}-{}-{}", + process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(path.join(".codegraph")).unwrap(); + path + } + + #[test] + fn acquire_publish_and_drop_clear_the_active_record() { + let root = project("lifecycle"); + let guard = match try_acquire_writer_lock(&root, "direct").unwrap() { + WriterAcquireResult::Acquired(guard) => guard, + other => panic!("unexpected acquisition result: {other:?}"), + }; + assert_eq!(read_writer_lock(&root).unwrap(), *guard.info()); + assert!(guard.path().is_file()); + let path = guard.path().to_path_buf(); + drop(guard); + assert!(read_writer_lock(&root).is_none()); + assert!( + path.is_file(), + "authority path remains stable between owners" + ); + assert_eq!( + fs::metadata(&path).unwrap().len(), + 0, + "the lock file never holds the record" + ); + assert!( + !writer_record_path(&path).exists(), + "drop removes the record" + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn a_live_owner_is_never_stolen_even_in_the_same_process() { + let root = project("taken"); + let first = match try_acquire_writer_lock(&root, "daemon").unwrap() { + WriterAcquireResult::Acquired(guard) => guard, + other => panic!("unexpected acquisition result: {other:?}"), + }; + match try_acquire_writer_lock(&root, "direct").unwrap() { + WriterAcquireResult::Taken { existing, .. } => { + assert_eq!(existing.unwrap().pid, process::id()); + } + other => panic!("a second writer must be rejected: {other:?}"), + } + drop(first); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn stale_bytes_are_overwritten_after_kernel_ownership_is_free() { + let root = project("stale"); + let path = writer_pid_path(&root).unwrap(); + fs::write(&path, r#"{"pid":4294967294,"mode":"direct","startedAt":1}"#).unwrap(); + fs::write( + writer_record_path(&path), + r#"{"pid":4294967294,"mode":"direct","startedAt":1}"#, + ) + .unwrap(); + // A record is trusted only while the kernel lock is held. + assert!(read_writer_lock(&root).is_none()); + assert!(clear_stale_writer_lock(&root)); + assert!(!writer_record_path(&path).exists()); + assert_eq!(fs::metadata(&path).unwrap().len(), 0); + let guard = match try_acquire_writer_lock(&root, "daemon").unwrap() { + WriterAcquireResult::Acquired(guard) => guard, + other => panic!("free kernel lock should be acquired: {other:?}"), + }; + assert_eq!(guard.info().pid, process::id()); + assert_eq!(guard.info().mode, "daemon"); + drop(guard); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn unindexed_project_is_never_initialized_by_writer_acquisition() { + let root = std::env::temp_dir().join(format!( + "codegraph-writer-unindexed-{}-{}", + process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + assert!(try_acquire_writer_lock(&root, "direct").is_err()); + assert!(!root.join(".codegraph").exists()); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml index 7d53792e..1f0eb0c7 100644 --- a/crates/codegraph-extract/Cargo.toml +++ b/crates/codegraph-extract/Cargo.toml @@ -51,7 +51,7 @@ tree-sitter-python = "0.25.0" tree-sitter-r = "1.2.0" tree-sitter-ruby = "0.23.1" tree-sitter-rust = "0.24.2" -tree-sitter-scala = "0.26.0" +tree-sitter-scala = "=0.26.2" tree-sitter-solidity = "1.2.13" tree-sitter-swift = "0.7.3" tree-sitter-typescript = "0.23.2" diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs index 09712333..7577f918 100644 --- a/crates/codegraph-extract/src/engine.rs +++ b/crates/codegraph-extract/src/engine.rs @@ -10,7 +10,7 @@ use codegraph_core::node_id::hash_content; use codegraph_core::types::{ExtractionResult, Language}; use rayon::prelude::*; use regex::Regex; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -21,6 +21,16 @@ use crate::ext_config::ExtensionOverrides; use crate::lang::{cpp_code_mask, spec_for_language}; use crate::walker::TreeSitterWalker; +/// Stable diagnostic fragment used when a supported grammar reports parse +/// errors and extraction collapses to only the synthetic file node. +pub const PARSE_COLLAPSE_WARNING: &str = "parse produced no symbols (tree has errors)"; + +/// Parse-collapse diagnostics are warning-only: the file stays indexed and the +/// index command succeeds, but callers must surface the message honestly. +pub fn is_extraction_warning(message: &str) -> bool { + message.contains(PARSE_COLLAPSE_WARNING) +} + #[derive(Debug, Clone)] pub struct ExtractOptions { pub max_file_size: u64, @@ -350,8 +360,20 @@ pub fn extract_source_with_observer( }; }; observer(ExtractionStage::Walk); - TreeSitterWalker::new(file_path, &parsed_source, spec, tree.root_node()) - .extract(start.elapsed().as_millis() as i64) + let parse_has_error = tree.root_node().has_error(); + let mut result = TreeSitterWalker::new(file_path, &parsed_source, spec, tree.root_node()) + .extract(start.elapsed().as_millis() as i64); + if parse_has_error + && result + .nodes + .iter() + .all(|node| node.kind == codegraph_core::types::NodeKind::File) + { + result.errors.push(format!( + "{file_path}: {PARSE_COLLAPSE_WARNING} - the file is indexed but contributes nothing to the graph" + )); + } + result } /// Extract one file under the DEFAULT options (no project config). Kept for @@ -444,8 +466,22 @@ pub fn extract_project( merge_results(&mut results) } +/// Result of one source discovery walk. Unsupported extensions are collected +/// during the same traversal so an unsupported-only project can be reported +/// honestly without a second filesystem scan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanProjectResult { + pub files: Vec, + pub unsupported_by_extension: BTreeMap, +} + pub fn scan_project(root: &Path, options: &ExtractOptions) -> Result> { + Ok(scan_project_with_stats(root, options)?.files) +} + +pub fn scan_project_with_stats(root: &Path, options: &ExtractOptions) -> Result { let mut files = Vec::new(); + let mut unsupported_by_extension = BTreeMap::new(); let ignored_dirs = options .ignore_dirs .iter() @@ -473,9 +509,13 @@ pub fn scan_project(root: &Path, options: &ExtractOptions) -> Result &include, &options.extensions, &mut files, + &mut unsupported_by_extension, )?; files.sort(); - Ok(files) + Ok(ScanProjectResult { + files, + unsupported_by_extension, + }) } #[allow(clippy::too_many_arguments)] @@ -488,6 +528,7 @@ fn scan_dir( include: &IncludeSet, overrides: &ExtensionOverrides, files: &mut Vec, + unsupported_by_extension: &mut BTreeMap, ) -> Result<()> { let entries = fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))?; for entry in entries { @@ -524,21 +565,31 @@ fn scan_dir( include, overrides, files, + unsupported_by_extension, )?; - } else if file_type.is_file() && is_extractable_source_path(&relative, overrides) { + } else if file_type.is_file() { // Post-model include decision: a model-ignored file is force-included // iff it matches `include` and is NOT overridden by an explicit // `exclude` (checked inside `IncludeSet::forces`). Built-in dir skips // are already handled structurally above, so include can never // resurface node_modules/dist/.git/etc. if !ignored || include.forces(&relative) { - files.push(relative); + if is_extractable_source_path(&relative, overrides) { + files.push(relative); + } else if let Some(extension) = unsupported_extension(&relative) { + *unsupported_by_extension.entry(extension).or_default() += 1; + } } } } Ok(()) } +fn unsupported_extension(relative: &str) -> Option { + let extension = Path::new(relative).extension()?.to_str()?; + (!extension.is_empty()).then(|| format!(".{}", extension.to_ascii_lowercase())) +} + fn merge_results(results: &mut [ExtractionResult]) -> Result { let mut merged = ExtractionResult { nodes: Vec::new(), @@ -1332,6 +1383,25 @@ mod tests { fs::remove_dir_all(&project).ok(); } + #[test] + fn scan_project_reports_visible_unsupported_extensions_in_the_same_walk() { + let project = unique_project("unsupported-extensions"); + touch(&project, ".gitignore", "ignored.move\n"); + touch(&project, "a.move", "module a {}\n"); + touch(&project, "nested/b.MOVE", "module b {}\n"); + touch(&project, "tool.pl", "print 1;\n"); + touch(&project, "ignored.move", "module ignored {}\n"); + touch(&project, "README", "no extension\n"); + + let report = scan_project_with_stats(&project, &ExtractOptions::default()).expect("scan"); + assert!(report.files.is_empty()); + assert_eq!( + report.unsupported_by_extension, + BTreeMap::from([(".move".to_string(), 2), (".pl".to_string(), 1)]) + ); + fs::remove_dir_all(&project).ok(); + } + #[test] fn extract_project_parallel_path_merges_results() { let project = unique_project("extract_project_par"); @@ -1451,6 +1521,53 @@ mod tests { assert!(result.errors.is_empty(), "errors: {:?}", result.errors); } + fn cpp_raw_string(delimiter: &str) -> String { + format!( + "const char* kTemplate = R\"{delimiter}(\nstruct Ignored {{ int v; }};\n){delimiter}\";\n\nint after_the_raw_string(int x) {{\n return x + 1;\n}}\n" + ) + } + + #[test] + fn parse_collapse_warning_is_honest_and_narrow() { + let collapsed = extract_source("min.cpp", &cpp_raw_string("FILE_TEMPLATE_V1"), None); + assert_eq!( + collapsed + .nodes + .iter() + .filter(|node| node.kind != codegraph_core::types::NodeKind::File) + .count(), + 0 + ); + assert_eq!(collapsed.errors.len(), 1); + assert!(is_extraction_warning(&collapsed.errors[0])); + assert!(collapsed.errors[0].contains(PARSE_COLLAPSE_WARNING)); + + let healthy = extract_source("min.cpp", &cpp_raw_string("FILE_TEMPLATE_V"), None); + assert!( + healthy + .nodes + .iter() + .any(|node| node.name == "after_the_raw_string") + ); + assert!(healthy.errors.is_empty(), "errors={:?}", healthy.errors); + + for (path, source) in [ + ("empty.cpp", ""), + ("includes.cpp", "#include \n#include \n"), + ( + "survivor.cpp", + "int before() { return 0; }\nconst char* x = R\"FILE_TEMPLATE_V1(\nstruct Ignored {};\n)FILE_TEMPLATE_V1\";\n", + ), + ] { + let result = extract_source(path, source, None); + assert!( + result.errors.is_empty(), + "a healthy/partially surviving file must not be called a collapse: {path}: {:?}", + result.errors + ); + } + } + #[test] fn observed_extraction_reports_stages_without_changing_output() { let source = "export function answer(): number { return 42; }\n"; diff --git a/crates/codegraph-extract/src/function_ref.rs b/crates/codegraph-extract/src/function_ref.rs index 23bccd2d..205bbdf2 100644 --- a/crates/codegraph-extract/src/function_ref.rs +++ b/crates/codegraph-extract/src/function_ref.rs @@ -290,7 +290,7 @@ const SCALA_SPEC: FnRefSpec = FnRefSpec { ("val_definition", CaptureMode::VarInit, Some("value")), ], layers: &[], - unwrap: &[("postfix_expression", None)], + unwrap: &[("postfix_expression", None), ("method_value", None)], special: &[], ungated_modes: &[], address_of_only: false, @@ -808,7 +808,11 @@ fn normalize_special<'tree>( } } - // `this.handleClick` (TS/JS) (function-ref.ts:714-721). + // TS/JS method values. `this.handleClick` keeps a class-scoped + // `this.` prefix; receiver-qualified values keep the same final + // receiver segment as direct-call extraction (`api.send`, + // `this.mailer.send` -> `mailer.send`). Call-result/index receivers + // remain dynamic and are dropped (#1820). "member_expression" => { let obj = child_by_field(node, "object"); let prop = child_by_field(node, "property"); @@ -819,17 +823,37 @@ fn normalize_special<'tree>( prop, )]; } + if prop.kind() == "property_identifier" + && let Some(receiver) = method_value_receiver_segment(obj, source) + { + return vec![NormalizedRef::new( + format!("{receiver}.{}", node_text(prop, source)), + prop, + )]; + } } Vec::new() } - // `self.handle_click` (Python) (function-ref.ts:724-731). + // Python method values. A direct `self.handle_click` is class-scoped; + // `store.fetch` and `self.store.fetch` retain `store.fetch`, matching + // the representation used by direct calls so both paths choose the + // same target. Dynamic receivers remain unlinked (#1820). "attribute" => { let obj = child_by_field(node, "object"); let attr = child_by_field(node, "attribute"); if let (Some(obj), Some(attr)) = (obj, attr) { if obj.kind() == "identifier" && node_text(obj, source) == "self" { - return vec![NormalizedRef::new(node_text(attr, source), attr)]; + return vec![NormalizedRef::new( + format!("this.{}", node_text(attr, source)), + attr, + )]; + } + if let Some(receiver) = method_value_receiver_segment(obj, source) { + return vec![NormalizedRef::new( + format!("{receiver}.{}", node_text(attr, source)), + attr, + )]; } } Vec::new() @@ -948,6 +972,34 @@ fn last_named_of_type<'tree>(node: SyntaxNode<'tree>, types: &[&str]) -> Option< found } +/// Final stable receiver segment for a first-class method value. Mirrors the +/// direct-call walker's one-segment receiver encoding: a simple receiver is +/// kept verbatim; a member receiver keeps only its final property/attribute. +/// Calls, indexing, literals, and other data-flow-dependent receivers return +/// `None` rather than guessing. +fn method_value_receiver_segment(node: SyntaxNode<'_>, source: &str) -> Option { + let value = match node.kind() { + "identifier" | "simple_identifier" => node_text(node, source), + "member_expression" => { + let object = child_by_field(node, "object")?; + let property = child_by_field(node, "property")?; + let property = node_text(property, source); + if object.kind() == "this" { + format!("this.{property}") + } else { + property + } + } + "attribute" => node_text(child_by_field(node, "attribute")?, source), + _ => return None, + }; + if value.starts_with("this.") || is_plain_receiver_segment(&value) { + Some(value) + } else { + None + } +} + /// Content of a PHP string literal node. Ports `phpStringContent` /// (function-ref.ts:813-819). fn php_string_content(node: SyntaxNode<'_>, source: &str) -> Option { @@ -1037,6 +1089,15 @@ fn is_simple_name(s: &str) -> bool { } } +/// Receiver segment accepted by the direct-call extractor: a single +/// identifier that is not a self-reference. `$` is valid in JS identifiers. +fn is_plain_receiver_segment(s: &str) -> bool { + !s.is_empty() + && !matches!(s, "self" | "this" | "cls" | "super") + && s.chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '$') +} + /// `^[A-Za-z_][\w:]*$` (a qualified `Cls::method` member-pointer name). fn is_qualified_name(s: &str) -> bool { let mut chars = s.chars(); @@ -1154,6 +1215,43 @@ class Panel { ); } + #[test] + fn tsjs_preserves_receiver_qualified_method_values() { + let src = r#" +class Store { fetch(ids: number[]) { return ids; } } +class Consumer { + store = new Store(); + wire(store: Store) { register(store.fetch); } + assign() { const callback = this.store.fetch; } + listValue() { return [this.store.fetch]; } + dynamic() { register(factory().fetch); } +} +"#; + let refs = fn_refs("src/receiver-values.ts", src, Language::TypeScript); + assert_eq!( + refs.iter() + .filter(|reference| reference.reference_name == "store.fetch") + .count(), + 1, + "the parameter receiver keeps its own name: names={:?}", + names(&refs) + ); + assert_eq!( + refs.iter() + .filter(|reference| reference.reference_name == "this.store.fetch") + .count(), + 2, + "class-field assignment and array forms keep the owner marker: names={:?}", + names(&refs) + ); + assert!( + !refs + .iter() + .any(|reference| reference.reference_name == "factory.fetch"), + "dynamic call-result receivers must remain unresolved" + ); + } + #[test] fn tsjs_captures_imported_callback_but_gates_unknown_bare_id() { // Imported name survives the gate; an unknown bare identifier does not. @@ -1218,10 +1316,64 @@ class W: register("blur", handler) "#; let refs = fn_refs("src/w.py", src, Language::Python); - assert!(has_fn_ref(&refs, "on_click"), "names={:?}", names(&refs)); + assert!( + has_fn_ref(&refs, "this.on_click"), + "names={:?}", + names(&refs) + ); assert!(has_fn_ref(&refs, "handler"), "names={:?}", names(&refs)); } + #[test] + fn python_preserves_method_value_receivers() { + let src = r#" +class Store: + def fetch(self, ids): + return ids + +class Consumer: + def own_handler(self, value): + return value + + def wire(self, store): + register(self.own_handler) + register(store.fetch) + + def assign(self): + callback = self.store.fetch + + def list_value(self): + callbacks = [self.store.fetch] + + def dynamic(self): + register(factory().fetch) +"#; + let refs = fn_refs("src/receiver_values.py", src, Language::Python); + assert!( + has_fn_ref(&refs, "this.own_handler"), + "self method must retain class scope: names={:?}", + names(&refs) + ); + assert!( + has_fn_ref(&refs, "store.fetch"), + "simple receiver method must retain the receiver: names={:?}", + names(&refs) + ); + assert_eq!( + refs.iter() + .filter(|reference| reference.reference_name == "store.fetch") + .count(), + 3, + "argument, assignment, and list forms must retain their distinct source owners" + ); + assert!( + !refs + .iter() + .any(|reference| reference.reference_name == "factory.fetch"), + "dynamic call-result receivers must remain unresolved" + ); + } + #[test] fn python_captures_single_return_value_but_not_tuple_members() { let src = r#" @@ -2086,7 +2238,7 @@ object M { let lang = tree_sitter_python::LANGUAGE.into(); let names = special_names(lang, "reg(self.handler)", "attribute"); assert!( - names.contains(&"handler".to_string()), + names.contains(&"this.handler".to_string()), "self.attr; names={names:?}" ); } diff --git a/crates/codegraph-extract/src/lang/c.rs b/crates/codegraph-extract/src/lang/c.rs index b2852df8..0b20b6c9 100644 --- a/crates/codegraph-extract/src/lang/c.rs +++ b/crates/codegraph-extract/src/lang/c.rs @@ -90,7 +90,8 @@ impl LanguageSpec for CSpec { include_import(node, source) } fn pre_parse(&self, source: &str, _file_path: &str) -> String { - let blanked = blank_c_leading_attr_macros(source); + let blanked = blank_c_designated_macro_args(source); + let blanked = blank_c_leading_attr_macros(&blanked); if crate::lang::cpp::looks_like_cuda_source(&blanked) { crate::lang::cpp::blank_cuda_constructs_str(&blanked) } else { @@ -99,6 +100,75 @@ impl LanguageSpec for CSpec { } } +/// Blank only the argument bytes of a statement-level, macro-cased call that +/// contains a C designated initializer (`.field = value` or `[index] = value`). +/// tree-sitter-c otherwise recovers a trailing-comma call by extending the +/// enclosing function to EOF, swallowing later declarations (#1729). The +/// C/C++ lexical mask keeps comments, ordinary strings, and raw strings out of +/// both the candidate and designator tests; newlines and byte offsets survive. +fn blank_c_designated_macro_args(source: &str) -> String { + if !source.contains('=') || !source.contains('(') { + return source.to_string(); + } + static HEAD_RE: OnceLock = OnceLock::new(); + let head_re = HEAD_RE.get_or_init(|| { + Regex::new(r"(?m)^[ \t]*([A-Z_][A-Z0-9_]*)\s*\(").expect("C designated macro head regex") + }); + static DESIGNATOR_RE: OnceLock = OnceLock::new(); + let designator_re = DESIGNATOR_RE.get_or_init(|| { + Regex::new(r"(?:^|[,{(\s])(?:\.[A-Za-z_]\w*|\[[^\]]+\])\s*=[^=]") + .expect("C designated initializer regex") + }); + + let bytes = source.as_bytes(); + let mask = crate::lang::cpp::cpp_code_mask(source); + let mut spans = Vec::new(); + for captures in head_re.captures_iter(source) { + let whole = captures.get(0).expect("C macro match"); + let name = captures.get(1).expect("C macro name"); + if !(name.start()..whole.end()).all(|index| mask[index]) { + continue; + } + let open = whole.end() - 1; + let Some(end) = crate::lang::cpp::balanced_paren_end(bytes, &mask, open) else { + continue; + }; + let close = end - 1; + let mut after = end; + while bytes.get(after).is_some_and(u8::is_ascii_whitespace) { + after += 1; + } + if bytes.get(after) != Some(&b';') { + continue; + } + + let mut visible_args = bytes[open + 1..close].to_vec(); + for (offset, byte) in visible_args.iter_mut().enumerate() { + if !mask[open + 1 + offset] && !matches!(*byte, b'\n' | b'\r') { + *byte = b' '; + } + } + let Ok(visible_args) = std::str::from_utf8(&visible_args) else { + continue; + }; + if designator_re.is_match(visible_args) { + spans.push((open + 1, close)); + } + } + if spans.is_empty() { + return source.to_string(); + } + let mut output = bytes.to_vec(); + for (start, end) in spans { + for byte in &mut output[start..end] { + if !matches!(*byte, b'\n' | b'\r') { + *byte = b' '; + } + } + } + String::from_utf8(output).unwrap_or_else(|_| source.to_string()) +} + /// Blank an attribute macro sitting in front of a C function definition's return /// type: `SEC_ATTR UINT32 LostName(VOID) { … }` where `SEC_ATTR` is a macro /// wrapping `__attribute__((…))` (common in embedded/kernel/firmware C). @@ -162,9 +232,11 @@ fn blank_c_leading_attr_macros(source: &str) -> String { Regex::new(r"(?m)^[ \t]*([A-Za-z_]\w*)\s+[A-Za-z_]\w*[\s*]+[A-Za-z_]\w*\s*\(") .expect("c-leading-attr-macro regex") }); + let mask = crate::lang::cpp::cpp_code_mask(source); let spans: Vec<(usize, usize)> = re .captures_iter(source) .filter_map(|caps| caps.get(1)) + .filter(|matched| (matched.start()..matched.end()).all(|index| mask[index])) .filter(|m| attr_macros.contains(m.as_str())) .map(|m| (m.start(), m.end())) .collect(); diff --git a/crates/codegraph-extract/src/lang/cpp.rs b/crates/codegraph-extract/src/lang/cpp.rs index 96c32811..a837d759 100644 --- a/crates/codegraph-extract/src/lang/cpp.rs +++ b/crates/codegraph-extract/src/lang/cpp.rs @@ -103,6 +103,9 @@ impl LanguageSpec for CppSpec { fn get_return_type(&self, node: Node<'_>, source: &str) -> Option { recover_return_type(node, source) } + fn get_signature(&self, node: Node<'_>, source: &str) -> Option { + cpp_constructor_signature(node, source) + } fn get_visibility(&self, node: Node<'_>) -> Option { let parent = node.parent()?; for child in parent.children(&mut parent.walk()) { @@ -112,6 +115,9 @@ impl LanguageSpec for CppSpec { } None } + fn is_abstract(&self, node: Node<'_>, source: &str) -> bool { + is_cpp_pure_virtual_method_decl(node, source) + } fn resolve_type_alias_kind(&self, node: Node<'_>, _source: &str) -> Option { for child in node.named_children(&mut node.walk()) { if child.kind() == "enum_specifier" && child_by_field(child, "body").is_some() { @@ -141,6 +147,216 @@ impl LanguageSpec for CppSpec { } } +/// A C++ pure-virtual declaration is a `field_declaration`, not a +/// `function_definition`: its declarator unwraps to a function declarator and +/// the pure specifier is a direct `number_literal` child equal to `0`. A default +/// argument's zero is nested below the parameter list and therefore cannot +/// satisfy this test (#1727). +pub(crate) fn is_cpp_pure_virtual_method_decl(node: Node<'_>, source: &str) -> bool { + if node.kind() != "field_declaration" { + return false; + } + let Some(mut declarator) = child_by_field(node, "declarator") else { + return false; + }; + while matches!( + declarator.kind(), + "pointer_declarator" | "reference_declarator" | "parenthesized_declarator" + ) { + let Some(inner) = + child_by_field(declarator, "declarator").or_else(|| declarator.named_child(0)) + else { + return false; + }; + declarator = inner; + } + if declarator.kind() != "function_declarator" { + return false; + } + // `int (*callback)(int) = 0;` is a data member initialized to null. Its + // outer declarator is also function-shaped, but the callable declarator is + // parenthesized around a pointer; a real method names its member directly. + if child_by_field(declarator, "declarator") + .is_some_and(|inner| inner.kind() == "parenthesized_declarator") + { + return false; + } + node.named_children(&mut node.walk()) + .any(|child| child.kind() == "number_literal" && node_text(child, source).trim() == "0") +} + +/// Internal reference-name prefix for a C++ local-object constructor call. +/// +/// The extractor already records the type-level `instantiates` relationship. +/// This second, synthetic `calls` reference is deliberately namespaced so the +/// resolver can require an exact owner + accepted-arity constructor match and +/// can never fall through to an ordinary same-spelled class/function target. +pub const CPP_CONSTRUCTOR_REFERENCE_PREFIX: &str = "codegraph:cpp-constructor:"; + +/// Encode a C++ constructor call without losing the call-site arity. +pub fn cpp_constructor_reference_name(type_name: &str, arity: usize) -> String { + format!("{CPP_CONSTRUCTOR_REFERENCE_PREFIX}{type_name}#{arity}") +} + +/// Decode a constructor reference emitted by [`cpp_constructor_reference_name`]. +pub fn parse_cpp_constructor_reference_name(name: &str) -> Option<(&str, usize)> { + let encoded = name.strip_prefix(CPP_CONSTRUCTOR_REFERENCE_PREFIX)?; + let (type_name, arity) = encoded.rsplit_once('#')?; + if type_name.is_empty() || arity.is_empty() || !arity.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + Some((type_name, arity.parse().ok()?)) +} + +/// Return the accepted positional-argument range for a constructor signature. +/// +/// Constructor nodes store only their raw `parameter_list` as `signature`, so +/// resolution can distinguish overloads without reparsing source files for +/// every call. Nested delimiters and lexically-masked literals/comments do not +/// split parameters; defaults lower the minimum accepted arity. Parameter packs +/// deliberately return `None`: proving their overload semantics requires more +/// type information than the graph carries, so resolution must fail closed. +pub fn cpp_constructor_arity_range(signature: &str) -> Option<(usize, usize)> { + let signature = signature.trim(); + let inner = signature.strip_prefix('(')?.strip_suffix(')')?.trim(); + if inner.is_empty() || inner == "void" { + return Some((0, 0)); + } + if inner.contains("...") { + return None; + } + + let mask = cpp_code_mask(inner); + let bytes = inner.as_bytes(); + let mut round = 0usize; + let mut square = 0usize; + let mut brace = 0usize; + let mut angle = 0usize; + let mut start = 0usize; + let mut spans = Vec::new(); + for (index, byte) in bytes.iter().copied().enumerate() { + if !mask[index] { + continue; + } + match byte { + b'(' => round += 1, + b')' => round = round.checked_sub(1)?, + b'[' => square += 1, + b']' => square = square.checked_sub(1)?, + b'{' => brace += 1, + b'}' => brace = brace.checked_sub(1)?, + // In a parameter list, angle brackets overwhelmingly delimit a + // template type. If a default expression uses comparison operators + // and leaves this unbalanced, the final balance check rejects the + // signature rather than guessing an overload. + b'<' => angle += 1, + b'>' if angle > 0 => angle -= 1, + b',' if round == 0 && square == 0 && brace == 0 && angle == 0 => { + spans.push((start, index)); + start = index + 1; + } + _ => {} + } + } + if round != 0 || square != 0 || brace != 0 || angle != 0 { + return None; + } + spans.push((start, bytes.len())); + + let mut required = 0usize; + for (start, end) in &spans { + let parameter = inner.get(*start..*end)?.trim(); + if parameter.is_empty() { + return None; + } + if !has_top_level_default(parameter) { + required += 1; + } + } + Some((required, spans.len())) +} + +fn has_top_level_default(parameter: &str) -> bool { + let mask = cpp_code_mask(parameter); + let bytes = parameter.as_bytes(); + let mut round = 0usize; + let mut square = 0usize; + let mut brace = 0usize; + let mut angle = 0usize; + for (index, byte) in bytes.iter().copied().enumerate() { + if !mask[index] { + continue; + } + match byte { + b'(' => round += 1, + b')' => round = round.saturating_sub(1), + b'[' => square += 1, + b']' => square = square.saturating_sub(1), + b'{' => brace += 1, + b'}' => brace = brace.saturating_sub(1), + b'<' => angle += 1, + b'>' if angle > 0 => angle -= 1, + b'=' if round == 0 && square == 0 && brace == 0 && angle == 0 => { + let previous = bytes.get(index.wrapping_sub(1)).copied(); + let next = bytes.get(index + 1).copied(); + if previous != Some(b'=') && next != Some(b'=') { + return true; + } + } + _ => {} + } + } + false +} + +/// Store signatures only for actual constructors. Broadly adding C++ callable +/// signatures would churn every existing golden node; constructor overload +/// resolution needs only this narrow subset. +fn cpp_constructor_signature(node: Node<'_>, source: &str) -> Option { + let declarator = child_by_field(node, "declarator")?; + let function = find_descendant_of_kind(declarator, "function_declarator")?; + let qualified = child_by_field(function, "declarator")?; + let qualified_text = node_text(qualified, source); + let name = qualified_text.rsplit("::").next()?.trim(); + if name.is_empty() || name.starts_with('~') { + return None; + } + + let receiver_matches = qualified_text + .rsplit_once("::") + .is_some_and(|(receiver, _)| { + strip_cpp_template_args(receiver) + .rsplit("::") + .next() + .is_some_and(|owner| owner.trim() == name) + }); + let inline_owner_matches = nearest_cpp_class_name(node, source).as_deref() == Some(name); + if !receiver_matches && !inline_owner_matches { + return None; + } + let parameters = child_by_field(function, "parameters")?; + Some(node_text(parameters, source)) +} + +fn nearest_cpp_class_name(mut node: Node<'_>, source: &str) -> Option { + while let Some(parent) = node.parent() { + if matches!(parent.kind(), "class_specifier" | "struct_specifier") { + return child_by_field(parent, "name").map(|name| node_text(name, source)); + } + node = parent; + } + None +} + +fn find_descendant_of_kind<'tree>(node: Node<'tree>, kind: &str) -> Option> { + if node.kind() == kind { + return Some(node); + } + node.named_children(&mut node.walk()) + .find_map(|child| find_descendant_of_kind(child, kind)) +} + /// Offset-preserving C++ pre-parse: blank heavily-reflected Unreal-Engine markup /// (member-level `*_API` prefixes, line-leading no-semicolon annotation macros, /// mid-line `UMETA`/`UPARAM`/`UE_DEPRECATED`) so the enclosing class parses @@ -186,7 +402,12 @@ fn blank_metal_attributes(bytes: Vec) -> Vec { ) .expect("metal-attribute regex") }); - let spans: Vec<(usize, usize)> = re.find_iter(source).map(|m| (m.start(), m.end())).collect(); + let mask = cpp_code_mask(source); + let spans: Vec<(usize, usize)> = re + .find_iter(source) + .filter(|m| (m.start()..m.end()).all(|index| mask[index])) + .map(|m| (m.start(), m.end())) + .collect(); let mut bytes = bytes; for (start, end) in spans { blank_span(&mut bytes, start, end); @@ -210,6 +431,7 @@ fn blank_cuda_constructs(bytes: Vec) -> Vec { Ok(source) => source, Err(_) => return bytes, }; + let mask = cpp_code_mask(source); let mut spans: Vec<(usize, usize)> = Vec::new(); if source.contains("__") { static BOUNDS_RE: OnceLock = OnceLock::new(); @@ -223,15 +445,25 @@ fn blank_cuda_constructs(bytes: Vec) -> Vec { ) .expect("specifier regex") }); - spans.extend(bounds_re.find_iter(source).map(|m| (m.start(), m.end()))); - spans.extend(spec_re.find_iter(source).map(|m| (m.start(), m.end()))); + spans.extend( + bounds_re + .find_iter(source) + .filter(|m| (m.start()..m.end()).all(|index| mask[index])) + .map(|m| (m.start(), m.end())), + ); + spans.extend( + spec_re + .find_iter(source) + .filter(|m| (m.start()..m.end()).all(|index| mask[index])) + .map(|m| (m.start(), m.end())), + ); } if source.contains("<<<") { static LAUNCH_RE: OnceLock = OnceLock::new(); let launch_re = LAUNCH_RE.get_or_init(|| Regex::new(r"<<<[^;]{0,400}?>>>").expect("launch regex")); for m in launch_re.find_iter(source) { - if is_brace_balanced(m.as_str()) { + if (m.start()..m.end()).all(|index| mask[index]) && is_brace_balanced(m.as_str()) { spans.push((m.start(), m.end())); } } @@ -286,25 +518,23 @@ fn blank_span(bytes: &mut [u8], start: usize, end: usize) { } } -/// Scan a balanced `(...)` from `open` (the index of the `(`), skipping string -/// and char literals so an embedded `)` cannot mis-close. All delimiters are -/// ASCII and UTF-8 continuation bytes never match them, so a byte scan is safe. -/// Returns the index just past the closing `)`, or `None` if unbalanced. -fn balanced_paren_end(bytes: &[u8], open: usize) -> Option { +/// Scan a balanced `(...)` from `open`, counting parentheses only where the +/// C/C++ lexical mask says the byte is code. Comments, ordinary literals, raw +/// strings (including custom delimiters), and directives therefore cannot +/// prematurely close or deepen a real annotation macro argument list (#1505). +pub(crate) fn balanced_paren_end(bytes: &[u8], mask: &[bool], open: usize) -> Option { + if bytes.len() != mask.len() || bytes.get(open) != Some(&b'(') || !mask[open] { + return None; + } let mut depth = 0i32; let mut i = open; while i < bytes.len() { - let c = bytes[i]; - if c == b'"' || c == b'\'' { - let quote = c; + if !mask[i] { i += 1; - while i < bytes.len() && bytes[i] != quote { - if bytes[i] == b'\\' { - i += 1; - } - i += 1; - } - } else if c == b'(' { + continue; + } + let c = bytes[i]; + if c == b'(' { depth += 1; } else if c == b')' { depth -= 1; @@ -334,9 +564,13 @@ fn blank_cpp_api_prefix_macros(bytes: Vec) -> Vec { let re = RE.get_or_init(|| { Regex::new(r"\b[A-Z][A-Z0-9_]*(?:_API|_EXPORT|_ABI)\b").expect("api-prefix regex") }); + let mask = cpp_code_mask(source); let spans: Vec<(usize, usize)> = re .find_iter(source) .filter(|m| { + if !(m.start()..m.end()).all(|index| mask[index]) { + return false; + } let mut saw_space = false; for c in source[m.end()..].chars() { if c.is_whitespace() { @@ -373,10 +607,15 @@ fn blank_cpp_inline_annotation_macros(bytes: Vec) -> Vec { let re = RE.get_or_init(|| { Regex::new(r"\b(?:UMETA|UPARAM|UE_DEPRECATED\w*)\s*\(").expect("inline-annotation regex") }); + let mask = cpp_code_mask(source); let mut spans: Vec<(usize, usize)> = Vec::new(); let mut search_from = 0usize; while let Some(m) = re.find_at(source, search_from) { - match balanced_paren_end(&bytes, m.end() - 1) { + if !(m.start()..m.end()).all(|index| mask[index]) { + search_from = m.end(); + continue; + } + match balanced_paren_end(&bytes, &mask, m.end() - 1) { Some(end) => { spans.push((m.start(), end)); search_from = end; @@ -413,12 +652,17 @@ fn blank_cpp_annotation_macro_calls(bytes: Vec) -> Vec { let re = RE.get_or_init(|| { Regex::new(r"(?m)^([ \t]*)([A-Z][A-Z0-9_]{2,})(\s*)\(").expect("annotation-call regex") }); + let mask = cpp_code_mask(source); let mut spans: Vec<(usize, usize)> = Vec::new(); let mut search_from = 0usize; while let Some(caps) = re.captures_at(source, search_from) { let whole = caps.get(0).expect("match 0"); let indent_len = caps.get(1).map_or(0, |g| g.as_str().len()); - let end = match balanced_paren_end(&bytes, whole.end() - 1) { + if !(whole.start() + indent_len..whole.end()).all(|index| mask[index]) { + search_from = whole.end(); + continue; + } + let end = match balanced_paren_end(&bytes, &mask, whole.end() - 1) { Some(end) => end, None => { search_from = whole.end(); diff --git a/crates/codegraph-extract/src/lang/dart.rs b/crates/codegraph-extract/src/lang/dart.rs index 6e334f10..2ba7489b 100644 --- a/crates/codegraph-extract/src/lang/dart.rs +++ b/crates/codegraph-extract/src/lang/dart.rs @@ -70,7 +70,11 @@ impl LanguageSpec for DartSpec { } fn extra_class_node_types(&self) -> &'static [&'static str] { - &["mixin_declaration", "extension_declaration"] + &[ + "mixin_declaration", + "extension_declaration", + "extension_type_declaration", + ] } fn name_field(&self) -> &'static str { @@ -315,6 +319,7 @@ fn dart_enclosing_type_name(node: Node<'_>, source: &str) -> Option { | "class_declaration" | "mixin_declaration" | "extension_declaration" + | "extension_type_declaration" | "enum_declaration" ) { return child_by_field(current, "name").map(|name| node_text(name, source)); diff --git a/crates/codegraph-extract/src/lang/javascript.rs b/crates/codegraph-extract/src/lang/javascript.rs index e638c098..97baaf26 100644 --- a/crates/codegraph-extract/src/lang/javascript.rs +++ b/crates/codegraph-extract/src/lang/javascript.rs @@ -24,8 +24,10 @@ impl LanguageSpec for JavaScriptSpec { fn function_types(&self) -> &'static [&'static str] { &[ "function_declaration", + "generator_function_declaration", "arrow_function", "function_expression", + "generator_function", ] } @@ -139,14 +141,20 @@ pub(crate) fn resolve_class_field_body<'tree>( ) -> Option> { for i in 0..node.named_child_count() { let child = node.named_child(i as u32)?; - if child.kind() == "arrow_function" || child.kind() == "function_expression" { + if matches!( + child.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { return child_by_field(child, body_field); } if child.kind() == "call_expression" { if let Some(args) = child_by_field(child, "arguments") { for j in 0..args.named_child_count() { if let Some(arg) = args.named_child(j as u32) { - if arg.kind() == "arrow_function" || arg.kind() == "function_expression" { + if matches!( + arg.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { return child_by_field(arg, body_field); } } diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index 73bceb1f..fb7a44b2 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -43,10 +43,13 @@ pub use cfml::CFML_SPEC; pub(crate) use cfml::{ cfml_component_name_from_path, cfml_string_attr_value, cfml_tag_attr, is_bare_script_cfml, }; -pub use cpp::CPP_SPEC; +pub use cpp::{ + CPP_CONSTRUCTOR_REFERENCE_PREFIX, CPP_SPEC, cpp_constructor_arity_range, + cpp_constructor_reference_name, parse_cpp_constructor_reference_name, +}; pub(crate) use cpp::{ ExplicitOperatorCall, ExportMacroClass, cpp_code_mask, detect_export_macro_class, - recover_explicit_operator_call, + is_cpp_pure_virtual_method_decl, recover_explicit_operator_call, }; pub use csharp::CSHARP_SPEC; pub use dart::DART_SPEC; @@ -80,6 +83,7 @@ pub use ruby::RUBY_SPEC; pub use rust::RUST_SPEC; pub(crate) use rust::rust_impl_type_name; pub use scala::SCALA_SPEC; +pub(crate) use scala::scala_base_type_name; pub use solidity::SOLIDITY_SPEC; pub use swift::SWIFT_SPEC; pub use terraform::TERRAFORM_SPEC; diff --git a/crates/codegraph-extract/src/lang/scala.rs b/crates/codegraph-extract/src/lang/scala.rs index 066b729e..ef62a16a 100644 --- a/crates/codegraph-extract/src/lang/scala.rs +++ b/crates/codegraph-extract/src/lang/scala.rs @@ -93,10 +93,13 @@ impl LanguageSpec for ScalaSpec { } fn classify_class_node(&self, node: Node<'_>) -> NodeKind { - if node.kind() == "trait_definition" { - NodeKind::Trait - } else { - NodeKind::Class + match node.kind() { + "trait_definition" => NodeKind::Trait, + // A Scala `object` is a singleton value, not a type. Keeping it + // distinct from a same-named companion trait/class lets inheritance + // resolution prefer the real type deterministically (#1824). + "object_definition" => NodeKind::Module, + _ => NodeKind::Class, } } @@ -171,6 +174,38 @@ impl LanguageSpec for ScalaSpec { } } +/// Resolve a Scala type subtree to the bare name used by inheritance matching. +/// +/// `extends A[X] with pkg.B` packs every parent into one `extends_clause`. +/// Unwrap generic bases and keep only the final segment of stable qualified +/// identifiers, matching the language's ordinary type-name resolution. +pub(crate) fn scala_base_type_name(node: Node<'_>, source: &str) -> Option { + match node.kind() { + "type_identifier" | "identifier" => Some(node_text(node, source)), + "generic_type" => node + .named_child(0) + .and_then(|child| scala_base_type_name(child, source)), + "stable_type_identifier" | "stable_identifier" => node + .named_children(&mut node.walk()) + .filter(|child| matches!(child.kind(), "type_identifier" | "identifier")) + .last() + .map(|child| node_text(child, source)), + _ => first_descendant_type_identifier(node).map(|identifier| node_text(identifier, source)), + } +} + +fn first_descendant_type_identifier(node: Node<'_>) -> Option> { + for child in node.named_children(&mut node.walk()) { + if child.kind() == "type_identifier" { + return Some(child); + } + if let Some(found) = first_descendant_type_identifier(child) { + return Some(found); + } + } + None +} + fn strip_bracket_generics(raw: &str) -> String { let mut out = String::new(); let mut depth = 0usize; diff --git a/crates/codegraph-extract/src/lang/typescript.rs b/crates/codegraph-extract/src/lang/typescript.rs index 0d2678dc..33ba6121 100644 --- a/crates/codegraph-extract/src/lang/typescript.rs +++ b/crates/codegraph-extract/src/lang/typescript.rs @@ -24,8 +24,10 @@ impl LanguageSpec for TypeScriptSpec { fn function_types(&self) -> &'static [&'static str] { &[ "function_declaration", + "generator_function_declaration", "arrow_function", "function_expression", + "generator_function", ] } @@ -34,7 +36,15 @@ impl LanguageSpec for TypeScriptSpec { } fn method_types(&self) -> &'static [&'static str] { - &["method_definition", "public_field_definition"] + &[ + "method_definition", + "public_field_definition", + "method_signature", + ] + } + + fn property_types(&self) -> &'static [&'static str] { + &["property_signature"] } fn class_member_is_method(&self, node: Node<'_>, _source: &str) -> bool { @@ -96,15 +106,20 @@ impl LanguageSpec for TypeScriptSpec { for i in 0..node.named_child_count() { let child = node.named_child(i as u32)?; - if child.kind() == "arrow_function" || child.kind() == "function_expression" { + if matches!( + child.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { return child_by_field(child, body_field); } if child.kind() == "call_expression" { if let Some(args) = child_by_field(child, "arguments") { for j in 0..args.named_child_count() { if let Some(arg) = args.named_child(j as u32) { - if arg.kind() == "arrow_function" || arg.kind() == "function_expression" - { + if matches!( + arg.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { return child_by_field(arg, body_field); } } @@ -199,14 +214,20 @@ pub(crate) fn class_field_is_callable(node: Node<'_>, field_kind: &str) -> bool let Some(child) = node.named_child(i as u32) else { continue; }; - if child.kind() == "arrow_function" || child.kind() == "function_expression" { + if matches!( + child.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { return true; } if child.kind() == "call_expression" { if let Some(args) = child_by_field(child, "arguments") { for j in 0..args.named_child_count() { if let Some(arg) = args.named_child(j as u32) { - if arg.kind() == "arrow_function" || arg.kind() == "function_expression" { + if matches!( + arg.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { return true; } } diff --git a/crates/codegraph-extract/src/lib.rs b/crates/codegraph-extract/src/lib.rs index 9b3c5d80..aa9d2b83 100644 --- a/crates/codegraph-extract/src/lib.rs +++ b/crates/codegraph-extract/src/lib.rs @@ -17,8 +17,9 @@ pub mod spec; pub mod walker; pub use engine::{ - ExtractOptions, ExtractionStage, detect_language, detect_language_with, extract_file, - extract_file_with_options, extract_file_with_options_observer, extract_project, extract_source, - extract_source_with, extract_source_with_observer, include_exclude_pattern_matches, + ExtractOptions, ExtractionStage, PARSE_COLLAPSE_WARNING, detect_language, detect_language_with, + extract_file, extract_file_with_options, extract_file_with_options_observer, extract_project, + extract_source, extract_source_with, extract_source_with_observer, + include_exclude_pattern_matches, is_extraction_warning, }; pub use ext_config::ExtensionOverrides; diff --git a/crates/codegraph-extract/src/spec.rs b/crates/codegraph-extract/src/spec.rs index 033a62c2..4c5fd79b 100644 --- a/crates/codegraph-extract/src/spec.rs +++ b/crates/codegraph-extract/src/spec.rs @@ -134,6 +134,13 @@ pub trait LanguageSpec: Sync { false } + /// Whether a callable declaration is abstract. C++ uses this for pure + /// virtual methods (`virtual int read() = 0;`); other languages inherit the + /// concrete default. + fn is_abstract(&self, _node: Node<'_>, _source: &str) -> bool { + false + } + fn is_const(&self, _node: Node<'_>) -> bool { false } diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 28af4314..815b8839 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -146,6 +146,14 @@ fn is_plain_receiver_segment(segment: &str) -> bool { .all(|c| c.is_alphanumeric() || c == '_' || c == '$') } +fn is_js_identifier(segment: &str) -> bool { + let mut chars = segment.chars(); + chars + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(ch, '_' | '$')) + && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) +} + /// Deterministic logical recursion bound for the native AST walker. /// /// Tree-sitter parses deeply nested input iteratively, but this walker descends @@ -353,7 +361,11 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } else if has_type(self.spec.extra_class_node_types(), node_type) { self.extract_class(node, NodeKind::Class); skip_children = true; - } else if has_type(self.spec.method_types(), node_type) { + } else if has_type(self.spec.method_types(), node_type) + && !(matches!(self.spec.language(), Language::TypeScript | Language::Tsx) + && node_type == "method_signature" + && !self.is_inside_class_like_node()) + { if self.spec.class_member_is_method(node, self.source) { self.extract_method(node); } else { @@ -1383,6 +1395,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } fn visit_cpp_node(&mut self, node: SyntaxNode<'tree>) -> bool { + if crate::lang::is_cpp_pure_virtual_method_decl(node, self.source) { + self.extract_method(node); + return true; + } if node.kind() != "namespace_definition" { return false; } @@ -2292,14 +2308,23 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { return; } let mut name = name_override.unwrap_or_else(|| self.extract_name(node)); + let mut common_js_export = false; if name == "" - && (node.kind() == "arrow_function" || node.kind() == "function_expression") + && matches!( + node.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { if let Some(parent) = node.parent() { if parent.kind() == "variable_declarator" { if let Some(var_name) = child_by_field(parent, "name") { name = node_text(var_name, self.source); } + } else if parent.kind() == "assignment_expression" { + if let Some(export_name) = self.common_js_export_name(parent, node) { + name = export_name; + common_js_export = true; + } } } } @@ -2324,7 +2349,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { docstring: self.preceding_docstring(node), signature: self.spec.get_signature(node, self.source), visibility: self.spec.get_visibility(node), - is_exported: self.spec.is_exported(node, self.source), + is_exported: common_js_export || self.spec.is_exported(node, self.source), is_async: self.spec.is_async(node), is_static: self.spec.is_static(node, self.source), return_type: self.spec.get_return_type(node, self.source), @@ -2341,6 +2366,142 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { self.node_stack.pop(); } + /// The property a CommonJS export assignment binds a function to — + /// `exports.NAME = ` / `module.exports.NAME = ` — or `None`. + /// The callable must be the assignment's complete right-hand side. + fn common_js_export_name( + &self, + assignment: SyntaxNode<'tree>, + value: SyntaxNode<'tree>, + ) -> Option { + if !matches!( + self.spec.language(), + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) { + return None; + } + let right = child_by_field(assignment, "right")?; + if right.start_byte() != value.start_byte() || right.end_byte() != value.end_byte() { + return None; + } + let left = child_by_field(assignment, "left")?; + if left.kind() != "member_expression" { + return None; + } + let object = child_by_field(left, "object")?; + let property = child_by_field(left, "property")?; + if property.kind() != "property_identifier" { + return None; + } + if !matches!( + node_text(object, self.source).as_str(), + "exports" | "module.exports" + ) { + return None; + } + Some(node_text(property, self.source)) + } + + /// Whether an anonymous JS-family callable is the complete value of a + /// plain-identifier variable declarator (`const NAME = () => { ... }`). + fn declarator_bound_function(&self, node: SyntaxNode<'tree>) -> bool { + if !matches!( + self.spec.language(), + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) || !matches!(node.kind(), "arrow_function" | "function_expression") + { + return false; + } + let Some(declarator) = node.parent() else { + return false; + }; + if declarator.kind() != "variable_declarator" { + return false; + } + let Some(value) = child_by_field(declarator, "value") else { + return false; + }; + if value.start_byte() != node.start_byte() || value.end_byte() != node.end_byte() { + return false; + } + child_by_field(declarator, "name").is_some_and(|name| name.kind() == "identifier") + } + + /// The declarator name a supported React handler hook binds an anonymous + /// callable to. The callable must be the hook's first argument and the call + /// must be the declarator's direct value. + fn react_hook_bound_name(&self, node: SyntaxNode<'tree>) -> Option { + if !matches!( + self.spec.language(), + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) || !matches!(node.kind(), "arrow_function" | "function_expression") + { + return None; + } + let args = node.parent()?; + if args.kind() != "arguments" { + return None; + } + let first = args.named_child(0)?; + if first.start_byte() != node.start_byte() || first.end_byte() != node.end_byte() { + return None; + } + let call = args.parent()?; + if call.kind() != "call_expression" { + return None; + } + let callee = child_by_field(call, "function")?; + let callee_text = node_text(callee, self.source); + let hook = callee_text + .strip_prefix("React.") + .unwrap_or(callee_text.as_str()); + if !matches!(hook, "useCallback" | "useEffectEvent" | "useEvent") { + return None; + } + let declarator = call.parent()?; + if declarator.kind() != "variable_declarator" { + return None; + } + let name = child_by_field(declarator, "name")?; + (name.kind() == "identifier").then(|| node_text(name, self.source)) + } + + /// The declarator name for a function passed to the second application of + /// a curried wrapper (`const NAME = factory(...)(function () { ... })`). + /// Requiring the callee itself to be a call keeps single-call computations + /// such as `useMemo` and `array.map` anonymous. + fn curried_wrapper_bound_name(&self, node: SyntaxNode<'tree>) -> Option { + if !matches!( + self.spec.language(), + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) || !matches!( + node.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { + return None; + } + let args = node.parent()?; + if args.kind() != "arguments" { + return None; + } + let first = args.named_child(0)?; + if first.start_byte() != node.start_byte() || first.end_byte() != node.end_byte() { + return None; + } + let call = args.parent()?; + if call.kind() != "call_expression" + || child_by_field(call, "function")?.kind() != "call_expression" + { + return None; + } + let declarator = call.parent()?; + if declarator.kind() != "variable_declarator" { + return None; + } + let name = child_by_field(declarator, "name")?; + (name.kind() == "identifier").then(|| node_text(name, self.source)) + } + // Upstream tree-sitter.ts:389-404: classifyClassNode routes a classTypes node // to the struct/enum/interface/trait/class extractor. fn extract_classified_class(&mut self, node: SyntaxNode<'tree>) { @@ -2349,6 +2510,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { NodeKind::Enum => self.extract_enum(node), NodeKind::Interface => self.extract_interface(node), NodeKind::Trait => self.extract_class(node, NodeKind::Trait), + NodeKind::Module => self.extract_class(node, NodeKind::Module), _ => self.extract_class(node, NodeKind::Class), } } @@ -2465,6 +2627,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { visibility: self.spec.get_visibility(node), is_async: self.spec.is_async(node), is_static: self.spec.is_static(node, self.source), + is_abstract: self.spec.is_abstract(node, self.source), return_type: self.spec.get_return_type(node, self.source), qualified_name: receiver_type .map(|receiver| self.compose_receiver_qualified_name(&receiver, &name)), @@ -2769,7 +2932,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } let value_node = child_by_field(child, "value"); if let Some(value) = value_node { - if value.kind() == "arrow_function" || value.kind() == "function_expression" { + if matches!( + value.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { self.extract_function(value, None); continue; } @@ -2843,7 +3009,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { let Some(value) = child_by_field(member, "value") else { continue; }; - if !matches!(value.kind(), "arrow_function" | "function_expression") { + if !matches!( + value.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { continue; } let Some(key) = @@ -3526,6 +3695,30 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } else { method_name }; + } else if receiver.kind() == "call_expression" + && matches!( + self.spec.language(), + Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + ) + { + // Keep a simple inner callee so resolution can + // reject an untyped call-result receiver instead of + // degrading it to a bare same-named callable. + let Some(inner) = self.plain_js_inner_callee(receiver) else { + return; + }; + callee_name = format!("{inner}().{method_name}"); + } else if self.spec.language() == Language::Rust + && receiver.kind() == "self" + { + // `self.method()` has an exact, statically known + // receiver: the type owning the enclosing method. + // Preserve it so resolution cannot substitute a + // nearer same-named method from another impl. + callee_name = format!("self.{method_name}"); } else if self.spec.language() == Language::Rust && receiver.kind() == "field_expression" { @@ -3548,6 +3741,18 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } _ => method_name, }; + } else if matches!( + self.spec.language(), + Language::TypeScript + | Language::Tsx + | Language::JavaScript + | Language::Jsx + ) && let Some(field) = self.js_this_field_of(receiver) + { + // `this.field.method()` is an exclusive typed-field + // path; keeping `this` prevents name heuristics from + // guessing an unrelated method. + callee_name = format!("this.{field}.{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 @@ -3601,6 +3806,30 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } + fn js_this_field_of(&self, receiver: SyntaxNode<'tree>) -> Option { + if receiver.kind() != "member_expression" { + return None; + } + let object = child_by_field(receiver, "object")?; + let property = child_by_field(receiver, "property")?; + if object.kind() != "this" || property.kind() != "property_identifier" { + return None; + } + Some(node_text(property, self.source)) + } + + fn plain_js_inner_callee(&self, call: SyntaxNode<'tree>) -> Option { + let inner = child_by_field(call, "function")?; + let text = node_text(inner, self.source) + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::(); + if text.is_empty() || !text.split('.').all(is_js_identifier) { + return None; + } + Some(text) + } + /// Encode a Java/Kotlin/PHP `object`/`scope` + `name` call into a resolver /// reference (tree-sitter.ts:2578-2663). Handles the PHP `Cls::for()->m()` /// and Java `Foo.factory().m()` chain re-encodings (`().`), @@ -3723,23 +3952,62 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { let Some(type_node) = child_by_field(node, "type") else { return; }; - if type_node.kind() != "type_identifier" { - return; - } - let has_construction = node.named_children(&mut node.walk()).any(|child| { - child.kind() == "init_declarator" - && child - .named_children(&mut child.walk()) - .any(|grand| matches!(grand.kind(), "argument_list" | "initializer_list")) - }); - if !has_construction { + if !matches!( + type_node.kind(), + "type_identifier" | "qualified_identifier" | "template_type" + ) { return; } let Some(from_id) = self.node_stack.last().cloned() else { return; }; - let class_name = node_text(type_node, self.source).trim().to_string(); - if !class_name.is_empty() { + let class_name = strip_cpp_template_args(node_text(type_node, self.source).trim()); + if class_name.is_empty() { + return; + } + + let caller_is_callable = self.nodes.iter().any(|candidate| { + candidate.id == from_id + && matches!(candidate.kind, NodeKind::Function | NodeKind::Method) + }); + let mut has_explicit_construction = false; + for declarator in node.named_children(&mut node.walk()) { + let (arity, anchor, explicit) = match declarator.kind() { + // `Type object;` default-initializes an object. A pointer, + // reference, array, or most-vexing `Type object();` has a + // different direct declarator kind and is intentionally skipped. + "identifier" | "field_identifier" => (Some(0), declarator, false), + "init_declarator" => { + let Some(value) = child_by_field(declarator, "value") else { + continue; + }; + if matches!(value.kind(), "argument_list" | "initializer_list") { + (Some(value.named_child_count()), value, true) + } else { + // Copy-initialization and nested temporary expressions + // require conversion/type analysis; their own call/new + // expression remains handled by the ordinary walker. + (None, value, false) + } + } + _ => (None, declarator, false), + }; + has_explicit_construction |= explicit; + if self.spec.language() == Language::Cpp + && caller_is_callable + && let Some(arity) = arity + { + let reference_name = + crate::lang::cpp_constructor_reference_name(&class_name, arity); + self.push_ref(&from_id, &reference_name, EdgeKind::Calls, anchor); + } + } + + // Preserve the existing type-level construction relationship exactly: + // only direct/brace initialization emitted `instantiates` before this + // constructor-call fix. Calls are resolved independently to a uniquely + // proven constructor method. + if has_explicit_construction { self.push_ref(&from_id, &class_name, EdgeKind::Instantiates, type_node); } } @@ -3813,6 +4081,18 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { self.extract_function(node, None); return; } + if let Some(bound) = self.react_hook_bound_name(node) { + self.extract_function(node, Some(bound)); + return; + } + if let Some(bound) = self.curried_wrapper_bound_name(node) { + self.extract_function(node, Some(bound)); + return; + } + if self.declarator_bound_function(node) { + self.extract_function(node, None); + return; + } } if has_type(self.spec.class_types(), node_type) { @@ -3841,7 +4121,16 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { child.kind(), "extends_clause" | "superclass" | "extends_interfaces" ) { - if let Some(target) = child.named_child(0) { + // Scala keeps `extends A[X] with B with C` in one clause. Walk + // every parent and normalize generic/qualified type shapes; + // the generic first-child fallback loses all `with` mixins. + if self.spec.language() == Language::Scala && child.kind() == "extends_clause" { + for target in child.named_children(&mut child.walk()) { + if let Some(name) = crate::lang::scala_base_type_name(target, self.source) { + self.push_ref(class_id, &name, EdgeKind::Extends, target); + } + } + } else if let Some(target) = child.named_child(0) { self.push_ref( class_id, &node_text(target, self.source), @@ -4136,7 +4425,10 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } } } - if node.kind() == "arrow_function" || node.kind() == "function_expression" { + if matches!( + node.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { return "".to_string(); } for child in node.named_children(&mut node.walk()) { @@ -4265,11 +4557,13 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { } /// Gate captured function-value candidates and push survivors as - /// `function_ref` references. `this.` and `::` candidates always - /// flush. C-family file-scope candidates in an ungated mode bypass the name - /// gate, as do `skip_gate` candidates (PHP HOF strings). Every other bare - /// name must be a function/method defined in this file, a Python class - /// defined in this file, or an imported name. + /// `function_ref` references. Receiver-qualified (`obj.method`), + /// `this.`, and `::` candidates always flush: their target is gated + /// later through the ordinary precise method resolver. C-family file-scope + /// candidates in an ungated mode bypass the name gate, as do `skip_gate` + /// candidates (PHP HOF strings). Every other bare name must be a + /// function/method defined in this file, a Python class defined in this + /// file, or an imported name. /// Ports `flushFnRefCandidates` (tree-sitter.ts:429-521) from the upstream /// TS/JS gate, adapted here across languages. fn flush_fn_ref_candidates(&mut self) { @@ -4330,11 +4624,11 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { { continue; } - // Gate by candidate shape: `this.`/`::` always flush; C-family - // file-scope initializers (ungated_modes) skip; PHP HOF strings - // (skip_gate) skip; everything else must be a same-file fn/method, - // a same-file Python class, or an import (tree-sitter.ts:493-512). - if !cand.name.starts_with("this.") && !cand.name.contains("::") { + // Gate by candidate shape: receiver-qualified / `this.` / `::` + // always flush; C-family file-scope initializers (ungated_modes) + // skip; PHP HOF strings (skip_gate) skip; everything else must be a + // same-file fn/method, a same-file Python class, or an import. + if !cand.name.contains('.') && !cand.name.contains("::") { let skip_gate = (at_file_scope && crate::function_ref::mode_is_ungated(spec, cand.mode)) || cand.skip_gate; @@ -6863,6 +7157,64 @@ void f() { ); } + #[test] + fn cpp_local_initialization_records_arity_scoped_constructor_calls() { + let src = r#" +class Widget { +public: + Widget() {} + Widget(int value) {} +}; +void build() { + Widget plain; + Widget braced{}; + Widget value(1); + Widget vexing(); + Widget *pointer; +} +"#; + let (nodes, refs) = run("constructors.cpp", src, Language::Cpp); + let constructors = nodes + .iter() + .filter(|node| node.kind == NodeKind::Method && node.name == "Widget") + .collect::>(); + assert_eq!(constructors.len(), 2); + assert_eq!(constructors[0].signature.as_deref(), Some("()")); + assert_eq!(constructors[1].signature.as_deref(), Some("(int value)")); + + let calls = refs + .iter() + .filter(|reference| reference.reference_kind == EdgeKind::Calls) + .map(|reference| reference.reference_name.as_str()) + .collect::>(); + assert_eq!( + calls, + vec![ + "codegraph:cpp-constructor:Widget#0", + "codegraph:cpp-constructor:Widget#0", + "codegraph:cpp-constructor:Widget#1", + ], + "most-vexing declarations and pointer declarations are not object construction" + ); + } + + #[test] + fn cpp_constructor_arity_range_handles_defaults_and_nested_types() { + use crate::lang::cpp_constructor_arity_range; + + assert_eq!(cpp_constructor_arity_range("()"), Some((0, 0))); + assert_eq!(cpp_constructor_arity_range("(void)"), Some((0, 0))); + assert_eq!( + cpp_constructor_arity_range("(std::pair value, int flags = 0)"), + Some((1, 2)) + ); + assert_eq!( + cpp_constructor_arity_range("(void (*callback)(int, int), const char *text = \"a,b\")"), + Some((1, 2)) + ); + assert_eq!(cpp_constructor_arity_range("(Args&&... args)"), None); + } + #[test] fn cpp_heap_new_still_instantiates() { // Guard: the existing new_expression path is unchanged. @@ -8313,17 +8665,17 @@ go() -> codec:decode(<<1,2,3>>, []).\n"; #[test] fn member_receiver_chain_keeps_last_segment() { - // `this.mailer.send(m)` must record `mailer.send`, not the bare `send`: - // the receiver's LAST segment is what lets resolution reach `Mailer::send` - // instead of proximity-picking the enclosing `Outbox::send`. + // `this.mailer.send(m)` keeps the explicit owner-field shape so + // resolution uses the field's declared type instead of proximity- + // picking the enclosing `Outbox::send`. let ts = call_refs( "src/mail.ts", "export class Outbox { send(m: string): void { this.mailer.send(m); } }\n", Language::TypeScript, ); assert!( - ts.iter().any(|c| c == "mailer.send"), - "TS `this.mailer.send(m)` must record `mailer.send`, got: {ts:?}" + ts.iter().any(|c| c == "this.mailer.send"), + "TS `this.mailer.send(m)` must record `this.mailer.send`, got: {ts:?}" ); // A deeper chain keeps only the last receiver segment (`obj.method` is the @@ -8384,7 +8736,7 @@ go() -> codec:decode(<<1,2,3>>, []).\n"; } #[test] - fn rust_only_single_hop_self_field_receiver_keeps_its_owner_shape() { + fn rust_self_and_single_hop_field_receivers_keep_their_owner_shape() { let calls = call_refs( "src/outer.rs", r#" @@ -8410,18 +8762,20 @@ impl Outer { .filter(|name| name.starts_with("self.")) .cloned() .collect::>(), - vec!["self.inner.run".to_string()] + vec![ + "self.inner.run".to_string(), + "self.make".to_string(), + "self.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")); + assert_eq!(calls.iter().filter(|name| *name == "run").count(), 3); } #[test] fn bare_this_receiver_still_skips() { - // A `this`/`self` receiver that is the WHOLE receiver keeps the existing - // skip semantics — the recursion self-call must stay a bare name, which is - // what keeps a legitimate recursion self-edge resolvable. + // JS-family `this` keeps the existing bare-name semantics; Rust `self` + // is intentionally owner-qualified by the dedicated branch above. let ts = call_refs( "src/rec.ts", "export class Walker { run(n: number): void { if (n > 0) { this.run(n - 1); } } }\n", diff --git a/crates/codegraph-extract/tests/batch_coverage_languages.rs b/crates/codegraph-extract/tests/batch_coverage_languages.rs index a86b75b0..d7759762 100644 --- a/crates/codegraph-extract/tests/batch_coverage_languages.rs +++ b/crates/codegraph-extract/tests/batch_coverage_languages.rs @@ -169,7 +169,7 @@ type Name = String let result = extract_source("src/shapes.scala", source, Some(Language::Scala)); assert!(result.errors.is_empty(), "{:?}", result.errors); assert_node(&result.nodes, NodeKind::Trait, "Shape"); - assert_node(&result.nodes, NodeKind::Class, "Registry"); + assert_node(&result.nodes, NodeKind::Module, "Registry"); assert_node(&result.nodes, NodeKind::Class, "Circle"); // tree-sitter-scala does not emit a `modifiers`/`access_modifier` child // holding a `private`/`protected` node for `private def`, so diff --git a/crates/codegraph-extract/tests/batch_coverage_languages2.rs b/crates/codegraph-extract/tests/batch_coverage_languages2.rs index de4bde0f..1e0c90d0 100644 --- a/crates/codegraph-extract/tests/batch_coverage_languages2.rs +++ b/crates/codegraph-extract/tests/batch_coverage_languages2.rs @@ -403,7 +403,7 @@ object Helper { "#; let result = extract_source("src/helper.scala", source, Some(Language::Scala)); assert!(result.errors.is_empty(), "{:?}", result.errors); - assert_node(&result.nodes, NodeKind::Class, "Helper"); + assert_node(&result.nodes, NodeKind::Module, "Helper"); // qualified return pkg.sub.Widget -> last segment "Widget". let make = assert_node(&result.nodes, NodeKind::Method, "make"); diff --git a/crates/codegraph-extract/tests/batch_coverage_languages3.rs b/crates/codegraph-extract/tests/batch_coverage_languages3.rs index d51b82d3..8695415b 100644 --- a/crates/codegraph-extract/tests/batch_coverage_languages3.rs +++ b/crates/codegraph-extract/tests/batch_coverage_languages3.rs @@ -128,9 +128,10 @@ class Widget: assert!(result.errors.is_empty(), "{:?}", result.errors); let refs = &result.unresolved_references; assert!(has_fn_ref(refs, "on_click"), "arg fn-ref; refs={refs:#?}"); - // `self.handle` (attribute special form) captures the attribute name only. + // `self.handle` preserves the class-scoped owner marker so resolution can + // distinguish it from unrelated same-named methods. assert!( - has_fn_ref(refs, "handle"), + has_fn_ref(refs, "this.handle"), "self.attr fn-ref; refs={refs:#?}" ); assert!( diff --git a/crates/codegraph-extract/tests/c_family_correctness.rs b/crates/codegraph-extract/tests/c_family_correctness.rs new file mode 100644 index 00000000..03477b81 --- /dev/null +++ b/crates/codegraph-extract/tests/c_family_correctness.rs @@ -0,0 +1,154 @@ +//! Focused C-family extraction regressions from the post-v1.6 upstream audit. + +use codegraph_core::types::{EdgeKind, Language, NodeKind}; +use codegraph_extract::extract_source; + +fn names_of_kind(source: &str, language: Language, kind: NodeKind) -> Vec { + let mut names = extract_source("fixture.cpp", source, Some(language)) + .nodes + .into_iter() + .filter(|node| node.kind == kind) + .map(|node| node.qualified_name) + .collect::>(); + names.sort(); + names +} + +#[test] +fn c_designated_macro_arguments_do_not_swallow_following_functions() { + let source = concat!( + "void f(void)\n", + "{\n", + " RESET_CONFIG(profile_t, profile,\n", + " .pid = { [PID_ROLL] = 10, [PID_YAW] = { 50, 75 } },\n", + " .limit = 500,\n", + " );\n", + " log_value(.5);\n", + " OTHER_MACRO(a == b, c);\n", + "}\n\n", + "void g(void) {}\n", + "int h(void) { return 1; }\n", + ); + let result = extract_source("pid.c", source, Some(Language::C)); + assert!(result.errors.is_empty(), "errors={:?}", result.errors); + let mut functions = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Function) + .collect::>(); + functions.sort_by_key(|node| node.start_line); + assert_eq!( + functions + .iter() + .map(|node| node.qualified_name.as_str()) + .collect::>(), + vec!["f", "g", "h"] + ); + assert_eq!(functions[0].end_line, 9); + assert!(result.unresolved_references.iter().any(|reference| { + reference.reference_kind == EdgeKind::Calls && reference.reference_name == "log_value" + })); + assert!(result.unresolved_references.iter().any(|reference| { + reference.reference_kind == EdgeKind::Calls && reference.reference_name == "OTHER_MACRO" + })); +} + +#[test] +fn cpp_raw_string_macro_text_is_opaque_but_real_annotations_still_blank() { + let source = concat!( + "namespace {\n", + "const char* text = u8R\"TAG(\n", + "DECLARE_THING(\n", + "struct Ignored { int value; };\n", + "UE_DEPRECATED(\n", + "class GHOST_API IgnoredApi {};\n", + "float4 position [[position]];\n", + "__global__ void ignored_kernel() {}\n", + "ignored_kernel<<<1, 1>>>();\n", + ")TAG\";\n", + "}\n\n", + "UPROPERTY(EditAnywhere)\n", + "int actual_field;\n\n", + "int create_scaffold(int x) { return x; }\n", + "int helper_after(int y) { return y + 1; }\n", + ); + let result = extract_source("scaffold.cpp", source, Some(Language::Cpp)); + assert!(result.errors.is_empty(), "errors={:?}", result.errors); + assert_eq!( + names_of_kind(source, Language::Cpp, NodeKind::Function), + vec!["create_scaffold", "helper_after"] + ); + assert!( + result.nodes.iter().all(|node| node.name != "Ignored"), + "raw-string source must not become syntax: {:?}", + result.nodes + ); +} + +#[test] +fn cpp_annotation_with_raw_argument_balances_on_the_real_closer() { + let source = concat!( + "ANNOTATE(R\"TAG(\" ) unbalanced ( \"\n", + "UE_DEPRECATED(\n", + ")TAG\")\n", + "int helper_after(void) { return 1; }\n", + ); + let result = extract_source("annotated.cpp", source, Some(Language::Cpp)); + assert!(result.errors.is_empty(), "errors={:?}", result.errors); + assert_eq!( + names_of_kind(source, Language::Cpp, NodeKind::Function), + vec!["helper_after"] + ); +} + +#[test] +fn cpp_pure_virtual_declarations_become_abstract_methods_only() { + let source = concat!( + "class Store {\n", + "public:\n", + " virtual int read(int key) = 0;\n", + " virtual Store* clone() = 0;\n", + " virtual Store& operator=(const Store&) = 0;\n", + " int inherited_pure() = 0;\n", + " int not_pure(int key = 0);\n", + " int (*callback)(int) = 0;\n", + " int data = 0;\n", + "};\n", + "class DiskStore : public Store {\n", + "public:\n", + " int read(int key) override { return key; }\n", + "};\n", + "int fetch(Store* store, int key) { return store->read(key); }\n", + ); + let result = extract_source("store.cpp", source, Some(Language::Cpp)); + assert!(result.errors.is_empty(), "errors={:?}", result.errors); + let methods = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Method) + .collect::>(); + for qualified in [ + "Store::read", + "Store::clone", + "Store::operator=", + "Store::inherited_pure", + ] { + let method = methods + .iter() + .find(|node| node.qualified_name == qualified) + .unwrap_or_else(|| panic!("missing {qualified}; methods={methods:#?}")); + assert!(method.is_abstract, "{qualified} must be abstract"); + } + assert!( + methods + .iter() + .any(|node| { node.qualified_name == "DiskStore::read" && !node.is_abstract }) + ); + assert!(methods.iter().all(|node| node.name != "not_pure")); + assert!(methods.iter().all(|node| node.name != "callback")); + assert!(methods.iter().all(|node| node.name != "data")); + assert!(result.unresolved_references.iter().any(|reference| { + reference.reference_kind == EdgeKind::Calls + && matches!(reference.reference_name.as_str(), "read" | "store.read") + })); +} diff --git a/crates/codegraph-extract/tests/js_callable_semantics.rs b/crates/codegraph-extract/tests/js_callable_semantics.rs new file mode 100644 index 00000000..f6e99cf1 --- /dev/null +++ b/crates/codegraph-extract/tests/js_callable_semantics.rs @@ -0,0 +1,192 @@ +//! JS/TS callable-completeness regressions from the post-v1.6 upstream audit. + +use codegraph_core::types::{EdgeKind, ExtractionResult, Language, Node, NodeKind}; +use codegraph_extract::extract_source; + +fn extract(file: &str, source: &str, language: Language) -> ExtractionResult { + let result = extract_source(file, source, Some(language)); + assert!( + result.errors.is_empty(), + "extraction errors: {:?}", + result.errors + ); + result +} + +fn node<'a>(result: &'a ExtractionResult, kind: NodeKind, name: &str) -> &'a Node { + result + .nodes + .iter() + .find(|node| node.kind == kind && node.name == name) + .unwrap_or_else(|| panic!("missing {kind:?} {name}; nodes={:#?}", result.nodes)) +} + +fn function_names(result: &ExtractionResult) -> Vec<&str> { + let mut names: Vec<_> = result + .nodes + .iter() + .filter(|node| node.kind == NodeKind::Function) + .map(|node| node.name.as_str()) + .collect(); + names.sort_unstable(); + names +} + +fn calls_from<'a>(result: &'a ExtractionResult, source: &str) -> Vec<&'a str> { + result + .unresolved_references + .iter() + .filter(|reference| { + reference.from_node_id == source && reference.reference_kind == EdgeKind::Calls + }) + .map(|reference| reference.reference_name.as_str()) + .collect() +} + +#[test] +fn ts_and_js_generators_are_callable_nodes() { + let source = r#" +function plain() { return 1; } +function* gen() { yield 2; } +async function* asyncGen() { yield 3; } +const assigned = function* () { yield 4; }; +export const exportedGen = async function* () { yield 5; }; +"#; + + for (file, language) in [ + ("gens.ts", Language::TypeScript), + ("gens.js", Language::JavaScript), + ] { + let result = extract(file, source, language); + assert_eq!( + function_names(&result), + vec!["assigned", "asyncGen", "exportedGen", "gen", "plain"] + ); + assert!(node(&result, NodeKind::Function, "exportedGen").is_exported); + assert!(!node(&result, NodeKind::Function, "assigned").is_exported); + } +} + +#[test] +fn typescript_interface_members_are_owned_declarations() { + let result = extract( + "api.d.ts", + r#" +export interface PlatformApi { + fetchPage(id: string): Promise; + version: string; +} +export type Handle = { stop(): void; label: string }; +"#, + Language::TypeScript, + ); + + let interface = node(&result, NodeKind::Interface, "PlatformApi"); + let method = node(&result, NodeKind::Method, "fetchPage"); + let property = node(&result, NodeKind::Property, "version"); + for member in [method, property] { + assert!(result.edges.iter().any(|edge| { + edge.kind == EdgeKind::Contains + && edge.source == interface.id + && edge.target == member.id + })); + } + assert!( + result + .nodes + .iter() + .all(|candidate| !(candidate.kind == NodeKind::Function && candidate.name == "stop")), + "a type-literal method signature must not become a free function" + ); +} + +#[test] +fn nested_declarator_handlers_own_their_calls() { + let result = extract( + "widget.tsx", + r#" +function formatLabel(x: unknown) { return x; } +export function Widget(onPick: (x: unknown) => void) { + const handleClear = () => { onPick(null); }; + const describe = function (item: unknown) { return formatLabel(item); }; + let later = (x: unknown) => formatLabel(x); + const count = 1; + const [a, b] = [() => 1, () => 2]; + return later(describe(count)); +} +"#, + Language::Tsx, + ); + + let widget = node(&result, NodeKind::Function, "Widget"); + let handle = node(&result, NodeKind::Function, "handleClear"); + let describe = node(&result, NodeKind::Function, "describe"); + assert_eq!(handle.qualified_name, "Widget::handleClear"); + assert!(result.edges.iter().any(|edge| { + edge.kind == EdgeKind::Contains && edge.source == widget.id && edge.target == handle.id + })); + assert!(calls_from(&result, &handle.id).contains(&"onPick")); + assert!(!calls_from(&result, &widget.id).contains(&"onPick")); + assert!(calls_from(&result, &describe.id).contains(&"formatLabel")); + assert!(!function_names(&result).contains(&"count")); + assert!(!function_names(&result).contains(&"a")); +} + +#[test] +fn hook_and_curried_wrapper_handlers_are_named_but_computations_are_not() { + let result = extract( + "handlers.tsx", + r#" +function helper() { return 1; } +export function Screen() { + const approved = useCallback(() => helper(), []); + const opened = React.useCallback(function () { return helper(); }, []); + const logged = useEffectEvent(() => helper()); + const wrapped = Effect.fn("Screen.wrapped")(function* () { return helper(); }); + const connected = connect({})(() => helper()); + const total = useMemo(() => helper(), []); + const mapped = [1].map(() => helper()); + return approved; +} +"#, + Language::Tsx, + ); + + let names = function_names(&result); + for expected in ["approved", "opened", "logged", "wrapped", "connected"] { + assert!( + names.contains(&expected), + "missing {expected}; names={names:?}" + ); + let function = node(&result, NodeKind::Function, expected); + assert!(calls_from(&result, &function.id).contains(&"helper")); + } + assert!(!names.contains(&"total")); + assert!(!names.contains(&"mapped")); +} + +#[test] +fn commonjs_export_assignments_name_only_exported_callable_members() { + let result = extract( + "controller.js", + r#" +function findItems() {} +function removeItem() {} +exports.getItems = async () => { findItems(); }; +module.exports.deleteItem = function () { removeItem(); }; +exports.plain = 42; +module.exports = { legacy: 1 }; +const handlers = {}; +handlers.onSave = () => { findItems(); }; +"#, + Language::JavaScript, + ); + + let get = node(&result, NodeKind::Function, "getItems"); + let delete = node(&result, NodeKind::Function, "deleteItem"); + assert!(get.is_exported && delete.is_exported); + assert!(get.is_async); + assert!(calls_from(&result, &get.id).contains(&"findItems")); + assert!(calls_from(&result, &delete.id).contains(&"removeItem")); + assert!(!function_names(&result).contains(&"onSave")); +} diff --git a/crates/codegraph-extract/tests/scala_dart_upstream.rs b/crates/codegraph-extract/tests/scala_dart_upstream.rs new file mode 100644 index 00000000..760773e8 --- /dev/null +++ b/crates/codegraph-extract/tests/scala_dart_upstream.rs @@ -0,0 +1,133 @@ +use std::collections::BTreeSet; + +use codegraph_core::types::{EdgeKind, Language, NodeKind}; +use codegraph_extract::extract_source; + +#[test] +fn scala_companion_object_is_a_module() { + let source = r#" +object ExtAgreement { + val Kind = "agreement" +} +trait ExtAgreement { + def extId: String = "x" +} +"#; + + let result = extract_source("ExtAgreement.scala", source, Some(Language::Scala)); + assert!(result.errors.is_empty(), "{:?}", result.errors); + + let companion = result + .nodes + .iter() + .find(|node| node.name == "ExtAgreement" && node.kind == NodeKind::Module); + assert!( + companion.is_some(), + "Scala object must be a module distinct from its companion type; nodes={:#?}", + result.nodes + ); + assert!( + result + .nodes + .iter() + .any(|node| node.name == "ExtAgreement" && node.kind == NodeKind::Trait), + "companion trait must remain a trait; nodes={:#?}", + result.nodes + ); +} + +#[test] +fn scala_multi_parameter_class_keeps_all_inheritance_targets() { + let source = r#" +trait ExtAgreement +trait Other +class MAgreement(agreement: String)(implicit qs: Int) +class MExtAgreement(agreement: String, ext: String)(implicit qs: Int) + extends MAgreement(agreement)(qs) with ExtAgreement with Other { + def foo: Int = 1 +} +"#; + + let result = extract_source("MExtAgreement.scala", source, Some(Language::Scala)); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let class = result + .nodes + .iter() + .find(|node| node.name == "MExtAgreement" && node.kind == NodeKind::Class) + .expect("MExtAgreement class"); + let targets: BTreeSet<_> = result + .unresolved_references + .iter() + .filter(|reference| { + reference.from_node_id == class.id && reference.reference_kind == EdgeKind::Extends + }) + .map(|reference| reference.reference_name.as_str()) + .collect(); + + assert_eq!( + targets, + BTreeSet::from(["ExtAgreement", "MAgreement", "Other"]), + "all Scala extends/with targets must survive parsing and extraction; refs={:#?}", + result.unresolved_references + ); +} + +#[test] +fn dart_extension_type_owns_its_members() { + let source = r#" +extension type MetersT(double value) { + double get km => value / 1000; + void report() { + print(km); + } +} + +class Widget { + double get half => 1.0; +} +"#; + + let result = extract_source("probe.dart", source, Some(Language::Dart)); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert!( + result + .nodes + .iter() + .any(|node| node.name == "MetersT" && node.kind == NodeKind::Class), + "extension type must be indexed as a class-like type; nodes={:#?}", + result.nodes + ); + + let km = result + .nodes + .iter() + .find(|node| node.name == "km") + .expect("extension-type getter must be indexed"); + assert_eq!(km.kind, NodeKind::Method); + assert_eq!(km.qualified_name, "MetersT::km"); + + let report = result + .nodes + .iter() + .find(|node| node.name == "report") + .expect("extension-type method must be indexed"); + assert_eq!(report.kind, NodeKind::Method); + assert_eq!(report.qualified_name, "MetersT::report"); + assert!( + report.end_line > report.start_line, + "method body span was cut" + ); + + assert!( + result + .nodes + .iter() + .any(|node| node.name == "Widget" && node.kind == NodeKind::Class) + ); + assert!( + result + .nodes + .iter() + .any(|node| node.name == "half" && node.kind == NodeKind::Method) + ); +} diff --git a/crates/codegraph-graph/src/graph/mod.rs b/crates/codegraph-graph/src/graph/mod.rs index bf40377c..4e856e73 100644 --- a/crates/codegraph-graph/src/graph/mod.rs +++ b/crates/codegraph-graph/src/graph/mod.rs @@ -1297,6 +1297,57 @@ pub fn find_all_definitions(store: &Store, name: &str) -> rusqlite::Result>, + pub filtered_out: bool, +} + +pub fn group_definitions(nodes: &[Node], file_filter: Option<&str>) -> DefinitionGroups { + let wanted = file_filter.map(|value| { + value + .replace('\\', "/") + .trim_start_matches("./") + .to_string() + }); + let narrowed = wanted.as_deref().map(|wanted| { + nodes + .iter() + .filter(|node| { + node.file_path == wanted || node.file_path.ends_with(&format!("/{wanted}")) + }) + .cloned() + .collect::>() + }); + let filtered_out = narrowed.as_ref().is_some_and(Vec::is_empty); + let pool = narrowed + .filter(|matches| !matches.is_empty()) + .unwrap_or_else(|| nodes.to_vec()); + + let mut groups: Vec> = Vec::new(); + for node in pool { + if let Some(group) = groups.iter_mut().find(|group| { + group.first().is_some_and(|head| { + head.file_path == node.file_path && head.qualified_name == node.qualified_name + }) + }) { + group.push(node); + } else { + groups.push(vec![node]); + } + } + DefinitionGroups { + groups, + filtered_out, + } +} + /// upstream graph/traversal.ts:88-91 — frontier ordering priority /// `contains` < `calls` < everything else. fn structural_priority(edge: &Edge) -> u8 { diff --git a/crates/codegraph-graph/tests/traversal.rs b/crates/codegraph-graph/tests/traversal.rs index 5532918b..00a27ecb 100644 --- a/crates/codegraph-graph/tests/traversal.rs +++ b/crates/codegraph-graph/tests/traversal.rs @@ -1,7 +1,9 @@ use std::collections::BTreeSet; use codegraph_core::types::{Edge, EdgeKind, Language, Node, NodeKind}; -use codegraph_graph::graph::{Direction, GraphTraverser, TraversalOptions, find_all_definitions}; +use codegraph_graph::graph::{ + Direction, GraphTraverser, TraversalOptions, find_all_definitions, group_definitions, +}; use codegraph_store::Store; fn temp_db_path(test_name: &str) -> std::path::PathBuf { @@ -336,6 +338,72 @@ fn node_name_ambiguity_returns_all_overloads() { assert_eq!(got, want, "ambiguous name must return all overloads"); } +#[test] +fn definition_groups_keep_overloads_together_and_disclose_filter_fallback() { + let nodes = vec![ + node( + "method:a1", + NodeKind::Method, + "run", + "Service::run", + "src/a.ts", + Language::TypeScript, + 10, + 12, + ), + node( + "method:a2", + NodeKind::Method, + "run", + "Service::run", + "src/a.ts", + Language::TypeScript, + 20, + 22, + ), + node( + "method:b", + NodeKind::Method, + "run", + "Other::run", + "vendor/b.ts", + Language::TypeScript, + 30, + 32, + ), + ]; + + let all = group_definitions(&nodes, None); + assert!(!all.filtered_out); + assert_eq!(all.groups.len(), 2); + assert_eq!(all.groups[0].len(), 2, "same-definition overloads group"); + assert_eq!(all.groups[1].len(), 1); + + let narrowed = group_definitions(&nodes, Some("a.ts")); + assert!(!narrowed.filtered_out); + assert_eq!(narrowed.groups.len(), 1); + assert_eq!(narrowed.groups[0].len(), 2); + + let windows = group_definitions(&nodes, Some(".\\vendor\\b.ts")); + assert!(!windows.filtered_out); + assert_eq!(windows.groups.len(), 1); + assert_eq!(windows.groups[0][0].id, "method:b"); + + let fallback = group_definitions(&nodes, Some("missing.ts")); + assert!(fallback.filtered_out); + assert_eq!( + fallback.groups.len(), + 2, + "no-match filter keeps all definitions" + ); + + let unaligned = group_definitions(&nodes, Some("b.ts")); + assert!(!unaligned.filtered_out); + assert_eq!(unaligned.groups[0][0].id, "method:b"); + let unaligned_miss = group_definitions(&nodes, Some("my_b.ts")); + assert!(unaligned_miss.filtered_out); +} + #[test] fn cyclic_graph_terminates_and_does_not_hang() { let mut store = Store::open(&temp_db_path("cycle")).expect("open store"); diff --git a/crates/codegraph-mcp/Cargo.toml b/crates/codegraph-mcp/Cargo.toml index b95947d4..dd487e4d 100644 --- a/crates/codegraph-mcp/Cargo.toml +++ b/crates/codegraph-mcp/Cargo.toml @@ -22,6 +22,7 @@ codegraph-core = { path = "../codegraph-core" } codegraph-store = { path = "../codegraph-store" } codegraph-graph = { path = "../codegraph-graph" } codegraph-resolve = { path = "../codegraph-resolve" } +codegraph-watch = { path = "../codegraph-watch" } regex = "1.10" # rmcp is now the SOLE MCP transport (Phase E): a regular dependency, no longer # feature-gated. The shipped binary links rmcp + tokio + axum unconditionally. diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index 47044167..5988b96f 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -18,7 +18,7 @@ use codegraph_core::deprioritize::DeprioritizeMatcher; use codegraph_core::file_class::{is_generated_file, is_test_file}; use codegraph_core::node_id::hash_content; use codegraph_core::types::{EdgeKind, FileRecord, Node, NodeKind}; -use codegraph_graph::graph::{GodotReach, GraphTraverser, NodeEdge}; +use codegraph_graph::graph::{GodotReach, GraphTraverser, NodeEdge, group_definitions}; use codegraph_graph::query::{SearchOptions, search_nodes}; use codegraph_graph::segment_match::get_segment_matches; use codegraph_graph::segments::extract_segment_search_words; @@ -28,7 +28,7 @@ use serde_json::Value; use crate::dynamic_boundaries::scan_dynamic_dispatch; use crate::explore_budget::{ExploreOutputBudget, get_explore_output_budget}; use crate::protocol::ToolResult; -use crate::query_paths::{extract_query_paths, query_might_contain_paths}; +use crate::query_paths::{extract_query_paths_with_file_probe, query_might_contain_paths}; /// Default caller/callee recursion depth for callers/callees tools. The upstream /// `getCallers`/`getCallees` default to `maxDepth: 1` (`traversal.ts` callers @@ -306,6 +306,17 @@ impl CodeGraphEngine { .collect()) } + /// Whether a CLI `node` positional resolves to an indexed file, including a + /// pasted line/range suffix such as `lib.rs:42-80` or `lib.rs#L42-L80`. + /// The MCP file-view handler uses the same resolver, so the CLI cannot drift + /// into symbol mode for a spelling the MCP tool accepts. + pub fn node_target_is_indexed_file(&self, target: &str) -> anyhow::Result { + let files = self.store.all_files()?; + Ok(!resolve_file_view_target(&files, target) + .candidates + .is_empty()) + } + /// Read-only access to the underlying store, for the CLI prompt-hook gate's /// query-time segment matching (`get_segment_matches`). pub fn store(&self) -> &Store { @@ -433,7 +444,12 @@ impl CodeGraphEngine { Ok(s) => s, Err(msg) => return Ok(ToolResult::error(msg)), }; - let limit = args.get("limit").and_then(Value::as_i64).unwrap_or(20) as usize; + let limit = args + .get("limit") + .and_then(Value::as_i64) + .unwrap_or(20) + .clamp(1, 100) as usize; + let file_filter = args.get("file").and_then(Value::as_str); let all_matches = self.find_all_symbols(&symbol)?; if all_matches.nodes.is_empty() { @@ -441,49 +457,135 @@ impl CodeGraphEngine { "Symbol \"{symbol}\" not found in the codebase. Use codegraph_search with query \"{symbol}\" to find fuzzy matches." ))); } - + let grouped = group_definitions(&all_matches.nodes, file_filter); + let filter_note = if grouped.filtered_out { + format!( + "\n\n> **Note:** no definition of \"{symbol}\" matches file \"{}\" — showing all definitions instead.", + file_filter.unwrap_or_default() + ) + } else { + String::new() + }; let traverser = GraphTraverser::new(&self.store); - let mut aggregated: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for node in &all_matches.nodes { - let edges: Vec = match dir { - CallDir::Callers => traverser.get_callers(&node.id, CALL_DEPTH)?, - CallDir::Callees => traverser.get_callees(&node.id, CALL_DEPTH)?, - }; - for ne in edges { - if seen.insert(ne.node.id.clone()) { - aggregated.push(ne.node); + let collect = |roots: &[Node]| -> anyhow::Result> { + let mut related = Vec::new(); + let mut seen = HashSet::new(); + for node in roots { + let edges: Vec = match dir { + CallDir::Callers => traverser.get_callers(&node.id, CALL_DEPTH)?, + CallDir::Callees => traverser.get_callees(&node.id, CALL_DEPTH)?, + }; + for entry in edges { + if seen.insert(entry.node.id.clone()) { + related.push(entry.node); + } } } - } - + Ok(related) + }; + let selected = grouped.groups.iter().flatten().cloned().collect::>(); let godot = match dir { - CallDir::Callers => self.godot_honesty(&all_matches.nodes)?, + CallDir::Callers => self.godot_honesty(&selected)?, CallDir::Callees => GodotHonesty::default(), }; + let label = match dir { + CallDir::Callers => "callers", + CallDir::Callees => "callees", + }; + let title = match dir { + CallDir::Callers => "Callers", + CallDir::Callees => "Callees", + }; - if aggregated.is_empty() { - let label = match dir { - CallDir::Callers => "callers", - CallDir::Callees => "callees", + if grouped.groups.len() == 1 { + let related = collect(&grouped.groups[0])?; + if related.is_empty() { + return Ok(ToolResult::not_found_text(format!( + "No {label} found for \"{symbol}\"{}{}{}", + if file_filter.is_some() && !grouped.filtered_out { + "" + } else { + &all_matches.note + }, + filter_note, + godot.annotation(true) + ))); + } + let shown = related.len().min(limit); + let cut = (related.len() > limit).then(|| { + format!( + "\n\n> Showing {shown} of {} {label}; pass `limit` (up to 100) to widen.", + related.len() + ) + }); + let note = if file_filter.is_some() && !grouped.filtered_out { + "" + } else { + &all_matches.note }; - return Ok(ToolResult::not_found_text(format!( - "No {label} found for \"{symbol}\"{}{}", - all_matches.note, - godot.annotation(true) - ))); + let formatted = format!( + "{}{}{}{}{}", + format_node_list(&format!("{title} of {symbol}"), &related[..shown]), + cut.as_deref().unwrap_or_default(), + note, + filter_note, + godot.annotation(false) + ); + return Ok(ToolResult::text(truncate_output(&formatted))); } - aggregated.truncate(limit); - let title = match dir { - CallDir::Callers => format!("Callers of {symbol}"), - CallDir::Callees => format!("Callees of {symbol}"), - }; + let mut lines = vec![format!( + "**{title} of {symbol} — {} distinct definitions (narrow with `file`)**", + grouped.groups.len() + )]; + let mut any = false; + for group in &grouped.groups { + let head = &group[0]; + let location = if head.start_line == 0 { + String::new() + } else { + format!(":{}", head.start_line) + }; + lines.push(String::new()); + lines.push(format!( + "### {} ({}) — {}{}", + head.qualified_name, + head.kind.as_str(), + head.file_path, + location + )); + let related = collect(group)?; + if related.is_empty() { + lines.push(format!("- (no {label})")); + continue; + } + any = true; + for node in related.iter().take(limit) { + let location = if node.start_line == 0 { + String::new() + } else { + format!(":{}", node.start_line) + }; + lines.push(format!( + "- {} ({}) - {}{}", + node.name, + node.kind.as_str(), + node.file_path, + location + )); + } + if related.len() > limit { + lines.push(format!( + "- … +{} more (pass `limit` to widen)", + related.len() - limit + )); + } + } let formatted = format!( "{}{}{}", - format_node_list(&title, &aggregated), - all_matches.note, - godot.annotation(false) + lines.join("\n"), + filter_note, + godot.annotation(!any) ); Ok(ToolResult::text(truncate_output(&formatted))) } @@ -497,7 +599,12 @@ impl CodeGraphEngine { Ok(s) => s, Err(msg) => return Ok(ToolResult::error(msg)), }; - let depth = args.get("depth").and_then(Value::as_i64).unwrap_or(2) as usize; + let depth = args + .get("depth") + .and_then(Value::as_i64) + .unwrap_or(2) + .clamp(1, 10) as usize; + let file_filter = args.get("file").and_then(Value::as_str); let all_matches = self.find_all_symbols(&symbol)?; if all_matches.nodes.is_empty() { @@ -505,34 +612,96 @@ impl CodeGraphEngine { "Symbol \"{symbol}\" not found in the codebase. Use codegraph_search with query \"{symbol}\" to find fuzzy matches." ))); } - + let grouped = group_definitions(&all_matches.nodes, file_filter); + let filter_note = if grouped.filtered_out { + format!( + "\n\n> **Note:** no definition of \"{symbol}\" matches file \"{}\" — showing all definitions instead.", + file_filter.unwrap_or_default() + ) + } else { + String::new() + }; let traverser = GraphTraverser::new(&self.store); - let mut merged_order: Vec = Vec::new(); - let mut merged: HashMap = HashMap::new(); - for node in &all_matches.nodes { - let sub = traverser.get_impact_radius(&node.id, depth)?; - for id in &sub.node_order { - if let Some(n) = sub.nodes.get(id) { - if !merged.contains_key(id) { - merged_order.push(id.clone()); + let impact_of = |roots: &[Node]| -> anyhow::Result<(Vec, HashMap)> { + let mut order = Vec::new(); + let mut nodes = HashMap::new(); + for root in roots { + let sub = traverser.get_impact_radius(&root.id, depth)?; + for id in &sub.node_order { + if let Some(node) = sub.nodes.get(id) { + if !nodes.contains_key(id) { + order.push(id.clone()); + } + nodes.insert(id.clone(), node.clone()); } - merged.insert(id.clone(), n.clone()); } } + Ok((order, nodes)) + }; + let selected = grouped.groups.iter().flatten().cloned().collect::>(); + let godot = self.godot_honesty(&selected)?; + + if grouped.groups.len() == 1 { + let (order, nodes) = impact_of(&grouped.groups[0])?; + let ordered = order + .iter() + .filter_map(|id| nodes.get(id)) + .collect::>(); + let root_ids = grouped.groups[0] + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let no_static_dependents = ordered + .iter() + .all(|node| root_ids.contains(node.id.as_str())); + let note = if file_filter.is_some() && !grouped.filtered_out { + "" + } else { + &all_matches.note + }; + let formatted = format!( + "{}{}{}{}", + format_impact(&symbol, &ordered), + note, + filter_note, + godot.annotation(no_static_dependents) + ); + return Ok(ToolResult::text(truncate_output(&formatted))); } - let ordered: Vec<&Node> = merged_order - .iter() - .filter_map(|id| merged.get(id)) - .collect(); - let godot = self.godot_honesty(&all_matches.nodes)?; - let match_ids: HashSet<&str> = all_matches.nodes.iter().map(|n| n.id.as_str()).collect(); - let no_static_dependents = ordered.iter().all(|n| match_ids.contains(n.id.as_str())); + let mut sections = vec![format!( + "**Impact of {symbol} — {} distinct definitions (each with its own blast radius; narrow with `file`)**", + grouped.groups.len() + )]; + let mut any_dependents = false; + for group in &grouped.groups { + let head = &group[0]; + let line = if head.start_line == 0 { + String::new() + } else { + format!(":{}", head.start_line) + }; + let (order, nodes) = impact_of(group)?; + let ordered = order + .iter() + .filter_map(|id| nodes.get(id)) + .collect::>(); + let roots = group + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + any_dependents |= ordered.iter().any(|node| !roots.contains(node.id.as_str())); + sections.push(String::new()); + sections.push(format_impact( + &format!("{} ({}{line})", head.qualified_name, head.file_path), + &ordered, + )); + } let formatted = format!( "{}{}{}", - format_impact(&symbol, &ordered), - all_matches.note, - godot.annotation(no_static_dependents) + sections.join("\n"), + filter_note, + godot.annotation(!any_dependents) ); Ok(ToolResult::text(truncate_output(&formatted))) } @@ -823,12 +992,8 @@ impl CodeGraphEngine { /// `handleFileView` (`tools.ts:2659-2799`): read an indexed file with line /// numbers + a dependents note (Read-equivalent). fn handle_file_view(&self, args: &Value, file_arg: &str) -> anyhow::Result { - let offset = args - .get("offset") - .and_then(Value::as_i64) - .unwrap_or(1) - .max(1) as usize; - let limit = args.get("limit").and_then(Value::as_i64); + let explicit_offset = args.get("offset").and_then(Value::as_i64); + let explicit_limit = args.get("limit").and_then(Value::as_i64); let symbols_only = args .get("symbolsOnly") .and_then(Value::as_bool) @@ -841,7 +1006,11 @@ impl CodeGraphEngine { )); } - let candidates = resolve_file_candidates(&files, file_arg); + // Resolve the literal spelling first: an indexed file genuinely named + // `foo.rs:42` must win over interpreting `:42` as a line pointer. Only a + // literal miss may retry after stripping a supported line suffix. + let resolved_target = resolve_file_view_target(&files, file_arg); + let candidates = &resolved_target.candidates; let file_path = match candidates.as_slice() { [one] => one.clone(), [] => { @@ -852,7 +1021,8 @@ impl CodeGraphEngine { many => { let mut out = vec![ format!( - "\"{file_arg}\" matches {} indexed files — pass a longer path:", + "\"{}\" matches {} indexed files — pass a longer path:", + resolved_target.shown_arg, many.len() ), String::new(), @@ -864,6 +1034,21 @@ impl CodeGraphEngine { } }; + // Explicit MCP pagination fields independently win over the suffix. + // A single-line pointer supplies only the offset; a closed range also + // supplies its inclusive length. + let offset = explicit_offset + .map(|value| value.max(1) as usize) + .or_else(|| resolved_target.selector.map(|selector| selector.start)) + .unwrap_or(1); + let limit = explicit_limit.or_else(|| { + resolved_target.selector.and_then(|selector| { + selector.end.and_then(|end| { + i64::try_from(end.saturating_sub(selector.start).saturating_add(1)).ok() + }) + }) + }); + let mut nodes = self.store.nodes_by_file_path(&file_path)?; nodes.retain(|n| n.kind != NodeKind::File); nodes.sort_by_key(|n| n.start_line); @@ -991,9 +1176,16 @@ impl CodeGraphEngine { .into_iter() .map(|file| file.path) .collect::>(); - extract_query_paths(&query, &indexed_paths, max_files.min(8)) + extract_query_paths_with_file_probe( + &query, + &indexed_paths, + max_files.min(8), + &|relative| project_regular_file_exists(&self.project_root, relative), + ) } else { - extract_query_paths(&query, &[], max_files.min(8)) + extract_query_paths_with_file_probe(&query, &[], max_files.min(8), &|relative| { + project_regular_file_exists(&self.project_root, relative) + }) }; let match_query = path_extraction.stripped_query.trim().to_string(); let subgraph = @@ -1805,6 +1997,7 @@ impl CodeGraphEngine { /// daemon-only (see KNOWN_DIFFS.md), so a static index omits them. fn handle_status(&self, _args: &Value) -> anyhow::Result { let counts = self.store.counts()?; + let pending = codegraph_watch::pending_project_changes(&self.project_root, &self.store)?; let db_size = fs::metadata(self.store.path()) .map(|m| m.len()) .unwrap_or(0); @@ -1835,6 +2028,24 @@ impl CodeGraphEngine { lines.push(format!("- {lang}: {count}")); } } + if !pending.is_empty() { + lines.push(String::new()); + lines.push(format!( + "### Pending sync: {} added, {} modified, {} removed", + pending.added.len(), + pending.modified.len(), + pending.removed.len() + )); + for (label, paths) in [ + ("added", &pending.added), + ("modified", &pending.modified), + ("removed", &pending.removed), + ] { + for path in paths { + lines.push(format!("- {label}: {path}")); + } + } + } // #1187: only appended when the interrupted-resolution marker is set, so a // healthy status is byte-identical to a pre-#1187 build. Tells the agent // the blast radius is incomplete until the next sync heals it. @@ -2160,9 +2371,21 @@ impl CodeGraphEngine { let mut ambient = HashSet::new(); for path in candidate_paths { let nodes = self.store.nodes_by_file_path(path)?; + let interface_owners = nodes + .iter() + .filter(|node| node.kind == NodeKind::Interface) + .map(|node| node.qualified_name.as_str()) + .collect::>(); + let is_interface_member = |node: &Node| { + matches!(node.kind, NodeKind::Method | NodeKind::Property) + && node + .qualified_name + .rsplit_once("::") + .is_some_and(|(owner, _)| interface_owners.contains(owner)) + }; let declared: Vec<&Node> = nodes .iter() - .filter(|n| !is_ambient_bookkeeping_kind(n.kind)) + .filter(|n| !is_ambient_bookkeeping_kind(n.kind) && !is_interface_member(n)) .collect(); // (1) at least one declaration, and (2) every one of them is a type // declaration — a file holding an implementation is never ambient. @@ -2202,7 +2425,23 @@ impl CodeGraphEngine { // (4) nothing anywhere in the index depends on it. Consumed only // through `is_empty`, so the query's SQLite scan order can never reach // the output. - if self.store.dependent_file_paths(path)?.is_empty() { + let mut depended_on = false; + for target in nodes.iter().filter(|node| !is_interface_member(node)) { + for edge in self.store.edges_by_target_kind(&target.id, None)? { + if self + .store + .node_by_id(&edge.source)? + .is_some_and(|source| source.file_path != *path) + { + depended_on = true; + break; + } + } + if depended_on { + break; + } + } + if !depended_on { ambient.insert(path.clone()); } } @@ -3366,6 +3605,155 @@ fn resolve_file_candidates(files: &[FileRecord], file_arg: &str) -> Vec .collect() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileLineSelector { + start: usize, + end: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct ResolvedFileViewTarget<'a> { + candidates: Vec, + shown_arg: &'a str, + selector: Option, +} + +/// Resolve a file-view argument while preserving literal-path precedence. +/// +/// Agents routinely paste editor locations (`a.rs:12`, `a.rs:12-40`, +/// `a.rs#L12-L40`). These are pagination hints only when the literal spelling +/// matches no indexed path and the stripped base does. This also prevents a +/// real filename ending in `:12` from being silently reinterpreted. +fn resolve_file_view_target<'a>( + files: &[FileRecord], + file_arg: &'a str, +) -> ResolvedFileViewTarget<'a> { + let literal = resolve_file_candidates(files, file_arg); + if !literal.is_empty() { + return ResolvedFileViewTarget { + candidates: literal, + shown_arg: file_arg, + selector: None, + }; + } + + if let Some((base, selector)) = split_file_line_selector(file_arg) { + let stripped = resolve_file_candidates(files, base); + if !stripped.is_empty() { + return ResolvedFileViewTarget { + candidates: stripped, + shown_arg: base, + selector: Some(selector), + }; + } + } + + ResolvedFileViewTarget { + candidates: Vec::new(), + shown_arg: file_arg, + selector: None, + } +} + +/// Split a supported trailing line selector from a path. +/// +/// A bare `C:42` stays literal because it is a valid Windows drive-relative +/// spelling; the final colon in `C:\\repo\\lib.rs:42` is unambiguous and is +/// accepted. Invalid, zero, reversed, or overflowing ranges stay literal. +fn split_file_line_selector(input: &str) -> Option<(&str, FileLineSelector)> { + if let Some((base, suffix)) = input.rsplit_once("#L") + && !base.is_empty() + && !is_bare_windows_drive(base) + && let Some(selector) = parse_line_selector(suffix, true) + { + return Some((base, selector)); + } + + if let Some((base, suffix)) = input.rsplit_once(':') + && !base.is_empty() + && !is_bare_windows_drive(base) + && let Some(selector) = parse_line_selector(suffix, false) + { + return Some((base, selector)); + } + + None +} + +fn parse_line_selector(suffix: &str, allow_end_l: bool) -> Option { + let (start_raw, end_raw) = match suffix.split_once('-') { + Some((start, end)) => (start, Some(end)), + None => (suffix, None), + }; + let start = parse_positive_line(start_raw)?; + let end = match end_raw { + Some(raw) => { + let raw = if allow_end_l { + raw.strip_prefix('L').unwrap_or(raw) + } else { + raw + }; + let end = parse_positive_line(raw)?; + (end >= start).then_some(end)? + } + None => return Some(FileLineSelector { start, end: None }), + }; + Some(FileLineSelector { + start, + end: Some(end), + }) +} + +fn parse_positive_line(raw: &str) -> Option { + if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let line = raw.parse::().ok()?; + (line > 0).then_some(line) +} + +fn is_bare_windows_drive(path: &str) -> bool { + let bytes = path.as_bytes(); + (bytes.len() == 1 && bytes[0].is_ascii_alphabetic()) + || (bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') +} + +/// Does a query-named relative path identify a regular project file that the +/// index does not contain? Lexical escapes and platform prefixes are rejected +/// before any metadata call; symlink components are rejected rather than +/// followed, so this honesty probe cannot leave the project tree. +fn project_regular_file_exists(project_root: &Path, relative: &str) -> bool { + let normalized = relative.replace('\\', "/"); + let bytes = normalized.as_bytes(); + if normalized.starts_with('/') || bytes.get(1) == Some(&b':') && bytes[0].is_ascii_alphabetic() + { + return false; + } + let segments = normalized + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + .collect::>(); + if segments.is_empty() || segments.contains(&"..") { + return false; + } + + let mut candidate = match project_root.canonicalize() { + Ok(root) => root, + Err(_) => return false, + }; + for segment in segments { + candidate.push(segment); + let metadata = match fs::symlink_metadata(&candidate) { + Ok(metadata) => metadata, + Err(_) => return false, + }; + if metadata.file_type().is_symlink() { + return false; + } + } + fs::metadata(candidate).is_ok_and(|metadata| metadata.is_file()) +} + fn basename(path: &str) -> &str { path.rsplit('/').next().unwrap_or(path) } @@ -4562,6 +4950,38 @@ mod tests { assert!(text_of(&ce).contains("helper")); } + #[test] + fn ext_callers_reports_when_limit_hides_rows() { + let mut engine = test_engine(); + let target = node_lang( + "save", + "save", + "svc.rs", + 1, + 5, + NodeKind::Function, + Language::Rust, + ); + let a = node_lang("a", "a", "a.rs", 1, 2, NodeKind::Function, Language::Rust); + let b = node_lang("b", "b", "b.rs", 1, 2, NodeKind::Function, Language::Rust); + put_nodes(&mut engine, &[target.clone(), a.clone(), b.clone()]); + put_edges( + &mut engine, + &[ + mk_edge(&a.id, &target.id, EdgeKind::Calls), + mk_edge(&b.id, &target.id, EdgeKind::Calls), + ], + ); + + let result = engine.execute( + "codegraph_callers", + &serde_json::json!({"symbol": "save", "limit": 1}), + ); + let text = text_of(&result); + assert!(text.contains("Showing 1 of 2 callers"), "got: {text}"); + assert!(text.contains("pass `limit` (up to 100)"), "got: {text}"); + } + #[test] fn ext_lookup_handlers_refuse_fuzzy_only_symbol_matches() { let mut engine = test_engine(); @@ -5605,6 +6025,100 @@ mod tests { assert!(rtxt.contains("pass `offset`/`limit`")); } + #[test] + fn ext_file_view_accepts_pasted_line_and_range_selectors() { + let engine = test_engine(); + put_indexed_source( + &engine, + "src/big.rs", + &(1..=80) + .map(|line| format!("value {line}\n")) + .collect::(), + Language::Rust, + 0, + ); + + for target in [ + "src/big.rs:40-42", + "big.rs:40-42", + "src/big.rs#L40-L42", + "big.rs#L40-42", + ] { + let text = + text_of(&engine.execute("codegraph_node", &serde_json::json!({"file": target}))); + assert!(text.contains("40\tvalue 40"), "target={target}: {text}"); + assert!(text.contains("42\tvalue 42"), "target={target}: {text}"); + assert!(!text.contains("39\tvalue 39"), "target={target}: {text}"); + assert!(!text.contains("43\tvalue 43"), "target={target}: {text}"); + } + + let pointer = + text_of(&engine.execute("codegraph_node", &serde_json::json!({"file": "big.rs:40"}))); + assert!(pointer.contains("40\tvalue 40"), "got: {pointer}"); + assert!(!pointer.contains("39\tvalue 39"), "got: {pointer}"); + } + + #[test] + fn ext_file_view_explicit_pagination_wins_over_selector() { + let engine = test_engine(); + put_indexed_source( + &engine, + "big.rs", + &(1..=80) + .map(|line| format!("value {line}\n")) + .collect::(), + Language::Rust, + 0, + ); + let text = text_of(&engine.execute( + "codegraph_node", + &serde_json::json!({ + "file": "big.rs:40-42", + "offset": 5, + "limit": 2, + }), + )); + assert!(text.contains("5\tvalue 5"), "got: {text}"); + assert!(text.contains("6\tvalue 6"), "got: {text}"); + assert!(!text.contains("40\tvalue 40"), "got: {text}"); + } + + #[test] + fn file_view_selector_parsing_preserves_literal_and_windows_ambiguities() { + let literal = file_rec("src/lib.rs:42", Language::Rust, 0); + let base = file_rec("src/lib.rs", Language::Rust, 0); + let resolved = resolve_file_view_target(&[literal, base.clone()], "src/lib.rs:42"); + assert_eq!(resolved.candidates, vec!["src/lib.rs:42"]); + assert_eq!(resolved.selector, None, "literal path must win"); + + let ranged = resolve_file_view_target(&[base], "src/lib.rs#L42-L80"); + assert_eq!(ranged.candidates, vec!["src/lib.rs"]); + assert_eq!( + ranged.selector, + Some(FileLineSelector { + start: 42, + end: Some(80), + }) + ); + + assert_eq!(split_file_line_selector("C:42"), None); + assert_eq!(split_file_line_selector("C:#L42"), None); + assert_eq!(split_file_line_selector(r"C:\repo\lib.rs"), None); + assert_eq!( + split_file_line_selector(r"C:\repo\lib.rs:42"), + Some(( + r"C:\repo\lib.rs", + FileLineSelector { + start: 42, + end: None, + }, + )) + ); + for invalid in ["lib.rs:0", "lib.rs:80-42", "lib.rs#Lx", "lib.rs:42-"] { + assert_eq!(split_file_line_selector(invalid), None, "{invalid}"); + } + } + #[test] fn ext_file_view_offset_past_end() { let mut engine = test_engine(); @@ -5736,6 +6250,50 @@ mod tests { ); } + #[test] + fn ext_explore_reports_an_existing_unindexed_extensionless_file() { + let engine = test_engine(); + std::fs::create_dir_all(engine.project_root.join("scripts")).unwrap(); + std::fs::write(engine.project_root.join("scripts/deploy"), "#!/bin/sh\n").unwrap(); + + let text = text_of(&engine.execute( + "codegraph_explore", + &serde_json::json!({"query": "scripts/deploy"}), + )); + assert!(text.contains("No relevant code found"), "got: {text}"); + assert!( + text.contains("no indexed file uniquely matches `scripts/deploy`"), + "got: {text}" + ); + } + + #[test] + fn project_file_probe_rejects_escapes_prefixes_and_directories() { + let engine = test_engine(); + std::fs::create_dir_all(engine.project_root.join("scripts")).unwrap(); + std::fs::write(engine.project_root.join("scripts/deploy"), "#!/bin/sh\n").unwrap(); + assert!(project_regular_file_exists( + &engine.project_root, + "scripts/deploy" + )); + assert!(!project_regular_file_exists( + &engine.project_root, + "scripts" + )); + assert!(!project_regular_file_exists( + &engine.project_root, + "../outside" + )); + assert!(!project_regular_file_exists( + &engine.project_root, + "/etc/passwd" + )); + assert!(!project_regular_file_exists( + &engine.project_root, + r"C:\outside\file" + )); + } + #[test] fn ext_explore_camel_segment_seeds_variables_and_constants() { let mut engine = test_engine(); @@ -7596,7 +8154,7 @@ mod tests { } #[test] - fn ext_callers_multi_match_aggregated_note() { + fn ext_callers_group_distinct_definitions_and_support_file_narrowing() { let mut engine = test_engine(); let a = node_lang( "api", @@ -7616,34 +8174,65 @@ mod tests { NodeKind::Function, Language::Rust, ); - let caller = node_lang( - "boot", - "boot", - "main.rs", + let caller_a = node_lang( + "boot_a", + "boot_a", + "a_main.rs", + 1, + 5, + NodeKind::Function, + Language::Rust, + ); + let caller_b = node_lang( + "boot_b", + "boot_b", + "b_main.rs", 1, 5, NodeKind::Function, Language::Rust, ); - put_nodes(&mut engine, &[a.clone(), b.clone(), caller.clone()]); + put_nodes( + &mut engine, + &[a.clone(), b.clone(), caller_a.clone(), caller_b.clone()], + ); put_edges( &mut engine, - &[mk_edge( - &caller.id, - &a.id, - codegraph_core::types::EdgeKind::Calls, - )], + &[ + mk_edge(&caller_a.id, &a.id, codegraph_core::types::EdgeKind::Calls), + mk_edge(&caller_b.id, &b.id, codegraph_core::types::EdgeKind::Calls), + ], ); let tr = engine.execute("codegraph_callers", &serde_json::json!({"symbol": "api"})); let txt = text_of(&tr); assert!( - txt.contains("Aggregated results across 2 symbols"), + txt.contains("2 distinct definitions") + && txt.contains("svc::api") + && txt.contains("core::api") + && txt.contains("boot_a") + && txt.contains("boot_b"), "got: {txt}" ); + + let narrowed = engine.execute( + "codegraph_callers", + &serde_json::json!({"symbol": "api", "file": "svc/a.rs"}), + ); + let narrowed = text_of(&narrowed); + assert!(narrowed.contains("boot_a"), "got: {narrowed}"); + assert!(!narrowed.contains("boot_b"), "got: {narrowed}"); + + let fallback = engine.execute( + "codegraph_callers", + &serde_json::json!({"symbol": "api", "file": "missing.rs"}), + ); + let fallback = text_of(&fallback); + assert!(fallback.contains("showing all definitions instead")); + assert!(fallback.contains("boot_a") && fallback.contains("boot_b")); } #[test] - fn ext_impact_multi_match_aggregated_note() { + fn ext_impact_groups_distinct_definitions() { let mut engine = test_engine(); let a = node_lang( "core", @@ -7666,9 +8255,9 @@ mod tests { put_nodes(&mut engine, &[a, b]); let tr = engine.execute("codegraph_impact", &serde_json::json!({"symbol": "core"})); let txt = text_of(&tr); - assert!(txt.contains("## Impact: \"core\""), "got: {txt}"); + assert!(txt.contains("2 distinct definitions"), "got: {txt}"); assert!( - txt.contains("Aggregated results across 2 symbols"), + txt.contains("a::core") && txt.contains("b::core"), "got: {txt}" ); } diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index 236d70e8..adfd8d39 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -14,6 +14,7 @@ pub mod dynamic_boundaries; pub mod engine; pub mod explore_budget; pub mod instructions; +pub mod project_services; pub mod protocol; pub(crate) mod query_paths; pub mod rmcp_handler; @@ -23,6 +24,13 @@ pub mod schemas; pub mod server; pub use engine::CodeGraphEngine; -pub use rmcp_handler::{serve_http, serve_stdio_rmcp}; +pub use project_services::{ + MAX_SESSION_PROJECT_SERVICES, ProjectService, ProjectServiceBroker, ProjectServiceHandle, + ProjectServiceStarter, +}; +pub use rmcp_handler::{ + serve_http, serve_http_with_project_services, serve_stdio_rmcp, + serve_stdio_rmcp_with_project_services, +}; pub use roots::{ServerRootResolution, resolve_server_root}; pub use server::{McpServer, RunUntilAdoption, initialize_result}; diff --git a/crates/codegraph-mcp/src/project_services.rs b/crates/codegraph-mcp/src/project_services.rs new file mode 100644 index 00000000..f4587b6c --- /dev/null +++ b/crates/codegraph-mcp/src/project_services.rs @@ -0,0 +1,452 @@ +//! Session-scoped lifecycle for repositories reached through an explicit +//! `projectPath`. +//! +//! Query routing alone is intentionally read-only and request-scoped. A shipped +//! MCP host may additionally install a [`ProjectServiceBroker`] whose starter +//! attaches the addressed existing index to its shared daemon, waits for one +//! catch-up, and retains a session connection so the daemon's one watcher stays +//! alive. Keeping the starter injectable avoids a dependency cycle: +//! `codegraph-daemon` depends on this crate to serve MCP sessions, while the CLI +//! (which depends on both crates) supplies the concrete daemon starter. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use anyhow::{Result, bail}; +use codegraph_core::IndexPaths; + +/// Maximum number of distinct explicit projects for which one MCP session may +/// retain live services. The graph/query surface can still address other +/// projects after the session is restarted; this cap prevents an accidental +/// stream of paths from accumulating daemon connections and watchers forever. +pub const MAX_SESSION_PROJECT_SERVICES: usize = 32; + +/// Live services retained for one explicit project. Dropping the final handle +/// must release that session's retained daemon/direct connection. +pub trait ProjectService: Send + Sync { + /// Whether the retained services still serve the index on disk. A daemon + /// that exited, or an index removed or re-created since attachment, makes + /// the handle stale: the broker then drops it and prepares afresh instead + /// of silently answering without live synchronization. + fn is_live(&self) -> bool { + true + } +} + +/// A token with no liveness to lose (tests and no-op starters). +impl ProjectService for () {} + +/// Opaque lifetime token returned by the concrete service starter. +pub type ProjectServiceHandle = Arc; + +/// Concrete service startup supplied by the binary/daemon integration layer. +pub type ProjectServiceStarter = + Arc Result + Send + Sync + 'static>; + +#[derive(Clone)] +pub struct ProjectServiceBroker { + inner: Arc, +} + +struct BrokerInner { + starter: ProjectServiceStarter, + handles: Mutex>, + gates: Mutex>>>, +} + +impl std::fmt::Debug for ProjectServiceBroker { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProjectServiceBroker") + .field("active_projects", &self.active_project_count()) + .field("limit", &MAX_SESSION_PROJECT_SERVICES) + .finish_non_exhaustive() + } +} + +impl ProjectServiceBroker { + #[must_use] + pub fn new(starter: ProjectServiceStarter) -> Self { + Self { + inner: Arc::new(BrokerInner { + starter, + handles: Mutex::new(HashMap::new()), + gates: Mutex::new(HashMap::new()), + }), + } + } + + /// Ensure live service ownership for one already-resolved indexed project. + /// + /// The physical project root is the cache key, so aliases cannot create two + /// retained services. Same-project concurrent calls serialize through a + /// per-project gate; different projects may prepare in parallel. Failures + /// are not cached, allowing a later call to retry after transient daemon or + /// lease contention. This function never creates an index namespace: the + /// concrete starter receives a root only after [`IndexPaths::resolve`], and + /// the tool handler calls it only after `projectPath` resolved as indexed. + pub fn ensure(&self, project: &Path) -> Result<()> { + let paths = IndexPaths::resolve(project, std::env::var("CODEGRAPH_DIR").ok().as_deref())?; + let key = paths.project().to_path_buf(); + + { + let mut handles = self + .inner + .handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match handles.get(&key) { + Some(handle) if handle.is_live() => return Ok(()), + // Stale: release it and re-attach below (a crashed daemon or an + // uninit→init cycle leaves nothing watching this project). + Some(_) => { + handles.remove(&key); + } + None => {} + } + } + + let gate = { + let mut gates = self + .inner + .gates + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !gates.contains_key(&key) && gates.len() >= MAX_SESSION_PROJECT_SERVICES { + bail!( + "explicit project live-service limit reached ({MAX_SESSION_PROJECT_SERVICES}); restart the MCP session (or, over HTTP, the server) before accessing more repositories" + ); + } + Arc::clone( + gates + .entry(key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + }; + let _preparing = gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let mut handles = self + .inner + .handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if handles.contains_key(&key) { + return Ok(()); + } + // Do not hold the handle map across potentially slow catch-up work: the + // per-project gate above provides same-key exclusion, while other keys + // stay independent. + drop(handles); + let handle = match (self.inner.starter)(&key) { + Ok(handle) => handle, + Err(error) => { + // A failed preparation owns no daemon connection or watcher, so + // it must not consume one of the bounded retained-project slots + // forever. Remove the reservation only when this caller is the + // final user of the gate (the map + this local Arc). If another + // same-project caller already cloned the gate, leave it in the + // map: that waiter must retry through the SAME mutex rather than + // racing a newly-created gate and starting a second service. + let mut gates = self + .inner + .gates + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if gates + .get(&key) + .is_some_and(|stored| Arc::ptr_eq(stored, &gate)) + && Arc::strong_count(&gate) == 2 + { + gates.remove(&key); + } + return Err(error); + } + }; + handles = self + .inner + .handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + handles.insert(key, handle); + Ok(()) + } + + #[must_use] + pub fn active_project_count(&self) -> usize { + self.inner + .handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + fn project(label: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "codegraph-project-services-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + root + } + + struct Toggle(Arc); + + impl ProjectService for Toggle { + fn is_live(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + } + + #[test] + fn a_stale_handle_is_released_and_services_are_prepared_again() { + let calls = Arc::new(AtomicUsize::new(0)); + let live = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let broker = ProjectServiceBroker::new(Arc::new({ + let calls = Arc::clone(&calls); + let live = Arc::clone(&live); + move |_| { + calls.fetch_add(1, Ordering::SeqCst); + live.store(true, Ordering::SeqCst); + Ok(Arc::new(Toggle(Arc::clone(&live))) as ProjectServiceHandle) + } + })); + let root = project("stale"); + broker.ensure(&root).unwrap(); + broker.ensure(&root).unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1, "a live handle is reused"); + + // The daemon exited or the index was re-created underneath the session. + live.store(false, Ordering::SeqCst); + broker.ensure(&root).unwrap(); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "a stale handle is re-prepared" + ); + assert_eq!(broker.active_project_count(), 1); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn one_physical_project_starts_once_and_retains_one_handle() { + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::new(Mutex::new(Vec::new())); + let broker = ProjectServiceBroker::new(Arc::new({ + let calls = Arc::clone(&calls); + let observed = Arc::clone(&observed); + move |root| { + calls.fetch_add(1, Ordering::SeqCst); + observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(root.to_path_buf()); + Ok(Arc::new(()) as ProjectServiceHandle) + } + })); + let root = project("once"); + + broker.ensure(&root).unwrap(); + broker.ensure(&root.join(".")).unwrap(); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(broker.active_project_count(), 1); + assert_eq!( + observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_slice(), + &[root.canonicalize().unwrap()] + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_failed_start_is_retryable() { + let calls = Arc::new(AtomicUsize::new(0)); + let broker = ProjectServiceBroker::new(Arc::new({ + let calls = Arc::clone(&calls); + move |_| { + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + anyhow::bail!("transient") + } + Ok(Arc::new(()) as ProjectServiceHandle) + } + })); + let root = project("retry"); + + assert!(broker.ensure(&root).is_err()); + broker.ensure(&root).unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(broker.active_project_count(), 1); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn failed_distinct_projects_do_not_exhaust_retention_capacity() { + let broker = ProjectServiceBroker::new(Arc::new(|_| anyhow::bail!("transient"))); + let roots = (0..=MAX_SESSION_PROJECT_SERVICES) + .map(|index| project(&format!("failed-cap-{index}"))) + .collect::>(); + + for root in roots.iter().take(MAX_SESSION_PROJECT_SERVICES) { + let error = broker.ensure(root).expect_err("starter must fail"); + assert_eq!(error.to_string(), "transient"); + } + assert_eq!(broker.active_project_count(), 0); + + let error = broker + .ensure(&roots[MAX_SESSION_PROJECT_SERVICES]) + .expect_err("the next starter still runs and reports its own failure"); + assert_eq!(error.to_string(), "transient"); + assert!( + !error.to_string().contains("live-service limit"), + "failed starts must release their capacity reservation" + ); + + for root in roots { + let _ = std::fs::remove_dir_all(root); + } + } + + #[test] + fn failed_same_project_waiters_keep_one_gate_until_the_last_retry() { + let calls = Arc::new(AtomicUsize::new(0)); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let release_rx = Arc::new(Mutex::new(release_rx)); + let broker = ProjectServiceBroker::new(Arc::new({ + let calls = Arc::clone(&calls); + let active = Arc::clone(&active); + let max_active = Arc::clone(&max_active); + let release_rx = Arc::clone(&release_rx); + move |_| { + let call = calls.fetch_add(1, Ordering::SeqCst); + let now = active.fetch_add(1, Ordering::SeqCst) + 1; + max_active.fetch_max(now, Ordering::SeqCst); + entered_tx.send(call).unwrap(); + release_rx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .recv() + .unwrap(); + active.fetch_sub(1, Ordering::SeqCst); + anyhow::bail!("transient-{call}") + } + })); + let root = project("same-key-failures"); + + let first = { + let broker = broker.clone(); + let root = root.clone(); + std::thread::spawn(move || broker.ensure(&root)) + }; + assert_eq!(entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(), 0); + + let second = { + let broker = broker.clone(); + let root = root.clone(); + std::thread::spawn(move || broker.ensure(&root)) + }; + wait_for_gate_users(&broker, &root, 3); + release_tx.send(()).unwrap(); + assert_eq!( + first.join().unwrap().unwrap_err().to_string(), + "transient-0" + ); + assert_eq!(entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(), 1); + + let third = { + let broker = broker.clone(); + let root = root.clone(); + std::thread::spawn(move || broker.ensure(&root)) + }; + wait_for_gate_users(&broker, &root, 3); + release_tx.send(()).unwrap(); + assert_eq!( + second.join().unwrap().unwrap_err().to_string(), + "transient-1" + ); + assert_eq!(entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(), 2); + release_tx.send(()).unwrap(); + assert_eq!( + third.join().unwrap().unwrap_err().to_string(), + "transient-2" + ); + + assert_eq!(calls.load(Ordering::SeqCst), 3); + assert_eq!(max_active.load(Ordering::SeqCst), 1); + assert_eq!(broker.active_project_count(), 0); + assert!( + broker + .inner + .gates + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty(), + "the final failed waiter releases the reservation" + ); + let _ = std::fs::remove_dir_all(root); + } + + fn wait_for_gate_users(broker: &ProjectServiceBroker, root: &Path, minimum: usize) { + let key = IndexPaths::resolve(root, std::env::var("CODEGRAPH_DIR").ok().as_deref()) + .unwrap() + .project() + .to_path_buf(); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let users = broker + .inner + .gates + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&key) + .map(Arc::strong_count) + .unwrap_or(0); + if users >= minimum { + return; + } + assert!( + Instant::now() < deadline, + "gate never reached {minimum} strong users (last={users})" + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn distinct_project_retention_is_bounded() { + let broker = + ProjectServiceBroker::new(Arc::new(|_| Ok(Arc::new(()) as ProjectServiceHandle))); + let roots = (0..=MAX_SESSION_PROJECT_SERVICES) + .map(|index| project(&format!("cap-{index}"))) + .collect::>(); + for root in roots.iter().take(MAX_SESSION_PROJECT_SERVICES) { + broker.ensure(root).unwrap(); + } + let error = broker + .ensure(&roots[MAX_SESSION_PROJECT_SERVICES]) + .expect_err("the next distinct project must hit the session cap"); + assert!(error.to_string().contains("live-service limit")); + assert_eq!(broker.active_project_count(), MAX_SESSION_PROJECT_SERVICES); + for root in roots { + let _ = std::fs::remove_dir_all(root); + } + } +} diff --git a/crates/codegraph-mcp/src/query_paths.rs b/crates/codegraph-mcp/src/query_paths.rs index 46668b52..941cce9e 100644 --- a/crates/codegraph-mcp/src/query_paths.rs +++ b/crates/codegraph-mcp/src/query_paths.rs @@ -43,17 +43,36 @@ pub(crate) fn query_might_contain_paths(query: &str) -> bool { }) } +#[cfg(test)] pub(crate) fn extract_query_paths( query: &str, indexed_paths: &[String], max_pins: usize, +) -> QueryPathExtraction { + extract_query_paths_inner(query, indexed_paths, max_pins, None) +} + +pub(crate) fn extract_query_paths_with_file_probe( + query: &str, + indexed_paths: &[String], + max_pins: usize, + exists_on_disk: &dyn Fn(&str) -> bool, +) -> QueryPathExtraction { + extract_query_paths_inner(query, indexed_paths, max_pins, Some(exists_on_disk)) +} + +fn extract_query_paths_inner( + query: &str, + indexed_paths: &[String], + max_pins: usize, + exists_on_disk: Option<&dyn Fn(&str) -> bool>, ) -> QueryPathExtraction { let passthrough = || QueryPathExtraction { stripped_query: query.to_string(), pinned_files: Vec::new(), unresolved_path_spans: Vec::new(), }; - if query.trim().is_empty() || indexed_paths.is_empty() { + if query.trim().is_empty() || (indexed_paths.is_empty() && exists_on_disk.is_none()) { return passthrough(); } let max_pins = max_pins.clamp(1, MAX_PINS); @@ -99,7 +118,10 @@ pub(crate) fn extract_query_paths( pinned.push(path); } } - } else if resolved.ambiguous || is_clearly_path_shaped(&normalized) { + } else if resolved.ambiguous + || is_clearly_path_shaped(&normalized) + || (normalized.contains('/') && exists_on_disk.is_some_and(|probe| probe(&normalized))) + { consumed.insert(index); if unresolved.len() < MAX_UNRESOLVED { unresolved.push(normalized); @@ -388,4 +410,49 @@ mod tests { assert_eq!(capped.pinned_files, vec!["src/lib/chat-manager.ts"]); assert_eq!(capped.stripped_query, "background-image-table then"); } + + #[test] + fn dotless_unindexed_file_is_reported_only_when_the_safe_probe_confirms_it() { + let seen = std::cell::RefCell::new(Vec::new()); + let exists = |path: &str| { + seen.borrow_mut().push(path.to_string()); + path == "scripts/deploy" + }; + let out = extract_query_paths_with_file_probe( + "why does scripts/deploy fail on release", + &index(), + 8, + &exists, + ); + assert!(out.pinned_files.is_empty()); + assert_eq!(out.unresolved_path_spans, vec!["scripts/deploy"]); + assert_eq!(out.stripped_query, "why does fail on release"); + assert!(seen.borrow().iter().any(|path| path == "scripts/deploy")); + + let prose = extract_query_paths_with_file_probe( + "does gen_server:call/2 block and/or timeout", + &index(), + 8, + &exists, + ); + assert!(prose.unresolved_path_spans.is_empty()); + assert_eq!( + prose.stripped_query, + "does gen_server:call/2 block and/or timeout" + ); + assert!(seen.borrow().iter().any(|path| path == "and/or")); + } + + #[test] + fn indexed_dotless_path_wins_without_consulting_the_file_probe() { + let exists = |_path: &str| panic!("indexed path must not reach filesystem probe"); + let out = extract_query_paths_with_file_probe( + "what does scripts/pre-commit run", + &index(), + 8, + &exists, + ); + assert_eq!(out.pinned_files, vec!["scripts/pre-commit"]); + assert!(out.unresolved_path_spans.is_empty()); + } } diff --git a/crates/codegraph-mcp/src/rmcp_handler.rs b/crates/codegraph-mcp/src/rmcp_handler.rs index d7151bd5..c01387d3 100644 --- a/crates/codegraph-mcp/src/rmcp_handler.rs +++ b/crates/codegraph-mcp/src/rmcp_handler.rs @@ -27,14 +27,15 @@ use std::time::Duration; use rmcp::handler::server::ServerHandler; use rmcp::model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorData, - Implementation, InitializeResult, JsonObject, ListToolsResult, PaginatedRequestParams, - ProtocolVersion, ServerCapabilities, ServerInfo, Tool, + Implementation, InitializeResult, JsonObject, ListToolsResult, MetaObject, + PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, }; use rmcp::service::{NotificationContext, RequestContext, RoleServer}; use serde_json::{Value, json}; use crate::engine::CodeGraphEngine; use crate::instructions::server_instructions; +use crate::project_services::ProjectServiceBroker; use crate::protocol::ToolResult; use crate::roots::{ ServerRootDiscovery, WorkspaceRoots, db_exists_for, db_path_for, format_subproject_candidates, @@ -116,15 +117,27 @@ pub struct CodeGraphHandler { cwd: Option, no_roots: bool, root_discovery: Option, + project_services: Option, } impl CodeGraphHandler { pub fn new(default_project: Option) -> Self { + Self::new_with_project_services(default_project, None) + } + + /// Pinned/no-roots handler with an optional explicit-`projectPath` live + /// service broker. The daemon and CLI integration layers supply the broker; + /// library callers that only need request-scoped reads keep using [`Self::new`]. + pub fn new_with_project_services( + default_project: Option, + project_services: Option, + ) -> Self { Self { default_project: Arc::new(Mutex::new(default_project)), cwd: std::env::current_dir().ok(), no_roots: true, root_discovery: None, + project_services, } } @@ -137,13 +150,28 @@ impl CodeGraphHandler { Self::new(Some(project)) } + pub fn http_with_project_services( + project: PathBuf, + project_services: Option, + ) -> Self { + Self::new_with_project_services(Some(project), project_services) + } + /// Non-pinned stdio constructor: `no_roots = false`, so /// `on_initialized` requests the client's roots and adopts an indexed one /// when the current default is displaceable (`roots::` adoption rules). This /// is the bare-`serve --mcp` / Zed-local case where `default_project` is a /// cwd-derived, possibly unindexed dir. pub fn serve_with_roots(default_project: Option, cwd: Option) -> Self { - Self::stdio(default_project, cwd, false) + Self::stdio(default_project, cwd, false, None) + } + + pub fn serve_with_roots_and_project_services( + default_project: Option, + cwd: Option, + project_services: Option, + ) -> Self { + Self::stdio(default_project, cwd, false, project_services) } /// Test-only constructor with an explicit cwd (mirrors @@ -151,10 +179,15 @@ impl CodeGraphHandler { /// exercised deterministically. #[doc(hidden)] pub fn new_with_cwd(default_project: Option, cwd: Option) -> Self { - Self::stdio(default_project, cwd, true) + Self::stdio(default_project, cwd, true, None) } - fn stdio(default_project: Option, cwd: Option, no_roots: bool) -> Self { + fn stdio( + default_project: Option, + cwd: Option, + no_roots: bool, + project_services: Option, + ) -> Self { let search_from = default_project.clone().or_else(|| cwd.clone()); let mut discovery = ServerRootDiscovery::new(search_from); let mut resolved_default = default_project; @@ -192,6 +225,7 @@ impl CodeGraphHandler { cwd, no_roots, root_discovery: Some(Arc::new(Mutex::new(discovery))), + project_services, } } @@ -337,6 +371,9 @@ fn tools_from_schema(tools_json: Value) -> Vec { serde_json::from_value(Value::Object(annotations)).unwrap_or_default(), ); } + if let Some(Value::Object(meta)) = obj.remove("_meta") { + built.meta = Some(MetaObject(meta)); + } Some(built) }) .collect() @@ -496,7 +533,6 @@ impl ServerHandler for CodeGraphHandler { return Ok(tool_result_to_call_result(&result).into()); } }; - // Handler half of the debug split (paired with the `debug_log_requests` // middleware): logs the resolved tool + projectPath — values already in // hand here, so no request-body buffering is needed. Emitted at debug @@ -525,8 +561,25 @@ impl ServerHandler for CodeGraphHandler { // unblocked fast with an isError result; the orphaned thread drains // on its own. `tool_timeout() == None` (env `0`) opts out entirely. let outcome_tool = tool_name.clone(); - let join_future = - tokio::task::spawn_blocking(move || execute_owned(&project_path, &tool_name, &args)); + let explicit_project = raw_project.is_some(); + let project_services = self.project_services.clone(); + let join_future = tokio::task::spawn_blocking(move || { + // Default-project reads keep the established startup lifecycle. An + // explicit path is unambiguous and may lazily attach its EXISTING + // index to a shared watcher owner. Keep the catch-up in this blocking + // closure: first access waits for it, the tokio executor stays free, + // and the existing per-tool timeout bounds the client's wait. + if explicit_project + && let Some(project_services) = &project_services + && let Err(error) = project_services.ensure(&project_path) + { + return ToolResult::error(format!( + "Failed to prepare live synchronization for {}: {error:#}", + project_path.display() + )); + } + execute_owned(&project_path, &tool_name, &args) + }); let timed_out; let join = match tool_timeout() { @@ -581,6 +634,13 @@ impl ServerHandler for CodeGraphHandler { /// adopts an indexed one when the cwd-derived default is displaceable — parity /// with the hand-rolled `McpServer::new` direct serve. pub fn serve_stdio_rmcp(project: Option) -> anyhow::Result<()> { + serve_stdio_rmcp_with_project_services(project, None) +} + +pub fn serve_stdio_rmcp_with_project_services( + project: Option, + project_services: Option, +) -> anyhow::Result<()> { use rmcp::ServiceExt; let runtime = tokio::runtime::Builder::new_multi_thread() @@ -588,7 +648,8 @@ pub fn serve_stdio_rmcp(project: Option) -> anyhow::Result<()> { .build()?; runtime.block_on(async move { let cwd = std::env::current_dir().ok(); - let handler = CodeGraphHandler::serve_with_roots(project, cwd); + let handler = + CodeGraphHandler::serve_with_roots_and_project_services(project, cwd, project_services); let running = handler .serve(rmcp::transport::stdio()) .await @@ -680,6 +741,14 @@ pub async fn debug_log_requests( pub fn serve_http( default_project: Option, addr: std::net::SocketAddr, +) -> anyhow::Result<()> { + serve_http_with_project_services(default_project, addr, None) +} + +pub fn serve_http_with_project_services( + default_project: Option, + addr: std::net::SocketAddr, + project_services: Option, ) -> anyhow::Result<()> { use rmcp::transport::streamable_http_server::StreamableHttpService; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; @@ -723,9 +792,15 @@ pub fn serve_http( } let handler_default = default_project.clone(); + let handler_project_services = project_services.clone(); let service: StreamableHttpService = StreamableHttpService::new( - move || Ok(CodeGraphHandler::new(handler_default.clone())), + move || { + Ok(CodeGraphHandler::new_with_project_services( + handler_default.clone(), + handler_project_services.clone(), + )) + }, Arc::new(LocalSessionManager::default()), build_http_config(addr, guard), ); @@ -1063,13 +1138,21 @@ mod handler_tests { "name": "t1", "description": "d1", "inputSchema": { "type": "object" }, - "annotations": { "title": "T1", "readOnlyHint": true } + "annotations": { "title": "T1", "readOnlyHint": true }, + "_meta": { "anthropic/alwaysLoad": true } }, { "name": "t2" } ])); assert_eq!(tools.len(), 2); assert_eq!(&*tools[0].name, "t1"); assert_eq!(tools[0].description.as_deref(), Some("d1")); + assert_eq!( + tools[0] + .meta + .as_ref() + .and_then(|meta| meta.0.get("anthropic/alwaysLoad")), + Some(&json!(true)) + ); assert_eq!(&*tools[1].name, "t2"); } diff --git a/crates/codegraph-mcp/src/rmcp_session.rs b/crates/codegraph-mcp/src/rmcp_session.rs index d4cd3603..932e3244 100644 --- a/crates/codegraph-mcp/src/rmcp_session.rs +++ b/crates/codegraph-mcp/src/rmcp_session.rs @@ -25,6 +25,7 @@ use std::path::PathBuf; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use crate::project_services::ProjectServiceBroker; use crate::rmcp_handler::CodeGraphHandler; /// Buffer size for the in-process duplex pipe bridging blocking socket halves to @@ -48,12 +49,23 @@ const PUMP_CHANNEL_DEPTH: usize = 16; /// The handler runs pinned to `project_root` (`no_roots` mode): the daemon is /// always launched pinned to a resolved project. pub async fn serve_session_rmcp_async(transport: T, project_root: PathBuf) -> anyhow::Result<()> +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + serve_session_rmcp_async_with_project_services(transport, project_root, None).await +} + +pub async fn serve_session_rmcp_async_with_project_services( + transport: T, + project_root: PathBuf, + project_services: Option, +) -> anyhow::Result<()> where T: AsyncRead + AsyncWrite + Send + Unpin + 'static, { use rmcp::ServiceExt; - let handler = CodeGraphHandler::new(Some(project_root)); + let handler = CodeGraphHandler::new_with_project_services(Some(project_root), project_services); let running = handler .serve(transport) .await @@ -75,6 +87,19 @@ where /// threads and a `tokio::io::duplex`, so rmcp still drives a genuinely-async /// transport (the blocking `poll_read` deadlock is impossible on the rmcp side). pub fn serve_session_rmcp(reader: R, writer: W, project_root: PathBuf) -> anyhow::Result<()> +where + R: BufRead + Send + 'static, + W: Write + Send + 'static, +{ + serve_session_rmcp_with_project_services(reader, writer, project_root, None) +} + +pub fn serve_session_rmcp_with_project_services( + reader: R, + writer: W, + project_root: PathBuf, + project_services: Option, +) -> anyhow::Result<()> where R: BufRead + Send + 'static, W: Write + Send + 'static, @@ -135,7 +160,12 @@ where } }); - let serve_result = serve_session_rmcp_async(rmcp_side, project_root).await; + let serve_result = serve_session_rmcp_async_with_project_services( + rmcp_side, + project_root, + project_services, + ) + .await; // Teardown ORDER guarantees rmcp's final response reaches the writer // before we return: drain the pipe to EOF (only after rmcp closed its diff --git a/crates/codegraph-mcp/src/schemas.rs b/crates/codegraph-mcp/src/schemas.rs index 0a8fcc97..491d5250 100644 --- a/crates/codegraph-mcp/src/schemas.rs +++ b/crates/codegraph-mcp/src/schemas.rs @@ -272,6 +272,27 @@ mod tests { } } + #[test] + fn explore_always_load_metadata_survives_every_schema_surface() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for surface in [ + tool_definitions(), + visible_tool_definitions(), + visible_tool_definitions_requiring_project_path(), + ] { + let explore = surface + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "codegraph_explore") + .expect("explore remains visible"); + assert_eq!( + explore["_meta"]["anthropic/alwaysLoad"], + serde_json::json!(true) + ); + } + } + #[test] fn is_known_tool_rejects_unknown_and_accepts_known() { assert!(is_known_tool("codegraph_search")); diff --git a/crates/codegraph-mcp/src/server.rs b/crates/codegraph-mcp/src/server.rs index d230f3b1..87a86269 100644 --- a/crates/codegraph-mcp/src/server.rs +++ b/crates/codegraph-mcp/src/server.rs @@ -17,6 +17,7 @@ use serde_json::{Value, json}; use crate::engine::CodeGraphEngine; use crate::instructions::server_instructions; +use crate::project_services::ProjectServiceBroker; use crate::protocol::{JsonRpcRequest, JsonRpcResponse, ToolResult, error_codes}; use crate::roots::{ ROOTS_LIST_REQUEST_ID, ServerRootDiscovery, WorkspaceRoots, db_exists_for, db_path_for, @@ -196,17 +197,31 @@ pub struct McpServer { workspace_roots: WorkspaceRoots, root_discovery: ServerRootDiscovery, no_roots: bool, + project_services: Option, } impl McpServer { pub fn new(default_project: Option) -> Self { - Self::with_cwd(default_project, std::env::current_dir().ok(), false) + Self::new_with_project_services(default_project, None) + } + + pub fn new_with_project_services( + default_project: Option, + project_services: Option, + ) -> Self { + Self::with_cwd( + default_project, + std::env::current_dir().ok(), + false, + project_services, + ) } fn with_cwd( default_project: Option, cwd: Option, use_cwd_for_discovery: bool, + project_services: Option, ) -> Self { let search_from = default_project .clone() @@ -249,6 +264,7 @@ impl McpServer { workspace_roots: WorkspaceRoots::new(), root_discovery, no_roots: false, + project_services, } } @@ -545,6 +561,18 @@ impl McpServer { ); } }; + if raw_project.is_some() + && let Some(project_services) = &self.project_services + && let Err(error) = project_services.ensure(&project_path) + { + return Dispatch::Reply( + serde_json::to_value(ToolResult::error(format!( + "Failed to prepare live synchronization for {}: {error:#}", + project_path.display() + ))) + .expect("ToolResult serializes"), + ); + } let engine = match self.engine_for(&project_path) { Ok(e) => e, @@ -641,7 +669,7 @@ impl McpServer { /// mutating the process-global working directory. #[doc(hidden)] pub fn new_with_cwd(default_project: Option, cwd: Option) -> Self { - Self::with_cwd(default_project, cwd, true) + Self::with_cwd(default_project, cwd, true, None) } } diff --git a/crates/codegraph-mcp/src/tools_list.json b/crates/codegraph-mcp/src/tools_list.json index 1a49661d..c3c106d4 100644 --- a/crates/codegraph-mcp/src/tools_list.json +++ b/crates/codegraph-mcp/src/tools_list.json @@ -59,6 +59,10 @@ "description": "Maximum number of callers to return (default: 20)", "default": 20 }, + "file": { + "type": "string", + "description": "Narrow same-named definitions by exact project-relative path or path suffix. A miss is disclosed and falls back to all definitions." + }, "projectPath": { "type": "string", "description": "Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases." @@ -90,6 +94,10 @@ "description": "Maximum number of callees to return (default: 20)", "default": 20 }, + "file": { + "type": "string", + "description": "Narrow same-named definitions by exact project-relative path or path suffix. A miss is disclosed and falls back to all definitions." + }, "projectPath": { "type": "string", "description": "Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases." @@ -121,6 +129,10 @@ "description": "How many levels of dependencies to traverse (default: 2)", "default": 2 }, + "file": { + "type": "string", + "description": "Narrow same-named definitions by exact project-relative path or path suffix. A miss is disclosed and falls back to all definitions." + }, "projectPath": { "type": "string", "description": "Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases." @@ -216,6 +228,9 @@ "destructiveHint": false, "idempotentHint": true, "openWorldHint": false + }, + "_meta": { + "anthropic/alwaysLoad": true } }, { diff --git a/crates/codegraph-resolve/src/alias_binding.rs b/crates/codegraph-resolve/src/alias_binding.rs new file mode 100644 index 00000000..4ce71752 --- /dev/null +++ b/crates/codegraph-resolve/src/alias_binding.rs @@ -0,0 +1,122 @@ +//! Resolution for a binding whose initializer is only another callable. + +use codegraph_core::types::{Node, NodeKind}; +use regex::Regex; + +use crate::name_matcher::same_language_family; +use crate::types::ResolutionContext; + +fn is_alias_binding_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Constant | NodeKind::Variable | NodeKind::Property + ) +} + +fn is_callable_kind(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Function | NodeKind::Method | NodeKind::Class | NodeKind::Component + ) +} + +fn regex_escape(value: &str) -> String { + regex::escape(value) +} + +/// Return the callable name a pure alias initializer names. +/// +/// `member_name` selects an object-literal property (`= { run: impl }` or +/// `= { impl }`); `None` accepts only a bare identifier initializer. +pub(crate) fn alias_target_name( + signature: Option<&str>, + member_name: Option<&str>, +) -> Option { + let initializer = signature?.trim(); + if let Some(member) = member_name { + let key = regex_escape(member); + let explicit = Regex::new(&format!( + r"(?:\{{|,)\s*{key}\s*:\s*([A-Za-z_$][\w$]*)\s*(?:,|\}})" + )) + .ok()?; + if let Some(target) = explicit + .captures(initializer) + .and_then(|captures| captures.get(1)) + { + return Some(target.as_str().to_string()); + } + let shorthand = Regex::new(&format!(r"(?:\{{|,)\s*({key})\s*(?:,|\}})")).ok()?; + return shorthand + .captures(initializer) + .and_then(|captures| captures.get(1)) + .map(|capture| capture.as_str().to_string()); + } + + let bare = + Regex::new(r"^=\s*([A-Za-z_$][\w$]*)\s*(?:as\s+[A-Za-z_$][\w.$<>\[\]]*\s*)?;?$").ok()?; + bare.captures(initializer) + .and_then(|captures| captures.get(1)) + .map(|capture| capture.as_str().to_string()) +} + +/// Follow one pure alias binding to the callable it names. Same-file targets +/// are preferred; a cross-file target must be unique. +pub(crate) fn resolve_alias_binding( + alias: &Node, + member_name: Option<&str>, + context: &dyn ResolutionContext, +) -> Option { + if !is_alias_binding_kind(alias.kind) { + return None; + } + let target_name = alias_target_name(alias.signature.as_deref(), member_name)?; + if target_name == alias.name { + return None; + } + let candidates = context + .get_nodes_by_name_shared(&target_name) + .into_iter() + .filter(|candidate| { + is_callable_kind(candidate.kind) + && same_language_family(candidate.language, alias.language) + }) + .collect::>(); + let same_file = candidates + .iter() + .filter(|candidate| candidate.file_path == alias.file_path) + .collect::>(); + if same_file.len() == 1 { + return Some(same_file[0].as_ref().clone()); + } + if same_file.is_empty() && candidates.len() == 1 { + return Some(candidates[0].as_ref().clone()); + } + None +} + +#[cfg(test)] +mod tests { + use super::alias_target_name; + + #[test] + fn parses_only_pure_alias_initializers() { + assert_eq!( + alias_target_name(Some("= realImpl"), None).as_deref(), + Some("realImpl") + ); + assert_eq!( + alias_target_name(Some("= realImpl as Handler;"), None).as_deref(), + Some("realImpl") + ); + assert_eq!( + alias_target_name(Some("= { run: realImpl }"), Some("run")).as_deref(), + Some("realImpl") + ); + assert_eq!( + alias_target_name(Some("= { realImpl }"), Some("realImpl")).as_deref(), + Some("realImpl") + ); + assert_eq!(alias_target_name(Some("= realImpl()"), None), None); + assert_eq!(alias_target_name(Some("= () => realImpl()"), None), None); + } +} diff --git a/crates/codegraph-resolve/src/awaited.rs b/crates/codegraph-resolve/src/awaited.rs new file mode 100644 index 00000000..a5f474cd --- /dev/null +++ b/crates/codegraph-resolve/src/awaited.rs @@ -0,0 +1,711 @@ +//! Awaited JS/TS receiver inference (`inferEsmAwaitedCallType`, upstream +//! v1.6.1, #1840/#1885). +//! +//! For `const handle = await makeEngine(); handle.run()`, the receiver's type is +//! the declared return type of the awaited callee, unwrapped from one +//! `Promise` layer. Every step is proof-carrying: the binding must be +//! visible in the brace scope of the call, not re-declared or re-assigned +//! before it, and bound to a bare call (no trailing member/index/call); the +//! callee must be an imported function or the one lexically reachable local +//! function; and the annotation must name a project class or interface. An +//! awaited shape that fails any step is [`AwaitedInference::Unresolved`], which +//! callers must not turn into a name-based guess. +//! +//! Offsets: the comment- and literal-blanked view replaces character for +//! character, so a source position maps to the view by its line and the +//! number of characters before it on that line. + +use crate::import_resolver::resolve_via_import; +use crate::source_facts::{LineIndex, SourceFacts}; +use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; +use crate::types::{RefView, ResolutionContext}; +use codegraph_core::types::{EdgeKind, Node, NodeKind}; +use regex::Regex; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock}; + +/// Upstream `TS_PRIMITIVE_TYPES`. +const TS_PRIMITIVE_TYPES: [&str; 12] = [ + "string", + "number", + "boolean", + "bigint", + "symbol", + "void", + "undefined", + "null", + "never", + "unknown", + "any", + "object", +]; + +/// Outcome of inspecting a receiver for an awaited binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AwaitedInference { + /// No awaited binding of this name is visible: other strategies proceed. + NotApplicable, + /// The awaited shape is present but its type is unknown, primitive or + /// unsafe; callers must not continue into name heuristics. + Unresolved, + /// The project class/interface the awaited value has, and its file. + Inferred { + type_name: String, + file_path: String, + }, +} + +/// One `{ … }` scope of the blanked view; the root has no opening brace. +#[derive(Debug)] +struct Scope { + start: Option, + end: usize, + parent: Option, +} + +/// Per-file awaited-binding index (`AWAITED_FILES`, prepared lazily). +#[derive(Debug)] +pub struct AwaitedIndex { + lines: LineIndex, + names: HashSet, + scopes: Vec, + declarations: HashMap>, +} + +fn awaited_binding_pattern() -> &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| { + Regex::new( + r"(?-u:\b)(?:const|let|var)\s+([0-9A-Za-z_$]+)\s*=\s*await\s+[0-9A-Za-z_$]+\s*\(", + ) + .expect("awaited binding pattern") + }) +} + +/// Names that raw source binds as `const x = await f(` — the cheap +/// eligibility check that runs before any sanitising. +pub(crate) fn raw_awaited_names(source: &str) -> HashSet { + awaited_binding_pattern() + .captures_iter(source) + .filter_map(|captures| captures.get(1).map(|name| name.as_str().to_string())) + .collect() +} + +impl AwaitedIndex { + /// Index the blanked view `code`: its awaited names, brace scope tree and + /// the declarations of those names. + pub(crate) fn new(code: &str) -> Self { + let names: HashSet = awaited_binding_pattern() + .captures_iter(code) + .filter_map(|captures| captures.get(1).map(|name| name.as_str().to_string())) + .collect(); + let mut scopes = vec![Scope { + start: None, + end: code.len(), + parent: None, + }]; + let mut stack = vec![0usize]; + for (offset, byte) in code.bytes().enumerate() { + if byte == b'{' { + let parent = *stack.last().expect("root scope"); + scopes.push(Scope { + start: Some(offset), + end: code.len(), + parent: Some(parent), + }); + stack.push(scopes.len() - 1); + } else if byte == b'}' && stack.len() > 1 { + let closed = stack.pop().expect("open scope"); + scopes[closed].end = offset; + } + } + static DECLARATION: OnceLock = OnceLock::new(); + let declaration = DECLARATION.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:const|let|var)\s+([0-9A-Za-z_$]+)\s*=\s*").expect("declaration") + }); + let mut declarations: HashMap> = HashMap::new(); + for captures in declaration.captures_iter(code) { + let (Some(whole), Some(name)) = (captures.get(0), captures.get(1)) else { + continue; + }; + if names.contains(name.as_str()) { + declarations + .entry(name.as_str().to_string()) + .or_default() + .push((whole.start(), whole.len())); + } + } + Self { + lines: LineIndex::new(code), + names, + scopes, + declarations, + } + } + + /// The innermost scope containing `offset` (binary search over openings, + /// then climb out of scopes already closed). + fn scope_at(&self, offset: usize) -> usize { + let (mut lo, mut hi) = (0usize, self.scopes.len()); + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if self.scopes[mid].start.is_some_and(|start| start < offset) { + lo = mid; + } else { + hi = mid; + } + } + while lo > 0 && self.scopes[lo].end < offset { + lo = self.scopes[lo].parent.unwrap_or(0); + } + lo + } + + fn visible_at(&self, declaration: usize, use_at: usize) -> bool { + let ancestor = self.scope_at(declaration); + let mut scope = Some(self.scope_at(use_at)); + while let Some(current) = scope { + if current == ancestor { + return true; + } + scope = self.scopes[current].parent; + } + false + } +} + +/// Byte offset in `to` of the position that `from_col` bytes into line +/// `line_index` of `from` denotes, matching by character count. +fn map_column( + from: &str, + from_lines: &LineIndex, + to: &str, + to_lines: &LineIndex, + line_index: usize, + from_col: usize, +) -> Option { + let from_line = from_lines.raw_line(from, line_index)?; + let from_col = from_col.min(from_line.len()); + if !from_line.is_char_boundary(from_col) { + return None; + } + let chars = from_line[..from_col].chars().count(); + let to_start = to_lines.raw_line_start(line_index)?; + let to_line = to_lines.raw_line(to, line_index)?; + let within = to_line + .char_indices() + .nth(chars) + .map_or(to_line.len(), |(offset, _)| offset); + Some(to_start + within) +} + +fn is_word(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' +} + +/// `\bNAME\b` (ASCII) somewhere in `text`. +fn contains_word(text: &str, name: &str) -> bool { + word_positions(text, name).next().is_some() +} + +/// Start offsets of `name` in `text` with an ASCII word boundary on both sides. +fn word_positions<'a>(text: &'a str, name: &'a str) -> impl Iterator + 'a { + let first_is_word = name.chars().next().is_some_and(is_word); + let last_is_word = name.chars().next_back().is_some_and(is_word); + text.match_indices(name).filter_map(move |(at, _)| { + let before = text[..at].chars().next_back().is_some_and(is_word); + let after = text[at + name.len()..].chars().next().is_some_and(is_word); + (before != first_is_word && after != last_is_word).then_some(at) + }) +} + +/// `hasParameterBinding`: `name` is an arrow parameter (`name =>`) or appears +/// inside a balanced parameter list that opens a function or arrow body. +pub(crate) fn has_parameter_binding(code: &str, name: &str) -> bool { + for at in word_positions(code, name) { + let rest = code[at + name.len()..].trim_start_matches(char::is_whitespace); + if rest.starts_with("=>") { + return true; + } + } + static BODY: OnceLock = OnceLock::new(); + let body = + BODY.get_or_init(|| Regex::new(r"^\s*(?::[^=;{]*)?(?:=>|\{)").expect("parameter body")); + let bytes = code.as_bytes(); + for (open, _) in code.match_indices('(') { + let head = code[..open].trim_end_matches(char::is_whitespace); + let keyword = ["if", "while", "for", "switch", "with"] + .iter() + .any(|keyword| { + head.strip_suffix(keyword) + .is_some_and(|before| !before.chars().next_back().is_some_and(is_word)) + }); + if keyword { + continue; + } + let mut depth = 1usize; + let mut close = open + 1; + while close < bytes.len() && depth > 0 { + match bytes[close] { + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + close += 1; + } + if depth == 0 + && contains_word(&code[open + 1..close - 1], name) + && body.is_match(&code[close..]) + { + return true; + } + } + false +} + +/// `\b(?:const|let|var|function|class)\s+(?:NAME\b|\{[^}]*\bNAME\b)` starts. +fn declaration_offsets(code: &str, name: &str) -> Vec { + static KEYWORD: OnceLock = OnceLock::new(); + let keyword = KEYWORD.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:const|let|var|function|class)\s+").expect("declaration keyword") + }); + let last_is_word = name.chars().next_back().is_some_and(is_word); + keyword + .find_iter(code) + .filter(|found| { + let rest = &code[found.end()..]; + let direct = rest.starts_with(name) + && rest[name.len()..].chars().next().is_some_and(is_word) != last_is_word; + let destructured = rest.strip_prefix('{').is_some_and(|inner| { + let inner = inner.split('}').next().unwrap_or(inner); + contains_word(inner, name) + }); + direct || destructured + }) + .map(|found| found.start()) + .collect() +} + +/// `\bNAME\s*=(?!=)`: an assignment (not a comparison) to `name`. +fn is_reassigned(code: &str, name: &str) -> bool { + word_positions(code, name).any(|at| { + let rest = code[at + name.len()..].trim_start_matches(char::is_whitespace); + rest.starts_with('=') && !rest[1..].starts_with('=') + }) +} + +/// `importShadowedAt`: a parameter of the enclosing function, or a declaration +/// in a scope enclosing the position, rebinds the imported `name`. +fn import_shadowed_at(name: &str, reference: &RefView, context: &dyn ResolutionContext) -> bool { + let enclosing_parameter = context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .any(|node| { + matches!(node.kind, NodeKind::Function | NodeKind::Method) + && node.start_line <= reference.line + && node.end_line >= reference.line + && node.signature.as_deref().is_some_and(|signature| { + has_parameter_binding(&format!("{signature} {{"), name) + }) + }); + if enclosing_parameter { + return true; + } + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(line_index) = (reference.line as usize).checked_sub(1) else { + return false; + }; + let lines = facts.lines(); + let Some(line_start) = lines.raw_line_start(line_index) else { + return false; + }; + let column = + (reference.column.max(0) as usize).min(facts.raw_line(line_index).map_or(0, str::len)); + let end = line_start + column; + if !facts.source().is_char_boundary(end) { + return false; + } + let code = blank_string_contents(&strip_comments_for_regex( + &facts.source()[..end], + CommentLang::TypeScript, + )); + let stack_at = |end: usize| { + let mut stack = Vec::new(); + for (offset, byte) in code.bytes().enumerate().take(end) { + if byte == b'{' { + stack.push(offset); + } else if byte == b'}' { + stack.pop(); + } + } + stack + }; + let scope = stack_at(code.len()); + declaration_offsets(&code, name).into_iter().any(|at| { + let declared = stack_at(at); + declared + .iter() + .enumerate() + .all(|(depth, open)| scope.get(depth) == Some(open)) + }) +} + +/// Infer the project type of an awaited receiver (`inferEsmAwaitedCallType`). +pub(crate) fn infer_awaited_receiver( + receiver: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> AwaitedInference { + let plain = receiver + .chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_' || first == '$') + && receiver.chars().all(|ch| is_word(ch) || ch == '$'); + if !plain { + return AwaitedInference::NotApplicable; + } + let Some(facts) = context.source_facts(&reference.file_path) else { + return AwaitedInference::NotApplicable; + }; + // Raw eligibility is cheap; the sanitised index is built only once a + // reference actually uses an awaited name. + if !facts + .awaited_raw_names(raw_awaited_names) + .contains(receiver) + { + return AwaitedInference::NotApplicable; + } + let index = facts.awaited_index(|code| Arc::new(AwaitedIndex::new(code))); + if !index.names.contains(receiver) { + return AwaitedInference::NotApplicable; + } + resolve_awaited(receiver, &facts, &index, reference, context) +} + +fn resolve_awaited( + receiver: &str, + facts: &SourceFacts, + index: &AwaitedIndex, + reference: &RefView, + context: &dyn ResolutionContext, +) -> AwaitedInference { + let unknown = AwaitedInference::Unresolved; + let code_full = facts.ts_blanked(); + let Some(line_index) = (reference.line as usize).checked_sub(1) else { + return AwaitedInference::NotApplicable; + }; + let Some(end) = map_column( + facts.source(), + facts.lines(), + code_full, + &index.lines, + line_index, + reference.column.max(0) as usize, + ) else { + return AwaitedInference::NotApplicable; + }; + let code = &code_full[..end.min(code_full.len())]; + let Some(&(binding_at, binding_len)) = index.declarations.get(receiver).and_then(|found| { + found + .iter() + .rev() + .find(|(at, _)| *at < end && index.visible_at(*at, end)) + }) else { + return AwaitedInference::NotApplicable; + }; + let init = &code[(binding_at + binding_len).min(code.len())..]; + if !init.starts_with("await") || init[5..].chars().next().is_some_and(is_word) { + return AwaitedInference::NotApplicable; + } + static CALL: OnceLock = OnceLock::new(); + let call = CALL.get_or_init(|| { + Regex::new(r"^await\s+([A-Za-z_$][0-9A-Za-z_$]*)\s*\(").expect("awaited call") + }); + let Some(captures) = call.captures(init) else { + return AwaitedInference::NotApplicable; + }; + let callee = captures + .get(1) + .map_or("", |callee| callee.as_str()) + .to_string(); + let mut depth = 1usize; + let mut call_end = captures.get(0).map_or(0, |whole| whole.end()); + let bytes = init.as_bytes(); + while call_end < bytes.len() && depth > 0 { + match bytes[call_end] { + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + call_end += 1; + } + if depth != 0 { + return unknown; + } + let tail = &init[call_end..]; + // Only a bare call result: `;` or a line end not continued by a member, + // index, call or conditional. + let after_blanks = tail.trim_start_matches([' ', '\t']); + let bare_result = after_blanks.starts_with(';') + || after_blanks + .strip_prefix("\r\n") + .or_else(|| after_blanks.strip_prefix('\n')) + .is_some_and(|next| { + !next + .trim_start_matches([' ', '\t']) + .starts_with(['.', '(', '[', '?']) + }); + if !bare_result { + return unknown; + } + if !declaration_offsets(tail, receiver).is_empty() + || is_reassigned(tail, receiver) + || has_parameter_binding(tail, receiver) + { + return unknown; + } + + let binding_line_index = code[..binding_at].matches('\n').count(); + let binding_line = binding_line_index as i64 + 1; + let Some(code_line_start) = index.lines.raw_line_start(binding_line_index) else { + return unknown; + }; + let binding_col = map_column( + code_full, + &index.lines, + facts.source(), + facts.lines(), + binding_line_index, + binding_at - code_line_start, + ) + .and_then(|absolute| { + facts + .lines() + .raw_line_start(binding_line_index) + .map(|start| absolute - start) + }) + .unwrap_or(0); + let mut binding_ref = reference.clone(); + binding_ref.line = binding_line; + binding_ref.column = binding_col as i64; + + let callee_is_parameter = context + .get_nodes_in_file_shared(&reference.file_path) + .iter() + .any(|node| { + matches!(node.kind, NodeKind::Function | NodeKind::Method) + && node.start_line <= binding_line + && node.end_line >= binding_line + && node.signature.as_deref().is_some_and(|signature| { + has_parameter_binding(&format!("{signature} {{"), &callee) + }) + }); + if callee_is_parameter { + return unknown; + } + + let imported = context + .get_import_mappings(&reference.file_path, reference.language) + .iter() + .any(|mapping| mapping.local_name == callee); + let declaring: Option> = if imported { + if import_shadowed_at(&callee, &binding_ref, context) { + return unknown; + } + let mut callee_ref = binding_ref.clone(); + callee_ref.reference_name = callee.clone(); + callee_ref.reference_kind = EdgeKind::Calls; + resolve_via_import(&callee_ref, context) + .and_then(|resolved| context.get_node_by_id_shared(&resolved.target_node_id)) + } else { + let local: Vec> = context + .get_nodes_by_name_shared(&callee) + .into_iter() + .filter(|node| { + node.kind == NodeKind::Function + && node.file_path == reference.file_path + && crate::name_matcher::is_esm_module_family(node.language) + && crate::name_matcher::is_lexically_reachable(node, &binding_ref, context) + }) + .collect(); + (local.len() == 1).then(|| Arc::clone(&local[0])) + }; + let Some(declaring) = declaring else { + return unknown; + }; + let Some(signature) = declaring + .signature + .as_deref() + .filter(|_| declaring.kind == NodeKind::Function) + else { + return unknown; + }; + if !imported { + let before_binding = &code[..binding_at]; + static LOCAL_DECL: OnceLock = OnceLock::new(); + let local_decl = LOCAL_DECL.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:const|let|var)\s+").expect("local declaration") + }); + for found in local_decl.find_iter(before_binding) { + let rest = &before_binding[found.end()..]; + let names_callee = rest.starts_with(callee.as_str()) + && rest[callee.len()..].chars().next().is_some_and(is_word) + != callee.chars().next_back().is_some_and(is_word); + if !names_callee || !index.visible_at(found.start(), binding_at) { + continue; + } + // A typed arrow function may itself be the declared local factory. + let shadow_line_index = code[..found.start()].matches('\n').count(); + let shadow_col = index + .lines + .raw_line_start(shadow_line_index) + .and_then(|start| { + map_column( + code_full, + &index.lines, + facts.source(), + facts.lines(), + shadow_line_index, + found.start() - start, + ) + }) + .and_then(|absolute| { + facts + .lines() + .raw_line_start(shadow_line_index) + .map(|start| absolute - start) + }) + .unwrap_or(usize::MAX); + if shadow_line_index as i64 + 1 != declaring.start_line + || shadow_col as i64 > declaring.start_column + { + return unknown; + } + } + } + static ANNOTATION: OnceLock = OnceLock::new(); + let annotation = + ANNOTATION.get_or_init(|| Regex::new(r"^\s*:\s*([\s\S]+)$").expect("return annotation")); + let after_params = &signature[signature + .rfind(')') + .map_or(signature.len(), |close| close + 1)..]; + let Some(annotation) = annotation + .captures(after_params) + .and_then(|captures| captures.get(1)) + .map(|found| found.as_str().trim()) + else { + return unknown; + }; + static PROMISE: OnceLock = OnceLock::new(); + let promise = PROMISE + .get_or_init(|| Regex::new(r"^Promise\s*<\s*([0-9A-Za-z_$]+)\s*>$").expect("Promise")); + let returned = promise + .captures(annotation) + .and_then(|captures| captures.get(1)) + .map_or(annotation, |inner| inner.as_str()); + let named = returned + .chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_' || first == '$') + && returned.chars().all(|ch| is_word(ch) || ch == '$'); + if !named || TS_PRIMITIVE_TYPES.contains(&returned) { + // A primitive awaited value is a built-in, never a project method. + return unknown; + } + let mut type_ref = binding_ref.clone(); + type_ref.from_node_id = declaring.id.clone(); + type_ref.file_path = declaring.file_path.clone(); + type_ref.language = declaring.language; + type_ref.line = declaring.start_line; + type_ref.column = declaring.start_column; + type_ref.reference_name = returned.to_string(); + type_ref.reference_kind = EdgeKind::References; + let type_import = context + .get_import_mappings(&declaring.file_path, declaring.language) + .iter() + .any(|mapping| mapping.local_name == returned); + let type_node = if type_import { + resolve_via_import(&type_ref, context) + .and_then(|resolved| context.get_node_by_id_shared(&resolved.target_node_id)) + } else { + context + .get_nodes_by_name_shared(returned) + .into_iter() + .find(|node| { + node.file_path == declaring.file_path + && crate::name_matcher::is_esm_module_family(node.language) + && matches!(node.kind, NodeKind::Class | NodeKind::Interface) + }) + }; + match type_node { + Some(node) if matches!(node.kind, NodeKind::Class | NodeKind::Interface) => { + AwaitedInference::Inferred { + type_name: node.name.clone(), + file_path: node.file_path.clone(), + } + } + _ => unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parameter_binding_matches_upstream_shapes() { + assert!(has_parameter_binding( + "(a, handle) => handle.run()", + "handle" + )); + assert!(has_parameter_binding("handle => 1", "handle")); + assert!(has_parameter_binding( + "function f(handle: Engine) {", + "handle" + )); + assert!(!has_parameter_binding("if (handle) { run(); }", "handle")); + assert!(!has_parameter_binding("call(handle);", "handle")); + assert!(!has_parameter_binding("(handler) => 1", "handle")); + } + + #[test] + fn declaration_and_reassignment_are_word_bounded() { + assert_eq!(declaration_offsets("let handle = 1;", "handle"), vec![0]); + assert_eq!( + declaration_offsets("const { a, handle } = o;", "handle"), + vec![0] + ); + assert!(declaration_offsets("const handler = 1;", "handle").is_empty()); + assert!(is_reassigned("handle = other;", "handle")); + assert!(!is_reassigned("if (handle == other) {}", "handle")); + assert!(!is_reassigned("handler = x;", "handle")); + } + + #[test] + fn scope_tree_resolves_nested_visibility() { + let code = "const a = await f();\n{ const b = await g(); }\nuse(a);\n"; + let index = AwaitedIndex::new(code); + assert!(index.names.contains("a") && index.names.contains("b")); + let a = index.declarations["a"][0].0; + let b = index.declarations["b"][0].0; + let use_at = code.find("use(a)").unwrap(); + assert!(index.visible_at(a, use_at)); + assert!( + !index.visible_at(b, use_at), + "a block-scoped binding is not visible after its block" + ); + } + + #[test] + fn map_column_counts_characters_across_blanked_views() { + let source = "/* é */ x\n"; + let code = strip_comments_for_regex(source, CommentLang::TypeScript); + let source_lines = LineIndex::new(source); + let code_lines = LineIndex::new(&code); + let column = source.find('x').unwrap(); + let mapped = map_column(source, &source_lines, &code, &code_lines, 0, column).unwrap(); + assert_eq!(&code[mapped..mapped + 1], "x"); + } +} diff --git a/crates/codegraph-resolve/src/context.rs b/crates/codegraph-resolve/src/context.rs index 31788dbe..912226e2 100644 --- a/crates/codegraph-resolve/src/context.rs +++ b/crates/codegraph-resolve/src/context.rs @@ -8,6 +8,7 @@ use crate::lru_cache::LruCache; use crate::path_aliases::{AliasMap, load_project_aliases}; +use crate::source_facts::SourceFacts; use crate::types::{GoModule, ImportMapping, ReExport, ResolutionContext}; use crate::workspace_packages::{WorkspacePackages, load_workspace_packages}; use crate::{import_resolver, pathutil}; @@ -36,7 +37,8 @@ pub struct StoreResolutionContext<'a> { struct Caches { node_cache: LruCache>>, - file_cache: LruCache>, + file_cache: LruCache>>, + source_facts_cache: LruCache>>, import_mapping_cache: LruCache>, re_export_cache: LruCache>, name_cache: LruCache>>, @@ -58,6 +60,7 @@ impl Caches { Self { node_cache: LruCache::new(limit), file_cache: LruCache::new(content_limit), + source_facts_cache: LruCache::new(content_limit), import_mapping_cache: LruCache::new(limit), re_export_cache: LruCache::new(limit), name_cache: LruCache::new(limit), @@ -89,6 +92,7 @@ impl<'a> StoreResolutionContext<'a> { let mut c = self.caches.borrow_mut(); c.node_cache.clear(); c.file_cache.clear(); + c.source_facts_cache.clear(); c.import_mapping_cache.clear(); c.re_export_cache.clear(); c.name_cache.clear(); @@ -242,21 +246,43 @@ impl ResolutionContext for StoreResolutionContext<'_> { { return true; } - let full_path = Path::new(&self.project_root).join(file_path); - full_path.exists() + pathutil::lexical_path_within_root(&self.project_root, file_path) + .is_some_and(|full_path| full_path.exists()) } fn read_file(&self, file_path: &str) -> Option { + self.read_file_shared(file_path) + .map(|content| content.to_string()) + } + + fn read_file_shared(&self, file_path: &str) -> Option> { let mut c = self.caches.borrow_mut(); if c.file_cache.has(&file_path.to_string()) { return c.file_cache.get(&file_path.to_string()).flatten(); } let full_path = Path::new(&self.project_root).join(file_path); - let content = std::fs::read_to_string(&full_path).ok(); + let content: Option> = std::fs::read_to_string(&full_path).ok().map(Arc::from); c.file_cache.set(file_path.to_string(), content.clone()); content } + fn source_facts(&self, file_path: &str) -> Option> { + { + let mut c = self.caches.borrow_mut(); + if c.source_facts_cache.has(&file_path.to_string()) { + return c.source_facts_cache.get(&file_path.to_string()).flatten(); + } + } + let facts = self + .read_file_shared(file_path) + .map(|source| Arc::new(SourceFacts::new(source))); + self.caches + .borrow_mut() + .source_facts_cache + .set(file_path.to_string(), facts.clone()); + facts + } + fn get_project_root(&self) -> &str { &self.project_root } @@ -315,7 +341,11 @@ impl ResolutionContext for StoreResolutionContext<'_> { let type_nodes = self .get_nodes_by_name_shared(type_name) .into_iter() - .filter(|n| SUPERTYPE_BEARING.contains(&n.kind) && n.language == language) + .filter(|n| { + n.language == language + // A Scala singleton inherits members though it can never be a parent. + && (SUPERTYPE_BEARING.contains(&n.kind) || crate::types::is_scala_singleton(n)) + }) .collect::>(); if type_nodes.is_empty() { return Vec::new(); @@ -764,6 +794,12 @@ mod tests { #[test] fn store_context_file_exists_and_read_file() { let root = temp_root("files"); + let outside = root.parent().unwrap().join(format!( + "outside-{}", + root.file_name().unwrap().to_string_lossy() + )); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret.ts"), "export const secret = 42;\n").unwrap(); let store = open_store(&root); store.upsert_file(&file_record("known.ts", 0)).unwrap(); std::fs::write(root.join("ondisk.ts"), "export const x = 1;\n").unwrap(); @@ -772,6 +808,13 @@ mod tests { assert!(ctx.file_exists("known.ts")); assert!(ctx.file_exists("ondisk.ts")); assert!(!ctx.file_exists("nowhere.ts")); + assert!( + !ctx.file_exists(&format!( + "../{}/secret.ts", + outside.file_name().unwrap().to_string_lossy() + )), + "an existing file outside the project must not become an existence oracle" + ); let content = ctx.read_file("ondisk.ts").expect("read"); assert!(content.contains("export const x")); @@ -782,6 +825,7 @@ mod tests { assert!(ctx.get_all_files().contains(&"known.ts".to_string())); std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&outside).ok(); } #[test] diff --git a/crates/codegraph-resolve/src/import_resolver.rs b/crates/codegraph-resolve/src/import_resolver.rs index cb371b56..fc88c495 100644 --- a/crates/codegraph-resolve/src/import_resolver.rs +++ b/crates/codegraph-resolve/src/import_resolver.rs @@ -21,6 +21,17 @@ use regex::Regex; use std::collections::BTreeSet; use std::sync::{Arc, OnceLock}; +/// Result of checking whether a PHP call is owned by a class import. +/// +/// `Claimed(None)` is intentionally distinct from `NotApplicable`: once a +/// `use ... as Alias` mapping owns `Alias::method()`, a missing or ambiguous +/// target must stay unresolved instead of falling through to a same-named +/// class or method in another namespace (#1545). +pub(crate) enum PhpImportedStaticCallResolution { + NotApplicable, + Claimed(Option), +} + /// Extension resolution order by language (`EXTENSION_RESOLUTION`, /// `import-resolver.ts:17-37`). fn extension_resolution(language: Language) -> &'static [&'static str] { @@ -1360,6 +1371,94 @@ fn drop_last_segment(path: &str) -> Vec<&str> { } } +/// Resolve a PHP scoped call through the exact class namespace named by its +/// local `use` binding. +/// +/// The extractor encodes both `Alias::method()` and `$Alias->method()` as +/// `Alias.method`. The source-position check keeps PHP's class and variable +/// namespaces separate: a leading `$` is an instance receiver and remains +/// eligible for local type inference. Otherwise an import mapping claims the +/// call even when its class or member is unavailable, preventing unrelated +/// name-matching fallbacks from manufacturing an edge (#1545). +pub(crate) fn resolve_php_imported_static_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> PhpImportedStaticCallResolution { + if reference.language != Language::Php + || reference.reference_kind != codegraph_core::types::EdgeKind::Calls + { + return PhpImportedStaticCallResolution::NotApplicable; + } + + let Some((receiver, member)) = reference.reference_name.split_once('.') else { + return PhpImportedStaticCallResolution::NotApplicable; + }; + if receiver.is_empty() + || member.is_empty() + || member.contains('.') + || !receiver.chars().all(|c| c == '_' || c.is_alphanumeric()) + || !member.chars().all(|c| c == '_' || c.is_alphanumeric()) + { + return PhpImportedStaticCallResolution::NotApplicable; + } + + let Some(import) = context + .get_import_mappings(&reference.file_path, reference.language) + .into_iter() + .find(|import| import.local_name == receiver) + else { + return PhpImportedStaticCallResolution::NotApplicable; + }; + + // Tree-sitter columns are byte offsets. Checking the byte directly avoids + // slicing a UTF-8 line at a potentially non-character boundary. + let is_variable_receiver = context.read_file(&reference.file_path).and_then(|source| { + source + .lines() + .nth((reference.line.max(1) - 1) as usize) + .and_then(|line| line.as_bytes().get(reference.column.max(0) as usize)) + .copied() + }) == Some(b'$'); + if is_variable_receiver { + return PhpImportedStaticCallResolution::NotApplicable; + } + + let imported_fqn = import.source.trim_start_matches('\\'); + let owner_qualified = imported_fqn.rsplit_once('\\').map_or_else( + || imported_fqn.to_string(), + |(namespace, name)| format!("{namespace}::{name}"), + ); + let owners: Vec> = context + .get_nodes_by_qualified_name_shared(&owner_qualified) + .into_iter() + .filter(|node| node.language == Language::Php && is_static_member_container(node.kind)) + .collect(); + let [owner] = owners.as_slice() else { + return PhpImportedStaticCallResolution::Claimed(None); + }; + + let member_qualified = format!("{}::{member}", owner.qualified_name); + let methods: Vec> = context + .get_nodes_by_qualified_name_shared(&member_qualified) + .into_iter() + .filter(|node| { + node.language == Language::Php + && node.kind == NodeKind::Method + && node.file_path == owner.file_path + }) + .collect(); + let [method] = methods.as_slice() else { + return PhpImportedStaticCallResolution::Claimed(None); + }; + + PhpImportedStaticCallResolution::Claimed(Some(ResolvedRef { + original: reference.clone(), + target_node_id: method.id.clone(), + confidence: 0.95, + resolved_by: ResolvedBy::Import, + })) +} + /// Resolve a reference using import mappings (`resolveViaImport`, /// `import-resolver.ts:1121-1309`). /// diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index f4ef1bfc..3c9feb3a 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -16,6 +16,8 @@ #![allow(clippy::collapsible_if, clippy::collapsible_else_if)] +mod alias_binding; +mod awaited; pub mod context; pub mod framework; // the FrameworkResolver extension point pub mod frameworks; // concrete react/vue/nestjs FrameworkResolvers @@ -26,6 +28,7 @@ pub mod path_aliases; pub mod pathutil; pub mod resolver; pub mod snapshot_context; +pub mod source_facts; pub mod strip_comments; pub mod types; pub mod workspace_packages; diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index fa0985a5..c83617ae 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -5,14 +5,17 @@ //! cross-language family gate — mirror the upstream exactly. Every strategy cites its //! upstream source range. +use crate::source_facts::SourceFacts; +use crate::strip_comments::{CommentLang, strip_comments_for_regex}; use crate::types::{ RefView, ResolutionContext, ResolvedBy, ResolvedRef, declares_type_name, is_esm_language, - kind_is_eligible_target, + node_is_eligible_target, }; use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; use regex::Regex; use std::borrow::Borrow; -use std::sync::{Arc, OnceLock}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; /// Ceiling on same-name candidates a proximity-SCORED strategy will rank /// (`AMBIGUOUS_NAME_CEILING`, upstream name-matcher; override @@ -220,7 +223,7 @@ fn apply_language_gate>(candidates: Vec, reference: &RefView) /// parent's line range. Class members are unaffected (their parent resolves to a /// class-like node), as are top-level symbols and C++ namespace-prefixed names /// (the prefix has no node). -fn is_lexically_reachable( +pub(crate) fn is_lexically_reachable( candidate: &Node, reference: &RefView, context: &dyn ResolutionContext, @@ -228,6 +231,13 @@ fn is_lexically_reachable( if candidate.kind != NodeKind::Function { return true; } + // C and C++ have no nested named functions: a function the graph shows + // inside another is an extraction artifact (a macro whose designated + // initializers derail tree-sitter-c error recovery), not a scope, so it + // stays reachable (`NO_NESTED_FUNCTIONS`, upstream #1708). + if matches!(candidate.language, Language::C | Language::Cpp) { + return true; + } let Some(sep) = candidate.qualified_name.rfind("::") else { return true; }; @@ -254,6 +264,421 @@ fn is_lexically_reachable( .any(|p| reference.line >= p.start_line && reference.line <= p.end_line) } +fn is_js_family(language: Language) -> bool { + matches!( + language, + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) +} + +/// Languages whose file boundary follows ESM-style import/export semantics. +/// ArkTS participates in module visibility even though the narrower JS call +/// inference gates deliberately exclude it (upstream #1719). +pub(crate) fn is_esm_module_family(language: Language) -> bool { + is_js_family(language) || language == Language::ArkTs +} + +/// A JS/TS/ArkTS file containing an import statement but no export is a sealed +/// module: none of its bindings can be named from another file. Classic scripts, +/// CommonJS modules, local export clauses, and ambient globals remain visible +/// (`isSealedModule`, upstream v1.6.1). +/// +/// Cheapest disqualifiers first, as upstream orders them: an exported node +/// (answered without reading the file), then the raw text's `import` and +/// CommonJS markers, and only then the comment- and string-blanked view — a +/// template literal spelling `import fake from "fake"` is data, not an import. +/// The decision is memoised per file for the resolution pass. +fn is_sealed_js_module(file_path: &str, context: &dyn ResolutionContext) -> bool { + static HAS_IMPORT: OnceLock = OnceLock::new(); + static HAS_EXPORT: OnceLock = OnceLock::new(); + static HAS_CJS_EXPORT: OnceLock = OnceLock::new(); + if context.file_has_exported_node(file_path) { + return false; + } + let Some(facts) = context.source_facts(file_path) else { + return false; + }; + facts.js_sealed_module(|facts| { + let source = facts.source(); + if !source.contains("import") { + return false; + } + // CommonJS assignments can execute inside template interpolations, + // which the blanked view hides, so this exemption reads raw source. + let has_cjs_export = HAS_CJS_EXPORT + .get_or_init(|| { + Regex::new(r"(?-u:\b)module\.exports(?-u:\b)|(?-u:\b)exports\s*[.\[]") + .expect("CommonJS export marker") + }) + .is_match(source); + if has_cjs_export { + return false; + } + let code = facts.ts_blanked(); + HAS_IMPORT + .get_or_init(|| Regex::new(r#"(?m)^[ \t]*import[\s{*'"]"#).expect("js import marker")) + .is_match(code) + && !HAS_EXPORT + .get_or_init(|| { + Regex::new(r"(?m)^[ \t]*export[\s{*]|^[ \t]*declare\s+global(?-u:\b)") + .expect("js export marker") + }) + .is_match(code) + }) +} + +/// Post-pipeline visibility gate for JS/TS name targets (`isCrossFileReachable`, +/// upstream v1.6.1). Applying this after a strategy chooses its target avoids +/// promoting an unrelated runner-up. +pub(crate) fn is_js_name_target_visible( + candidate: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if reference.reference_kind == EdgeKind::Calls + && is_esm_module_family(candidate.language) + && matches!(candidate.kind, NodeKind::Constant | NodeKind::Variable) + && is_json_require_signature(candidate.signature.as_deref().unwrap_or("")) + { + // `const data = require('./data.json')` names a JSON value; it is + // never callable. + return false; + } + candidate.file_path == reference.file_path + || !is_esm_module_family(candidate.language) + || !is_sealed_js_module(&candidate.file_path, context) +} + +/// `^=\s*require\s*\(\s*(['"])[^'"]+\.json\1\s*\)\s*;?\s*$` (upstream), with +/// the back-reference spelled out per quote. +fn is_json_require_signature(signature: &str) -> bool { + static JSON_REQUIRE: OnceLock<[Regex; 2]> = OnceLock::new(); + JSON_REQUIRE + .get_or_init(|| { + [ + Regex::new(r#"^=\s*require\s*\(\s*'[^'"]+\.json'\s*\)\s*;?\s*$"#) + .expect("single-quoted JSON require"), + Regex::new(r#"^=\s*require\s*\(\s*"[^'"]+\.json"\s*\)\s*;?\s*$"#) + .expect("double-quoted JSON require"), + ] + }) + .iter() + .any(|pattern| pattern.is_match(signature)) +} + +/// Keywords after which a name starts an expression, so the call that follows +/// has no receiver (`BARE_CALL_KEYWORDS`, upstream v1.6.1; `go`/`defer` are the +/// Go statement forms). +const BARE_CALL_KEYWORDS: [&str; 14] = [ + "return", + "await", + "yield", + "typeof", + "void", + "new", + "else", + "case", + "throw", + "in", + "of", + "instanceof", + "go", + "defer", +]; + +fn is_ascii_word_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' +} + +/// Whether a `calls` ref is RECEIVER-LESS — `serialize(x)`, not +/// `this.serialize(x)` / `obj.serialize(x)` (`isReceiverLessCall`, upstream +/// v1.6.1). The extractor emits `this.m()` and `pkg.F().M()` under the bare +/// member name, so the receiver is read back from the call site's own line: +/// the text at the ref's column must start with the name, be followed by a +/// call opener, and be preceded by nothing, an operator/opener, or one of +/// [`BARE_CALL_KEYWORDS`]. +/// +/// Unlike upstream, the opener may sit on a following line: JavaScript's +/// automatic semicolon insertion never splits `finish\n()`, so that is a real +/// bare call. (Go inserts a semicolon after a line-final identifier, so no Go +/// call takes that shape.) +fn is_receiver_less_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + if reference.reference_kind != EdgeKind::Calls || reference.reference_name.contains('.') { + return false; + } + let Some(line_index) = (reference.line as usize).checked_sub(1) else { + return false; + }; + let Some(facts) = context.source_facts(&reference.file_path) else { + return false; + }; + let Some(line) = facts.raw_line(line_index) else { + return false; + }; + let name = reference.reference_name.as_str(); + let column = reference.column.max(0) as usize; + if column > line.len() || !line.is_char_boundary(column) || !line[column..].starts_with(name) { + return false; + } + let Some(line_start) = facts.lines().raw_line_start(line_index) else { + return false; + }; + let after = + facts.source()[line_start + column + name.len()..].trim_start_matches(char::is_whitespace); + if !after.starts_with('(') && !after.starts_with('<') { + return false; + } + let before = line[..column].trim_end_matches(char::is_whitespace); + let Some(tail) = before.chars().next_back() else { + return true; + }; + if !(is_ascii_word_char(tail) || matches!(tail, '.' | '$' | ']' | ')')) { + return true; + } + let word_len = before + .bytes() + .rev() + .take_while(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + .count(); + BARE_CALL_KEYWORDS.contains(&&before[before.len() - word_len..]) +} + +/// A receiver-less JS/TS call can never bind to a method (#1714). +fn is_bare_js_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + is_js_family(reference.language) && is_receiver_less_call(reference, context) +} + +/// A receiver-less Go call — a func parameter, a local func value, a +/// package-level function — is never a method, in its own package or in one +/// the file does not import (#1857, upstream `isBareGoCall`). +fn is_bare_go_call(reference: &RefView, context: &dyn ResolutionContext) -> bool { + reference.language == Language::Go && is_receiver_less_call(reference, context) +} + +/// Sticky binding patterns for one plain identifier +/// (`localBindingPatterns`, upstream v1.6.1), anchored at the offset they are +/// tried from. `\b`/`\w` are ASCII, as in upstream's JavaScript. The +/// `function`/`class` form is matched by hand in [`binds_at_sites`]. +struct LocalBindingPatterns { + decl: Regex, + param: Regex, +} + +impl LocalBindingPatterns { + fn new(name: &str) -> Option { + let n = regex::escape(name); + // `const { name } = require('./m')` / `= await import('./m')` binds an + // IMPORT, not a shadow; group 1 carries the initializer to tell them + // apart. + let decl = format!( + r"^(?-u:\b)(?:const|let|var)\s+(?:{n}(?-u:\b)|[{{\[][^;=]*?(?-u:\b){n}(?-u:\b)[^;=]*?[}}\]])\s*(?:=\s*([^;\n]*))?" + ); + // A parameter: every token before the name in the list is itself a + // parameter (identifier, optional type, optional default), each with + // exactly one parse, so a string argument containing the word cannot + // match and a failing search cannot backtrack combinatorially. + let param = format!( + r"^\(\s*(?:(?:\.\.\.)?[0-9A-Za-z_$]+(?:\s*(?:\?\s*)?:[^,()]+|\s*=[^,()]+|\s*),\s*)*{n}(?-u:\b)(?:\s*\??\s*:[^,()]*)?(?:\s*=[^,()]*)?(?:\s*,\s*[^()]*)?\)\s*(?::[^=;{{]*)?(?:=>|\{{)" + ); + Some(Self { + decl: Regex::new(&decl).ok()?, + param: Regex::new(¶m).ok()?, + }) + } +} + +/// Binding patterns by name — the same names recur file after file +/// (`LOCAL_BINDING_PATTERNS`). Compiled only for names whose binding a +/// cheap necessary-condition check could not already rule out, and capped +/// lower than upstream's 4096 because a compiled Rust regex is much larger. +fn local_binding_patterns(name: &str) -> Option> { + const CAP: usize = 1024; + type PatternCache = ( + HashMap>>, + std::collections::VecDeque, + ); + static CACHE: OnceLock> = OnceLock::new(); + let cache = + CACHE.get_or_init(|| Mutex::new((HashMap::new(), std::collections::VecDeque::new()))); + if let Some(patterns) = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .0 + .get(name) + { + return patterns.clone(); + } + let patterns = LocalBindingPatterns::new(name).map(Arc::new); + let mut guard = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let (map, order) = &mut *guard; + if !map.contains_key(name) { + if map.len() >= CAP + && let Some(oldest) = order.pop_front() + { + map.remove(&oldest); + } + map.insert(name.to_string(), patterns.clone()); + order.push_back(name.to_string()); + } + patterns +} + +fn is_import_binding_value(value: &str) -> bool { + static IMPORT_BINDING_VALUE: OnceLock = OnceLock::new(); + IMPORT_BINDING_VALUE + .get_or_init(|| { + Regex::new(r"^\s*(?:await\s+)?(?:require|import)\s*\(").expect("import binding value") + }) + .is_match(value) +} + +/// Whether a JS/TS file binds `name` itself — as a `const`/`let`/`var`/ +/// `function`/`class` declaration (destructuring included) or as a parameter +/// of a function or arrow (`isLocallyBoundJsName`, upstream v1.6.1). Such a +/// binding shadows every same-named symbol in other files, so a bare call to +/// it has no cross-file candidate. Read from raw source, memoised per +/// file + name. +fn is_locally_bound_js_name(name: &str, file_path: &str, context: &dyn ResolutionContext) -> bool { + let Some(facts) = context.source_facts(file_path) else { + return false; + }; + facts.js_local_binding(name, |facts| { + let plain = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$'); + if plain { + binds_at_sites(facts, name) + } else { + binds_anywhere(facts.source(), name) + } + }) +} + +/// [`is_locally_bound_js_name`]'s patterns searched through the whole source +/// (`bindsAnywhere`), for names that are not plain identifiers. +fn binds_anywhere(source: &str, name: &str) -> bool { + let n = regex::escape(name); + let unanchored = |pattern: String| Regex::new(&pattern).ok(); + let decl = unanchored(format!( + r"(?-u:\b)(?:const|let|var)\s+(?:{n}(?-u:\b)|[{{\[][^;=]*?(?-u:\b){n}(?-u:\b)[^;=]*?[}}\]])\s*(?:=\s*([^;\n]*))?" + )); + if let Some(decl) = decl + && decl.captures_iter(source).any(|captures| { + !is_import_binding_value(captures.get(1).map_or("", |value| value.as_str())) + }) + { + return true; + } + let function = unanchored(format!(r"(?-u:\b)(?:function|class)\s+{n}(?-u:\b)")); + let param = unanchored(format!( + r"\(\s*(?:(?:\.\.\.)?[0-9A-Za-z_$]+(?:\s*(?:\?\s*)?:[^,()]+|\s*=[^,()]+|\s*),\s*)*{n}(?-u:\b)(?:\s*\??\s*:[^,()]*)?(?:\s*=[^,()]*)?(?:\s*,\s*[^()]*)?\)\s*(?::[^=;{{]*)?(?:=>|\{{)" + )); + let arrow = unanchored(format!(r"(?:^|[^0-9A-Za-z_$.]){n}\s*=>")); + [function, param, arrow] + .into_iter() + .flatten() + .any(|pattern| pattern.is_match(source)) +} + +/// [`binds_anywhere`] for a plain identifier, tried only where a match can +/// start (`bindsAtSites`): a declaration at its keyword (in order, resuming +/// past each match as a global search does), a parameter at the `(` its list +/// opens with, and `name =>` at each arrow. +fn binds_at_sites(facts: &SourceFacts, name: &str) -> bool { + let source = facts.source(); + let sites = facts.js_binding_sites(); + // Compile the name's patterns only once a site passes its cheap + // necessary condition; most names never need them. + let mut patterns: Option>> = None; + let mut patterns = || { + patterns + .get_or_insert_with(|| local_binding_patterns(name)) + .clone() + }; + let mut from = 0; + for &at in &sites.var_decls { + if at < from { + continue; + } + // Every decl match spells the name before the first `;` or `=` + // after its keyword. + let segment_end = source[at..] + .find([';', '=']) + .map_or(source.len(), |offset| at + offset); + if !source[at..segment_end].contains(name) { + continue; + } + let Some(patterns) = patterns() else { + return false; + }; + let Some(captures) = patterns.decl.captures(&source[at..]) else { + continue; + }; + from = at + captures.get(0).map_or(0, |whole| whole.end()); + if !is_import_binding_value(captures.get(1).map_or("", |value| value.as_str())) { + return true; + } + } + // `\b(?:function|class)\s+name\b` at each keyword site. + let names_declaration = |at: usize| { + let rest = &source[at..]; + let Some(keyword) = ["function", "class"] + .into_iter() + .find(|keyword| rest.starts_with(keyword)) + else { + return false; + }; + let after = &rest[keyword.len()..]; + let spelled = after.trim_start_matches(char::is_whitespace); + // `\b` after the name: an ASCII word/non-word transition. + spelled.len() < after.len() + && spelled.starts_with(name) + && name.chars().next_back().is_some_and(is_ascii_word_char) + != spelled[name.len()..] + .chars() + .next() + .is_some_and(is_ascii_word_char) + }; + if sites.fn_decls.iter().any(|&at| names_declaration(at)) { + return true; + } + let mut tried = None; + let mut search_from = 0; + while let Some(found) = source[search_from..].find(name) { + let at = search_from + found; + search_from = at + 1; + let Some(open) = source[..at].rfind('(') else { + continue; + }; + if tried == Some(open) || source[..at].rfind(')').is_some_and(|close| close > open) { + continue; + } + tried = Some(open); + let Some(patterns) = patterns() else { + return false; + }; + if patterns.param.is_match(&source[open..]) { + return true; + } + } + // `name =>`: the name ends where the whitespace before the arrow starts. + sites.arrows.iter().any(|&arrow| { + let end = source[..arrow].trim_end_matches(char::is_whitespace).len(); + let Some(start) = end.checked_sub(name.len()) else { + return false; + }; + source.is_char_boundary(start) + && source[start..].starts_with(name) + && !source[..start] + .chars() + .next_back() + .is_some_and(|ch| is_ascii_word_char(ch) || matches!(ch, '$' | '.')) + }) +} + /// Try to resolve a reference by exact name match (`matchByExactName`, /// `name-matcher.ts:173-209`). pub fn match_by_exact_name( @@ -273,9 +698,24 @@ pub fn match_by_exact_name( // importable (#1537/#1536). Filtering BEFORE ranking — not just refusing the // winner afterwards — is what lets a legitimate supertype OUTRANK a // same-named enum member instead of the whole reference being dropped. + let bare_js = is_bare_js_call(reference, context); + let bare_go = is_bare_go_call(reference, context); let candidates: Vec> = reachable .iter() - .filter(|n| kind_is_eligible_target(reference.reference_kind, n.kind)) + .filter(|n| node_is_eligible_target(reference.reference_kind, n)) + // A receiver-less JS/TS or Go call cannot reach a method (#1714, #1857). + .filter(|n| !((bare_js || bare_go) && n.kind == NodeKind::Method)) + // A name the file binds itself shadows every other file's symbol of + // that name, so a bare call has no cross-file candidate. + .filter(|n| { + !(bare_js + && n.file_path != reference.file_path + && is_locally_bound_js_name( + &reference.reference_name, + &reference.file_path, + context, + )) + }) .cloned() .collect(); @@ -570,8 +1010,10 @@ pub(crate) fn resolve_method_on_type( let matches: Vec> = method_candidates .into_iter() .filter(|m| { + // One language family shares one type system: a `.tsx` caller + // reaches a `.ts` class's method (upstream #1862). m.kind == NodeKind::Method - && m.language == reference.language + && same_language_family(m.language, reference.language) && (m.qualified_name == want || m.qualified_name.ends_with(&format!("::{want}"))) }) .collect(); @@ -1006,6 +1448,12 @@ fn local_receiver_type_patterns_tagged(language: Language, r: &str) -> Vec<(Rege ], Language::Python => vec![ format!(r"\b{r}\b\s*=\s*([A-Z][\w.]*)\s*\("), + // Quoted forward references are ordinary Python annotations (and + // the form produced by `from __future__ import annotations`). Keep + // this stricter shape before the unquoted annotation pattern so + // `obj: "pkg.Type"` recovers the same receiver type as + // `obj: pkg.Type` (#1684). + format!(r#"\b{r}\b\s*:\s*["']([A-Z][\w.]*)["']"#), format!(r"\b{r}\b\s*:\s*([A-Z][\w.]*)"), ], Language::Java => vec![ @@ -1233,6 +1681,140 @@ fn infer_class_field_receiver_type( None } +/// Resolve a JS/TS `this..()` exclusively through the field's +/// declaration on the enclosing class. Unproven/builtin/ambiguous types remain +/// unresolved rather than falling through to receiver-name heuristics. +fn match_ts_this_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_qualified, _) = caller.qualified_name.rsplit_once("::")?; + let owner_name = owner_qualified.rsplit("::").next()?; + let owners = context + .get_nodes_by_name_shared(owner_name) + .into_iter() + .filter(|node| { + node.file_path == reference.file_path + && same_language_family(node.language, reference.language) + && matches!(node.kind, NodeKind::Class | NodeKind::Component) + && node.qualified_name == owner_qualified + }) + .collect::>(); + if owners.len() != 1 { + return None; + } + let owner = &owners[0]; + let source = context.read_file(&owner.file_path)?; + let code = strip_comments_for_regex(&source, CommentLang::TypeScript); + let lines: Vec<&str> = code.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 escaped = regex_escape(field); + let typeof_pattern = Regex::new(&format!( + r"\b{escaped}\b\s*[?!]?\s*:\s*(?:readonly\s+)?typeof\s+([A-Za-z_$][\w.$]*)" + )) + .ok()?; + let declared_pattern = Regex::new(&format!( + r"\b{escaped}\b\s*[?!]?\s*:\s*(?:readonly\s+)?([A-Za-z_$][\w.$]*)" + )) + .ok()?; + let initialized_pattern = + Regex::new(&format!(r"\b{escaped}\b\s*=\s*new\s+([A-Za-z_$][\w.$]*)")).ok()?; + let parameter_property = Regex::new(&format!( + r"\b(?:public|protected|private|readonly)(?:\s+(?:public|protected|private|readonly))*\s+{escaped}\b" + )) + .ok()?; + let owner_members = context + .get_nodes_in_file_shared(&owner.file_path) + .into_iter() + .filter(|node| { + matches!(node.kind, NodeKind::Method | NodeKind::Function) + && node + .qualified_name + .starts_with(&format!("{owner_qualified}::")) + }) + .collect::>(); + + for (offset, line) in lines[start..end].iter().enumerate() { + if line.len() > 10_000 { + continue; + } + let source_line = (start + offset + 1) as i64; + if let Some(member) = owner_members.iter().find(|member| { + source_line >= member.start_line + && source_line <= member.end_line.max(member.start_line) + }) && !(member.name == "constructor" && parameter_property.is_match(line)) + { + // A method-local parameter or variable is not a `this` field. + continue; + } + if let Some(value_name) = typeof_pattern + .captures(line) + .and_then(|captures| captures.get(1)) + .map(|capture| capture.as_str().rsplit('.').next().unwrap_or_default()) + { + let holders = prefer_call_site_file( + context.get_nodes_by_name_shared(value_name), + &reference.file_path, + ); + for holder in holders.into_iter().filter(|node| { + matches!(node.kind, NodeKind::Constant | NodeKind::Variable) + && same_language_family(node.language, reference.language) + }) { + if let Some(resolved) = resolve_object_literal_member( + &holder, + method_name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + ) { + return Some(resolved); + } + } + return None; + } + + let type_name = declared_pattern + .captures(line) + .and_then(|captures| captures.get(1)) + .or_else(|| { + initialized_pattern + .captures(line) + .and_then(|captures| captures.get(1)) + }) + .map(|capture| capture.as_str().rsplit('.').next().unwrap_or_default()); + let Some(type_name) = type_name else { continue }; + if !type_name + .chars() + .next() + .is_some_and(|first| first.is_ascii_uppercase()) + { + return None; + } + return resolve_method_on_type( + type_name, + method_name, + reference, + context, + 0.85, + ResolvedBy::InstanceMethod, + None, + 0, + ); + } + None +} + /// Is the inferred receiver type `type_name` BOUND at the call site (#1566)? /// /// A built-in receiver (`new Map()`) normalizes to a bare type @@ -1512,6 +2094,15 @@ pub fn match_method_call( if let Some(field) = object_or_class.strip_prefix("self.") { return match_rust_self_field_call(field, &method_name, reference, context); } + if object_or_class == "self" { + return match_rust_self_call(&method_name, reference, context); + } + } + + if is_js_family(reference.language) { + if let Some(field) = object_or_class.strip_prefix("this.") { + return match_ts_this_field_call(field, &method_name, reference, context); + } } // Lua table functions carry the stable qualified form `Table::method`, @@ -1594,6 +2185,40 @@ pub fn match_method_call( ) { return Some(typed); } + } else { + match crate::awaited::infer_awaited_receiver(&object_or_class, reference, context) { + crate::awaited::AwaitedInference::NotApplicable => {} + crate::awaited::AwaitedInference::Unresolved => return None, + crate::awaited::AwaitedInference::Inferred { + type_name, + file_path, + } => { + // Resolve on the awaited type from its own file, and accept + // only a method that file's type declares (upstream v1.6.1). + let mut at_type = reference.clone(); + at_type.file_path = file_path.clone(); + let typed = resolve_method_on_type( + &type_name, + &method_name, + &at_type, + context, + 0.9, + ResolvedBy::InstanceMethod, + None, + 0, + )?; + let target = context.get_node_by_id_shared(&typed.target_node_id)?; + if target.qualified_name.starts_with(&format!("{type_name}::")) + && target.file_path != file_path + { + return None; + } + return Some(ResolvedRef { + original: reference.clone(), + ..typed + }); + } + } } } @@ -1653,10 +2278,12 @@ pub fn match_method_call( context.get_nodes_by_name_shared(&object_or_class), &reference.file_path, ) { + // A Scala `object` owns methods callable as `Obj.method()` (#1824). if matches!( class_node.kind, NodeKind::Class | NodeKind::Struct | NodeKind::Union | NodeKind::Interface - ) { + ) || crate::types::is_scala_singleton(&class_node) + { if class_node.language != reference.language { continue; } @@ -1682,7 +2309,8 @@ pub fn match_method_call( if matches!( class_node.kind, NodeKind::Class | NodeKind::Struct | NodeKind::Union | NodeKind::Interface - ) { + ) || crate::types::is_scala_singleton(&class_node) + { if class_node.language != reference.language { continue; } @@ -1868,6 +2496,81 @@ fn strip_rust_field_line_comments(line: &str) -> String { output } +/// Resolve `self.method()` on the Rust type that owns the calling method. +/// +/// The extractor preserves this shape as `self.` so the resolver can +/// read the owner from the caller's qualified name. This is deliberately an +/// exclusive, exact match: if the caller is not a method, the owner is absent, +/// or more than one method claims the same owner-qualified name, the reference +/// stays unresolved instead of falling through to proximity/name heuristics. +fn match_rust_self_call( + method_name: &str, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + if method_name.is_empty() || !is_word(method_name) { + return None; + } + let caller = context.get_node_by_id_shared(&reference.from_node_id)?; + if caller.language != Language::Rust || caller.kind != NodeKind::Method { + return None; + } + let (owner, _) = caller.qualified_name.rsplit_once("::")?; + if owner.is_empty() { + return None; + } + let wanted = format!("{owner}::{method_name}"); + let mut owned: Vec> = context + .get_nodes_by_name_shared(method_name) + .into_iter() + .filter(|candidate| { + candidate.language == Language::Rust + && candidate.kind == NodeKind::Method + && candidate.qualified_name == wanted + }) + .collect(); + // Rust qualified names omit module paths, so two modules can each declare + // `Target`; matching `Target::reset` alone then proves nothing. With more + // than one owner declaration, require exactly one in the caller's file and + // a method in that file (upstream #1882). A unique owner still permits + // impl blocks split across files. + let owners = context + .get_nodes_by_qualified_name_shared(owner) + .into_iter() + .filter(|node| { + node.language == Language::Rust + && matches!( + node.kind, + NodeKind::Struct + | NodeKind::Enum + | NodeKind::Union + | NodeKind::Trait + | NodeKind::Class + ) + }) + .collect::>(); + if owners.len() > 1 { + if owners + .iter() + .filter(|node| node.file_path == caller.file_path) + .count() + != 1 + { + return None; + } + owned.retain(|node| node.file_path == caller.file_path); + } + let [target] = owned.as_slice() else { + return None; + }; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: target.id.clone(), + confidence: 0.9, + resolved_by: ResolvedBy::QualifiedName, + }) +} + /// 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( @@ -2321,6 +3024,16 @@ fn find_best_match<'a, T: Borrow>( _ => {} } + // A Scala companion `object` is a module and may share both name and + // file with its trait/class. It remains type-eligible for languages that + // do inherit/include modules, but an actual type must win an inheritance + // tie deterministically (#1824). + if crate::types::is_inheritance_ref(reference.reference_kind) + && candidate.kind == NodeKind::Module + { + score -= 50.0; + } + if candidate.is_exported { score += 10.0; } @@ -2369,17 +3082,38 @@ pub fn match_fuzzy(reference: &RefView, context: &dyn ResolutionContext) -> Opti callable_candidates }; - if final_candidates.len() == 1 { - let is_cross_language = final_candidates[0].language != reference.language; - return Some(ResolvedRef { - original: reference.clone(), - target_node_id: final_candidates[0].id.clone(), - confidence: if is_cross_language { 0.3 } else { 0.5 }, - resolved_by: ResolvedBy::Fuzzy, - }); + // Every gate below judges the ONE candidate this strategy would commit to, + // never the candidate set: filtering the unreachable ones out of a crowd + // would leave a single survivor and hand it every call of that name. + // These gates may reject a unique guess; they must never manufacture one + // (upstream v1.6.1 `matchFuzzy`). Cross-file visibility and sealed modules + // are enforced post-pipeline for every strategy. + let [candidate] = final_candidates.as_slice() else { + return None; + }; + let bare_js_rejects = is_bare_js_call(reference, context) + && (candidate.kind == NodeKind::Method + || (candidate.file_path != reference.file_path + && is_locally_bound_js_name( + &reference.reference_name, + &reference.file_path, + context, + ))); + if bare_js_rejects + || (candidate.kind == NodeKind::Method && is_bare_go_call(reference, context)) + // A builtin method call (`res.text()`) whose only same-named project + // symbol is some function's closure must decline (#1708). + || !is_lexically_reachable(candidate, reference, context) + { + return None; } - - None + let is_cross_language = candidate.language != reference.language; + Some(ResolvedRef { + original: reference.clone(), + target_node_id: candidate.id.clone(), + confidence: if is_cross_language { 0.3 } else { 0.5 }, + resolved_by: ResolvedBy::Fuzzy, + }) } /// Resolve a `::`-scoped factory chain (`matchScopedCallChain`, @@ -2661,6 +3395,15 @@ pub fn match_reference( } } + // A JS/TS call-result receiver carries no proven result type. It stays + // unresolved instead of degrading to a global same-named callable (#1683). + if reference.reference_kind == EdgeKind::Calls + && is_js_family(reference.language) + && reference.reference_name.contains("().") + { + return None; + } + // 2. Method call pattern. if let Some(result) = match_method_call(reference, context) { return Some(result); @@ -2691,19 +3434,36 @@ pub(crate) fn is_python_class_function_ref_target(language: Language, kind: Node language == Language::Python && kind == NodeKind::Class } -/// Resolve a `function_ref` (callback-as-value) reference: exact name, -/// function/method targets plus Python class targets (Python bare methods remain -/// excluded), same language family, same-file first, cross-file only when unique. -/// No fuzzy fallback. `this.` refs are resolved elsewhere -/// (resolve_this_member_fn_ref). Ports `matchFunctionRef` (name-matcher.ts:179-310). +/// Resolve a `function_ref` (callback-as-value) reference. Receiver-qualified +/// method values reuse the ordinary method-call target selection (#1820); bare +/// names use exact function/method targets plus Python class targets (Python +/// bare methods remain excluded), same language family, same-file first, and +/// cross-file only when unique. No fuzzy fallback. `this.` refs are +/// resolved elsewhere (`resolve_this_member_fn_ref`). Ports `matchFunctionRef` +/// (name-matcher.ts:179-310) plus the issue #1820 extension. pub fn match_function_ref( reference: &RefView, context: &dyn ResolutionContext, ) -> Option { - if reference.reference_name.starts_with("this.") { + if reference + .reference_name + .strip_prefix("this.") + .is_some_and(|member| !member.contains('.')) + { return None; } + // Receiver-qualified first-class method value (#1820): reuse the exact + // same conservative target selection as `obj.method(...)`, but preserve + // the original `references` kind and label the result as function-ref. + // This gives callback wiring and direct calls identical receiver inference + // without fabricating an immediate `calls` edge. + if match_dot_call(&reference.reference_name).is_some() { + let mut resolved = match_method_call(reference, context)?; + resolved.resolved_by = ResolvedBy::FunctionRef; + return Some(resolved); + } + // A bare identifier cannot be a method value in JS/TS/C++/Python/PHP // (methods need a receiver). Those match FUNCTIONS; Python additionally // accepts CLASS targets through the shared predicate, but still not METHODS. @@ -3921,6 +4681,130 @@ mod tests { assert!((resolved.confidence - 0.85).abs() < 1e-9); } + #[test] + fn arkts_import_only_module_is_sealed_across_files() { + let candidate = mk( + "function:hidden", + NodeKind::Function, + "hidden", + "hidden", + "src/private.ets", + Language::ArkTs, + ); + let reference = refv( + "hidden", + EdgeKind::Calls, + "src/consumer.ets", + Language::ArkTs, + 1, + ); + let context = Ctx::default().file( + "src/private.ets", + "import { fs } from '@ohos.file.fs';\nfunction hidden() { return fs; }\n", + ); + + assert!( + !is_js_name_target_visible(&candidate, &reference, &context), + "ArkTS shares the ESM sealed-module boundary" + ); + } + + #[test] + fn rust_self_call_uses_the_callers_impl_owner_and_declines_uncertainty() { + let caller = mk( + "method:target-run", + NodeKind::Method, + "run", + "Target::run", + "src/target.rs", + Language::Rust, + ); + let target = mk( + "method:target-reset", + NodeKind::Method, + "reset", + "Target::reset", + "src/target.rs", + Language::Rust, + ); + let decoy = mk( + "method:decoy-reset", + NodeKind::Method, + "reset", + "Decoy::reset", + "src/target.rs", + Language::Rust, + ); + let unrelated_missing = mk( + "method:other-missing", + NodeKind::Method, + "missing", + "Other::missing", + "src/other.rs", + Language::Rust, + ); + let ctx = Ctx::default() + .node_by_id(caller) + .name("reset", vec![decoy, target]) + .name("missing", vec![unrelated_missing]); + let mut reference = refv( + "self.reset", + EdgeKind::Calls, + "src/target.rs", + Language::Rust, + 40, + ); + reference.from_node_id = "method:target-run".to_string(); + + let resolved = match_method_call(&reference, &ctx).expect("owner-qualified method"); + assert_eq!(resolved.target_node_id, "method:target-reset"); + assert_eq!(resolved.resolved_by, ResolvedBy::QualifiedName); + assert!((resolved.confidence - 0.9).abs() < f64::EPSILON); + + reference.reference_name = "self.missing".to_string(); + assert!( + match_method_call(&reference, &ctx).is_none(), + "a method absent from the enclosing type must not bind to another type" + ); + + let duplicate = mk( + "method:target-reset-duplicate", + NodeKind::Method, + "reset", + "Target::reset", + "src/target.rs", + Language::Rust, + ); + let ambiguous = Ctx::default() + .node_by_id(mk( + "method:target-run", + NodeKind::Method, + "run", + "Target::run", + "src/target.rs", + Language::Rust, + )) + .name( + "reset", + vec![ + mk( + "method:target-reset", + NodeKind::Method, + "reset", + "Target::reset", + "src/target.rs", + Language::Rust, + ), + duplicate, + ], + ); + reference.reference_name = "self.reset".to_string(); + assert!( + match_method_call(&reference, &ambiguous).is_none(), + "two owner-qualified candidates must remain unresolved" + ); + } + #[test] fn rust_self_field_call_does_not_guess_external_or_container_types() { for (field_decl, field_name, method_name, type_name) in [ @@ -8555,4 +9439,356 @@ mod tests { let res = match_by_exact_name(&r, &ctx).expect("type alias is a legal supertype"); assert_eq!(res.target_node_id, "type_alias:Shape"); } + + // ================= v1.6.1 gate ports ====================================== + + fn at_column( + name: &str, + kind: EdgeKind, + path: &str, + lang: Language, + line: i64, + column: i64, + ) -> RefView { + let mut reference = refv(name, kind, path, lang, line); + reference.column = column; + reference + } + + #[test] + fn receiver_less_call_reads_the_call_site_line() { + let source = "serialize(x);\nthis.serialize(x);\n return helper();\nobj.helper();\nx = helper ();\nfinish\n ();\nnew Foo();\nxreturn helper();\n"; + let ctx = Ctx::default().file("a.ts", source); + let bare = |name: &str, line: i64, column: i64| { + is_bare_js_call( + &at_column( + name, + EdgeKind::Calls, + "a.ts", + Language::TypeScript, + line, + column, + ), + &ctx, + ) + }; + assert!(bare("serialize", 1, 0)); + assert!(!bare("serialize", 2, 5), "this.serialize has a receiver"); + assert!(bare("helper", 3, 9), "a keyword opens an expression"); + assert!(!bare("helper", 4, 4), "obj.helper has a receiver"); + assert!(bare("helper", 5, 4), "an operator precedes a bare call"); + assert!(bare("finish", 6, 0), "ASI never splits `finish\\n()`"); + assert!(bare("Foo", 8, 4), "`new` is a bare-call keyword"); + assert!( + !bare("helper", 9, 8), + "a word that merely ends in a keyword is not one" + ); + assert!( + !bare("serialize", 1, 1), + "the name must start at the column" + ); + // Go shares the check, with its own statement keywords. + let go = Ctx::default().file("m.go", "\tgo work()\n\tp.work()\n"); + assert!(is_bare_go_call( + &at_column("work", EdgeKind::Calls, "m.go", Language::Go, 1, 4), + &go + )); + assert!(!is_bare_go_call( + &at_column("work", EdgeKind::Calls, "m.go", Language::Go, 2, 3), + &go + )); + // Only `calls` refs, and only the owning language family. + assert!(!is_bare_js_call( + &at_column( + "serialize", + EdgeKind::References, + "a.ts", + Language::TypeScript, + 1, + 0 + ), + &ctx + )); + assert!(!is_bare_go_call( + &at_column( + "serialize", + EdgeKind::Calls, + "a.ts", + Language::TypeScript, + 1, + 0 + ), + &ctx + )); + } + + #[test] + fn binding_sites_agree_with_the_full_search_for_plain_names() { + let sources = [ + "const helper = 1;\nhelper();", + "let { a, helper } = obj;\n", + "var [x, helper] = arr;", + "const { helper } = require('./m');\nhelper();", + "const helper = await import('./m');", + "const t = await helper();", + "function helper() {}\n", + "class helper {}\n", + "function f(a, helper = 1, ...rest) { return helper; }", + "const g = (x, helper) => helper(x);", + "items.map(helper => helper.id);", + "const h = (name: string = \"helper\") => name;", + "call(\"helper\");\nhelper();", + "obj.helper => 1;", + "function helperish() {}\nconst helperX = 1;", + "export const $helper = 1;", + "const a = 1; const helper = require('./x'); const helper = 2;", + "function f(\n a: number,\n helper?: string,\n) {}", + ]; + for source in sources { + let facts = SourceFacts::new(Arc::from(source)); + for name in ["helper", "$helper", "a", "helperish", "name"] { + assert_eq!( + binds_at_sites(&facts, name), + binds_anywhere(source, name), + "{name:?} in {source:?}" + ); + } + } + } + + #[test] + fn local_binding_exempts_import_initialisers() { + let ctx = Ctx::default() + .file("a.js", "const { run } = require('./m');\nrun();\n") + .file("b.js", "const run = makeRunner();\nrun();\n") + .file("c.js", "function go(run) { run(); }\n"); + assert!(!is_locally_bound_js_name("run", "a.js", &ctx)); + assert!(is_locally_bound_js_name("run", "b.js", &ctx)); + assert!(is_locally_bound_js_name("run", "c.js", &ctx)); + assert!(!is_locally_bound_js_name("run", "missing.js", &ctx)); + } + + #[test] + fn sealed_module_reads_code_not_literals() { + let exported = { + let mut node = mk( + "function:x", + NodeKind::Function, + "x", + "x", + "exported.js", + Language::JavaScript, + ); + node.is_exported = true; + node + }; + let ctx = Ctx::default() + .file( + "template.js", + "const t = `\nimport fake from \"fake\";\n`;\nfunction globalFn() {}\n", + ) + .file( + "sealed.js", + "import fs from 'node:fs';\nfunction hidden() { return fs; }\n", + ) + .file( + "common.js", + "import os from 'node:os';\nmodule.exports = { helper };\n", + ) + .file( + "comment.js", + "// import x from 'y';\nfunction classic() {}\n", + ) + .file("exported.js", "import a from './a';\nfunction x() {}\n") + .file( + "ambient.ts", + "import a from './a';\ndeclare global { interface W {} }\n", + ) + .nodes_in_file("exported.js", vec![exported]); + assert!( + !is_sealed_js_module("template.js", &ctx), + "a template spelling import is data" + ); + assert!(is_sealed_js_module("sealed.js", &ctx)); + assert!( + !is_sealed_js_module("common.js", &ctx), + "CommonJS exports stay visible" + ); + assert!( + !is_sealed_js_module("comment.js", &ctx), + "a commented import is not a module marker" + ); + assert!( + !is_sealed_js_module("exported.js", &ctx), + "an exported node needs no text scan" + ); + assert!( + !is_sealed_js_module("ambient.ts", &ctx), + "declare global is an export" + ); + assert!(!is_sealed_js_module("missing.js", &ctx)); + } + + #[test] + fn json_require_constant_is_not_a_call_target() { + let mut data = mk( + "constant:data", + NodeKind::Constant, + "data", + "data", + "cfg.js", + Language::JavaScript, + ); + data.signature = Some("= require('./data.json');".to_string()); + let mut plain = data.clone(); + plain.signature = Some("= require('./data');".to_string()); + let ctx = Ctx::default().file( + "cfg.js", + "const data = require('./data.json');\nmodule.exports = { data };\n", + ); + let call = refv("data", EdgeKind::Calls, "use.js", Language::JavaScript, 1); + assert!(!is_js_name_target_visible(&data, &call, &ctx)); + assert!(is_js_name_target_visible(&plain, &call, &ctx)); + let value = refv( + "data", + EdgeKind::References, + "use.js", + Language::JavaScript, + 1, + ); + assert!( + is_js_name_target_visible(&data, &value, &ctx), + "only calls are rejected" + ); + } + + #[test] + fn fuzzy_judges_the_survivor_instead_of_filtering_the_crowd() { + let method = mk( + "method:Other::save", + NodeKind::Method, + "save", + "Other::save", + "other.ts", + Language::TypeScript, + ); + let function = mk( + "function:save", + NodeKind::Function, + "save", + "save", + "lib.ts", + Language::TypeScript, + ); + let ctx = Ctx::default() + .file("a.ts", "save();\n") + .lower("save", vec![method.clone(), function.clone()]); + let reference = at_column("save", EdgeKind::Calls, "a.ts", Language::TypeScript, 1, 0); + assert!( + match_fuzzy(&reference, &ctx).is_none(), + "two candidates stay ambiguous even though one is a method" + ); + let lone_method = Ctx::default() + .file("a.ts", "save();\n") + .lower("save", vec![method]); + assert!( + match_fuzzy(&reference, &lone_method).is_none(), + "a bare call never reaches a method" + ); + let lone_function = Ctx::default() + .file("a.ts", "save();\n") + .lower("save", vec![function]); + assert_eq!( + match_fuzzy(&reference, &lone_function).map(|r| r.target_node_id), + Some("function:save".to_string()) + ); + } + + #[test] + fn fuzzy_declines_a_closure_but_c_nesting_stays_reachable() { + let mut inner = mk( + "function:outer::inner", + NodeKind::Function, + "inner", + "outer::inner", + "lib.js", + Language::JavaScript, + ); + inner.start_line = 2; + inner.end_line = 3; + let mut outer = mk( + "function:outer", + NodeKind::Function, + "outer", + "outer", + "lib.js", + Language::JavaScript, + ); + outer.start_line = 1; + outer.end_line = 4; + let ctx = Ctx::default() + .file("use.js", "x.inner();\n") + .lower("inner", vec![inner.clone()]) + .qualified("outer", vec![outer.clone()]); + let reference = at_column( + "inner", + EdgeKind::Calls, + "use.js", + Language::JavaScript, + 1, + 2, + ); + assert!( + match_fuzzy(&reference, &ctx).is_none(), + "#1708: a closure is not reachable" + ); + + let mut c_inner = inner; + c_inner.language = Language::C; + c_inner.file_path = "pid.c".to_string(); + let mut c_outer = outer; + c_outer.language = Language::C; + c_outer.file_path = "pid.c".to_string(); + let c_ctx = Ctx::default() + .file("main.c", "inner();\n") + .lower("inner", vec![c_inner]) + .qualified("outer", vec![c_outer]); + let c_reference = at_column("inner", EdgeKind::Calls, "main.c", Language::C, 1, 0); + assert!( + match_fuzzy(&c_reference, &c_ctx).is_some(), + "C has no nested functions" + ); + } + + #[test] + fn exact_match_keeps_bare_go_calls_off_methods() { + let method = mk( + "method:T::run", + NodeKind::Method, + "run", + "T::run", + "t.go", + Language::Go, + ); + let function = mk( + "function:run", + NodeKind::Function, + "run", + "run", + "run.go", + Language::Go, + ); + let ctx = Ctx::default() + .file("main.go", "\trun()\n\tt.run()\n") + .name("run", vec![method.clone(), function]); + let bare = at_column("run", EdgeKind::Calls, "main.go", Language::Go, 1, 1); + assert_eq!( + match_by_exact_name(&bare, &ctx).map(|r| r.target_node_id), + Some("function:run".to_string()) + ); + let only_method = Ctx::default() + .file("main.go", "\trun()\n") + .name("run", vec![method]); + assert!(match_by_exact_name(&bare, &only_method).is_none()); + } } diff --git a/crates/codegraph-resolve/src/pathutil.rs b/crates/codegraph-resolve/src/pathutil.rs index b0b3c9fc..43bb49bc 100644 --- a/crates/codegraph-resolve/src/pathutil.rs +++ b/crates/codegraph-resolve/src/pathutil.rs @@ -9,6 +9,8 @@ //! //! [`ResolutionContext`]: crate::types::ResolutionContext +use std::path::{Path, PathBuf}; + /// Lexically normalize a POSIX path, collapsing `.` and `..` segments. /// /// Equivalent to `path.posix.normalize` for the inputs the resolver produces. @@ -76,6 +78,37 @@ pub fn resolve(base: &str, relative: &str) -> String { normalize(&joined) } +/// Resolve a project-relative candidate without allowing it to escape the +/// project root lexically. +/// +/// Resolution's filesystem fallback receives paths containing `..` from +/// relative-import candidates. A plain [`Path::join`] would let repository +/// content probe arbitrary paths outside the project. This intentionally does +/// not canonicalize or reject symlinks: indexing permits an in-project symlink +/// whose target is outside the root, so this guard covers lexical traversal +/// only (#1631). +pub(crate) fn lexical_path_within_root(project_root: &str, file_path: &str) -> Option { + let normalized_separators = file_path.replace('\\', "/"); + let bytes = normalized_separators.as_bytes(); + let has_windows_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + // After separators are normalized, a leading `/` covers POSIX absolute + // paths plus Windows root-relative, UNC, and device forms. Check it directly + // rather than using `Path::is_absolute`: on Windows `/outside` is rooted but + // not "absolute" (it has no drive prefix), yet joining it to `C:\repo` + // still escapes to `C:\outside`. The prefix check covers both `C:/outside` + // and drive-relative `C:outside` forms on every host. + if normalized_separators.starts_with('/') || has_windows_prefix { + return None; + } + + let relative = normalize(&normalized_separators); + if relative == ".." || relative.starts_with("../") { + return None; + } + + Some(Path::new(project_root).join(relative)) +} + /// Compute a relative path from `from` to `to`, both treated as POSIX /// directories/paths rooted the same way (`path.posix.relative`). Used to turn /// an absolute-ish base back into a project-relative path. @@ -174,6 +207,59 @@ mod tests { assert_eq!(resolve("", "./a"), "a"); } + #[test] + fn lexical_path_within_root_rejects_relative_and_absolute_escapes() { + let root = if cfg!(windows) { r"C:\repo" } else { "/repo" }; + assert_eq!( + lexical_path_within_root(root, "src/../lib/a.ts"), + Some(Path::new(root).join("lib/a.ts")) + ); + assert!(lexical_path_within_root(root, "../outside/a.ts").is_none()); + assert!(lexical_path_within_root(root, "a/../../outside.ts").is_none()); + assert!(lexical_path_within_root(root, "/outside/a.ts").is_none()); + assert!(lexical_path_within_root(root, r"\outside\a.ts").is_none()); + assert!(lexical_path_within_root(root, r"C:\outside\a.ts").is_none()); + assert!(lexical_path_within_root(root, r"C:outside\a.ts").is_none()); + } + + #[cfg(any(unix, windows))] + #[test] + fn lexical_path_within_root_preserves_in_root_symlink_targets() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let sandbox = std::env::temp_dir().join(format!( + "cg-pathutil-symlink-{}-{nanos}", + std::process::id() + )); + let root = sandbox.join("repo"); + let outside = sandbox.join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret.ts"), "export const secret = 42;\n").unwrap(); + + #[cfg(unix)] + let link_result = std::os::unix::fs::symlink(&outside, root.join("vendor")); + #[cfg(windows)] + let link_result = std::os::windows::fs::symlink_dir(&outside, root.join("vendor")); + + if link_result.is_ok() { + let candidate = lexical_path_within_root( + root.to_str().expect("UTF-8 temp path"), + "vendor/secret.ts", + ) + .expect("the symlink path is lexically inside the project"); + assert_eq!(candidate, root.join("vendor/secret.ts")); + assert!( + candidate.exists(), + "the filesystem probe still follows symlinks" + ); + } + + std::fs::remove_dir_all(&sandbox).unwrap(); + } + #[test] fn relative_computes_updots_and_forward() { assert_eq!(relative("a/b", "a/c"), "../c"); diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index b3b758a9..9e1d9956 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -7,25 +7,31 @@ //! `FrameworkResolver` extension point and callback synthesis) is deferred to v1 //! follow-ups — see `KNOWN_DIFFS.md`. Resolution is synchronous (rusqlite). +use crate::alias_binding::resolve_alias_binding; use crate::framework::FrameworkResolver; use crate::import_resolver::{ - is_bound_to_out_of_repo_module, is_php_include_path_ref, python_module_member_is_claimed, - resolve_jvm_import, resolve_via_import, + PhpImportedStaticCallResolution, is_bound_to_out_of_repo_module, is_php_include_path_ref, + python_module_member_is_claimed, resolve_import_path, resolve_jvm_import, + resolve_php_imported_static_call, resolve_via_import, }; use crate::name_matcher::{ - crosses_known_family, is_php_property_receiver_shape, is_python_class_function_ref_target, - match_dotted_call_chain, match_function_ref, match_method_call, match_reference, - match_scoped_call_chain, same_language_family, + crosses_known_family, is_js_name_target_visible, is_php_property_receiver_shape, + is_python_class_function_ref_target, match_dotted_call_chain, match_function_ref, + match_method_call, match_reference, match_scoped_call_chain, same_language_family, }; use crate::snapshot_context::{SnapshotResolutionContext, build_edge_adjacency}; +use crate::source_facts::SourceFacts; +use crate::strip_comments::{CommentLang, strip_comments_for_regex}; use crate::types::{ RefView, ResolutionContext, ResolutionResult, ResolutionStats, ResolvedBy, ResolvedRef, }; use codegraph_core::types::{Edge, EdgeKind, Language, Node, NodeKind, UnresolvedRef}; +use codegraph_extract::lang::{cpp_constructor_arity_range, parse_cpp_constructor_reference_name}; use codegraph_store::Store; use codegraph_store::queries::ReferenceSite; use rayon::prelude::*; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use regex::Regex; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::sync::{Arc, OnceLock}; /// Read-only deferred-pass intent returned by [`ReferenceResolver::resolve_one_pure`] @@ -64,6 +70,15 @@ fn is_scoped_chain_language(language: Language) -> bool { language == Language::Rust } +fn is_js_call_result_chain(reference: &RefView) -> bool { + reference.reference_kind == EdgeKind::Calls + && matches!( + reference.language, + Language::TypeScript | Language::Tsx | Language::JavaScript | Language::Jsx + ) + && reference.reference_name.contains("().") +} + /// The extractor's chained-receiver encoding `().` /// (`CHAIN_SHAPE`, `index.ts:44`). fn has_chain_shape(name: &str) -> bool { @@ -77,6 +92,61 @@ fn has_chain_shape(name: &str) -> bool { && method.chars().all(|c| c.is_alphanumeric() || c == '_') } +fn cpp_type_spelling_matches(spelled: &str, qualified: &str) -> bool { + if spelled.contains("::") { + qualified == spelled + || qualified + .strip_suffix(spelled) + .is_some_and(|prefix| prefix.ends_with("::")) + } else { + qualified.rsplit("::").next() == Some(spelled) + } +} + +/// Pick one C++ type owner without using file proximity as namespace proof. +/// An unqualified spelling may be resolved by the caller's lexical qualified +/// scope (longest segment-aligned prefix), otherwise the project must contain +/// exactly one matching owner. Equal-best candidates remain unresolved. +fn unique_cpp_type_owner( + mut candidates: Vec>, + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option> { + candidates.sort_by(|left, right| left.id.cmp(&right.id)); + candidates.dedup_by(|left, right| left.id == right.id); + if candidates.len() == 1 { + return candidates.pop(); + } + let from = context.get_node_by_id_shared(&reference.from_node_id)?; + let caller_scope = from + .qualified_name + .rsplit_once("::") + .map_or("", |(scope, _)| scope); + let mut scoped = candidates + .into_iter() + .filter_map(|candidate| { + let owner_scope = candidate + .qualified_name + .rsplit_once("::") + .map_or("", |(scope, _)| scope); + let in_scope = owner_scope.is_empty() + || caller_scope == owner_scope + || caller_scope + .strip_prefix(owner_scope) + .is_some_and(|tail| tail.starts_with("::")); + let depth = if owner_scope.is_empty() { + 0 + } else { + owner_scope.split("::").count() + }; + in_scope.then_some((depth, candidate)) + }) + .collect::>(); + let best_depth = scoped.iter().map(|(depth, _)| *depth).max()?; + scoped.retain(|(depth, _)| *depth == best_depth); + (scoped.len() == 1).then(|| scoped.pop().expect("one scoped owner").1) +} + /// Node-count threshold at/above which batched resolution switches from the /// whole-graph [`SnapshotResolutionContext`] + rayon path to the memory-bounded /// STREAMING FALLBACK (a per-batch [`StoreResolutionContext`](crate::context::StoreResolutionContext) @@ -745,6 +815,413 @@ fn cpp_built_ins() -> &'static BTreeSet<&'static str> { }) } +fn c_family_language_for_path(path: &str) -> Option { + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".c") { + Some(Language::C) + } else if [ + ".h", ".hh", ".hpp", ".hxx", ".inc", ".inl", ".ipp", ".cc", ".cpp", ".cxx", ".c++", ".m", + ".mm", + ] + .iter() + .any(|extension| lower.ends_with(extension)) + { + Some(Language::Cpp) + } else { + None + } +} + +/// Build `file -> function-like macro names visible from that file` through +/// project-local include edges. Macro names propagate backwards from a header +/// to every transitive includer; unrelated headers never suppress a real call. +fn build_c_family_visible_macros( + context: &dyn ResolutionContext, +) -> BTreeMap> { + let mut files = context.get_all_files(); + files.sort(); + let mut visible = BTreeMap::>::new(); + let mut reverse_includes = BTreeMap::>::new(); + + for file in files { + let Some(language) = c_family_language_for_path(&file) else { + continue; + }; + let Some(source) = context.read_file(&file) else { + continue; + }; + visible.insert(file.clone(), function_like_macro_names(&source)); + + for import in context.get_import_mappings(&file, language) { + let from_dir = crate::pathutil::dirname(&file); + let sibling = crate::pathutil::normalize(&if from_dir.is_empty() { + import.source.clone() + } else { + format!("{from_dir}/{}", import.source) + }); + let included = if context.file_exists(&sibling) { + Some(sibling) + } else { + resolve_import_path(&import.source, &file, language, context) + }; + if let Some(included) = included { + reverse_includes + .entry(included) + .or_default() + .insert(file.clone()); + } + } + } + + // Monotone work queue: when a file learns a macro, propagate the complete + // set to its includers. Cycles terminate because every set only grows. + let mut queue: VecDeque = visible + .iter() + .filter(|(_, names)| !names.is_empty()) + .map(|(file, _)| file.clone()) + .collect(); + while let Some(included) = queue.pop_front() { + let inherited = visible.get(&included).cloned().unwrap_or_default(); + let parents = reverse_includes.get(&included).cloned().unwrap_or_default(); + for parent in parents { + let names = visible.entry(parent.clone()).or_default(); + let before = names.len(); + names.extend(inherited.iter().cloned()); + if names.len() != before { + queue.push_back(parent); + } + } + } + visible +} + +/// Collect real preprocessor function macros (`#define NAME(`) while ignoring +/// comments, quoted literals, C++ raw strings, and continuation lines. The C +/// standard requires `(` to immediately follow the name for a function-like +/// macro, so `#define NAME (value)` is correctly not admitted. +fn function_like_macro_names(source: &str) -> BTreeSet { + let bytes = source.as_bytes(); + let mut names = BTreeSet::new(); + let mut i = 0usize; + let mut directive_allowed = true; + + while i < bytes.len() { + match bytes[i] { + b'\n' => { + directive_allowed = true; + i += 1; + } + b' ' | b'\t' | b'\r' if directive_allowed => i += 1, + b'/' if bytes.get(i + 1) == Some(&b'/') => { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2; + while i < bytes.len() { + if bytes[i] == b'\n' { + directive_allowed = true; + } + if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') { + i += 2; + break; + } + i += 1; + } + } + b'#' if directive_allowed => { + if let Some(name) = function_macro_name_on_directive(&bytes[i..]) { + names.insert(name.to_string()); + } + i = c_logical_directive_end(bytes, i); + directive_allowed = true; + } + b'"' if i > 0 && bytes[i - 1] == b'R' => { + directive_allowed = false; + i = skip_cpp_raw_string(bytes, i); + } + quote @ (b'"' | b'\'') => { + directive_allowed = false; + i = skip_c_quoted_literal(bytes, i, quote); + } + _ => { + directive_allowed = false; + i += 1; + } + } + } + names +} + +fn function_macro_name_on_directive(bytes: &[u8]) -> Option<&str> { + let line_end = bytes + .iter() + .position(|byte| *byte == b'\n') + .unwrap_or(bytes.len()); + let line = &bytes[..line_end]; + let mut i = 1usize; // leading '#' + while matches!(line.get(i), Some(b' ' | b'\t')) { + i += 1; + } + let keyword = b"define"; + if line.get(i..i + keyword.len())? != keyword { + return None; + } + i += keyword.len(); + if !matches!(line.get(i), Some(b' ' | b'\t')) { + return None; + } + while matches!(line.get(i), Some(b' ' | b'\t')) { + i += 1; + } + let start = i; + if !line + .get(i) + .is_some_and(|byte| *byte == b'_' || byte.is_ascii_alphabetic()) + { + return None; + } + i += 1; + while line + .get(i) + .is_some_and(|byte| *byte == b'_' || byte.is_ascii_alphanumeric()) + { + i += 1; + } + if line.get(i) != Some(&b'(') { + return None; + } + std::str::from_utf8(&line[start..i]).ok() +} + +fn c_logical_directive_end(bytes: &[u8], start: usize) -> usize { + let mut i = start; + loop { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + let mut back = i; + if back > start && bytes[back - 1] == b'\r' { + back -= 1; + } + let continued = back > start && bytes[back - 1] == b'\\'; + if i < bytes.len() { + i += 1; + } + if !continued || i >= bytes.len() { + return i; + } + } +} + +fn skip_c_quoted_literal(bytes: &[u8], start: usize, quote: u8) -> usize { + let mut i = start + 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i = (i + 2).min(bytes.len()), + b'\n' => return i, + byte if byte == quote => return i + 1, + _ => i += 1, + } + } + i +} + +fn skip_cpp_raw_string(bytes: &[u8], quote: usize) -> usize { + let mut delimiter_end = quote + 1; + while delimiter_end < bytes.len() + && bytes[delimiter_end] != b'(' + && delimiter_end - quote - 1 < 16 + && !bytes[delimiter_end].is_ascii_whitespace() + { + delimiter_end += 1; + } + if delimiter_end >= bytes.len() || bytes[delimiter_end] != b'(' { + return skip_c_quoted_literal(bytes, quote, b'"'); + } + let mut closer = Vec::with_capacity(delimiter_end - quote + 1); + closer.push(b')'); + closer.extend_from_slice(&bytes[quote + 1..delimiter_end]); + closer.push(b'"'); + let mut i = delimiter_end + 1; + while i + closer.len() <= bytes.len() { + if bytes[i..i + closer.len()] == closer { + return i + closer.len(); + } + i += 1; + } + bytes.len() +} + +fn c_family_static_function_is_file_local( + target: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + let translation_unit = [".c", ".cc", ".cpp", ".cxx", ".c++", ".m", ".mm"] + .iter() + .any(|extension| target.file_path.to_ascii_lowercase().ends_with(extension)); + if !matches!( + target.language, + Language::C | Language::Cpp | Language::ObjC + ) || target.kind != NodeKind::Function + || target.file_path == reference.file_path + || !translation_unit + { + return false; + } + let Some(facts) = context.source_facts(&target.file_path) else { + return false; + }; + facts.node_decision("c_static", &target.id, |facts| { + let line_count = facts.line_count(); + if line_count == 0 { + return false; + } + // A tree-sitter `function_definition` starts at its storage/type + // specifier. Inspect only this definition's bounded header (never the + // preceding line, which may be a different one-line static function). + let line = (target.start_line.max(1) - 1) as usize; + let end = (line + 16).min(line_count); + if line >= end { + return false; + } + let head = facts.join_lines(line, end, "\n"); + let head = head.split_once('{').map_or(head.as_str(), |(head, _)| head); + let head = strip_comments_for_regex(head, CommentLang::Java); + static STATIC_RE: OnceLock = OnceLock::new(); + STATIC_RE + .get_or_init(|| Regex::new(r"(?:^|[\s;}])static(?:\s|$)").expect("C static regex")) + .is_match(&head) + }) +} + +fn rust_module_dir(file_path: &str) -> String { + let normalized = file_path.replace('\\', "/"); + let dir = crate::pathutil::dirname(&normalized); + let base = crate::pathutil::basename(&normalized); + if matches!(base, "lib.rs" | "main.rs" | "mod.rs") { + dir + } else { + let stem = base.strip_suffix(".rs").unwrap_or(base); + if dir.is_empty() { + stem.to_string() + } else { + format!("{dir}/{stem}") + } + } +} + +fn rust_method_is_trait_visible(target: &Node, context: &dyn ResolutionContext) -> bool { + if target.kind != NodeKind::Method { + return false; + } + // A trait's own (default) method is as visible as the trait itself. + if let Some((owner, _)) = target.qualified_name.rsplit_once("::") + && context + .get_nodes_by_name_shared(owner.rsplit("::").next().unwrap_or(owner)) + .iter() + .any(|node| { + node.language == Language::Rust + && node.kind == NodeKind::Trait + && node.qualified_name == owner + }) + { + return true; + } + let Some(facts) = context.source_facts(&target.file_path) else { + return false; + }; + facts.node_decision("rust_trait_impl", &target.id, |facts| { + rust_method_is_in_trait_impl(facts, target.start_line) + }) +} + +/// Whether the method starting at 1-based `start_line` sits in an +/// `impl Trait for Type` block (`isRustTraitImplMethod`, upstream v1.6.1): +/// read upward to the nearest `impl` header; a top-level item line above the +/// method means it was not inside an impl at all. +fn rust_method_is_in_trait_impl(facts: &SourceFacts, start_line: i64) -> bool { + static IMPL_HEADER: OnceLock = OnceLock::new(); + static TOP_LEVEL_ITEM: OnceLock = OnceLock::new(); + static FOR_CLAUSE: OnceLock = OnceLock::new(); + let impl_header = IMPL_HEADER.get_or_init(|| { + Regex::new(r"^\s*(pub(\([^)]*\))?\s+)?(unsafe\s+)?impl(?-u:\b)").expect("Rust impl header") + }); + let top_level_item = TOP_LEVEL_ITEM.get_or_init(|| { + Regex::new(r"^(pub(\([^)]*\))?\s+)?(fn|struct|enum|mod|trait|const|static|type)(?-u:\b)") + .expect("Rust top-level item") + }); + let for_clause = + FOR_CLAUSE.get_or_init(|| Regex::new(r"\sfor\s").expect("Rust impl for clause")); + let Some(mut index) = usize::try_from(start_line) + .ok() + .and_then(|line| line.checked_sub(2)) + else { + return false; + }; + loop { + let line = facts.raw_line(index).unwrap_or(""); + if impl_header.is_match(line) { + let code = line.split_once("//").map_or(line, |(code, _)| code); + return for_clause.is_match(code); + } + if top_level_item.is_match(line) { + return false; + } + let Some(previous) = index.checked_sub(1) else { + return false; + }; + index = previous; + } +} + +fn target_is_visible_across_files( + target: &Node, + reference: &RefView, + context: &dyn ResolutionContext, +) -> bool { + if target.file_path == reference.file_path { + return true; + } + if c_family_static_function_is_file_local(target, reference, context) { + return false; + } + if matches!( + target.language, + Language::Kotlin + | Language::Java + | Language::CSharp + | Language::Swift + | Language::Scala + | Language::Dart + | Language::Php + ) && target.visibility.as_deref() == Some("private") + { + return false; + } + if target.language == Language::Go + && target + .name + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + { + return crate::pathutil::dirname(&target.file_path) + == crate::pathutil::dirname(&reference.file_path); + } + if target.language == Language::Rust && target.visibility.as_deref() == Some("private") { + if rust_method_is_trait_visible(target, context) { + return true; + } + let owner = rust_module_dir(&target.file_path); + let from = reference.file_path.replace('\\', "/"); + return !owner.is_empty() && from.starts_with(&format!("{owner}/")); + } + true +} + /// Orchestrates reference resolution using multiple strategies /// (`ReferenceResolver`, `index.ts:199-1189`). /// @@ -760,6 +1237,13 @@ pub struct ReferenceResolver { /// Distinct symbol names known to the graph, for the fast pre-filter /// (`knownNames`, `index.ts:224`). Populated by `warm_caches`. known_names: Option>, + /// Function-like macro names visible from each C/C++ file through its + /// project-local include closure. Built once with the other read-only + /// resolution caches, then shared by the parallel resolver. + c_family_visible_macros: BTreeMap>, + /// Constructor node id -> accepted positional-argument range. Built once + /// from the narrow C++ constructor signatures stored by extraction. + cpp_constructor_arities: BTreeMap, /// `this.` fn-refs whose member wasn't on the enclosing class /// itself — retried in the supertype pass once implements/extends edges /// exist (`deferredThisMemberRefs`, index.ts:214 / #808). A `Mutex` (not @@ -783,6 +1267,8 @@ impl ReferenceResolver { project_root: project_root.into(), framework_resolver_extensions: Vec::new(), known_names: None, + c_family_visible_macros: BTreeMap::new(), + cpp_constructor_arities: BTreeMap::new(), deferred_this_member_refs: std::sync::Mutex::new(Vec::new()), deferred_chain_refs: std::sync::Mutex::new(Vec::new()), } @@ -929,6 +1415,16 @@ impl ReferenceResolver { pub fn warm_caches(&mut self, context: &dyn ResolutionContext) { let names: BTreeSet = context.known_node_names().into_iter().collect(); self.known_names = Some(names); + self.c_family_visible_macros = build_c_family_visible_macros(context); + self.cpp_constructor_arities = context + .get_nodes_by_kind_shared(NodeKind::Method) + .into_iter() + .filter(|node| node.language == Language::Cpp) + .filter_map(|node| { + let range = cpp_constructor_arity_range(node.signature.as_deref()?)?; + Some((node.id.clone(), range)) + }) + .collect(); } /// Resolve all unresolved references (`resolveAll`, `index.ts:511-572`). @@ -961,6 +1457,12 @@ impl ReferenceResolver { .or_insert(0) += 1; resolved.push(result); } + None if parse_cpp_constructor_reference_name(&reference.reference_name) + .is_some() => + { + // Internal claimed reference: a miss means "no uniquely + // proven constructor", not a dangling source call. + } None => unresolved.push(reference.clone()), } } @@ -1019,9 +1521,96 @@ impl ReferenceResolver { let (resolved, deferred) = self.resolve_one_pure_inner(reference, context); let resolved = self.gate_target_kind(resolved, reference, context); let resolved = self.gate_import_locality(resolved, reference, context); + let resolved = self.gate_c_macro_calls(resolved, reference); + let resolved = self.forward_alias_binding(resolved, reference, context); + let resolved = self.gate_language_visibility(resolved, reference, context); + let resolved = self.gate_js_visibility(resolved, reference, context); (resolved, deferred) } + /// C/C++ preprocessor binding gate: a call spelled like a function-like + /// macro visible from the calling file is an expansion, not a call. (A + /// receiver-less Go call is kept off methods inside the name matchers, + /// #1857.) + fn gate_c_macro_calls( + &self, + result: Option, + reference: &RefView, + ) -> Option { + let result = result?; + + if matches!(reference.language, Language::C | Language::Cpp) + && reference.reference_kind == EdgeKind::Calls + && self + .c_family_visible_macros + .get(&reference.file_path) + .is_some_and(|names| names.contains(&reference.reference_name)) + { + return None; + } + + Some(result) + } + + fn gate_language_visibility( + &self, + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, + ) -> Option { + let result = result?; + if result.resolved_by == ResolvedBy::Framework { + // A framework resolver supplies its own reachability proof (for + // example a private Rust `#[tauri::command]` invoked from TS). + // Language-local name matching rules must not erase that bridge. + return Some(result); + } + let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { + return Some(result); + }; + target_is_visible_across_files(&target, reference, context).then_some(result) + } + + fn forward_alias_binding( + &self, + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, + ) -> Option { + let mut result = result?; + if reference.reference_kind != EdgeKind::Calls { + return Some(result); + } + let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { + return Some(result); + }; + let member = reference + .reference_name + .rsplit_once('.') + .map(|(_, member)| member); + let Some(forwarded) = resolve_alias_binding(&target, member, context) else { + return Some(result); + }; + if forwarded.id != result.target_node_id { + result.target_node_id = forwarded.id; + result.confidence = result.confidence.min(0.85); + } + Some(result) + } + + fn gate_js_visibility( + &self, + result: Option, + reference: &RefView, + context: &dyn ResolutionContext, + ) -> Option { + let result = result?; + let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { + return Some(result); + }; + is_js_name_target_visible(&target, reference, context).then_some(result) + } + /// Drop a result whose reference name is bound by an import from OUTSIDE the /// repository (#1537/#1536). /// @@ -1080,7 +1669,7 @@ impl ReferenceResolver { let Some(target) = context.get_node_by_id_shared(&result.target_node_id) else { return Some(result); }; - if crate::types::kind_is_eligible_target(reference.reference_kind, target.kind) { + if crate::types::node_is_eligible_target(reference.reference_kind, &target) { Some(result) } else { None @@ -1092,6 +1681,13 @@ impl ReferenceResolver { reference: &RefView, context: &dyn ResolutionContext, ) -> (Option, Option) { + // C++ local object initialization is encoded separately from ordinary + // calls. It is a claimed reference: only one lexically-owned type and + // one constructor accepting the recorded arity may resolve, and a miss + // must never fall through to a same-spelled class/function (#1839). + if parse_cpp_constructor_reference_name(&reference.reference_name).is_some() { + return (self.resolve_cpp_constructor(reference, context), None); + } if self.is_built_in_or_external(reference) { return (None, None); } @@ -1104,12 +1700,14 @@ impl ReferenceResolver { } else { &reference.reference_name }; + let js_call_result_chain = is_js_call_result_chain(reference); if !self.has_any_possible_match_for(existence_name, reference.language) && !self.matches_any_import(reference, context) && !self .framework_resolver_extensions .iter() .any(|f| f.claims_reference(&reference.reference_name)) + && !js_call_result_chain { return (None, None); } @@ -1117,7 +1715,11 @@ impl ReferenceResolver { // Function-as-value refs (#756) get a dedicated, strictly-gated path, // never reaching framework/fuzzy strategies (index.ts:686-699). if reference.is_function_ref { - if reference.reference_name.starts_with("this.") { + if reference + .reference_name + .strip_prefix("this.") + .is_some_and(|member| !member.contains('.')) + { let (resolved, deferred) = self.resolve_this_member_fn_ref_pure(reference, context); return (self.gate_language(resolved, reference, context), deferred); } @@ -1143,6 +1745,16 @@ impl ReferenceResolver { return (Some(jvm_import), None); } + // An explicit PHP class import owns its static calls. If the imported + // class or method is absent/ambiguous, keep the reference unresolved + // rather than guessing an unrelated same-named target (#1545). + match resolve_php_imported_static_call(reference, context) { + PhpImportedStaticCallResolution::NotApplicable => {} + PhpImportedStaticCallResolution::Claimed(result) => { + return (self.gate_language(result, reference, context), None); + } + } + let mut candidates: Vec = Vec::new(); // Strategy 1: `FrameworkResolver` extension point (index.ts:695-701). @@ -1160,6 +1772,13 @@ impl ReferenceResolver { } } + // An imported root in `make().run()` does not prove what `make` + // returns. Preserve any framework result, but never let import or + // unique-name heuristics guess the result type (#1683). + if js_call_result_chain { + return (candidates.into_iter().reduce(highest_confidence), None); + } + // Strategy 2: import-based resolution (index.ts:704-708). if let Some(import_result) = self.gate_language(resolve_via_import(reference, context), reference, context) @@ -1216,6 +1835,57 @@ impl ReferenceResolver { (candidates.into_iter().reduce(highest_confidence), None) } + fn resolve_cpp_constructor( + &self, + reference: &RefView, + context: &dyn ResolutionContext, + ) -> Option { + if reference.language != Language::Cpp || reference.reference_kind != EdgeKind::Calls { + return None; + } + let (spelled_type, call_arity) = + parse_cpp_constructor_reference_name(&reference.reference_name)?; + let base_name = spelled_type + .rsplit("::") + .find(|segment| !segment.is_empty())?; + + let owners = context + .get_nodes_by_name_shared(base_name) + .into_iter() + .filter(|candidate| { + candidate.language == Language::Cpp + && matches!( + candidate.kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Union + ) + && cpp_type_spelling_matches(spelled_type, &candidate.qualified_name) + }) + .collect::>(); + let owner = unique_cpp_type_owner(owners, reference, context)?; + let constructor_name = format!("{}::{base_name}", owner.qualified_name); + let candidates = context + .get_nodes_by_name_shared(base_name) + .into_iter() + .filter(|candidate| { + candidate.language == Language::Cpp + && candidate.kind == NodeKind::Method + && candidate.qualified_name == constructor_name + && self.cpp_constructor_arities.get(&candidate.id).is_some_and( + |(minimum, maximum)| *minimum <= call_arity && call_arity <= *maximum, + ) + }) + .collect::>(); + if candidates.len() != 1 { + return None; + } + Some(ResolvedRef { + original: reference.clone(), + target_node_id: candidates[0].id.clone(), + confidence: 0.98, + resolved_by: ResolvedBy::QualifiedName, + }) + } + /// Resolve a `this.` function_ref against the enclosing class's own /// members (same file, function/method kind); a member not on the class /// RETURNS [`DeferredIntent::ThisMemberRef`] for the #808 supertype pass @@ -1467,6 +2137,7 @@ impl ReferenceResolver { if !result.resolved.is_empty() { delete_resolved_rows(store, &result.resolved)?; } + delete_internal_constructor_rows(store, &unresolved_refs)?; // #750 conformance pass for chained calls whose method lives on a // supertype, then #808 supertype pass for inherited this. refs — @@ -1698,6 +2369,12 @@ impl ReferenceResolver { .resolved .iter() .filter_map(|reference| reference.original.row_id) + .chain(batch.iter().filter_map(|reference| { + parse_cpp_constructor_reference_name(&reference.reference_name) + .is_some() + .then_some(reference.id) + .flatten() + })) .collect(); if !batch_row_ids.is_empty() { store.delete_resolved_unresolved_refs(&batch_row_ids)?; @@ -1788,6 +2465,8 @@ impl ReferenceResolver { .or_insert(0) += 1; resolved.push(result); } + None if parse_cpp_constructor_reference_name(&reference.reference_name) + .is_some() => {} None => unresolved.push(reference.clone()), } } @@ -1841,6 +2520,7 @@ impl ReferenceResolver { if !result.resolved.is_empty() { delete_resolved_rows(store, &result.resolved)?; } + delete_internal_constructor_rows(store, &scoped)?; // #750 conformance pass then #808 supertype pass, after implements/extends // edges exist (index.ts:508-511). @@ -2277,6 +2957,25 @@ fn delete_resolved_rows(store: &mut Store, resolved: &[ResolvedRef]) -> anyhow:: Ok(()) } +fn delete_internal_constructor_rows( + store: &mut Store, + references: &[UnresolvedRef], +) -> anyhow::Result<()> { + let mut row_ids = references + .iter() + .filter(|reference| { + parse_cpp_constructor_reference_name(&reference.reference_name).is_some() + }) + .filter_map(|reference| reference.id) + .collect::>(); + row_ids.sort_unstable(); + row_ids.dedup(); + if !row_ids.is_empty() { + store.delete_resolved_unresolved_refs(&row_ids)?; + } + Ok(()) +} + fn union_persisted_refs( lanes: impl IntoIterator>, ) -> anyhow::Result> { @@ -3944,6 +4643,116 @@ mod tests { assert!(target.is_none()); } + #[test] + fn cross_file_visibility_matrix_is_fail_closed() { + let mut reference = mk_ref("hidden", EdgeKind::Calls, Language::Kotlin); + reference.file_path = "src/use.kt".to_string(); + for language in [ + Language::Kotlin, + Language::Java, + Language::CSharp, + Language::Swift, + Language::Scala, + Language::Dart, + Language::Php, + ] { + reference.language = language; + let mut target = mk_node2( + "method:hidden", + NodeKind::Method, + "hidden", + "src/owner.file", + language, + ); + target.visibility = Some("private".to_string()); + assert!(!target_is_visible_across_files( + &target, + &reference, + &MinimalCtx { + files: HashMap::new() + } + )); + target.file_path = reference.file_path.clone(); + assert!(target_is_visible_across_files( + &target, + &reference, + &MinimalCtx { + files: HashMap::new() + } + )); + } + + let mut rust_reference = mk_ref("shared", EdgeKind::Calls, Language::Rust); + rust_reference.file_path = "src/net/tcp.rs".to_string(); + let mut rust_private = mk_node2( + "function:shared", + NodeKind::Function, + "shared", + "src/net.rs", + Language::Rust, + ); + rust_private.visibility = Some("private".to_string()); + assert!(target_is_visible_across_files( + &rust_private, + &rust_reference, + &MinimalCtx { + files: HashMap::new() + } + )); + rust_reference.file_path = "src/util.rs".to_string(); + assert!(!target_is_visible_across_files( + &rust_private, + &rust_reference, + &MinimalCtx { + files: HashMap::new() + } + )); + + let mut trait_method = mk_node2( + "method:area", + NodeKind::Method, + "area", + "src/shape.rs", + Language::Rust, + ); + trait_method.qualified_name = "Circle::area".to_string(); + trait_method.visibility = Some("private".to_string()); + trait_method.start_line = 4; + rust_reference.file_path = "src/draw.rs".to_string(); + let ctx = MinimalCtx { + files: HashMap::from([( + "src/shape.rs".to_string(), + "pub trait Area { fn area(&self); }\npub struct Circle;\nimpl Area for Circle {\n fn area(&self) {}\n}\n" + .to_string(), + )]), + }; + assert!(target_is_visible_across_files( + &trait_method, + &rust_reference, + &ctx + )); + + let mut c_reference = mk_ref("hidden", EdgeKind::Calls, Language::Cpp); + c_reference.file_path = "main.cpp".to_string(); + let target = mk_node2( + "function:hidden", + NodeKind::Function, + "hidden", + "other.cpp", + Language::Cpp, + ); + let ctx = MinimalCtx { + files: HashMap::from([( + "other.cpp".to_string(), + "static void hidden() {}\n".to_string(), + )]), + }; + assert!(!target_is_visible_across_files(&target, &c_reference, &ctx)); + let mut header = target; + header.file_path = "shared.hpp".to_string(); + assert!(target_is_visible_across_files(&header, &c_reference, &ctx)); + } + // --------------------------------------------------------------------- // helpers // --------------------------------------------------------------------- @@ -4272,4 +5081,19 @@ mod tests { let _ = std::fs::remove_file(&db); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn rust_trait_impl_scan_reads_upward_to_the_nearest_impl_header() { + let source = "pub trait Tr { fn t(&self); }\npub struct S;\nimpl Tr for S {\n fn helper() { simple(); }\n fn t(&self) {}\n}\nimpl S {\n fn u(&self) {}\n}\npub(crate) unsafe impl Send for S {}\nfn free() {}\nimpl Tr for Vec // comment for S\n{\n fn t(&self) {}\n}\n"; + let facts = crate::source_facts::SourceFacts::new(std::sync::Arc::from(source)); + // `fn t` in `impl Tr for S`, past a body that spells `simple`. + assert!(rust_method_is_in_trait_impl(&facts, 5)); + assert!(!rust_method_is_in_trait_impl(&facts, 8), "inherent impl"); + assert!( + rust_method_is_in_trait_impl(&facts, 14), + "the header's for-clause is read before any comment" + ); + assert!(!rust_method_is_in_trait_impl(&facts, 1)); + assert!(!rust_method_is_in_trait_impl(&facts, 0)); + } } diff --git a/crates/codegraph-resolve/src/snapshot_context.rs b/crates/codegraph-resolve/src/snapshot_context.rs index 221d98d7..892143c8 100644 --- a/crates/codegraph-resolve/src/snapshot_context.rs +++ b/crates/codegraph-resolve/src/snapshot_context.rs @@ -30,6 +30,8 @@ use crate::context::{DEFAULT_CACHE_LIMIT, order_candidates}; use crate::import_resolver; use crate::path_aliases::{AliasMap, load_project_aliases}; +use crate::pathutil; +use crate::source_facts::SourceFacts; use crate::types::{GoModule, ImportMapping, ReExport, ResolutionContext}; use crate::workspace_packages::{WorkspacePackages, load_workspace_packages}; use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; @@ -159,6 +161,7 @@ where #[derive(Default)] struct SnapshotCacheCounters { file_loads: AtomicUsize, + source_facts_builds: AtomicUsize, import_parses: AtomicUsize, re_export_parses: AtomicUsize, } @@ -166,6 +169,9 @@ struct SnapshotCacheCounters { /// Whole-run file-derived caches shared by every per-chunk context clone. struct SnapshotCaches { file_contents: BoundedFileMemo>>, + /// Derived source views and memoised per-file gate decisions. Shares the + /// content budget: a fact set retains its file's text. + source_facts: BoundedFileMemo>>, import_mappings: BoundedFileMemo>>, re_exports: BoundedFileMemo>>, #[cfg(test)] @@ -179,6 +185,7 @@ impl SnapshotCaches { let content_limit = std::cmp::max(64, DEFAULT_CACHE_LIMIT / 5); Self { file_contents: BoundedFileMemo::new(content_limit), + source_facts: BoundedFileMemo::new(content_limit), import_mappings: BoundedFileMemo::new(DEFAULT_CACHE_LIMIT), re_exports: BoundedFileMemo::new(DEFAULT_CACHE_LIMIT), #[cfg(test)] @@ -200,6 +207,8 @@ struct NodeSnapshot { by_kind: HashMap>>, by_file_path: HashMap>>, by_id: HashMap>, + /// Files holding at least one exported node (`fileHasExportedNode`). + files_with_exported_nodes: HashSet, known_node_names: Vec, known_file_paths: HashSet, all_file_paths: Arc>, @@ -300,6 +309,12 @@ impl SnapshotResolutionContext { by_kind.insert(kind, entries); } + let files_with_exported_nodes: HashSet = by_id + .values() + .filter(|node| node.is_exported) + .map(|node| node.file_path.clone()) + .collect(); + let known_node_names = store.all_node_names().unwrap_or_default(); let known_file_paths: HashSet = store @@ -341,6 +356,7 @@ impl SnapshotResolutionContext { by_kind, by_file_path, by_id, + files_with_exported_nodes, known_node_names, known_file_paths, all_file_paths, @@ -469,9 +485,8 @@ impl ResolutionContext for SnapshotResolutionContext { if normalized != file_path && self.snapshot.known_file_paths.contains(&normalized) { return true; } - Path::new(&self.snapshot.project_root) - .join(file_path) - .exists() + pathutil::lexical_path_within_root(&self.snapshot.project_root, file_path) + .is_some_and(|full_path| full_path.exists()) } fn read_file(&self, file_path: &str) -> Option { @@ -484,6 +499,26 @@ impl ResolutionContext for SnapshotResolutionContext { self.read_file_cached(file_path).is_some() } + fn read_file_shared(&self, file_path: &str) -> Option> { + self.read_file_cached(file_path) + } + + fn source_facts(&self, file_path: &str) -> Option> { + self.caches.source_facts.get_or_init(file_path, || { + #[cfg(test)] + self.caches + .counters + .source_facts_builds + .fetch_add(1, Ordering::Relaxed); + self.read_file_cached(file_path) + .map(|source| Arc::new(SourceFacts::new(source))) + }) + } + + fn file_has_exported_node(&self, file_path: &str) -> bool { + self.snapshot.files_with_exported_nodes.contains(file_path) + } + fn get_project_root(&self) -> &str { &self.snapshot.project_root } @@ -544,7 +579,11 @@ impl ResolutionContext for SnapshotResolutionContext { let type_nodes = self .get_nodes_by_name_shared(type_name) .into_iter() - .filter(|n| SUPERTYPE_BEARING.contains(&n.kind) && n.language == language) + .filter(|n| { + n.language == language + // A Scala singleton inherits members though it can never be a parent. + && (SUPERTYPE_BEARING.contains(&n.kind) || crate::types::is_scala_singleton(n)) + }) .collect::>(); if type_nodes.is_empty() { return Vec::new(); @@ -762,14 +801,28 @@ mod tests { #[test] fn file_exists_uses_known_set_and_fs() { let root = temp_root("exists"); + let outside = root.parent().unwrap().join(format!( + "outside-{}", + root.file_name().unwrap().to_string_lossy() + )); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret.ts"), "x").unwrap(); let store = populated_store(&root); std::fs::write(root.join("ondisk.ts"), "x").unwrap(); let ctx = SnapshotResolutionContext::from_store(&store, root.to_str().unwrap()).unwrap(); assert!(ctx.file_exists("a.ts")); assert!(ctx.file_exists("ondisk.ts")); assert!(!ctx.file_exists("nowhere.ts")); + assert!( + !ctx.file_exists(&format!( + "../{}/secret.ts", + outside.file_name().unwrap().to_string_lossy() + )), + "an existing file outside the project must not become an existence oracle" + ); assert!(ctx.get_all_files().contains(&"a.ts".to_string())); std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&outside).ok(); } #[test] @@ -818,6 +871,35 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn source_facts_are_shared_once_per_file_across_chunk_clones() { + let root = temp_root("facts"); + let store = Store::open(&root.join("index.db")).unwrap(); + let source = "const a = 1;\nexport function b() {}\n"; + std::fs::write(root.join("f.ts"), source).unwrap(); + let ctx = SnapshotResolutionContext::from_store(&store, root.to_str().unwrap()).unwrap(); + let cloned = ctx.with_edge_adjacency(Arc::new(HashMap::new())); + + let first = ctx.source_facts("f.ts").expect("readable file has facts"); + let second = cloned.source_facts("f.ts").expect("shared cache"); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(first.source(), source); + assert_eq!( + ctx.caches + .counters + .source_facts_builds + .load(Ordering::Relaxed), + 1 + ); + // The shared buffer is the cached content, not a copy. + let shared = ctx.read_file_shared("f.ts").unwrap(); + assert!(Arc::ptr_eq(&shared, &ctx.read_file_shared("f.ts").unwrap())); + assert!(ctx.source_facts("missing.ts").is_none()); + // No indexed nodes here, so no file reports an exported node. + assert!(!ctx.file_has_exported_node("f.ts")); + std::fs::remove_dir_all(&root).ok(); + } + #[test] fn bounded_file_memo_is_single_flight_and_bounded() { let cache = Arc::new(BoundedFileMemo::new(2)); diff --git a/crates/codegraph-resolve/src/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs new file mode 100644 index 00000000..d61adbfb --- /dev/null +++ b/crates/codegraph-resolve/src/source_facts.rs @@ -0,0 +1,404 @@ +//! Per-file source facts shared by the resolver's text-scanning gates. +//! +//! Several resolution gates inspect a file's source text for every reference +//! they see: the call site's own line, a comment-stripped view of the whole +//! file, or a JS/TS module-shape decision. Deriving those views afresh per +//! reference made resolution `O(references × file size)` on large files. +//! +//! A [`SourceFacts`] is built once per file and per resolution pass (production +//! contexts memoise it next to their file-content cache), and every derived +//! view is computed lazily, at most once. Each value is a pure function of the +//! source text — the memo slots additionally depend only on the immutable node +//! snapshot of the pass — so a cached answer is identical to recomputing it. + +use crate::awaited::AwaitedIndex; +use crate::strip_comments::{CommentLang, blank_string_contents, strip_comments_for_regex}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Newline positions of a text. +/// +/// Answers both [`str::lines`]-compatible queries and the raw +/// `split('\n')` view that upstream's column arithmetic uses, without +/// materialising a `Vec<&str>` for every caller. +#[derive(Debug)] +pub struct LineIndex { + newlines: Vec, + len: usize, +} + +impl LineIndex { + /// Index every `\n` in `text`. + pub fn new(text: &str) -> Self { + Self { + newlines: text + .bytes() + .enumerate() + .filter_map(|(offset, byte)| (byte == b'\n').then_some(offset)) + .collect(), + len: text.len(), + } + } + + /// Byte offset where 0-based line `index` starts when every `\n` counts + /// as a line break, or `None` past the last newline. + pub fn raw_line_start(&self, index: usize) -> Option { + if index == 0 { + Some(0) + } else { + self.newlines.get(index - 1).map(|newline| newline + 1) + } + } + + /// Segment `index` of `text.split('\n')` (a `\r` before the newline is + /// kept, and a final empty segment after a trailing newline exists). + pub fn raw_line<'t>(&self, text: &'t str, index: usize) -> Option<&'t str> { + let start = self.raw_line_start(index)?; + let end = self.newlines.get(index).copied().unwrap_or(self.len); + Some(&text[start..end]) + } + + /// Number of lines [`str::lines`] yields for the indexed text. + pub fn line_count(&self) -> usize { + let tail_start = self.newlines.last().map_or(0, |newline| newline + 1); + self.newlines.len() + usize::from(tail_start < self.len) + } + + /// Line `index` exactly as [`str::lines`] yields it: a `\n` terminator is + /// removed together with one `\r` before it; an unterminated final line is + /// returned verbatim. + /// + /// # Panics + /// When `index >= self.line_count()` or `text` is not the indexed text. + pub fn line<'t>(&self, text: &'t str, index: usize) -> &'t str { + let start = self + .raw_line_start(index) + .expect("line index within the indexed text"); + match self.newlines.get(index) { + Some(&newline) => { + let line = &text[start..newline]; + line.strip_suffix('\r').unwrap_or(line) + } + None => &text[start..self.len], + } + } + + /// `lines[start..end].join(separator)` over [`str::lines`]. + pub fn join(&self, text: &str, start: usize, end: usize, separator: &str) -> String { + let mut joined = String::new(); + for index in start..end { + if index > start { + joined.push_str(separator); + } + joined.push_str(self.line(text, index)); + } + joined + } +} + +/// Offsets where a JS/TS local binding can start (`localBindingSites`, +/// upstream v1.6.1): every `const`/`let`/`var` keyword, every +/// `function`/`class` keyword, and every `=>`. +#[derive(Debug, Default)] +pub struct LocalBindingSites { + pub var_decls: Vec, + pub fn_decls: Vec, + pub arrows: Vec, +} + +/// Lazily derived, memoised facts about one file's source text. +#[derive(Debug)] +pub struct SourceFacts { + source: Arc, + lines: OnceLock, + ts_comment_free: OnceLock<(String, LineIndex)>, + ts_blanked: OnceLock, + js_sealed_module: OnceLock, + js_binding_sites: OnceLock, + js_local_bindings: Mutex>, + node_decisions: Mutex>, + awaited_raw_names: OnceLock>, + awaited_index: OnceLock>, +} + +impl SourceFacts { + /// Facts over `source`; nothing is derived until first asked for. + pub fn new(source: Arc) -> Self { + Self { + source, + lines: OnceLock::new(), + ts_comment_free: OnceLock::new(), + ts_blanked: OnceLock::new(), + js_sealed_module: OnceLock::new(), + js_binding_sites: OnceLock::new(), + js_local_bindings: Mutex::new(HashMap::new()), + node_decisions: Mutex::new(HashMap::new()), + awaited_raw_names: OnceLock::new(), + awaited_index: OnceLock::new(), + } + } + + /// The raw source text. + pub fn source(&self) -> &str { + &self.source + } + + /// Line index of the raw source. + pub fn lines(&self) -> &LineIndex { + self.lines.get_or_init(|| LineIndex::new(&self.source)) + } + + /// Number of lines [`str::lines`] yields for the raw source. + pub fn line_count(&self) -> usize { + self.lines().line_count() + } + + /// Raw source line `index`, as [`str::lines`] yields it. + pub fn line(&self, index: usize) -> &str { + self.lines().line(&self.source, index) + } + + /// Segment `index` of `source.split('\n')`. + pub fn raw_line(&self, index: usize) -> Option<&str> { + self.lines().raw_line(&self.source, index) + } + + /// `source.lines()[start..end].join(separator)`. + pub fn join_lines(&self, start: usize, end: usize, separator: &str) -> String { + self.lines().join(&self.source, start, end, separator) + } + + /// The whole file through [`strip_comments_for_regex`] with the + /// TypeScript rules (shared by every JS-family language), plus its line + /// index. Comment bytes become spaces; string contents are kept. + pub fn ts_comment_free(&self) -> (&str, &LineIndex) { + let (code, lines) = self.ts_comment_free.get_or_init(|| { + let code = strip_comments_for_regex(&self.source, CommentLang::TypeScript); + let lines = LineIndex::new(&code); + (code, lines) + }); + (code, lines) + } + + /// [`Self::ts_comment_free`] with every string and template literal's + /// contents blanked too (`blankStringContents(stripCommentsForRegex(…))`), + /// so line-anchored patterns see only code. + pub fn ts_blanked(&self) -> &str { + self.ts_blanked + .get_or_init(|| blank_string_contents(self.ts_comment_free().0)) + } + + /// Where a JS/TS local binding can start in the raw source. + pub fn js_binding_sites(&self) -> &LocalBindingSites { + self.js_binding_sites.get_or_init(|| { + let source: &str = &self.source; + LocalBindingSites { + var_decls: keyword_sites(source, &["const", "let", "var"]), + fn_decls: keyword_sites(source, &["function", "class"]), + arrows: source.match_indices("=>").map(|(at, _)| at).collect(), + } + }) + } + + /// Memoised sealed-module decision for this file; `compute` runs once. + pub fn js_sealed_module(&self, compute: impl FnOnce(&Self) -> bool) -> bool { + *self.js_sealed_module.get_or_init(|| compute(self)) + } + + /// Memoised "is `name` bound locally in this file" decision; `compute` + /// runs at most once per name. + pub fn js_local_binding(&self, name: &str, compute: impl FnOnce(&Self) -> bool) -> bool { + if let Some(&known) = self + .js_local_bindings + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(name) + { + return known; + } + let bound = compute(self); + self.js_local_bindings + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(name.to_string(), bound); + bound + } + + /// Names the raw source binds as `const x = await f(`; `scan` runs once. + pub(crate) fn awaited_raw_names( + &self, + scan: impl FnOnce(&str) -> HashSet, + ) -> &HashSet { + self.awaited_raw_names.get_or_init(|| scan(&self.source)) + } + + /// The awaited-binding index over [`Self::ts_blanked`]; `build` runs once. + pub(crate) fn awaited_index( + &self, + build: impl FnOnce(&str) -> Arc, + ) -> Arc { + Arc::clone(self.awaited_index.get_or_init(|| build(self.ts_blanked()))) + } + + /// Memoised per-node decision (`kind` names the gate, `node_id` the + /// candidate defined in this file); `compute` runs at most once per key. + pub fn node_decision( + &self, + kind: &'static str, + node_id: &str, + compute: impl FnOnce(&Self) -> bool, + ) -> bool { + let key = (kind, node_id.to_string()); + if let Some(&known) = self + .node_decisions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + { + return known; + } + let decision = compute(self); + self.node_decisions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, decision); + decision + } +} + +/// Offsets of `\b(?:kw1|kw2|…)\s` matches: a keyword preceded by a non-word +/// character (or the start) and followed by whitespace. `\b`/`\w` are ASCII, +/// as in upstream's JavaScript regexes. +fn keyword_sites(source: &str, keywords: &[&str]) -> Vec { + let bytes = source.as_bytes(); + let is_word = |byte: u8| byte.is_ascii_alphanumeric() || byte == b'_'; + let mut sites = Vec::new(); + let mut at = 0; + while at < bytes.len() { + if (at == 0 || !is_word(bytes[at - 1])) + && let Some(keyword) = keywords.iter().find(|keyword| { + bytes[at..].starts_with(keyword.as_bytes()) + && source[at + keyword.len()..] + .chars() + .next() + .is_some_and(char::is_whitespace) + }) + { + sites.push(at); + at += keyword.len(); + continue; + } + at += 1; + } + sites +} + +#[cfg(test)] +mod tests { + use super::*; + + fn std_lines(text: &str) -> Vec<&str> { + text.lines().collect() + } + + #[test] + fn line_index_matches_str_lines_for_every_terminator_shape() { + for text in [ + "", + "a", + "a\n", + "a\nb", + "a\nb\n", + "\n", + "\n\n", + "a\r\nb\r\n", + "a\r\nb\r", + "a\rb\n", + "\r\n", + "x\n\ny\r\n\r\nz", + "é\nü\r\n中", + ] { + let index = LineIndex::new(text); + let expected = std_lines(text); + assert_eq!(index.line_count(), expected.len(), "count for {text:?}"); + for (i, line) in expected.iter().enumerate() { + assert_eq!(index.line(text, i), *line, "line {i} of {text:?}"); + } + for start in 0..=expected.len() { + for end in start..=expected.len() { + assert_eq!( + index.join(text, start, end, "\n"), + expected[start..end].join("\n"), + "join {start}..{end} of {text:?}" + ); + } + } + let split: Vec<&str> = text.split('\n').collect(); + for (i, segment) in split.iter().enumerate() { + assert_eq!( + index.raw_line(text, i), + Some(*segment), + "raw {i} of {text:?}" + ); + } + assert_eq!(index.raw_line(text, split.len()), None); + } + } + + #[test] + fn raw_line_start_counts_every_newline() { + let text = "ab\r\ncd\n\nef"; + let index = LineIndex::new(text); + assert_eq!(index.raw_line_start(0), Some(0)); + assert_eq!(index.raw_line_start(1), Some(4)); + assert_eq!(index.raw_line_start(2), Some(7)); + assert_eq!(index.raw_line_start(3), Some(8)); + assert_eq!(index.raw_line_start(4), None); + } + + #[test] + fn keyword_sites_require_ascii_boundary_and_trailing_space() { + let source = "const a = 1; xconst b; let\tc; var(d); function f() {} class C {}"; + let facts = SourceFacts::new(Arc::from(source)); + let sites = facts.js_binding_sites(); + let at = |needle: &str| source.find(needle).unwrap(); + assert_eq!(sites.var_decls, vec![at("const a"), at("let\t")]); + assert_eq!(sites.fn_decls, vec![at("function"), at("class")]); + assert_eq!(sites.arrows, Vec::::new()); + } + + #[test] + fn local_binding_decision_is_computed_once_per_name() { + let facts = SourceFacts::new(Arc::from("const a = 1; // b\n")); + let mut runs = 0; + assert!(facts.js_local_binding("a", |facts| { + runs += 1; + facts.source().contains("const a") + })); + assert!(facts.js_local_binding("a", |_| unreachable!("memoised"))); + assert!(!facts.js_local_binding("b", |facts| { + runs += 1; + facts.source().contains("const b") + })); + assert_eq!(runs, 2); + } + + #[test] + fn sealed_and_node_decisions_are_computed_once() { + let facts = SourceFacts::new(Arc::from("import x from 'y';\n")); + assert!(facts.js_sealed_module(|_| true)); + assert!(facts.js_sealed_module(|_| unreachable!("memoised"))); + assert!(facts.node_decision("c_static", "function:1", |_| true)); + assert!(facts.node_decision("c_static", "function:1", |_| unreachable!("memoised"))); + assert!(!facts.node_decision("rust_trait", "function:1", |_| false)); + } + + #[test] + fn ts_blanked_hides_template_imports() { + let facts = SourceFacts::new(Arc::from( + "const t = `\nimport fake from \"fake\";\n`;\n// import x\n", + )); + assert!(!facts.ts_blanked().contains("import")); + assert_eq!(facts.ts_blanked().matches('\n').count(), 4); + } +} diff --git a/crates/codegraph-resolve/src/strip_comments.rs b/crates/codegraph-resolve/src/strip_comments.rs index bd2a3fcb..3f93fd53 100644 --- a/crates/codegraph-resolve/src/strip_comments.rs +++ b/crates/codegraph-resolve/src/strip_comments.rs @@ -497,10 +497,154 @@ fn starts_with(chars: &[char], at: usize, needle: &str) -> bool { chars[at..at + nch.len()] == nch[..] } +/// Blank the contents of `'…'`, `"…"` and `` `…` `` literals with spaces, +/// keeping the quotes and every newline (`blankStringContents`, +/// `strip-comments.ts`, upstream v1.6.1). +/// +/// A quote inside a JS regex literal is data, not the start of a string: a +/// `/` that follows expression-start punctuation or keyword skips to that +/// regex's closing `/` on the same line. Single- and double-quoted literals +/// stop at an unterminated line end; templates span lines. Run it after +/// [`strip_comments_for_regex`], so a quote inside a comment cannot open a +/// literal. Like that stripper it replaces character for character, so the +/// composed view keeps every line's character offsets. +pub fn blank_string_contents(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut out = chars.clone(); + let n = chars.len(); + // Byte offset of each char, for the regex-start look-behind window. + let offsets: Vec = text.char_indices().map(|(offset, _)| offset).collect(); + let mut i = 0; + while i < n { + let c = chars[i]; + if c == '/' && regex_literal_may_start(&text[..offsets[i]]) { + let mut end = i + 1; + let mut in_class = false; + while end < n && chars[end] != '\n' { + match chars[end] { + '\\' => { + end += 2; + continue; + } + '[' => in_class = true, + ']' => in_class = false, + '/' if !in_class => break, + _ => {} + } + end += 1; + } + if end < n && chars[end] == '/' { + i = end + 1; + continue; + } + } + if c == '"' || c == '\'' || c == '`' { + let quote = c; + i += 1; + while i < n && chars[i] != quote { + if chars[i] == '\\' && i + 1 < n { + out[i] = ' '; + out[i + 1] = ' '; + i += 2; + continue; + } + if quote != '`' && chars[i] == '\n' { + break; + } + if chars[i] != '\n' { + out[i] = ' '; + } + i += 1; + } + if i < n && chars[i] == quote { + i += 1; + } + continue; + } + i += 1; + } + out.into_iter().collect() +} + +/// Whether a `/` whose preceding text is `before` opens a regex literal: +/// upstream tests `(?:^|[=(:,)!&|?;{}\[\]+*%~^<>-]|\b(?:return|…))\s*$` +/// against the 32 characters before it, where `^` is the start of that window. +fn regex_literal_may_start(before: &str) -> bool { + const KEYWORDS: [&str; 14] = [ + "return", + "throw", + "case", + "yield", + "await", + "else", + "do", + "typeof", + "void", + "delete", + "new", + "in", + "of", + "instanceof", + ]; + let window_start = before + .char_indices() + .rev() + .nth(31) + .map_or(0, |(offset, _)| offset); + let window = &before[window_start..]; + let trimmed = window.trim_end_matches(char::is_whitespace); + let Some(last) = trimmed.chars().next_back() else { + return true; + }; + if "=(:,)!&|?;{}[]+*%~^<>-".contains(last) { + return true; + } + KEYWORDS.iter().any(|keyword| { + trimmed.strip_suffix(keyword).is_some_and(|head| { + !head + .chars() + .next_back() + .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_') + }) + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn blank_string_contents_keeps_quotes_newlines_and_code() { + let input = "import a from \"./a\";\nconst t = `x\nimport fake from \"fake\";\n`;\nf('b');"; + assert_eq!( + blank_string_contents(input), + "import a from \" \";\nconst t = ` \n \n`;\nf(' ');" + ); + } + + #[test] + fn blank_string_contents_skips_quotes_inside_regex_literals() { + // The `'` inside the regex is data; the literal after it is blanked. + let input = "const r = /it's/; const s = 'x';"; + assert_eq!( + blank_string_contents(input), + "const r = /it's/; const s = ' ';" + ); + // Division is not a regex: `a / b` keeps scanning for literals. + assert_eq!(blank_string_contents("x = a / b + 'q'"), "x = a / b + ' '"); + // A keyword opens an expression, so `return /'/` is a regex. + assert_eq!( + blank_string_contents("return /'/.test(s)"), + "return /'/.test(s)" + ); + } + + #[test] + fn blank_string_contents_stops_single_quotes_at_line_end() { + assert_eq!(blank_string_contents("'abc\nd'e'"), "' \nd' '"); + assert_eq!(blank_string_contents("\"é\\\"ü\" x"), "\" \" x"); + } + #[test] fn python_blanks_comment_preserves_offsets() { // strip-comments.ts:9-11 worked example. diff --git a/crates/codegraph-resolve/src/types.rs b/crates/codegraph-resolve/src/types.rs index 26e94d05..b78e8ad5 100644 --- a/crates/codegraph-resolve/src/types.rs +++ b/crates/codegraph-resolve/src/types.rs @@ -6,6 +6,7 @@ //! [`codegraph_core::types::UnresolvedRef`] row read from the store. We model it //! here as [`RefView`] so the two never get confused. +use crate::source_facts::SourceFacts; use codegraph_core::types::{EdgeKind, Language, Node, NodeKind, ReferenceSubkind}; use std::sync::Arc; @@ -104,6 +105,22 @@ pub fn is_inheritance_ref(kind: EdgeKind) -> bool { /// The two gates of item 6's kind eligibility, in one predicate so the candidate /// filter and the resolution seam cannot drift apart. Every other reference kind /// is unconstrained here and resolves exactly as before. +/// Whether `target` may be the target of a `reference_kind` reference: +/// [`kind_is_eligible_target`] plus the one language exception upstream +/// makes (`isSupertypeTarget`, #1824) — a Scala `object` is a singleton +/// value, never a parent type, even though Ruby modules are inheritable. +pub fn node_is_eligible_target(reference_kind: EdgeKind, target: &Node) -> bool { + if is_inheritance_ref(reference_kind) && is_scala_singleton(target) { + return false; + } + kind_is_eligible_target(reference_kind, target.kind) +} + +/// A Scala `object` (extracted as a `Module`). +pub fn is_scala_singleton(node: &Node) -> bool { + node.language == Language::Scala && node.kind == NodeKind::Module +} + pub fn kind_is_eligible_target(reference_kind: EdgeKind, target_kind: NodeKind) -> bool { if is_inheritance_ref(reference_kind) { return declares_type_name(target_kind); @@ -359,6 +376,27 @@ pub trait ResolutionContext { fn is_file_readable(&self, file_path: &str) -> bool { self.read_file(file_path).is_some() } + /// Shared-buffer variant of [`Self::read_file`]. Contexts with a file + /// content cache return the cached buffer without copying the file. + fn read_file_shared(&self, file_path: &str) -> Option> { + self.read_file(file_path).map(Arc::from) + } + /// Lazily derived facts about `file_path`'s source text, shared by the + /// resolver's text-scanning gates. Contexts that live for one resolution + /// pass cache one [`SourceFacts`] per file so every view — and every + /// memoised gate decision — is computed at most once; the default derives + /// fresh facts on each call, which is correct but uncached. + fn source_facts(&self, file_path: &str) -> Option> { + self.read_file_shared(file_path) + .map(|source| Arc::new(SourceFacts::new(source))) + } + /// Whether any node in `file_path` is exported (`fileHasExportedNode`). + /// Production contexts answer from a precomputed set. + fn file_has_exported_node(&self, file_path: &str) -> bool { + self.get_nodes_in_file_shared(file_path) + .iter() + .any(|node| node.is_exported) + } /// Get project root (`getProjectRoot`). fn get_project_root(&self) -> &str; /// Get all files (`getAllFiles`). diff --git a/crates/codegraph-resolve/tests/awaited_receivers.rs b/crates/codegraph-resolve/tests/awaited_receivers.rs new file mode 100644 index 00000000..8c491cbb --- /dev/null +++ b/crates/codegraph-resolve/tests/awaited_receivers.rs @@ -0,0 +1,285 @@ +//! Awaited JS/TS receiver inference (upstream v1.6.1 #1840/#1885). +//! +//! Ported from upstream `__tests__/awaited-receiver.test.ts`; the incremental +//! invalidation cases are covered by the per-pass `SourceFacts` cache, which a +//! fresh resolution pass always rebuilds. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{EdgeKind, FileRecord, Language, NodeKind}; +use codegraph_extract::extract_file; +use codegraph_resolve::ReferenceResolver; +use codegraph_store::Store; + +static NONCE: AtomicU64 = AtomicU64::new(0); + +struct Project { + root: PathBuf, + store: Option, +} + +impl Drop for Project { + fn drop(&mut self) { + drop(self.store.take()); + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn language(path: &str) -> Language { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("ts") => Language::TypeScript, + Some("tsx") => Language::Tsx, + other => panic!("unexpected extension: {other:?}"), + } +} + +fn resolve_project(files: &[(&str, &str)]) -> Project { + let root = std::env::temp_dir().join(format!( + "codegraph-awaited-{}-{}", + std::process::id(), + NONCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).expect("create fixture root"); + for (relative, source) in files { + std::fs::write(root.join(relative), source).expect("write fixture"); + } + let mut store = Store::open(&root.join("graph.db")).expect("open store"); + for (relative, _) in files { + let result = extract_file(&root, relative).expect("extract fixture"); + store + .upsert_file(&FileRecord { + path: (*relative).to_string(), + content_hash: "fixture".to_string(), + language: language(relative), + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: Vec::new(), + generated: false, + }) + .expect("upsert file"); + store.upsert_nodes(&result.nodes).expect("upsert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert unresolved refs"); + } + let mut resolver = ReferenceResolver::new(root.to_string_lossy().to_string()); + resolver.initialize(&codegraph_resolve::StoreResolutionContext::new( + &store, + root.to_string_lossy().as_ref(), + )); + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + Project { + root, + store: Some(store), + } +} + +/// `file:qualifiedName` of every `calls` target of function `name` in `file`, +/// with the call line. +fn calls(project: &Project, name: &str, file: &str) -> Vec<(String, i64)> { + let store = project.store.as_ref().expect("store"); + let source = store + .nodes_by_kind(NodeKind::Function) + .expect("functions") + .into_iter() + .find(|node| node.name == name && node.file_path == file) + .unwrap_or_else(|| panic!("missing function {name} in {file}")); + let mut targets: Vec<(String, i64)> = store + .edges_by_source_kind(&source.id, Some(EdgeKind::Calls)) + .expect("calls") + .into_iter() + .filter_map(|edge| { + let line = edge.line.unwrap_or(0); + store + .node_by_id(&edge.target) + .expect("target query") + .map(|node| (format!("{}:{}", node.file_path, node.qualified_name), line)) + }) + .collect(); + targets.sort(); + targets +} + +fn targets(project: &Project, name: &str) -> Vec { + calls(project, name, "caller.ts") + .into_iter() + .map(|(target, _)| target) + .collect() +} + +const ENGINE: &str = "export class Engine { run() {} }\nexport async function makeEngine(): Promise { return new Engine(); }"; + +#[test] +fn follows_an_imported_factory_alias_not_an_unrelated_namesake() { + let project = resolve_project(&[ + ("engine.ts", ENGINE), + ( + "decoy.ts", + "export async function load(): Promise { return \"\"; }", + ), + ( + "caller.ts", + "import { makeEngine as load } from \"./engine\";\nexport async function drive() { const handle = await load(); handle.run(); }", + ), + ]); + assert_eq!( + targets(&project, "drive"), + vec!["engine.ts:Engine::run", "engine.ts:makeEngine"] + ); +} + +#[test] +fn resolves_return_type_aliases_in_the_factory_module() { + let project = resolve_project(&[ + ("engine.ts", ENGINE), + ( + "factory.ts", + "import { Engine as Service } from \"./engine\";\nexport async function load(): Promise { return new Service(); }", + ), + ( + "caller.ts", + "import { load } from \"./factory\";\nclass Service { run() {} }\nexport async function drive() { const handle = await load(); handle.run(); }", + ), + ]); + assert_eq!( + targets(&project, "drive"), + vec!["engine.ts:Engine::run", "factory.ts:load"] + ); +} + +#[test] +fn a_factory_hidden_by_a_parameter_is_not_inferred() { + let project = resolve_project(&[ + ("engine.ts", ENGINE), + ( + "caller.ts", + "import { makeEngine } from \"./engine\";\nexport async function drive(makeEngine: () => Promise) { const handle = await makeEngine(); handle.run(); }", + ), + ]); + assert!(!targets(&project, "drive").contains(&"engine.ts:Engine::run".to_string())); +} + +#[test] +fn same_named_receivers_in_sibling_blocks_stay_distinct() { + let project = resolve_project(&[( + "caller.ts", + "class PaneManager { split() {} } +async function text(): Promise { return \"\"; } +async function pane(): Promise { return new PaneManager(); } +export async function drive() { + { const value = await text(); value.split(); } + { const value = await pane(); value.split(); } +}", + )]); + let splits: Vec<(String, i64)> = calls(&project, "drive", "caller.ts") + .into_iter() + .filter(|(target, _)| target.ends_with("PaneManager::split")) + .collect(); + assert_eq!( + splits, + vec![("caller.ts:PaneManager::split".to_string(), 6)] + ); +} + +#[test] +fn captured_bindings_resolve_but_shadowing_parameters_do_not() { + let project = resolve_project(&[( + "caller.ts", + &format!( + "{ENGINE} +export async function outer() {{ + const handle = await makeEngine(); + function captured() {{ handle.run(); }} + function shadow(handle: any) {{ handle.run(); }} + return {{ captured, shadow }}; +}}" + ), + )]); + assert!(targets(&project, "captured").contains(&"caller.ts:Engine::run".to_string())); + assert!(!targets(&project, "shadow").contains(&"caller.ts:Engine::run".to_string())); +} + +#[test] +fn reads_a_multiline_factory_annotation_and_keeps_ordinary_receivers() { + let project = resolve_project(&[( + "caller.ts", + "class Engine { run() {} } +class Decoy { run() {} } +async function load( + input: string +): Promise { return new Engine(); } +export async function drive() { const value = await load(''); value.run(); } +export function ordinary() { const engine = new Engine(); engine.run(); }", + )]); + assert_eq!( + targets(&project, "drive"), + vec!["caller.ts:Engine::run", "caller.ts:load"] + ); + assert!(targets(&project, "ordinary").contains(&"caller.ts:Engine::run".to_string())); +} + +#[test] +fn newline_terminated_bindings_resolve_but_chained_results_do_not() { + let project = resolve_project(&[( + "caller.ts", + &format!( + "{ENGINE} +export async function drive() {{ + const value = await makeEngine() + value.run() +}} +export async function chained() {{ + const value = await makeEngine().toString(); + value.run(); +}}" + ), + )]); + assert!(targets(&project, "drive").contains(&"caller.ts:Engine::run".to_string())); + assert!(!targets(&project, "chained").contains(&"caller.ts:Engine::run".to_string())); +} + +#[test] +fn a_nearer_variable_binding_hides_the_local_factory_annotation() { + let project = resolve_project(&[( + "caller.ts", + &format!( + "{ENGINE} +export async function drive(other: () => Promise) {{ + const makeEngine = other; + const value = await makeEngine(); + value.run(); +}}" + ), + )]); + assert!(!targets(&project, "drive").contains(&"caller.ts:Engine::run".to_string())); +} + +#[test] +fn tsx_consumer_follows_an_imported_ts_factory() { + // The review finding behind #1840's Rust port: a `.tsx` caller importing a + // `.ts` factory must not lose the typed receiver to a language filter. + let project = resolve_project(&[ + ("engine.ts", ENGINE), + ( + "caller.tsx", + "import { makeEngine } from \"./engine\";\nexport async function drive() { const handle = await makeEngine(); handle.run(); }", + ), + ]); + let found: Vec = calls(&project, "drive", "caller.tsx") + .into_iter() + .map(|(target, _)| target) + .collect(); + assert!( + found.contains(&"engine.ts:Engine::run".to_string()), + "{found:?}" + ); +} diff --git a/crates/codegraph-resolve/tests/c_go_correctness.rs b/crates/codegraph-resolve/tests/c_go_correctness.rs new file mode 100644 index 00000000..88e8ca13 --- /dev/null +++ b/crates/codegraph-resolve/tests/c_go_correctness.rs @@ -0,0 +1,480 @@ +//! Focused regressions for C/C++/Go false call edges found after upstream 1.6. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{Edge, EdgeKind, FileRecord, Language, Node, NodeKind, UnresolvedRef}; +use codegraph_extract::{detect_language, extract_file}; +use codegraph_resolve::{ReferenceResolver, StoreResolutionContext}; +use codegraph_store::Store; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + +struct ResolvedGraph { + nodes: Vec, + edges: Vec, + unresolved: Vec, +} + +impl ResolvedGraph { + fn node(&self, kind: NodeKind, name: &str, file: &str) -> &Node { + self.nodes + .iter() + .find(|node| node.kind == kind && node.name == name && node.file_path == file) + .unwrap_or_else(|| panic!("missing {kind:?} {name} in {file}; nodes={:#?}", self.nodes)) + } + + fn outgoing(&self, source: &Node, kind: EdgeKind) -> Vec<&Node> { + self.edges + .iter() + .filter(|edge| edge.source == source.id && edge.kind == kind) + .map(|edge| { + self.nodes + .iter() + .find(|node| node.id == edge.target) + .unwrap_or_else(|| { + panic!("missing target {}; edges={:#?}", edge.target, self.edges) + }) + }) + .collect() + } +} + +fn temp_path(slug: &str) -> PathBuf { + let nonce = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "codegraph-c-go-{slug}-{}-{nonce}", + std::process::id() + )) +} + +fn collect_files(root: &Path, dir: &Path, output: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read fixture directory") { + let path = entry.expect("fixture entry").path(); + if path.is_dir() { + collect_files(root, &path, output); + } else { + output.push( + path.strip_prefix(root) + .expect("fixture path under root") + .to_string_lossy() + .replace('\\', "/"), + ); + } + } +} + +fn resolve_project(slug: &str, files: &[(&str, &str)]) -> ResolvedGraph { + let root = temp_path(slug); + std::fs::create_dir_all(&root).expect("create fixture root"); + for (relative, source) in files { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create fixture parent"); + } + std::fs::write(path, source).expect("write fixture source"); + } + + let mut relative = Vec::new(); + collect_files(&root, &root, &mut relative); + relative.sort(); + let db = temp_path(&format!("{slug}-db")).with_extension("db"); + let mut store = Store::open(&db).expect("open fixture store"); + for path in &relative { + let result = extract_file(&root, path).expect("extract fixture"); + let language = detect_language(path); + assert_ne!( + language, + Language::Unknown, + "unknown fixture language: {path}" + ); + store + .upsert_file(&FileRecord { + path: path.clone(), + content_hash: "fixture".to_string(), + language, + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: result.errors, + generated: false, + }) + .expect("insert fixture file"); + store + .upsert_nodes(&result.nodes) + .expect("insert fixture nodes"); + store + .insert_edges(&result.edges) + .expect("insert extraction edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert fixture references"); + } + + let root_string = root.to_string_lossy().to_string(); + let mut resolver = ReferenceResolver::new(&root_string); + { + let context = StoreResolutionContext::new(&store, &root_string); + resolver.initialize(&context); + } + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + + let mut nodes = Vec::new(); + for kind in NodeKind::ALL { + nodes.extend(store.nodes_by_kind(kind).expect("read nodes")); + } + let edges = store.all_edges().expect("read edges"); + let unresolved = store.all_unresolved_refs().expect("read unresolved refs"); + drop(store); + let _ = std::fs::remove_file(db); + let _ = std::fs::remove_dir_all(root); + ResolvedGraph { + nodes, + edges, + unresolved, + } +} + +#[test] +fn c_static_function_is_visible_only_in_its_translation_unit() { + let graph = resolve_project( + "c-static-visibility", + &[ + ("protocol.h", "static inline void header_helper(void) {}\n"), + ( + "core.c", + concat!( + "#include \"protocol.h\"\n", + "static void same_file(void) {}\n", + "void same_file_caller(void) { same_file(); }\n", + "void core_run(void) { hidden(); visible(); header_helper(); }\n", + ), + ), + ( + "other.c", + concat!( + "static void\n", + "hidden(void) {}\n", + "/* mentioning static in a comment changes no visibility */\n", + "void visible(void) {}\n", + ), + ), + ], + ); + + let core = graph.node(NodeKind::Function, "core_run", "core.c"); + let core_targets: BTreeSet<_> = graph + .outgoing(core, EdgeKind::Calls) + .into_iter() + .map(|node| (node.file_path.as_str(), node.name.as_str())) + .collect(); + assert_eq!( + core_targets, + BTreeSet::from([("other.c", "visible"), ("protocol.h", "header_helper")]) + ); + assert!(graph.unresolved.iter().any(|reference| { + reference.from_node_id == core.id && reference.reference_name == "hidden" + })); + + let local = graph.node(NodeKind::Function, "same_file_caller", "core.c"); + assert_eq!( + graph + .outgoing(local, EdgeKind::Calls) + .into_iter() + .map(|node| (node.file_path.as_str(), node.name.as_str())) + .collect::>(), + vec![("core.c", "same_file")] + ); +} + +#[test] +fn cpp_macros_and_value_initialization_do_not_fabricate_calls() { + let graph = resolve_project( + "cpp-macro-constructor", + &[ + ( + "marker.hpp", + concat!( + "#include \"nested.hpp\"\n", + "static const char *text = R\"tag(\n", + "#define FAKE(value) value\n", + ")tag\";\n", + ), + ), + ("nested.hpp", "#define TRACE_POINT(value) ((void)(value))\n"), + ("unrelated.hpp", "#define REAL_FUNCTION() 0\n"), + ("macro_decoy.cpp", "void TRACE_POINT(int value) {}\n"), + ( + "exercise.cpp", + concat!( + "#include \"marker.hpp\"\n", + "struct Aggregate { int value; };\n", + "class WithConstructor { public: WithConstructor(); };\n", + "WithConstructor::WithConstructor() {}\n", + "void REAL_FUNCTION() {}\n", + "void FAKE(int value) {}\n", + "void exercise() { TRACE_POINT(1); REAL_FUNCTION(); FAKE(1); }\n", + "void aggregate_value() { Aggregate(); Aggregate item{}; }\n", + "void constructor_braced() { WithConstructor item{}; }\n", + "void constructor_temporary() { WithConstructor(); }\n", + ), + ), + ], + ); + + let exercise = graph.node(NodeKind::Function, "exercise", "exercise.cpp"); + let exercise_calls: BTreeSet<_> = graph + .outgoing(exercise, EdgeKind::Calls) + .into_iter() + .map(|node| node.name.as_str()) + .collect(); + assert_eq!(exercise_calls, BTreeSet::from(["FAKE", "REAL_FUNCTION"])); + assert!(graph.unresolved.iter().any(|reference| { + reference.from_node_id == exercise.id && reference.reference_name == "TRACE_POINT" + })); + + let aggregate = graph.node(NodeKind::Function, "aggregate_value", "exercise.cpp"); + assert!( + graph.outgoing(aggregate, EdgeKind::Calls).is_empty(), + "aggregate initialization must not produce a Calls edge to a type node" + ); + assert_eq!( + graph + .outgoing(aggregate, EdgeKind::Instantiates) + .into_iter() + .map(|node| (node.kind, node.name.as_str())) + .collect::>(), + vec![ + (NodeKind::Struct, "Aggregate"), + (NodeKind::Struct, "Aggregate") + ], + "main already models aggregate/value construction as Instantiates, not Calls" + ); + let ctor_caller = graph.node(NodeKind::Function, "constructor_temporary", "exercise.cpp"); + let ctor_calls = graph.outgoing(ctor_caller, EdgeKind::Calls); + assert_eq!(ctor_calls.len(), 1); + assert_eq!(ctor_calls[0].kind, NodeKind::Method); + assert_eq!( + ctor_calls[0].qualified_name, + "WithConstructor::WithConstructor" + ); + let braced = graph.node(NodeKind::Function, "constructor_braced", "exercise.cpp"); + let braced_calls = graph.outgoing(braced, EdgeKind::Calls); + assert_eq!(braced_calls.len(), 1); + assert_eq!(braced_calls[0].kind, NodeKind::Method); + assert_eq!( + braced_calls[0].qualified_name, + "WithConstructor::WithConstructor" + ); +} + +#[test] +fn go_parameter_and_local_callable_bindings_beat_unimported_methods() { + let graph = resolve_project( + "go-local-callable", + &[ + ( + "internal/impulse/relogin.go", + concat!( + "package impulse\n", + "type socketLoop struct{}\n", + "func (s *socketLoop) relogin() error { return nil }\n", + "func (s *socketLoop) callback() error { return nil }\n", + ), + ), + ( + "internal/synapse/scheduler.go", + concat!( + "package synapse\n", + "type localLoop struct{}\n", + "func (s *localLoop) relogin() error { return nil }\n", + "func realHelper() error { return nil }\n", + "func RunReloginScheduler(relogin func() error) error {\n", + " if err := realHelper(); err != nil { return err }\n", + " return relogin()\n", + "}\n", + "func RunLocalBinding() error {\n", + " callback := func() error { return nil }\n", + " return callback()\n", + "}\n", + "func RunGrouped(_ int, relogin func() error) error { return relogin() }\n", + "func RunVarBinding() error {\n", + " var callback func() error\n", + " callback = func() error { return nil }\n", + " return callback()\n", + "}\n", + "func RunVarBlock() error {\n", + " var (\n", + " callback func() error\n", + " )\n", + " callback = func() error { return nil }\n", + " return callback()\n", + "}\n", + "func RunReceiver() error {\n", + " loop := &localLoop{}\n", + " return loop.relogin()\n", + "}\n", + ), + ), + ], + ); + + let scheduler = graph.node( + NodeKind::Function, + "RunReloginScheduler", + "internal/synapse/scheduler.go", + ); + assert_eq!( + graph + .outgoing(scheduler, EdgeKind::Calls) + .into_iter() + .map(|node| node.qualified_name.as_str()) + .collect::>(), + vec!["realHelper"] + ); + + let local = graph.node( + NodeKind::Function, + "RunLocalBinding", + "internal/synapse/scheduler.go", + ); + assert!(graph.outgoing(local, EdgeKind::Calls).is_empty()); + for caller in ["RunGrouped", "RunVarBinding", "RunVarBlock"] { + let node = graph.node(NodeKind::Function, caller, "internal/synapse/scheduler.go"); + assert!( + graph.outgoing(node, EdgeKind::Calls).is_empty(), + "local callable binding in {caller} must stay unresolved" + ); + } + + let receiver = graph.node( + NodeKind::Function, + "RunReceiver", + "internal/synapse/scheduler.go", + ); + assert_eq!( + graph + .outgoing(receiver, EdgeKind::Calls) + .into_iter() + .map(|node| node.qualified_name.as_str()) + .collect::>(), + vec!["localLoop::relogin"], + "a real typed receiver call must remain intact" + ); + assert!(graph.unresolved.iter().any(|reference| { + reference.from_node_id == scheduler.id && reference.reference_name == "relogin" + })); + assert!(graph.unresolved.iter().any(|reference| { + reference.from_node_id == local.id && reference.reference_name == "callback" + })); +} + +#[test] +fn cpp_static_and_go_package_visibility_are_enforced_after_target_selection() { + let cpp = resolve_project( + "cpp-static-visibility", + &[ + ( + "main.cpp", + "static void local_only() {}\nvoid run() { local_only(); hidden(); visible(); }\n", + ), + ("other.cpp", "static void hidden() {}\nvoid visible() {}\n"), + ], + ); + let run = cpp.node(NodeKind::Function, "run", "main.cpp"); + assert_eq!( + cpp.outgoing(run, EdgeKind::Calls) + .into_iter() + .map(|node| (node.file_path.as_str(), node.name.as_str())) + .collect::>(), + BTreeSet::from([("main.cpp", "local_only"), ("other.cpp", "visible")]) + ); + + let go = resolve_project( + "go-package-visibility", + &[ + ( + "pkg/a/util.go", + "package a\nfunc helper() {}\nfunc RunLocal() { helper() }\nfunc RunForeign() { hidden() }\nfunc RunExported() { Report() }\n", + ), + ( + "pkg/b/util.go", + "package b\nfunc helper() {}\nfunc hidden() {}\nfunc Report() {}\n", + ), + ], + ); + let local = go.node(NodeKind::Function, "RunLocal", "pkg/a/util.go"); + assert_eq!( + go.outgoing(local, EdgeKind::Calls) + .into_iter() + .map(|node| node.file_path.as_str()) + .collect::>(), + vec!["pkg/a/util.go"] + ); + let foreign = go.node(NodeKind::Function, "RunForeign", "pkg/a/util.go"); + assert!(go.outgoing(foreign, EdgeKind::Calls).is_empty()); + let exported = go.node(NodeKind::Function, "RunExported", "pkg/a/util.go"); + assert_eq!( + go.outgoing(exported, EdgeKind::Calls) + .into_iter() + .map(|node| node.file_path.as_str()) + .collect::>(), + vec!["pkg/b/util.go"] + ); +} + +#[test] +fn kotlin_private_and_rust_module_visibility_are_enforced() { + let kotlin = resolve_project( + "kotlin-private-visibility", + &[ + ( + "Secret.kt", + "class Secret {\n private fun hidden() {}\n fun own() { hidden() }\n}\nfun visible() {}\n", + ), + ("Use.kt", "fun use() { hidden(); visible() }\n"), + ], + ); + let own = kotlin.node(NodeKind::Method, "own", "Secret.kt"); + assert_eq!( + kotlin + .outgoing(own, EdgeKind::Calls) + .into_iter() + .map(|node| node.qualified_name.as_str()) + .collect::>(), + vec!["Secret::hidden"] + ); + let use_fn = kotlin.node(NodeKind::Function, "use", "Use.kt"); + assert_eq!( + kotlin + .outgoing(use_fn, EdgeKind::Calls) + .into_iter() + .map(|node| node.name.as_str()) + .collect::>(), + vec!["visible"] + ); + + let rust = resolve_project( + "rust-module-visibility", + &[ + ("src/net.rs", "fn shared() {}\npub fn exported() {}\n"), + ( + "src/net/tcp.rs", + "pub fn open() { shared(); hidden(); exported(); }\n", + ), + ("src/util.rs", "fn hidden() {}\n"), + ], + ); + let open = rust.node(NodeKind::Function, "open", "src/net/tcp.rs"); + assert_eq!( + rust.outgoing(open, EdgeKind::Calls) + .into_iter() + .map(|node| (node.file_path.as_str(), node.name.as_str())) + .collect::>(), + BTreeSet::from([("src/net.rs", "exported"), ("src/net.rs", "shared")]) + ); +} diff --git a/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs b/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs new file mode 100644 index 00000000..a175d92f --- /dev/null +++ b/crates/codegraph-resolve/tests/cpp_constructor_resolution.rs @@ -0,0 +1,234 @@ +//! C++ local-object initialization must reach a uniquely proven constructor +//! method, never an aggregate/type node or an arbitrary overload (#1839). + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{Edge, EdgeKind, FileRecord, Language, Node, NodeKind}; +use codegraph_extract::{detect_language, extract_file}; +use codegraph_resolve::{ReferenceResolver, StoreResolutionContext}; +use codegraph_store::Store; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + +struct ResolvedGraph { + nodes: Vec, + edges: Vec, +} + +impl ResolvedGraph { + fn node(&self, kind: NodeKind, name: &str, file: &str) -> &Node { + self.nodes + .iter() + .find(|node| node.kind == kind && node.name == name && node.file_path == file) + .unwrap_or_else(|| panic!("missing {kind:?} {name} in {file}")) + } + + fn call_targets(&self, source: &Node) -> Vec<&Node> { + let mut targets = self + .edges + .iter() + .filter(|edge| edge.source == source.id && edge.kind == EdgeKind::Calls) + .map(|edge| { + self.nodes + .iter() + .find(|node| node.id == edge.target) + .expect("edge target") + }) + .collect::>(); + targets.sort_by_key(|node| (node.file_path.as_str(), node.start_line)); + targets + } +} + +fn temp_path(slug: &str) -> PathBuf { + let nonce = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "codegraph-cpp-constructor-{slug}-{}-{nonce}", + std::process::id() + )) +} + +fn collect_files(root: &Path, dir: &Path, output: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read fixture directory") { + let path = entry.expect("fixture entry").path(); + if path.is_dir() { + collect_files(root, &path, output); + } else { + output.push( + path.strip_prefix(root) + .expect("fixture path under root") + .to_string_lossy() + .replace('\\', "/"), + ); + } + } +} + +fn resolve_project(slug: &str, files: &[(&str, &str)]) -> ResolvedGraph { + let root = temp_path(slug); + std::fs::create_dir_all(&root).expect("create fixture root"); + for (relative, source) in files { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create fixture parent"); + } + std::fs::write(path, source).expect("write fixture source"); + } + + let mut relative = Vec::new(); + collect_files(&root, &root, &mut relative); + relative.sort(); + let db = temp_path(&format!("{slug}-db")).with_extension("db"); + let mut store = Store::open(&db).expect("open fixture store"); + for path in &relative { + let result = extract_file(&root, path).expect("extract fixture"); + let language = detect_language(path); + assert_ne!(language, Language::Unknown, "unknown fixture: {path}"); + store + .upsert_file(&FileRecord { + path: path.clone(), + content_hash: "fixture".to_string(), + language, + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: result.errors, + generated: false, + }) + .expect("insert file"); + store.upsert_nodes(&result.nodes).expect("insert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert references"); + } + + let root_string = root.to_string_lossy().to_string(); + let mut resolver = ReferenceResolver::new(&root_string); + { + let context = StoreResolutionContext::new(&store, &root_string); + resolver.initialize(&context); + } + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + + assert!( + store + .all_unresolved_refs() + .expect("read remaining refs") + .iter() + .all(|reference| !reference + .reference_name + .starts_with("codegraph:cpp-constructor:")), + "internal constructor probes must be consumed, not exposed as dangling calls" + ); + + let nodes = store.all_nodes().expect("read nodes"); + let edges = store.all_edges().expect("read edges"); + drop(store); + std::fs::remove_file(db).expect("remove fixture db"); + std::fs::remove_dir_all(root).expect("remove fixture root"); + ResolvedGraph { nodes, edges } +} + +#[test] +fn local_initializers_resolve_by_owner_and_accepted_arity() { + let graph = resolve_project( + "local-overloads", + &[( + "main.cpp", + concat!( + "struct Aggregate { int value; };\n", + "class WithConstructor { public: WithConstructor(); explicit WithConstructor(int value); };\n", + "WithConstructor::WithConstructor() {}\n", + "WithConstructor::WithConstructor(int value) {}\n", + "void aggregate_initialization() { Aggregate item{}; }\n", + "void constructor_default() { WithConstructor item; }\n", + "void constructor_braced() { WithConstructor item{}; }\n", + "void constructor_value() { WithConstructor item(1); }\n", + ), + )], + ); + + let aggregate = graph.node(NodeKind::Function, "aggregate_initialization", "main.cpp"); + assert!(graph.call_targets(aggregate).is_empty()); + + for (caller, expected_line) in [ + ("constructor_default", 3), + ("constructor_braced", 3), + ("constructor_value", 4), + ] { + let caller = graph.node(NodeKind::Function, caller, "main.cpp"); + let targets = graph.call_targets(caller); + assert_eq!(targets.len(), 1, "targets={targets:#?}"); + assert_eq!(targets[0].kind, NodeKind::Method); + assert_eq!( + targets[0].qualified_name, + "WithConstructor::WithConstructor" + ); + assert_eq!(targets[0].start_line, expected_line); + } +} + +#[test] +fn ambiguous_owner_or_overload_stays_unresolved() { + let graph = resolve_project( + "ambiguous", + &[( + "ambiguous.cpp", + concat!( + "namespace left { struct Widget { Widget() {} }; }\n", + "namespace right { struct Widget { Widget() {} }; }\n", + "struct Defaults {\n", + " Defaults() {}\n", + " Defaults(int x = 0) {}\n", + "};\n", + "void ambiguous_owner() { Widget value; }\n", + "void explicit_owner() { left::Widget value; }\n", + "void ambiguous_overload() { Defaults value; }\n", + ), + )], + ); + + for caller_name in ["ambiguous_owner", "ambiguous_overload"] { + let caller = graph.node(NodeKind::Function, caller_name, "ambiguous.cpp"); + assert!( + graph.call_targets(caller).is_empty(), + "{caller_name} must fail closed" + ); + } + let caller = graph.node(NodeKind::Function, "explicit_owner", "ambiguous.cpp"); + let targets = graph.call_targets(caller); + assert_eq!(targets.len(), 1, "targets={targets:#?}"); + assert_eq!(targets[0].qualified_name, "left::Widget::Widget"); +} + +#[test] +fn lexical_namespace_prefers_nested_or_root_owner_without_path_guessing() { + let graph = resolve_project( + "lexical-owner", + &[( + "scope.cpp", + concat!( + "struct Widget { Widget() {} };\n", + "namespace nested {\n", + "struct Widget { Widget() {} };\n", + "void build_nested() { Widget value; }\n", + "}\n", + "void build_root() { Widget value; }\n", + ), + )], + ); + let nested = graph.node(NodeKind::Function, "build_nested", "scope.cpp"); + let nested_targets = graph.call_targets(nested); + assert_eq!(nested_targets.len(), 1); + assert_eq!(nested_targets[0].qualified_name, "nested::Widget::Widget"); + + let root = graph.node(NodeKind::Function, "build_root", "scope.cpp"); + let root_targets = graph.call_targets(root); + assert_eq!(root_targets.len(), 1); + assert_eq!(root_targets[0].qualified_name, "Widget::Widget"); +} diff --git a/crates/codegraph-resolve/tests/golden_resolution.rs b/crates/codegraph-resolve/tests/golden_resolution.rs index ad4c7493..a0993477 100644 --- a/crates/codegraph-resolve/tests/golden_resolution.rs +++ b/crates/codegraph-resolve/tests/golden_resolution.rs @@ -9,7 +9,7 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; -use codegraph_core::types::{Edge, FileRecord, Language}; +use codegraph_core::types::{Edge, EdgeKind, FileRecord, Language, NodeKind}; use codegraph_extract::extract_file; use codegraph_resolve::ReferenceResolver; use codegraph_store::Store; @@ -124,6 +124,7 @@ fn resolve_fixture(test_name: &str, root: &Path, relative_files: &[&str]) -> Vec Some("java") => Language::Java, Some("cpp") => Language::Cpp, Some("php") => Language::Php, + Some("rs") => Language::Rust, Some("xml") => Language::Xml, other => panic!("unexpected fixture extension {other:?}"), }; @@ -403,10 +404,9 @@ fn callback_function_ref_emits_references_edges_with_fn_ref_metadata() { #[test] fn callback_function_ref_python_bare_function_value() { - // Multi-language function_ref (upstream #756): Python `register(worker)` bare - // function value yields a `references` edge tagged fnRef:true / resolvedBy - // function-ref. Edge count + target captured byte-identical from the upstream 1.0.1 - // on this exact source (the upstream emits exactly one fnRef edge here). + // Multi-language function_ref (upstream #756 plus #1820): Python + // `register(worker)` and class-scoped `self.on_click` method values each + // yield a `references` edge tagged fnRef:true / resolvedBy function-ref. let dir = std::env::temp_dir().join(format!( "codegraph-fn-ref-py-{}-{}", std::process::id(), @@ -438,15 +438,101 @@ fn callback_function_ref_python_bare_function_value() { .collect(); assert_eq!( fn_refs.len(), - 1, - "expected 1 Python fnRef edge (setup->worker), got: {resolved:#?}" + 2, + "expected Python fnRef edges setup->worker and wire->on_click, got: {resolved:#?}" + ); + assert!( + fn_refs + .iter() + .any(|edge| edge.target.starts_with("function:")), + "bare worker must reach a function target" + ); + assert!( + fn_refs + .iter() + .any(|edge| edge.target.starts_with("method:")), + "self.on_click must reach the enclosing class method" + ); + for edge in fn_refs { + assert_eq!( + edge.metadata.as_ref().expect("metadata")["resolvedBy"].as_str(), + Some("function-ref") + ); + } +} + +#[test] +fn callback_function_ref_python_receiver_method_value() { + // #1820: a receiver-qualified method used as a VALUE must reuse the same + // conservative target selection as a direct method call, but create a + // `references` edge rather than fabricating an immediate `calls` edge. + let dir = fresh_fixture_dir("fn-ref-py-receiver"); + std::fs::write( + dir.join("src/store.py"), + "class Base:\n pass\n\nclass Store(Base):\n def fetch(self, ids):\n return ids\n\nclass Decoy:\n def fetch(self, ids):\n return []\n", + ) + .expect("write store.py"); + std::fs::write( + dir.join("src/consumer.py"), + "from concurrent.futures import ThreadPoolExecutor\nfrom store import Base\n\nclass Consumer:\n def __init__(self, store: Base):\n self.store = store\n\n def direct(self, ids):\n return self.store.fetch(ids)\n\n def via_callback(self, ids, pool: ThreadPoolExecutor):\n return pool.submit(self.store.fetch, ids)\n\n def ambiguous_receiver(self, ids, pool: ThreadPoolExecutor):\n return pool.submit(self.client.fetch, ids)\n", + ) + .expect("write consumer.py"); + + let resolved = resolve_fixture( + "fn-ref-py-receiver", + &dir, + &["src/store.py", "src/consumer.py"], + ); + let _ = std::fs::remove_dir_all(&dir); + + let fetch_targets = resolved + .iter() + .filter(|edge| { + edge.target.starts_with("method:") + && edge.kind != codegraph_core::types::EdgeKind::Contains + }) + .collect::>(); + let direct = fetch_targets + .iter() + .find(|edge| edge.kind == codegraph_core::types::EdgeKind::Calls) + .expect("direct call must continue to resolve"); + let callback = fetch_targets + .iter() + .find(|edge| { + edge.kind == codegraph_core::types::EdgeKind::References + && edge + .metadata + .as_ref() + .and_then(|metadata| metadata.get("fnRef")) + .and_then(serde_json::Value::as_bool) + == Some(true) + }) + .expect("receiver-qualified callback must resolve as a function reference"); + + assert_eq!( + callback.target, direct.target, + "callback and direct-call resolution must select the same concrete method" ); - let edge = fn_refs[0]; - assert!(edge.target.starts_with("function:")); assert_eq!( - edge.metadata.as_ref().expect("metadata")["resolvedBy"].as_str(), + callback.metadata.as_ref().expect("metadata")["resolvedBy"].as_str(), Some("function-ref") ); + assert!( + !resolved.iter().any(|edge| { + edge.source == callback.source + && edge.target == callback.target + && edge.kind == codegraph_core::types::EdgeKind::Calls + }), + "passing a method value must not claim it was immediately called" + ); + assert_eq!( + fetch_targets + .iter() + .filter(|edge| edge.kind == codegraph_core::types::EdgeKind::References) + .count(), + 1, + "the unrelated `client.fetch` receiver is ambiguous and must remain unlinked" + ); } /// Build a unique temp fixture directory rooted under the system temp dir. @@ -770,3 +856,315 @@ fn mybatis_qualified_refid_resolves_across_namespaces_and_rejects_the_decoy() { "a refid naming a namespace that does not exist must emit NO edge rather than binding an arbitrary same-named fragment; edges={resolved:#?}" ); } + +#[test] +fn python_quoted_annotation_resolves_receiver_type() { + // #1684: a quoted forward-reference annotation is the same receiver type as + // its unquoted form. Keep both quote styles, an unquoted control, and a + // missing-type negative beside same-named method/function decoys. + let dir = fresh_fixture_dir("python-quoted-annotation"); + std::fs::write( + dir.join("src/types.py"), + concat!( + "def render(value):\n return value\n\n", + "class Alpha:\n def render(self):\n return 'alpha'\n\n", + "class Beta:\n def render(self):\n return 'beta'\n", + ), + ) + .expect("write Python types"); + std::fs::write( + dir.join("src/app.py"), + concat!( + "from __future__ import annotations\n", + "from src.types import Alpha, Beta\n\n", + "def quoted(value: \"Alpha\"):\n return value.render()\n\n", + "def single_quoted(value: 'Beta'):\n return value.render()\n\n", + "def unquoted(value: Alpha):\n return value.render()\n\n", + "def missing(value: \"Missing\"):\n return value.render()\n", + ), + ) + .expect("write Python callers"); + + let types = codegraph_extract::extract_file(&dir, "src/types.py").expect("extract types"); + let callers = codegraph_extract::extract_file(&dir, "src/app.py").expect("extract callers"); + let id = |result: &codegraph_core::types::ExtractionResult, kind: NodeKind, qualified: &str| { + result + .nodes + .iter() + .find(|node| node.kind == kind && node.qualified_name == qualified) + .unwrap_or_else(|| panic!("missing {kind:?} {qualified}; nodes={:#?}", result.nodes)) + .id + .clone() + }; + let alpha_render = id(&types, NodeKind::Method, "Alpha::render"); + let beta_render = id(&types, NodeKind::Method, "Beta::render"); + let quoted = id(&callers, NodeKind::Function, "quoted"); + let single_quoted = id(&callers, NodeKind::Function, "single_quoted"); + let unquoted = id(&callers, NodeKind::Function, "unquoted"); + let missing = id(&callers, NodeKind::Function, "missing"); + + let resolved = resolve_fixture( + "python-quoted-annotation", + &dir, + &["src/types.py", "src/app.py"], + ); + let _ = std::fs::remove_dir_all(&dir); + let call_target = |source: &str| { + resolved + .iter() + .filter(|edge| edge.kind == EdgeKind::Calls && edge.source == source) + .map(|edge| edge.target.as_str()) + .collect::>() + }; + + assert_eq!(call_target("ed), vec![alpha_render.as_str()]); + assert_eq!(call_target(&single_quoted), vec![beta_render.as_str()]); + assert_eq!(call_target(&unquoted), vec![alpha_render.as_str()]); + assert!( + call_target(&missing).is_empty(), + "an unavailable quoted type must not donate a same-named method/function; edges={resolved:#?}" + ); +} + +#[test] +fn php_import_alias_owns_static_call_without_stealing_variable_receiver() { + // #1545: the namespace named by `use ... as Alias` owns a static call, + // including a missing member. A `$Alias->method()` on the same source line + // remains a variable receiver and must continue through local type inference. + let dir = fresh_fixture_dir("php-import-alias-static"); + std::fs::write( + dir.join("src/Services.php"), + r#"getSettlesToExcel(); + Settle::getSettlesToExcel(); + } + public function unavailable() { + Settle::missing(); + } +} +"#, + ) + .expect("write PHP controller"); + + let services = + codegraph_extract::extract_file(&dir, "src/Services.php").expect("extract PHP services"); + let repository = codegraph_extract::extract_file(&dir, "src/Repository.php") + .expect("extract PHP repository"); + let controller = codegraph_extract::extract_file(&dir, "src/Controller.php") + .expect("extract PHP controller"); + let id = |result: &codegraph_core::types::ExtractionResult, kind: NodeKind, qualified: &str| { + result + .nodes + .iter() + .find(|node| node.kind == kind && node.qualified_name == qualified) + .unwrap_or_else(|| panic!("missing {kind:?} {qualified}; nodes={:#?}", result.nodes)) + .id + .clone() + }; + let service_method = id( + &services, + NodeKind::Method, + "App\\Services::SettleService::getSettlesToExcel", + ); + let variable_method = id( + &services, + NodeKind::Method, + "App\\Services::OtherService::getSettlesToExcel", + ); + let repository_method = id( + &repository, + NodeKind::Method, + "App\\Repositories::SettleRepository::getSettlesToExcel", + ); + let repository_missing = id( + &repository, + NodeKind::Method, + "App\\Repositories::SettleRepository::missing", + ); + let excel = id( + &controller, + NodeKind::Method, + "App\\Http::Controller::excel", + ); + let unavailable = id( + &controller, + NodeKind::Method, + "App\\Http::Controller::unavailable", + ); + + let resolved = resolve_fixture( + "php-import-alias-static", + &dir, + &[ + "src/Services.php", + "src/Repository.php", + "src/Controller.php", + ], + ); + let _ = std::fs::remove_dir_all(&dir); + let targets = |source: &str| { + resolved + .iter() + .filter(|edge| edge.kind == EdgeKind::Calls && edge.source == source) + .map(|edge| edge.target.as_str()) + .collect::>() + }; + + assert_eq!( + targets(&excel), + BTreeSet::from([service_method.as_str(), variable_method.as_str()]), + "the static alias and same-spelled variable must resolve in separate namespaces; edges={resolved:#?}" + ); + assert!(!targets(&excel).contains(repository_method.as_str())); + assert!( + targets(&unavailable).is_empty(), + "a missing member on the imported class must not fall back to the decoy; edges={resolved:#?}" + ); + assert!(!targets(&unavailable).contains(repository_missing.as_str())); +} +#[test] +fn rust_self_call_resolves_across_split_and_trait_impls_without_global_fallback() { + // #1861/PR #1866: `self.method()` carries an exact receiver. The owner is + // the enclosing method's qualified-name prefix, not the nearest or unique + // same-named method elsewhere in the project. Split inherent impls and + // trait impls are both ordinary Rust ownership shapes. + let dir = fresh_fixture_dir("rust-self-call-owner"); + std::fs::write( + dir.join("src/lib.rs"), + r#"pub struct Target; +impl Target { + pub fn reset(&mut self) {} +} + +pub struct Decoy; +impl Decoy { + pub fn reset(&mut self) {} + pub fn missing(&mut self) {} +} + +impl Target { + pub fn run(&mut self) { + self.reset(); + self.missing(); + } +} + +pub trait Run { + fn go(&mut self); +} + +pub struct StepDecoy; +impl StepDecoy { + pub fn step(&mut self) {} +} + +pub struct Doer; +impl Doer { + pub fn step(&mut self) {} +} + +impl Run for Doer { + fn go(&mut self) { + self.step(); + } +} +"#, + ) + .expect("write Rust self-call fixture"); + + let extracted = extract_file(&dir, "src/lib.rs").expect("extract Rust fixture"); + let id = |qualified: &str| { + extracted + .nodes + .iter() + .find(|node| node.kind == NodeKind::Method && node.qualified_name == qualified) + .unwrap_or_else(|| { + panic!( + "missing Rust method {qualified}; nodes={:#?}", + extracted.nodes + ) + }) + .id + .clone() + }; + let target_reset = id("Target::reset"); + let target_run = id("Target::run"); + let decoy_missing = id("Decoy::missing"); + let doer_step = id("Doer::step"); + let doer_go = id("Doer::go"); + + let refs = extracted + .unresolved_references + .iter() + .filter(|reference| reference.reference_kind == EdgeKind::Calls) + .map(|reference| { + ( + reference.from_node_id.as_str(), + reference.reference_name.as_str(), + ) + }) + .collect::>(); + assert!( + refs.contains(&(target_run.as_str(), "self.reset")), + "refs={refs:#?}" + ); + assert!( + refs.contains(&(target_run.as_str(), "self.missing")), + "refs={refs:#?}" + ); + assert!( + refs.contains(&(doer_go.as_str(), "self.step")), + "refs={refs:#?}" + ); + + let resolved = resolve_fixture("rust-self-call-owner", &dir, &["src/lib.rs"]); + let _ = std::fs::remove_dir_all(&dir); + let targets = |source: &str| { + resolved + .iter() + .filter(|edge| edge.kind == EdgeKind::Calls && edge.source == source) + .map(|edge| edge.target.as_str()) + .collect::>() + }; + + assert_eq!( + targets(&target_run), + BTreeSet::from([target_reset.as_str()]) + ); + assert!( + !targets(&target_run).contains(decoy_missing.as_str()), + "an absent owner method must never fall back to another type; edges={resolved:#?}" + ); + assert_eq!(targets(&doer_go), BTreeSet::from([doer_step.as_str()])); +} diff --git a/crates/codegraph-resolve/tests/js_resolution_safety.rs b/crates/codegraph-resolve/tests/js_resolution_safety.rs new file mode 100644 index 00000000..da94f505 --- /dev/null +++ b/crates/codegraph-resolve/tests/js_resolution_safety.rs @@ -0,0 +1,557 @@ +//! JS/TS resolution correctness regressions from the post-v1.6 upstream audit. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{EdgeKind, FileRecord, Language, NodeKind}; +use codegraph_extract::extract_file; +use codegraph_resolve::ReferenceResolver; +use codegraph_store::Store; + +static NONCE: AtomicU64 = AtomicU64::new(0); + +struct Project { + root: PathBuf, + store: Option, +} + +impl Drop for Project { + fn drop(&mut self) { + drop(self.store.take()); + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn language(path: &str) -> Language { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("ts") => Language::TypeScript, + Some("tsx") => Language::Tsx, + Some("js") => Language::JavaScript, + Some("jsx") => Language::Jsx, + other => panic!("unexpected extension: {other:?}"), + } +} + +fn resolve_project(name: &str, files: &[(&str, &str)]) -> Project { + let root = std::env::temp_dir().join(format!( + "codegraph-js-resolution-{name}-{}-{}", + std::process::id(), + NONCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).expect("create fixture root"); + for (relative, source) in files { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create fixture parent"); + } + std::fs::write(path, source).expect("write fixture"); + } + + let mut store = Store::open(&root.join("graph.db")).expect("open store"); + for (relative, _) in files { + let result = extract_file(&root, relative).expect("extract fixture"); + assert!(result.errors.is_empty(), "{relative}: {:?}", result.errors); + store + .upsert_file(&FileRecord { + path: (*relative).to_string(), + content_hash: "fixture".to_string(), + language: language(relative), + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: Vec::new(), + generated: false, + }) + .expect("upsert file"); + store.upsert_nodes(&result.nodes).expect("upsert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert unresolved refs"); + } + + let mut resolver = ReferenceResolver::new(root.to_string_lossy().to_string()); + resolver.initialize(&codegraph_resolve::StoreResolutionContext::new( + &store, + root.to_string_lossy().as_ref(), + )); + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + Project { + root, + store: Some(store), + } +} + +fn node_id(project: &Project, kind: NodeKind, qualified_name: &str) -> String { + project + .store + .as_ref() + .expect("store") + .nodes_by_kind(kind) + .expect("nodes by kind") + .into_iter() + .find(|node| node.qualified_name == qualified_name) + .unwrap_or_else(|| panic!("missing {kind:?} {qualified_name}")) + .id +} + +fn call_targets(project: &Project, kind: NodeKind, qualified_name: &str) -> Vec { + let store = project.store.as_ref().expect("store"); + let source = node_id(project, kind, qualified_name); + let mut targets = store + .edges_by_source_kind(&source, Some(EdgeKind::Calls)) + .expect("calls") + .into_iter() + .filter_map(|edge| store.node_by_id(&edge.target).expect("target query")) + .map(|node| node.qualified_name) + .collect::>(); + targets.sort(); + targets +} + +#[test] +fn call_result_receiver_does_not_guess_a_global_callable() { + let project = resolve_project( + "call-result", + &[( + "collect.js", + r#" +function append(item) { return item; } +function run() { return 1; } +function make() { return {}; } +function get() { return {}; } +function bucket(d, k, v) { + d.setdefault(k, []).append(v); + make().run(); + (0, make)().run(); +} +function viaStore() { get().run(); } +module.exports = { append, run, make, get, bucket, viaStore }; +"#, + )], + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "bucket"), + vec!["make"] + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "viaStore"), + vec!["get"], + "a unique callable name is not proof of get()'s result type" + ); +} + +#[test] +fn this_field_calls_resolve_only_through_the_declared_type() { + let project = resolve_project( + "this-field", + &[ + ( + "mailer.ts", + "export class Mailer { send(msg: string): string { return msg; } }\n", + ), + ( + "notifier.ts", + r#" +import { Mailer } from './mailer'; +export class Notifier { + constructor(private readonly mailer: Mailer, private items: string[]) {} + send(msg: string): string { return this.mailer.send(msg); } + other(msg: string): string { return this.mailer.send(msg); } + push(msg: string): void { this.items.push(msg); } +} +export class NoField { + fake(shadow: Mailer): string { return this.shadow.send("wrong"); } + local(): string { const shadow = new Mailer(); return this.shadow.send("wrong"); } +} +"#, + ), + ( + "storage.ts", + r#" +export const DraftStorage = { + get(key: string): string { return key; }, + settings(): object { return {}; }, +}; +"#, + ), + ( + "keeper.ts", + r#" +import { DraftStorage } from './storage'; +export class Keeper { + constructor(private readonly storage: typeof DraftStorage) {} + get(key: string): string { return this.storage.get(key); } + load(): object { return this.storage.settings(); } +} +"#, + ), + ], + ); + + assert_eq!( + call_targets(&project, NodeKind::Method, "Notifier::send"), + vec!["Mailer::send"] + ); + assert_eq!( + call_targets(&project, NodeKind::Method, "Notifier::other"), + vec!["Mailer::send"] + ); + assert!(call_targets(&project, NodeKind::Method, "Notifier::push").is_empty()); + assert!(call_targets(&project, NodeKind::Method, "NoField::fake").is_empty()); + assert!(call_targets(&project, NodeKind::Method, "NoField::local").is_empty()); + assert_eq!( + call_targets(&project, NodeKind::Method, "Keeper::get"), + vec!["DraftStorage::get"] + ); + assert_eq!( + call_targets(&project, NodeKind::Method, "Keeper::load"), + vec!["DraftStorage::settings"] + ); +} + +#[test] +fn awaited_receiver_uses_the_callees_declared_promise_result() { + let project = resolve_project( + "awaited", + &[ + ( + "engine.ts", + r#" +export class Engine { run(): string { return "ran"; } } +export class Decoy { run(): string { return "decoy"; } } +export class Pane { split(): string { return "pane"; } } +export class Response { run(): string { return "project-response"; } } +"#, + ), + ( + "factory.ts", + r#" +import { Engine } from './engine'; +export async function makeEngine(): Promise { return new Engine(); } +export async function listPaths(): Promise { return "a"; } +export async function fetchResponse(): Promise { return fetch("/") as any; } +"#, + ), + ( + "drive.ts", + r#" +import { makeEngine, listPaths, fetchResponse } from './factory'; +export async function drive(): Promise { + const handle = await makeEngine(); + return handle.run(); +} +export async function snapshot(): Promise { + const listed = await listPaths(); + return listed.split("\\0"); +} +export async function externalResponse(): Promise { + const response = await fetchResponse(); + return response.run(); +} +"#, + ), + ], + ); + + assert_eq!( + call_targets(&project, NodeKind::Function, "drive"), + vec!["Engine::run", "makeEngine"] + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "snapshot"), + vec!["listPaths"] + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "externalResponse"), + vec!["fetchResponse"], + "an unbound DOM Response annotation must not hijack a project Response" + ); +} + +#[test] +fn bare_js_call_never_targets_a_method_and_respects_local_bindings() { + let project = resolve_project( + "bare-call", + &[ + ( + "record.ts", + r#" +function serialize(value: string): string { return value; } +export class Record { + serialize(): string { return serialize("x"); } + recurse(): string { return this.serialize(); } +} +export function ping(): Promise { + return new Promise((resolve) => { resolve(); }); +} +export function multiline(): void { + finish + (); +} +"#, + ), + ( + "other.ts", + r#" +export function resolve(): void {} +export class Other { serialize(): string { return "wrong"; } finish(): void {} } +"#, + ), + ], + ); + + assert_eq!( + call_targets(&project, NodeKind::Method, "Record::serialize"), + vec!["serialize"] + ); + assert_eq!( + call_targets(&project, NodeKind::Method, "Record::recurse"), + vec!["Record::serialize"] + ); + assert!(call_targets(&project, NodeKind::Function, "ping").is_empty()); + assert!(call_targets(&project, NodeKind::Function, "multiline").is_empty()); +} + +#[test] +fn import_only_js_module_is_not_a_cross_file_name_target() { + let project = resolve_project( + "sealed-module", + &[ + ( + "private.js", + "import fs from 'node:fs';\nfunction hidden() { return fs; }\nhidden();\n", + ), + ( + "consumer.js", + "function run() { hidden(); globalFn(); helper(); bracketed(); later(); }\n", + ), + ("classic.js", "function globalFn() { return 1; }\n"), + ( + "common.js", + "import os from 'node:os';\nfunction helper() { return os; }\nmodule.exports = { helper };\n", + ), + ( + "bracket.js", + "import url from 'node:url';\nfunction bracketed() { return url; }\nexports[\"bracketed\"] = bracketed;\n", + ), + ( + "later.js", + "import path from 'node:path';\nfunction later() { return path; }\nexport { later };\n", + ), + ], + ); + + assert_eq!( + call_targets(&project, NodeKind::Function, "run"), + vec!["bracketed", "globalFn", "helper", "later"] + ); + assert!(call_targets(&project, NodeKind::Function, "hidden").is_empty()); +} + +#[test] +fn calls_through_alias_bindings_reach_the_underlying_callable() { + let project = resolve_project( + "aliases", + &[ + ( + "impl.ts", + r#" +export function realImpl(): number { return 1; } +export const aliasName = realImpl; +export const api = { run: realImpl }; +function laterImpl(): number { return 2; } +export { laterImpl as laterAlias }; +"#, + ), + ( + "consumer.ts", + r#" +import { aliasName, api, laterAlias } from './impl'; +export function viaAlias(): number { return aliasName(); } +export function viaMember(): number { return api.run(); } +export function viaClause(): number { return laterAlias(); } +"#, + ), + ( + "local.ts", + r#" +function localImpl(): number { return 1; } +const localAlias = localImpl; +export function viaLocal(): number { return localAlias(); } +"#, + ), + ], + ); + + assert_eq!( + call_targets(&project, NodeKind::Function, "viaAlias"), + vec!["realImpl"] + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "viaMember"), + vec!["realImpl"] + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "viaClause"), + vec!["laterImpl"] + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "viaLocal"), + vec!["localImpl"] + ); +} + +#[test] +fn wrappers_and_ambiguous_cross_file_alias_targets_do_not_hop() { + let project = resolve_project( + "alias-guards", + &[ + ("one.ts", "export function shared(): number { return 1; }\n"), + ("two.ts", "export function shared(): number { return 2; }\n"), + ( + "alias.ts", + "import { shared } from './one';\nexport const aliasName = shared;\n", + ), + ( + "consumer.ts", + "import { aliasName } from './alias';\nexport function consumer(): number { return aliasName(); }\n", + ), + ( + "wrapper.ts", + "export function realImpl(): number { return 1; }\nexport const wrapper = (): number => realImpl();\n", + ), + ( + "use-wrapper.ts", + "import { wrapper } from './wrapper';\nexport function useWrapper(): number { return wrapper(); }\n", + ), + ], + ); + + assert_eq!( + call_targets(&project, NodeKind::Function, "consumer"), + vec!["aliasName"] + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "useWrapper"), + vec!["wrapper"] + ); +} + +#[test] +fn imported_typescript_interface_method_is_a_real_call_target() { + let project = resolve_project( + "interface-method", + &[ + ( + "api.d.ts", + "export interface PlatformApi { fetchPage(id: string): Promise; }\n", + ), + ( + "consumer.ts", + "import type { PlatformApi } from './api';\nexport function load(api: PlatformApi) { return api.fetchPage('x'); }\n", + ), + ], + ); + assert_eq!( + call_targets(&project, NodeKind::Function, "load"), + vec!["PlatformApi::fetchPage"] + ); +} + +#[test] +fn receiver_method_values_resolve_as_references_not_calls() { + let project = resolve_project( + "method-values", + &[ + ( + "mailer.ts", + "export class Mailer { send(msg: string): string { return msg; } }\n", + ), + ( + "consumer.ts", + r#" +import { Mailer } from './mailer'; +export function register(callback: (msg: string) => string): void {} +export class Consumer { + constructor(private readonly mailer: Mailer) {} + wire(): void { register(this.mailer.send); } + wireParam(mailer: Mailer): void { register(mailer.send); } + dynamic(): void { register(this.unknown.send); } +} +"#, + ), + ], + ); + let store = project.store.as_ref().expect("store"); + let wire = node_id(&project, NodeKind::Method, "Consumer::wire"); + let send = node_id(&project, NodeKind::Method, "Mailer::send"); + let edges = store.edges_by_source_kind(&wire, None).expect("wire edges"); + let callback = edges + .iter() + .find(|edge| edge.kind == EdgeKind::References && edge.target == send) + .unwrap_or_else(|| { + panic!( + "receiver-qualified callback reference; edges={edges:#?}; unresolved={:#?}", + store.all_unresolved_refs().expect("unresolved") + ) + }); + assert_eq!( + callback + .metadata + .as_ref() + .and_then(|metadata| metadata.get("fnRef")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + callback + .metadata + .as_ref() + .and_then(|metadata| metadata.get("resolvedBy")) + .and_then(serde_json::Value::as_str), + Some("function-ref") + ); + assert!( + !edges + .iter() + .any(|edge| edge.kind == EdgeKind::Calls && edge.target == send), + "passing a method value must not claim an immediate call: {edges:#?}" + ); + + let wire_param = node_id(&project, NodeKind::Method, "Consumer::wireParam"); + let param_edges = store + .edges_by_source_kind(&wire_param, None) + .expect("wireParam edges"); + assert!(param_edges.iter().any(|edge| { + edge.kind == EdgeKind::References + && edge.target == send + && edge + .metadata + .as_ref() + .and_then(|metadata| metadata.get("fnRef")) + .and_then(serde_json::Value::as_bool) + == Some(true) + })); + assert!( + !param_edges + .iter() + .any(|edge| edge.kind == EdgeKind::Calls && edge.target == send) + ); + + let dynamic = node_id(&project, NodeKind::Method, "Consumer::dynamic"); + assert!( + store + .edges_by_source_kind(&dynamic, Some(EdgeKind::References)) + .expect("dynamic refs") + .into_iter() + .all(|edge| edge.target != send), + "an undeclared receiver must remain unresolved" + ); +} diff --git a/crates/codegraph-resolve/tests/rust_self_owner.rs b/crates/codegraph-resolve/tests/rust_self_owner.rs new file mode 100644 index 00000000..04397d15 --- /dev/null +++ b/crates/codegraph-resolve/tests/rust_self_owner.rs @@ -0,0 +1,165 @@ +//! `self.method()` owner resolution across same-named Rust types (upstream +//! v1.6.1 #1861/#1882), ported from `__tests__/rust-self-owner.test.ts`. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use codegraph_core::types::{EdgeKind, FileRecord, Language, NodeKind}; +use codegraph_extract::extract_file; +use codegraph_resolve::ReferenceResolver; +use codegraph_store::Store; + +static NONCE: AtomicU64 = AtomicU64::new(0); + +struct Project { + root: PathBuf, + store: Option, +} + +impl Drop for Project { + fn drop(&mut self) { + drop(self.store.take()); + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn resolve_project(files: &[(&str, &str)]) -> Project { + let root = std::env::temp_dir().join(format!( + "codegraph-rust-self-owner-{}-{}", + std::process::id(), + NONCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(root.join("src")).expect("create fixture root"); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname=\"owners\"\nversion=\"0.1.0\"\nedition=\"2021\"\n", + ) + .expect("write manifest"); + let mut store = Store::open(&root.join("graph.db")).expect("open store"); + for (file, source) in files { + let relative = format!("src/{file}"); + std::fs::write(root.join(&relative), source).expect("write fixture"); + let result = extract_file(&root, &relative).expect("extract fixture"); + store + .upsert_file(&FileRecord { + path: relative.clone(), + content_hash: "fixture".to_string(), + language: Language::Rust, + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: Vec::new(), + generated: false, + }) + .expect("upsert file"); + store.upsert_nodes(&result.nodes).expect("upsert nodes"); + store.insert_edges(&result.edges).expect("insert edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert unresolved refs"); + } + let mut resolver = ReferenceResolver::new(root.to_string_lossy().to_string()); + resolver.initialize(&codegraph_resolve::StoreResolutionContext::new( + &store, + root.to_string_lossy().as_ref(), + )); + resolver + .resolve_and_persist(&mut store) + .expect("resolve fixture"); + Project { + root, + store: Some(store), + } +} + +/// `file:qualifiedName` of every call target of `Target::run` in `src/`. +fn targets(project: &Project, file: &str) -> Vec { + let store = project.store.as_ref().expect("store"); + let caller = store + .nodes_by_kind(NodeKind::Method) + .expect("methods") + .into_iter() + .find(|node| { + node.file_path == format!("src/{file}") && node.qualified_name == "Target::run" + }) + .expect("caller Target::run"); + let mut found: Vec = store + .edges_by_source_kind(&caller.id, Some(EdgeKind::Calls)) + .expect("calls") + .into_iter() + .filter_map(|edge| store.node_by_id(&edge.target).expect("target query")) + .map(|node| format!("{}:{}", node.file_path, node.qualified_name)) + .collect(); + found.sort(); + found +} + +#[test] +fn does_not_borrow_a_missing_method_from_a_same_named_type_in_another_module() { + let project = resolve_project(&[ + ("lib.rs", "pub mod caller; pub mod decoy;"), + ( + "caller.rs", + "pub struct Target;\nimpl Target { pub fn run(&self) { self.reset(); } }", + ), + ( + "decoy.rs", + "pub struct Target;\nimpl Target { pub fn reset(&self) {} }", + ), + ]); + assert_eq!(targets(&project, "caller.rs"), Vec::::new()); +} + +#[test] +fn keeps_a_proven_local_owner_despite_a_same_named_type_elsewhere() { + let project = resolve_project(&[ + ("lib.rs", "pub mod caller; pub mod decoy;"), + ( + "caller.rs", + "pub struct Target;\nimpl Target { pub fn reset(&self) {} }\nimpl Target { pub fn run(&self) { self.reset(); } }", + ), + ( + "decoy.rs", + "pub struct Target;\nimpl Target { pub fn reset(&self) {} }", + ), + ]); + assert_eq!( + targets(&project, "caller.rs"), + vec!["src/caller.rs:Target::reset"] + ); +} + +#[test] +fn keeps_a_unique_owner_whose_impl_is_split_across_files() { + let project = resolve_project(&[ + ( + "lib.rs", + "pub mod caller;\npub struct Target;\nimpl Target { pub fn reset(&self) {} }", + ), + ( + "caller.rs", + "use crate::Target;\nimpl Target { pub fn run(&self) { self.reset(); } }", + ), + ]); + assert_eq!( + targets(&project, "caller.rs"), + vec!["src/lib.rs:Target::reset"] + ); +} + +#[test] +fn declines_indistinguishable_inline_module_owners() { + let project = resolve_project(&[( + "lib.rs", + "mod a { + pub struct Target; + impl Target { pub fn reset(&self) {} } + } + mod b { + pub struct Target; + impl Target { pub fn run(&self) { self.reset(); } } + }", + )]); + assert_eq!(targets(&project, "lib.rs"), Vec::::new()); +} diff --git a/crates/codegraph-resolve/tests/scala_companion.rs b/crates/codegraph-resolve/tests/scala_companion.rs new file mode 100644 index 00000000..cd0c9b0d --- /dev/null +++ b/crates/codegraph-resolve/tests/scala_companion.rs @@ -0,0 +1,173 @@ +use std::path::{Path, PathBuf}; + +use codegraph_core::types::{EdgeKind, FileRecord, Language, NodeKind}; +use codegraph_extract::extract_file; +use codegraph_resolve::ReferenceResolver; +use codegraph_store::Store; + +fn temp_root() -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "codegraph-scala-companion-{}-{nanos}", + std::process::id() + )) +} + +fn add_file(store: &mut Store, root: &Path, relative: &str) { + let result = extract_file(root, relative).expect("extract Scala fixture"); + store + .upsert_file(&FileRecord { + path: relative.to_string(), + content_hash: "fixture".to_string(), + language: Language::Scala, + size: 0, + modified_at: 0, + indexed_at: 0, + node_count: result.nodes.len() as i64, + errors: result.errors, + generated: false, + }) + .expect("upsert fixture file"); + store.upsert_nodes(&result.nodes).expect("upsert nodes"); + store + .insert_edges(&result.edges) + .expect("insert contains edges"); + store + .insert_unresolved_refs(&result.unresolved_references) + .expect("insert unresolved refs"); +} + +#[test] +fn scala_inheritance_prefers_type_over_earlier_companion_module() { + let root = temp_root(); + std::fs::create_dir_all(&root).expect("create fixture root"); + // Put the module first so a stable line-order tie would select the wrong + // target unless inheritance scoring explicitly prefers the real type. + std::fs::write( + root.join("ExtAgreement.scala"), + r#"object ExtAgreement { + val Kind = "agreement" +} +trait ExtAgreement { + def extId: String = "x" +} +"#, + ) + .expect("write companion fixture"); + std::fs::write( + root.join("MExtAgreement.scala"), + r#"class MExtAgreement extends ExtAgreement { + def render(): String = extId +} +"#, + ) + .expect("write subtype fixture"); + + let db = root.join("graph.db"); + let mut store = Store::open(&db).expect("open store"); + add_file(&mut store, &root, "ExtAgreement.scala"); + add_file(&mut store, &root, "MExtAgreement.scala"); + + let companion = store + .nodes_by_kind(NodeKind::Module) + .expect("query modules") + .into_iter() + .find(|node| node.name == "ExtAgreement") + .expect("companion module"); + let trait_node = store + .nodes_by_kind(NodeKind::Trait) + .expect("query traits") + .into_iter() + .find(|node| node.name == "ExtAgreement") + .expect("companion trait"); + let subtype = store + .nodes_by_kind(NodeKind::Class) + .expect("query classes") + .into_iter() + .find(|node| node.name == "MExtAgreement") + .expect("subtype class"); + + ReferenceResolver::new(root.to_string_lossy().into_owned()) + .resolve_and_persist(&mut store) + .expect("resolve Scala inheritance"); + + let inheritance = store + .edges_by_source_kind(&subtype.id, Some(EdgeKind::Extends)) + .expect("query inheritance edges"); + assert!( + inheritance.iter().any(|edge| edge.target == trait_node.id), + "subtype must extend the trait; edges={inheritance:#?}" + ); + assert!( + inheritance.iter().all(|edge| edge.target != companion.id), + "companion module must not capture inheritance; edges={inheritance:#?}" + ); +} + +#[test] +fn scala_singleton_is_never_a_parent_but_owns_callable_methods() { + let root = temp_root(); + std::fs::create_dir_all(&root).expect("create fixture root"); + // No same-named type exists: the singleton alone must not become a parent. + std::fs::write( + root.join("Registry.scala"), + r#"object Registry { + def lookup(key: String): String = key +} +"#, + ) + .expect("write singleton fixture"); + std::fs::write( + root.join("Client.scala"), + r#"class Client extends Registry { + def run(): String = Registry.lookup("k") +} +"#, + ) + .expect("write client fixture"); + + let mut store = Store::open(&root.join("graph.db")).expect("open store"); + add_file(&mut store, &root, "Registry.scala"); + add_file(&mut store, &root, "Client.scala"); + let client = store + .nodes_by_kind(NodeKind::Class) + .expect("query classes") + .into_iter() + .find(|node| node.name == "Client") + .expect("client class"); + let run = store + .nodes_by_kind(NodeKind::Method) + .expect("query methods") + .into_iter() + .find(|node| node.qualified_name == "Client::run") + .expect("client run"); + let lookup = store + .nodes_by_kind(NodeKind::Method) + .expect("query methods") + .into_iter() + .find(|node| node.qualified_name == "Registry::lookup") + .expect("registry lookup"); + + ReferenceResolver::new(root.to_string_lossy().into_owned()) + .resolve_and_persist(&mut store) + .expect("resolve Scala singleton"); + + let inheritance = store + .edges_by_source_kind(&client.id, Some(EdgeKind::Extends)) + .expect("query inheritance edges"); + assert!( + inheritance.is_empty(), + "a singleton is not a parent type; edges={inheritance:#?}" + ); + let calls = store + .edges_by_source_kind(&run.id, Some(EdgeKind::Calls)) + .expect("query call edges"); + assert!( + calls.iter().any(|edge| edge.target == lookup.id), + "Registry.lookup() resolves on the singleton; edges={calls:#?}" + ); + std::fs::remove_dir_all(&root).ok(); +} diff --git a/crates/codegraph-store/src/file_identity.rs b/crates/codegraph-store/src/file_identity.rs index 23274e0b..c746a137 100644 --- a/crates/codegraph-store/src/file_identity.rs +++ b/crates/codegraph-store/src/file_identity.rs @@ -1,4 +1,5 @@ -//! Private cross-platform filesystem-object identity helpers. +//! Cross-platform filesystem-object identity helpers: crate-private, plus +//! the opaque public [`PathIdentity`]. #[cfg(windows)] use std::fs::OpenOptions; @@ -23,6 +24,30 @@ pub(crate) enum FileIdentity { }, } +/// Opaque identity of the filesystem object a path names right now. A file +/// rewritten in place keeps it; a file deleted and re-created, or replaced by +/// a rename, does not — which is how a long-lived holder notices that the +/// index it attached to is no longer the one on disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PathIdentity(FileIdentity); + +impl PathIdentity { + /// Capture the identity of the regular file `path` names. + /// + /// # Errors + /// When the path is missing, unreadable, or not a regular file. + pub fn of(path: &Path) -> io::Result { + let metadata = std::fs::symlink_metadata(path)?; + if !is_regular(&metadata) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path does not name a regular file", + )); + } + identity_for_validated_path(path, &metadata).map(Self) + } +} + pub(crate) fn is_alias(metadata: &Metadata) -> bool { if metadata.file_type().is_symlink() { return true; @@ -162,7 +187,11 @@ pub(crate) fn identity_for_validated_path( } } -pub(crate) fn path_still_names_file(path: &Path, file: &File) -> io::Result { +/// Whether `path` still names the exact physical object held by `file`. +/// +/// Exported for other index-lifecycle crates that need the same Unix inode / +/// Windows FileIdInfo corroboration as [`crate::IndexLease`]. +pub fn path_still_names_file(path: &Path, file: &File) -> io::Result { path_still_names_identity(path, identity_for_file(file)?) } diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index 70862261..ec331148 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 = 12; +pub const CURRENT_EXTRACTION_VERSION: u64 = 13; /// 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/src/lib.rs b/crates/codegraph-store/src/lib.rs index ccc34a61..cc97cef4 100644 --- a/crates/codegraph-store/src/lib.rs +++ b/crates/codegraph-store/src/lib.rs @@ -15,6 +15,7 @@ pub use connection::{ ExtractionStampIssue, Store, StoreError, StoreStatusOpen, StoreWriteAuthorization, StoreWriteOpen, StoreWritePurpose, }; +pub use file_identity::{PathIdentity, path_still_names_file}; pub use index_lease::{IndexLease, IndexLeaseError, IndexLeaseValidationError}; pub use index_state::{ AuthoritativeSlot, CURRENT_EXTRACTION_VERSION, CURRENT_STORAGE_PROTOCOL, CorruptReason, diff --git a/crates/codegraph-store/src/queries.rs b/crates/codegraph-store/src/queries.rs index 8aaee18c..33631840 100644 --- a/crates/codegraph-store/src/queries.rs +++ b/crates/codegraph-store/src/queries.rs @@ -790,10 +790,6 @@ impl Store { if refs.is_empty() { return Ok(()); } - let from_ids = refs - .iter() - .map(|unresolved| unresolved.from_node_id.as_str()) - .collect::>(); // Snapshot `from_node_id` existence INSIDE a `BEGIN IMMEDIATE` transaction // (see `insert_edges`): a concurrent writer deleting the source node @@ -803,42 +799,7 @@ impl Store { let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; - let existing_node_ids = existing_node_ids(&tx, &from_ids)?; - { - let mut stmt = tx.prepare_cached( - r#" - INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language, reference_subkind) - VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language, @referenceSubkind) - "#, - )?; - for unresolved in refs { - if !existing_node_ids.contains(&unresolved.from_node_id) { - continue; - } - let candidates = unresolved - .candidates - .as_ref() - .map(serde_json::to_string) - .transpose() - .map_err(json_to_sql_error)?; - let reference_kind = if unresolved.is_function_ref { - "function_ref" - } else { - unresolved.reference_kind.as_str() - }; - stmt.execute(named_params! { - "@fromNodeId": unresolved.from_node_id, - "@referenceName": unresolved.reference_name, - "@referenceKind": reference_kind, - "@line": unresolved.line, - "@col": unresolved.col, - "@candidates": candidates, - "@filePath": unresolved.file_path, - "@language": unresolved.language.as_str(), - "@referenceSubkind": unresolved.reference_subkind.map(|s| s.as_str()), - })?; - } - } + insert_unresolved_refs_in_connection(&tx, refs)?; tx.commit() } @@ -1480,6 +1441,85 @@ impl Store { Ok(removed) } + /// Atomically replace every resolution-produced edge and unresolved row for + /// one source file with the freshly extracted references (#1833 / #1849). + /// + /// Incremental sync previously committed the two DELETE operations before + /// attempting the INSERT. A disk/trigger/constraint failure during that + /// insertion permanently removed the last committed relationship. Keeping + /// all three operations in one `BEGIN IMMEDIATE` transaction makes the old + /// graph answer survive any failed requeue. + pub fn replace_resolution_state_from_file( + &mut self, + file_path: &str, + refs: &[UnresolvedRef], + ) -> rusqlite::Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let removed = tx.execute( + r#"DELETE FROM edges + WHERE id IN ( + SELECT e.id + FROM edges e + JOIN nodes src ON src.id = e.source + WHERE src.file_path = ?1 + AND e.kind != 'contains' + )"#, + [file_path], + )?; + tx.execute( + "DELETE FROM unresolved_refs WHERE file_path = ?1", + [file_path], + )?; + insert_unresolved_refs_in_connection(&tx, refs)?; + tx.commit()?; + Ok(removed) + } + + /// Atomically replace the resolution state of selected source-site sibling + /// groups with freshly extracted references (#1833 / #1849). + /// + /// Target, edge kind, and reference name are intentionally absent from the + /// predicates: resolution may promote an edge kind, and every sibling at one + /// source coordinate must move together to preserve multiplicity. + pub fn replace_resolution_state_at_sites( + &mut self, + sites: &[ReferenceSite], + refs: &[UnresolvedRef], + ) -> rusqlite::Result { + let unique = sites.iter().cloned().collect::>(); + if unique.is_empty() { + return Ok(0); + } + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let mut removed = 0; + { + let mut delete_edges = tx.prepare_cached( + r#"DELETE FROM edges + WHERE source = ?1 AND line = ?2 AND col = ?3 + AND kind != 'contains'"#, + )?; + let mut delete_refs = tx.prepare_cached( + r#"DELETE FROM unresolved_refs + WHERE from_node_id = ?1 AND line = ?2 AND col = ?3"#, + )?; + for site in unique { + removed += delete_edges.execute(params![ + site.from_node_id.as_str(), + site.line, + site.col + ])?; + delete_refs.execute(params![site.from_node_id.as_str(), site.line, site.col])?; + } + } + insert_unresolved_refs_in_connection(&tx, refs)?; + tx.commit()?; + Ok(removed) + } + /// Distinct source files of every resolution-produced edge whose TARGET node /// is named one of `names`. When a synced file changes the set of nodes /// sharing a name, the exact-name resolution of refs that already resolved to @@ -1899,6 +1939,58 @@ fn existing_node_ids( Ok(out) } +/// Insert unresolved references through `conn`, which may be either the Store's +/// connection or a caller-owned transaction. Keeping the statement body here is +/// what lets incremental sync compose edge deletion, stale-ref deletion, and +/// fresh-ref insertion into one crash-safe transaction. +fn insert_unresolved_refs_in_connection( + conn: &Connection, + refs: &[UnresolvedRef], +) -> rusqlite::Result<()> { + if refs.is_empty() { + return Ok(()); + } + let from_ids = refs + .iter() + .map(|unresolved| unresolved.from_node_id.as_str()) + .collect::>(); + let existing_node_ids = existing_node_ids(conn, &from_ids)?; + let mut stmt = conn.prepare_cached( + r#" + INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language, reference_subkind) + VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language, @referenceSubkind) + "#, + )?; + for unresolved in refs { + if !existing_node_ids.contains(&unresolved.from_node_id) { + continue; + } + let candidates = unresolved + .candidates + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(json_to_sql_error)?; + let reference_kind = if unresolved.is_function_ref { + "function_ref" + } else { + unresolved.reference_kind.as_str() + }; + stmt.execute(named_params! { + "@fromNodeId": unresolved.from_node_id, + "@referenceName": unresolved.reference_name, + "@referenceKind": reference_kind, + "@line": unresolved.line, + "@col": unresolved.col, + "@candidates": candidates, + "@filePath": unresolved.file_path, + "@language": unresolved.language.as_str(), + "@referenceSubkind": unresolved.reference_subkind.map(|s| s.as_str()), + })?; + } + Ok(()) +} + fn fts_query(query: &str) -> String { query .replace("::", " ") @@ -3552,6 +3644,90 @@ mod tests { ); } + fn requeue_fixture(test_name: &str) -> (Store, ReferenceSite, UnresolvedRef) { + let mut store = store(test_name); + store + .upsert_nodes(&[ + node("function:caller", "caller", "a.rs"), + node("function:callee", "callee", "b.rs"), + ]) + .unwrap(); + let mut old_edge = edge("function:caller", "function:callee", EdgeKind::Calls); + old_edge.line = Some(10); + old_edge.col = Some(2); + store.insert_edges(&[old_edge]).unwrap(); + let old_ref = UnresolvedRef { + id: None, + from_node_id: "function:caller".to_string(), + reference_name: "old".to_string(), + reference_kind: EdgeKind::Calls, + line: 10, + col: 2, + candidates: None, + file_path: "a.rs".to_string(), + language: Language::Rust, + is_function_ref: false, + reference_subkind: None, + }; + store + .insert_unresolved_refs(std::slice::from_ref(&old_ref)) + .unwrap(); + store + .connection() + .execute_batch( + r#"CREATE TRIGGER interrupt_fresh_requeue + BEFORE INSERT ON unresolved_refs + WHEN NEW.reference_name = 'fresh' + BEGIN + SELECT RAISE(ABORT, 'forced requeue interruption'); + END;"#, + ) + .unwrap(); + ( + store, + ReferenceSite { + from_node_id: "function:caller".to_string(), + line: 10, + col: 2, + }, + UnresolvedRef { + reference_name: "fresh".to_string(), + ..old_ref + }, + ) + } + + fn assert_requeue_rollback(store: &Store) { + let edges = store.all_edges().unwrap(); + assert_eq!(edges.len(), 1, "the last committed edge must survive"); + assert_eq!(edges[0].source, "function:caller"); + assert_eq!(edges[0].target, "function:callee"); + assert_eq!(edges[0].line, Some(10)); + let refs = store.unresolved_refs_by_file_path("a.rs").unwrap(); + assert_eq!(refs.len(), 1, "the old unresolved sibling must survive"); + assert_eq!(refs[0].reference_name, "old"); + } + + #[test] + fn file_requeue_rolls_back_edge_and_ref_deletion_when_insert_fails() { + let (mut store, _site, fresh) = requeue_fixture("file-requeue-rollback"); + let error = store + .replace_resolution_state_from_file("a.rs", &[fresh]) + .expect_err("the trigger must interrupt the requeue"); + assert!(error.to_string().contains("forced requeue interruption")); + assert_requeue_rollback(&store); + } + + #[test] + fn site_requeue_rolls_back_edge_and_ref_deletion_when_insert_fails() { + let (mut store, site, fresh) = requeue_fixture("site-requeue-rollback"); + let error = store + .replace_resolution_state_at_sites(&[site], &[fresh]) + .expect_err("the trigger must interrupt the requeue"); + assert!(error.to_string().contains("forced requeue interruption")); + assert_requeue_rollback(&store); + } + #[test] fn counts_reflects_nodes_edges_files() { let mut store = store("counts"); diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs index 0aa78f8c..bbc79542 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 = 11; + const PREVIOUS_EXTRACTION_VERSION: u64 = 12; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); diff --git a/crates/codegraph-watch/src/lib.rs b/crates/codegraph-watch/src/lib.rs index 3e5bbf58..2ac7b8bd 100644 --- a/crates/codegraph-watch/src/lib.rs +++ b/crates/codegraph-watch/src/lib.rs @@ -13,8 +13,8 @@ pub use policy::{ CODEGRAPH_NO_WATCH, TooBroadRoot, WatchPolicy, too_broad_root_reason, watch_disabled_reason, }; pub use sync::{ - SyncCancellation, SyncOutcome, sync_changed_paths, sync_project_once, - sync_project_once_cancellable, sync_project_once_with_progress, + PendingChanges, SyncCancellation, SyncOutcome, pending_project_changes, sync_changed_paths, + sync_project_once, sync_project_once_cancellable, sync_project_once_with_progress, }; pub use watcher::{ PendingFile, ProjectWatcher, WatchOptions, start_serve_watcher, watch_options_for_project, diff --git a/crates/codegraph-watch/src/sync.rs b/crates/codegraph-watch/src/sync.rs index fe5dd449..63dd177d 100644 --- a/crates/codegraph-watch/src/sync.rs +++ b/crates/codegraph-watch/src/sync.rs @@ -103,6 +103,81 @@ pub struct SyncOutcome { pub trigger_paths: Vec, } +/// Read-only difference between the current project scope and the persisted +/// file inventory. Paths are root-relative, sorted, and mutually exclusive. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PendingChanges { + pub added: Vec, + pub modified: Vec, + pub removed: Vec, +} + +impl PendingChanges { + #[must_use] + pub fn is_empty(&self) -> bool { + self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty() + } + + #[must_use] + pub fn total(&self) -> usize { + self.added.len() + self.modified.len() + self.removed.len() + } +} + +/// Compute the same whole-project candidate delta an ordinary full `sync` would +/// inspect, without acquiring a writer lease or mutating any index byte (#1829). +/// +/// This intentionally uses the correctness-first full scan instead of Git's +/// working-tree status: a committed change is absent from `git status`, history +/// can be rebased/shallow, and CodeGraph also supports non-Git projects. The +/// scan reuses the addressed project's include/exclude/custom-extension policy. +/// Existing files use sync's `(size, mtime) -> content hash` decision, so the +/// reported counts match the work the next whole-tree sync will perform. +pub fn pending_project_changes( + project_root: impl AsRef, + store: &Store, +) -> Result { + let project_root = project_root.as_ref(); + let paths = index_paths(project_root)?; + let scope = ProjectScope::load(project_root, &paths)?; + let on_disk = codegraph_extract::engine::scan_project(project_root, &scope.options)?; + let tracked = store + .all_files()? + .into_iter() + .map(|file| (file.path.clone(), file)) + .collect::>(); + let on_disk_set = on_disk.iter().cloned().collect::>(); + + let mut pending = PendingChanges::default(); + for relative in on_disk { + let Some(stored) = tracked.get(&relative) else { + pending.added.push(relative); + continue; + }; + let full = project_root.join(&relative); + let metadata = fs::metadata(&full).with_context(|| format!("stat {}", full.display()))?; + if stored.size == metadata.len() as i64 && stored.modified_at == modified_millis(&metadata) + { + continue; + } + let source = fs::read_to_string(&full) + .with_context(|| format!("read pending source {}", full.display()))?; + if stored.content_hash != hash_content(&source) { + pending.modified.push(relative); + } + } + for relative in tracked.keys() { + if !on_disk_set.contains(relative) { + pending.removed.push(relative.clone()); + } + } + + pending.added.sort(); + pending.modified.sort(); + pending.removed.sort(); + Ok(pending) +} + pub fn sync_project_once(project_root: impl AsRef) -> Result { sync_project_once_with_progress(project_root, |_, _| {}) } @@ -532,9 +607,7 @@ fn refresh_dependent_refs( }) }); if needs_fallback { - store.delete_resolved_edges_from_file(&relative)?; - delete_unresolved_refs_by_file(store, &relative)?; - store.insert_unresolved_refs(&refs)?; + store.replace_resolution_state_from_file(&relative, &refs)?; refreshed_files.insert(relative); continue; } @@ -550,9 +623,7 @@ fn refresh_dependent_refs( }) .collect::>(); let site_list = sites.iter().cloned().collect::>(); - store.delete_resolved_edges_at_sites(&site_list)?; - store.delete_unresolved_refs_at_sites(&site_list)?; - store.insert_unresolved_refs(&selected)?; + store.replace_resolution_state_at_sites(&site_list, &selected)?; refreshed_sites.extend(sites.iter().cloned()); } Ok((refreshed_sites, refreshed_files)) diff --git a/docs/architecture.md b/docs/architecture.md index 22d25d4f..6a2de2bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,325 +1,249 @@ -# CodeGraph (Rust) 架构 / Architecture +# CodeGraph architecture -本文档描述 Rust 移植版 CodeGraph 的 AS-BUILT 架构:10 个 crate 的依赖关系与职责、 -提取 → 落库 → 解析 → 接口的流水线、图遍历与检索的语义、MCP/CLI 表面,以及 -daemon/watch 生命周期。 +This page describes the current Rust implementation. It covers ownership and +data flow; detailed CLI, MCP, storage, and golden contracts live in their owning +references. -所有断言均对照已提交的代码核对。性能数字不在本文范围。 +## Workspace graph ---- - -## 1. Crate 依赖图 / Crate Dependency Graph - -工作区为 10 个 crate:8 个库、1 个二进制(`codegraph`,由 `codegraph-cli` 经 -`[[bin]]` 产出)、1 个库/二进制混合(`codegraph-bench`)。下图只画**工作区内部** -依赖(不含 serde/rusqlite/tree-sitter 等第三方依赖)。 +The root `Cargo.toml` workspace list and each crate manifest are authoritative. +The following diagram shows normal (shipped) intra-workspace dependencies; test +and benchmark-only development dependencies are omitted. ```mermaid graph TD core[codegraph-core] - store[codegraph-store] extract[codegraph-extract] - graph_[codegraph-graph] + store[codegraph-store] resolve[codegraph-resolve] + graph[codegraph-graph] mcp[codegraph-mcp] - cli[codegraph-cli
bin: codegraph] - daemon[codegraph-daemon] watch[codegraph-watch] + daemon[codegraph-daemon] + cli[codegraph-rs / codegraph binary] bench[codegraph-bench] - store --> core extract --> core - graph_ --> core - graph_ --> store + store --> core resolve --> core - resolve --> store resolve --> extract + resolve --> store + graph --> core + graph --> store mcp --> core - mcp --> store - mcp --> graph_ + mcp --> graph mcp --> resolve - mcp --> extract - daemon --> core - daemon --> mcp + mcp --> store watch --> core - watch --> store watch --> extract watch --> resolve + watch --> store + daemon --> core + daemon --> mcp + daemon --> watch cli --> core - cli --> store cli --> extract - cli --> graph_ + cli --> store cli --> resolve + cli --> graph cli --> mcp + cli --> watch cli --> daemon bench --> core bench --> extract ``` -依赖关系直接来自各 crate 的 `Cargo.toml`(`grep '^codegraph-'`)。要点: - -- `codegraph-core` 是叶子,被所有人依赖,自身**零**工作区内部依赖。 -- `codegraph-extract` 只依赖 `core`:提取层不感知存储或图。 -- `codegraph-graph`(遍历 + 检索)依赖 `store`,不依赖 `extract`。 -- `codegraph-resolve` 依赖 `extract`(用其公共 `extract_file`/`detect_language`)、 - `store`(读节点/边、写解析边)与 `core`。 -- `codegraph-mcp` 聚合 `store`/`graph`/`resolve`/`extract`,是查询侧的汇聚点。 -- `codegraph-daemon` 依赖 `mcp`(守护进程 socket 后面跑 MCP 会话)。 -- `codegraph-cli` 依赖除 `watch`/`bench` 外的全部产品 crate,并组装索引流水线。 -- `codegraph-bench` 仅依赖 `core` + `extract`(用于 Rust 端 parse-only 子指标 + - 等价预言机),不修改任何产品 crate。 +| Crate | Responsibility | +| ------------------- | ------------------------------------------------------------------- | +| `codegraph-core` | shared types, node IDs, configuration, classification, logging | +| `codegraph-extract` | source discovery, language/custom extraction, unresolved references | +| `codegraph-store` | SQLite/FTS5 schema, migrations, persistence, queries, leases | +| `codegraph-resolve` | generic import/name matching and framework-aware resolution | +| `codegraph-graph` | traversal, impact, query parsing, search ranking | +| `codegraph-mcp` | tool schemas/engine, rmcp stdio and streamable-HTTP transports | +| `codegraph-watch` | incremental reconciliation and filesystem watcher | +| `codegraph-daemon` | detached shared process, IPC, and process registries | +| `codegraph-rs` | clap CLI, agent installer, orchestration, shipped binary | +| `codegraph-bench` | equivalence oracle and benchmark harness; not shipped | -> 注:`codegraph-cli` 当前未直接依赖 `codegraph-watch`(`sync` 复用全量索引路径, -> 见 §7)。`codegraph-watch` 已由 `codegraph-daemon` 接入,驱动实时增量重建—— -> daemon 启动时开启文件监听,变更触发去抖动后的增量同步;watch crate 不再是"待接线" -> 状态,而是 daemon 生命周期的组成部分(详见 §9)。 +The dependency direction keeps extraction independent of persistence and keeps +storage independent of query presentation. The CLI is the product assembly root. ---- - -## 2. 分层概览 / Layering Overview +## Indexing data flow ```mermaid graph LR - src[源码文件] --> EX[extract
tree-sitter 游走器] - EX -->|nodes/contains 边/未解析引用| ST[store
SQLite+FTS5] - ST --> RS[resolve
import+name matcher] - RS -->|已解析边| ST - ST --> GR[graph
遍历 + FTS 打分] - GR --> MCP[mcp 8 工具] - GR --> CLI[cli 26 子命令] - ST --> MCP - ST --> CLI - DA[daemon] --> MCP - WA[watch] --> EX - WA --> RS - WA --> ST + source[Project files] --> scan[scan policy + language detection] + scan --> extract[tree-sitter / custom extraction] + extract --> facts[nodes + structural edges + unresolved refs] + facts --> store[(SQLite + FTS5)] + store --> resolve[generic + framework resolution] + resolve --> edges[resolved edges] + edges --> store + store --> query[graph traversal + search] + query --> cli[CLI] + query --> mcp[MCP] + watcher[watch events] --> reconcile[incremental reconcile] + reconcile --> extract + reconcile --> resolve ``` -数据从左向右流动:提取产生事实,落库持久化,解析补全跨文件边,图/检索在持久化 -数据上回答查询,MCP/CLI 是两个对外表面。daemon 负责进程生命周期,watch 负责 -变更驱动的增量重建。 - ---- - -## 3. 提取流水线 / Extraction Pipeline(`codegraph-extract`) - -入口:`extract_source(file_path, source, language)` 与 `extract_file` / -`scan_project`(`engine.rs`)。语言识别由 `detect_language()`(按扩展名 + 嵌入式 -检测)完成。 - -分派顺序(自定义路径优先于文法路径): - -1. **嵌入式 / 自定义** — `embedded::extract_embedded(...)` 处理 `.vue`/`.svelte`/ - `.razor`/`.cshtml`/`.liquid`/Shopify JSON/`.xml`(MyBatis)/`.dfm`/`.fmx`。 -2. **文件级(file-level-only)** — `yaml`/`twig`/`properties` 返回空结果 - (0 节点、0 边、**无**文件节点)。 -3. **tree-sitter 文法** — `spec_for_language()` 返回某语言的 `LanguageSpec` - (TypeScript/Python/Go/Rust/Java/C/C++/C#/PHP/Ruby/Scala/Lua/Luau/Dart/Kotlin/ - Swift/Pascal/ObjC 及 TSX/JSX);其余返回空(`Unknown` 静默,其他记一条 - `Unsupported language` 错误)。 - -文法路径内部(`walker.rs` 的 `TreeSitterWalker`): - -- `extract`:创建**文件节点**(字面量 ID `file:{relpath}`),初始化节点栈, - 组装 `ExtractionResult`。 -- `visit_node`:按节点类型分派到 `extract_function`/`extract_class`/ - `extract_method`/`extract_enum`/`extract_interface`/`extract_import`/ - `extract_call`/`extract_instantiation` 等,并控制是否跳过子树。 -- `create_node`:生成节点 ID(非文件节点走 - `generate_node_id(file, kind, name, start_line)`,1-based 行号),从语义栈 - (排除文件节点)构造 `qualified_name`,并即时发出 `contains` 边。 -- **深度安全边界**:正式语义遍历前先用显式 `Vec` 迭代检查逻辑 named-node - 深度,固定上限 256;主 walker 与函数体 walker 共享运行时深度闩锁。超限时整份 - 文件结果 fail-closed(节点、边、未解析引用全部丢弃,只保留稳定文件错误),其他 - 文件继续处理。Rust grammar 在嵌套 block 之间插入的单子 - `expression_statement` 是无语义包装层,预检与 walker 都将其折叠,因此 200 层 - 正常源码嵌套仍在安全预算内。`sync` 会先替换该文件旧结果,所以不会遗留陈旧图谱。 - -**关键边界:** 提取层把**结构性边**(`contains`)与**未解析引用** -(calls/imports/instantiates/decorates)分离——`contains` 立即发出,其余作为 -`unresolved_references` 留给解析层。提取层不感知 `store`/`graph`。 - -`LanguageSpec`(`spec.rs`)定义每种语言的节点类型 -映射 + body/name/signature/export/static/async/import/modifiers 钩子,便于把 -各语言规则机械地落到 `lang/.rs`。 - -`scan_project` 对路径排序后解析,rayon 仅并行**独立文件**的解析;输出按排序顺序 -合并,保证确定性(sequential 与 parallel 结果逐字节相等)。 - ---- - -## 4. 落库层 / Store Layer(`codegraph-store`) - -`Store::open` 执行有效初始化:打开/创建 SQLite → 应用连接 PRAGMA → -执行 `schema.sql` → 记录 schema 版本 5(`Initial schema includes all migrations`, -避免在全新库上重放 v2–v5)→ 运行 `ANALYZE`(使 `.schema` 含 `sqlite_stat1`)。 - -- 连接 PRAGMA、6 张应用表、FTS5 虚表 `nodes_fts`(external-content)、3 个触发器 - (`nodes_ai`/`nodes_ad`/`nodes_au`)、迁移序列等存储契约的权威定义见 - [`data-model.md`](data-model.md)。 -- CRUD/查询表面对应 `db/queries.ts` 的查询集:`upsert_nodes`(用 - `ON CONFLICT(id) DO UPDATE` 以正确触发 FTS 更新,而非 `INSERT OR REPLACE`)、 - `insert_edges`(`INSERT OR IGNORE` + 端点预检)、按 name/qualified_name/file_path/ - kind/lower(name) 的读取,以及 `search_nodes_fts*`、`nodes_by_ids`、 - `node_counts_by_kind` 等供上层使用。 -- 跨语言读取健壮性:`files.modified_at` 在真实数据库中为 SQLite REAL - (JS `Date.now()`),故 `millis_column` 把 INTEGER|REAL|NULL 统一为 i64。 - ---- - -## 5. 解析层 / Resolution Layer(`codegraph-resolve`) - -把提取阶段留下的 `unresolved_references` 解析为跨文件的边 -(`src/resolution/` 的解析逻辑)。 - -- **import resolver**(`import_resolver.rs`):`extract_import_mappings`(JS/TS/ - Svelte/Vue/Python/Go/Java/Kotlin/PHP/C/C++)、`resolve_via_import`、 - `resolve_jvm_import`、`extract_re_exports` + `find_exported_symbol`(re-export - 追链,深度上限 8)。路径运算(resolve/relative/dirname/...)在 `pathutil.rs` 中 - **纯词法**实现(不碰真实文件系统,是否存在交给 `ResolutionContext::file_exists`)。 -- **name matcher**(`name_matcher.rs`):`match_reference` 按策略序 0/1/1c/1d/2/3/4 - 匹配——文件路径、qualified-name、作用域链(PHP/Rust)、点号链(Java/Kotlin/C#/ - Swift/Go/Scala/Dart)、方法调用、精确名、模糊匹配。 -- **orchestrator**(`resolver.rs`):`ReferenceResolver` 提供 `resolve_all` / - `resolve_one` / `create_edges` / `resolve_and_persist`,含内建/外部过滤集合与 - 语言/框架门控。解析对 `&Store` 同步只读(`StoreResolutionContext`),解析后经 - 独立 `&mut Store` 持久化边,缓存用 `RefCell` 内部可变。 - -**`FrameworkResolver` 扩展点(关键约束):** 这是一个**仅 trait** 的扩展点 -(`framework.rs`),v1 **零具体实现**。orchestrator 持有空的 -`Vec>`,因此行为等同于“零框架检测”下的结果。 -框架感知解析、回调合成、C++/Java 接收者推断、批处理/conformance 二次扫描等均为 -延后项(内部上游差异台账登记)。 - -mini golden 校验:提取 → 落库 → `resolve_and_persist` 后回读非 `contains` 边, -其集合与 `reference/golden/mini/edges.json` 的 11 条已解析边**逐项相等** -(kind/source/target/line/col/resolvedBy/confidence)。 - ---- - -## 6. 图遍历与检索 / Graph Traversal & Search(`codegraph-graph`) - -该 crate 含两个隔离的子模块:`graph/`(遍历)与 `query/`(检索)。 -`GraphTraverser<'store>` 对 `&Store` 同步只读。 - -**遍历(`graph/mod.rs`,对应 `graph/traversal.ts` 的遍历语义):** - -- `get_callers` / `get_callees`:**仅**沿 `calls`/`references`/`imports` 三类边, - 递归到 `max_depth`,`visited` 防环。`callees` **不**含 `instantiates`。 -- `get_impact_radius`:沿**入边**(依赖者)但**排除** `contains`(容器不依赖其成员); - 容器类节点(class/interface/struct/trait/protocol/module/enum)在**同深度**把 - `contains` 子节点拉入集合,再在 depth+1 递归这些子节点的依赖者。 -- 另有 `traverse_bfs`/`traverse_dfs`(BFS 前沿排序 `contains`(0) < `calls`(1) < - 其他(2))、`get_type_hierarchy`、`get_call_graph`、`find_usages`、`find_path`、 - `get_ancestors`(沿首条入向 `contains` 上溯)、`get_children`、 - `find_all_definitions`(枚举同名重载,文件/行消歧留给 MCP 层)。 - -**检索(`query/mod.rs`,对应 `searchNodes`(queries.ts:775-892)的检索管线):** -`parseQuery`(拆出 `kind:`/`lang:`/`path:`/`name:` 过滤)→ 与选项合并去重 → -FTS5 候选(`search_nodes_fts_filtered`)→(空且文本≥2)LIKE 阶梯 →(空且文本≥3) -有界编辑距离模糊 → 精确名补充 → 重打分(`kindBonus` + `scorePathRelevance` + -`nameMatchBonus`)→ 硬 `path:`/`name:` 过滤。FTS5 转义与 store 的 `fts_query` -一致(`::`→空格、剥离 `'"*():^`、丢弃 AND/OR/NOT/NEAR、每词 `"term"*` 以 OR 连接)。 -`reference/golden/mini/search.golden.json` 的 20 个用例(id + 顺序 + score 在 1e-6 -内)全部复现。 - ---- - -## 7. CLI 表面 / CLI Surface(`codegraph-cli`) - -`codegraph` 单一二进制,clap 26 个子命令(见 -[CLI 参考](cli.md))。CLI bootstrap: -`Config::load_env_or_default` fail-fast(人类可读错误,只配置 logger,不代表任何项目) -→ `init_logger`(在 `main` 中持有 guard;日志只走 stderr,`serve --mcp` 独占 stdout -以免污染 JSON-RPC 流)。每个针对项目的操作再各自从该项目解析出的索引根加载其 -不可变配置。 - -- 索引编排只用各 crate 公共 API:`extract::engine::{scan_project, extract_file}` → - `store::Store` upsert/insert → `resolve::ReferenceResolver::resolve_and_persist`。 - 全量索引写 `.codegraph/codegraph.db`、文件记录、结构边、未解析引用、已解析边及 - 索引元数据。 -- `serve --mcp` 调用 `codegraph_mcp::McpServer` 公共入口(stdin/stdout)。 -- **`sync` 为真正的单文件增量路径**:`codegraph_watch::sync_project_once_with_progress` - 自行扫描候选文件,按内容哈希跳过未变更文件,对变更文件逐个删除并重新插入, - 删除已消失的文件,最后整体重新解析,因此结果等价于 `index --force`。 - 输出报告 reindexed / skipped / removed 三个计数。 - -子命令的输出/JSON 形状遵循固定的输出/JSON 契约(源码注释标注行号)。 - ---- - -## 8. MCP 表面 / MCP Surface(`codegraph-mcp`) - -stdio JSON-RPC 服务器,**换行分隔分帧**(非 LSP `Content-Length`)。 - -- crate 布局:`protocol.rs`(JSON-RPC 线类型 + `ToolResult`)、`instructions.rs` - (`SERVER_INSTRUCTIONS` 原样)、`schemas.rs`(内嵌 `tools_list.json` 保证 schema - parity,并含“恰好 8 工具、无 trace/context”的守护测试)、`engine.rs`(8 个同步 - 处理器 + 渲染器)、`server.rs`(同步 stdio 循环 + 按项目的 engine 缓存)。 -- 协议:`initialize` 返回 `{protocolVersion:"2024-11-05", capabilities:{tools:{}}, -serverInfo:{name:"codegraph",version}, instructions}`;`tools/list` 返回 8 工具; - `tools/call` 返回 `ToolResult`;并回应 `ping`/`resources/list`/ - `resources/templates/list`/`prompts/list` 以抑制探针噪声;未知方法 `-32601`, - 不可解析行 `-32700`(保持存活)。 -- **双错误通道:** 未知**工具名** → JSON-RPC 错误 `-32602`(派发前校验);缺失/非法 - **必填参数** → 工具结果 `{content, isError:true}`,文本 `Error: `。 -- 8 工具:`codegraph_search`/`callers`/`callees`/`impact`/`node`/`explore`/`status`/ - `files`(语义见 README)。`node` 兼具符号视图与文件视图(带 `\t` 行号 - 源码);`explore` 输出 blast-radius + 关系图(小项目门控关闭)+ 源码块 - 结构(RWR/个性化 PageRank 排序为已登记差异,见 `KNOWN_DIFFS.md`)。同步逻辑, - 不引入 tokio。 - -逻辑层(遍历/检索/解析)全部委派给 `graph`/`resolve`/`store`,MCP 只做协议、参数 -校验与输出渲染(多数工具对 mini golden 字节一致)。 - ---- - -## 9. Daemon / Watch 生命周期 - -**`codegraph-daemon`(按项目单实例,共享分离后台进程):** - -- 入口 `start_or_attach(project_root, DaemonOptions)`:首个调用者以分离进程 - (Unix `process_group(0)`,Windows `DETACHED_PROCESS`)启动守护进程,后续 - 调用者 proxy 到既有 socket;所有 MCP 客户端共享同一 daemon 实例。daemon 在 - 所有客户端断开且空闲超时后自动退出(`CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS`, - 默认 5 分钟)。另有 `run_foreground`/`attach_to_daemon`/`unlock_project`/ - `daemon_pid_path`/`daemon_socket_path`。 -- 会合文件位于当前索引根 `/.codegraph/`:`daemon.pid`、`daemon.sock` - 与 `daemon.log`,全部经 `IndexPaths` 派生(socket 路径过长时回落到哈希后的 - tmpdir socket)。 -- 加锁逻辑(`daemon.ts:393-412`):完整 JSON 锁信息先写私有临时文件,再原子 - hard-link 就位,避免空/半写 pidfile 竞争。 -- 陈旧解锁镜像 `daemon.ts:453-481`:删除前重读、已知 pid 时比对、**绝不**清除存活 - pid。看门狗镜像 `ppid-watchdog.ts:48-61`:ppid 偏离或宿主 pid 存活判定触发停机。 -- 每个连接一个会话(hello 行 + 独立会话计时),MCP 会话经 `codegraph_mcp::McpServer` - 在 socket 后服务。`codegraph unlock` 也尝试通过 `unlock_project` 回收陈旧 - pidfile。 - -**`codegraph-watch`(去抖动 + 增量同步):** - -- 公共 API:`start_serve_watcher`、`ProjectWatcher`、`WatchOptions`、 - `sync_changed_paths`、`sync_project_once`、git hook 助手等。 -- 基于 `notify` v6 `RecommendedWatcher`(递归 + 测试用 inert seam)。事件循环按 - burst 合并:每事件重置单一去抖截止点(`watcher.ts:529-540`)。 -- 默认去抖 `CODEGRAPH_WATCH_DEBOUNCE_MS`,clamp 到 100ms..60s,回落 2000ms。 -- 过滤逻辑:默认忽略目录 + 当前索引根 `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。 - ---- - -## 10. 基准 / Benchmark(`codegraph-bench`) - -`bench` 二进制(`--run`/`--corpora`/`--out`/`--report-md`/`--render-md`)执行 -跨实现的全量/查询性能对比,并内含等价预言机库 -(`oracle::canonicalize_db`/`assert_equivalent`)。只依赖 `core` + `extract` -(用于 Rust 端 parse-only 子指标),**不**修改产品 crate。 - -等价保证的层级定义见 [`equivalence.md`](equivalence.md)。 +### Discovery and extraction + +`codegraph-extract` applies project configuration, ignore rules, extension +overrides, generated-file signals, and deterministic path ordering before +parallel file parsing. A file then follows one of these paths: + +1. a custom/embedded extractor (Vue, Svelte, Astro, Razor, Liquid, MyBatis XML, + DFM/FMX); +2. a grammar-backed `LanguageSpec` and generic tree-sitter walker; or +3. file-level handling for formats that intentionally expose no language symbols. + +The walker emits file/symbol nodes and structural ownership edges immediately. +Calls, imports, type relationships, decorators, and framework evidence that need +global knowledge are stored as unresolved references. Per-file extraction is +fail-closed: an unsafe tree depth or fatal parse condition cannot leave a partial +replacement graph for that file. + +File parsing can run in parallel because each input is independent. Persistence +and later resolution restore stable order before output; scheduler completion +order is never a graph contract. + +### Storage + +`codegraph-store` owns a project-local SQLite database with FTS5. Fresh databases +start at the current schema; older supported databases advance through ordered, +transactional migrations. Foreign keys, WAL behavior, indexes, FTS triggers, and +lease/state gates are configured by the store rather than by callers. + +See [`data-model.md`](data-model.md) for the current schema version, tables, +indexes, and migration history. + +### Resolution + +`ReferenceResolver` reads the complete extracted snapshot (or the bounded +streaming path for very large graphs), then applies deterministic strategies: + +1. language-aware imports, re-exports, packages, and paths; +2. qualified, scoped, receiver/type, lexical, and exact-name matching; +3. detected `FrameworkResolver` implementations; and +4. bounded follow-up passes for relationships that require graph conformance. + +The selected target then passes fail-closed correctness gates: language/module +visibility, C-family macro and translation-unit scope, Go local bindings, +external-import locality, and target-kind eligibility. Pure JS-family alias +bindings may forward to a unique callable; wrappers and ambiguity stop at the +binding. A framework-resolved edge carries its own reachability proof and is not +erased by ordinary source-language visibility (for example Tauri IPC to a +private Rust command). + +The registered framework candidates are NestJS, React/Next.js, Vue/Nuxt, Godot, +and Tauri, in a deliberate order. Each activates only when its detection guard +matches. Godot also extracts project/scene/resource relationships; Tauri bridges +literal JS-family `invoke` calls to uniquely registered Rust commands. Ambiguous +or dynamic targets remain unresolved rather than falling through to a global +same-name guess. + +The static/runtime boundaries for supported languages and Godot are documented in +[`languages.md`](languages.md), [`godot.md`](godot.md), and the upstream-difference +ledger in [`upstream-sync/KNOWN_DIFFS.md`](upstream-sync/KNOWN_DIFFS.md). + +## Query layer + +`codegraph-graph` operates on persisted nodes and edges. It owns callers, +callees, impact, paths, hierarchies, cycle detection, FTS candidates, fallback +matching, filters, and deterministic scoring. Generated/ambient/deprioritized +paths can affect ranking without removing graph facts. + +The CLI and MCP share the same query engine for `search`, `explore`, and `node`, +so source selection, staleness handling, and relationship rendering do not drift +between interfaces. + +## CLI assembly + +The `codegraph-rs` package builds the `codegraph` binary. The CLI owns: + +- lifecycle commands (`init`, `sync`, full index, status, recovery); +- query and analysis commands; +- MCP process launch and HTTP/stdio observability commands; +- agent/IDE configuration and Skill installation; +- shell completion and self-update; and +- human/JSON output contracts. + +The CLI directly uses `codegraph-watch` for one-shot incremental synchronization +and `codegraph-daemon` for shared MCP service. It keeps logs on stderr so JSON and +MCP stdout remain protocol-clean. The complete command contract is +[`cli.md`](cli.md). + +## MCP architecture + +`codegraph-mcp` uses rmcp and Tokio for MCP protocol handling over stdio and +streamable HTTP. The source schema defines a known callable tool set and a smaller +default `tools/list` surface; an allowlist can expose any known tool. The current +source-derived counts are validated by `scripts/docs-check.py`, not maintained as +an unchecked architecture constant. + +Each tool call resolves its project independently. A pinned server uses its +launch project; an unpinned server can use an explicit `projectPath`, client MCP +roots, or bounded deterministic workspace adoption. Engine caches are reopened +when the database is replaced and do not grant filesystem authority outside a +validated project. + +Explicit project routing has a separate, bounded lifecycle layer. The first +access to an existing index goes through a session broker (maximum 32 projects), +which starts or attaches that project's shared daemon, retains a client +connection, and completes one catch-up before query execution. The daemon, not +the MCP handler, owns the watcher and long-lived writer lock. This keeps multiple +child indexes independent without choosing a default or creating state, while +several MCP sessions converge on one watcher owner per project. + +Protocol revisions, default methods, tool schemas, project selection, staleness, +and HTTP headers are specified in [`mcp.md`](mcp.md). + +## Daemon and watch lifecycle + +`serve --mcp` chooses direct mode or the per-project shared daemon. A daemon owns +one project rendezvous, accepts multiple client sessions, starts the watcher, and +exits after its last client has been idle for the configured period. IPC uses Unix +local sockets or Windows named pipes. Project lock/state files stay under the +selected index root; long Unix socket paths may use a deterministic temporary +socket location recorded in the rendezvous metadata. + +The watcher maintains per-directory watches outside ignored trees, debounces +bursts, reloads project control files, and reconciles additions, modifications, +deletions, and scope changes through `codegraph-watch`. Each mutation acquires the +store's short-lived `index.lock` writer authority; readers use corroborated state +and shared leases. A separate persistent `writer.pid` file carries an OS kernel +exclusive lock for the lifetime of the ONE process allowed to run watcher/catch-up +services for that project. Its JSON (`pid`, mode, start time) is diagnostic only; +the kernel lock is authority and is released automatically on process exit. + +Default daemon mode multiplexes every MCP client onto that one writer. On a cold +start the foreground stdio session answers immediately without its own background +services while the detached daemon takes ownership. Explicit direct mode +(`CODEGRAPH_NO_DAEMON=1`) may own the writer instead; a second direct process fails +fast rather than alternating sync mutations. An unindexed explicit path remains +state-free: only `codegraph init` creates an index namespace. + +Streamable HTTP is different: a server is keyed by bind address and can be pinned +to one project or globally accept explicit per-call projects. Detached HTTP and +foreground stdio processes use global observability registries. Those registries +do not replace project authorization or database leases. + +Operational details and environment variables are in [`cli.md`](cli.md) and +[`mcp.md`](mcp.md). + +## Determinism boundary + +The durable compatibility boundary is the canonical graph, not SQLite file bytes +or process scheduling. Tests lock: + +- node ID inputs and file-node IDs; +- canonical nodes, edges, unresolved references, files, and schema; +- incremental/full-index convergence; +- deterministic candidate and output ordering; +- structural MCP compatibility; and +- installer/release asset contracts. + +See [`equivalence.md`](equivalence.md). Performance evidence is a separate +measurement discipline described in [`benchmark.md`](benchmark.md). diff --git a/docs/cli.md b/docs/cli.md index 64915178..b2958b9e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI Subcommand Reference -`codegraph` ships 26 subcommands. All commands accept `--help` for usage details. +`codegraph --help` is the authoritative command inventory. Every command accepts `--help` for usage details; the table below documents the current public surface without maintaining a separate unchecked count. ## Path Convention @@ -50,6 +50,16 @@ `query` remains a visible backward-compatible alias for `search`; new scripts, documentation, and diagnostics should use `search`. +`status` performs a read-only, scope-aware source inventory against the current +index. It reports pending added, modified, and removed paths even after those +changes were committed (a clean `git status` is not treated as an up-to-date +CodeGraph index), after history rewrites, and in non-Git projects. The detector +reuses full-sync include/exclude/custom-extension rules and the same +`(size,mtime) → sha256` decision. JSON always includes counts plus +`addedPaths`/`modifiedPaths`/`removedPaths`; text lists the same paths only when +non-empty. Running status acquires only the normal read lease and does not mutate +the database. + ### Project-path argument contract Lifecycle commands (`init`, `uninit`, `index`, `sync`, `status`, `unlock`) @@ -77,11 +87,34 @@ passed directly to `node`, avoiding name ambiguity: codegraph node "function:b8b1c4a981a1841066418516bc8ebf86" -p . ``` +File mode accepts editor-style line selectors and uses them as an inclusive read +window: + +```bash +codegraph node "src/main.rs:42" -p . # start at line 42 +codegraph node "src/main.rs:42-80" -p . # lines 42 through 80 +codegraph node "src/main.rs#L42-L80" -p . # GitHub/editor spelling +``` + +The literal indexed path is resolved first, so a real filename ending in `:42` +is never reinterpreted. Only after a literal miss does `node` strip `:`, +`:-`, `#L`, or `#L-L`. Invalid, zero, reversed, and +overflowing selectors remain literal. Windows drive-relative `C:42` and `C:#L42` +also remain literal, while `C:\repo\src\main.rs:42` is unambiguous. CLI file mode +uses the selector directly; MCP callers can override its offset and limit as +described in [`mcp.md`](mcp.md). + Case-insensitive exact-name search probes seek through `idx_nodes_lower_name` rather than scanning `nodes`. Explore separately supplements its context seeds with camelCase/snake-case segments, including Variable and Constant definitions. These are query-time changes only; they do not change the stored graph. +Explore treats a slashed, extensionless path that exists as a regular file +inside the project (for example `scripts/deploy`) as an explicit but unindexed +path. It removes the path fragments from FTS and reports the path caveat instead +of returning unrelated matches. Slashed prose such as `input/output` stays in +the query, and absolute, `..`, or symlink-escaping paths are never followed. + > **Note:** `serve --no-watch` and `CODEGRAPH_NO_WATCH=1` are fully equivalent — > both disable the live file watcher. See > [Daemon, watch & environment variables](#daemon-watch--environment-variables) @@ -93,6 +126,11 @@ These are query-time changes only; they do not change the stored graph. > would be enormous and would make a home-launched `serve --mcp` peg a CPU. Run > these commands inside a specific project directory. +> **Unsupported-only projects are explicit.** If discovery sees files but none +> use a language CodeGraph indexes, `init`/`index` prints the total and the five +> most common extensions, then states that CodeGraph is inactive for the +> workspace. Empty projects keep the ordinary `No files found to index` output. + > **`affected` output fields.** `codegraph affected` always emits JSON on stdout > (there is no `--json` flag). Its keys are `changedFiles` (the input files), > `affectedTests` (only the traversed dependents that look like test files, per @@ -117,16 +155,25 @@ The written MCP command launches the Rust binary: `command: "codegraph"`, `args: "--mcp"]` (Cursor injects `--path`; Kiro injects `--path` only on a project-local install). -> **Kiro must be installed project-level.** Kiro launches its stdio MCP -> subprocess from `$HOME` and its `initialize` carries no workspace root and no -> `roots` capability, so a bare `serve --mcp` would degrade to home safe mode. -> Run `codegraph install --target=kiro --local` from each project root — that -> pins the project's absolute `--path`. A **global** Kiro install intentionally -> writes **no** MCP entry (and removes a stale one left by an older version), -> because Kiro CLI does not expand `${workspaceFolder}` in `mcp.json` args: a -> global `--path ${workspaceFolder}` would resolve to a literal, non-existent -> directory and break the watcher and catch-up sync. - +> **Kiro global versus project-local.** A global Kiro install writes a bare +> `serve --mcp` entry with no `--path`. It can list tools and query any existing +> index when the agent supplies `projectPath` per call, but it does not own a +> project's live watcher. Run `codegraph init --target=kiro ` (or a +> local Kiro install from that project) to write a project-level entry with an +> absolute `--path` and enable live catch-up/watch. Kiro does not expand +> `${workspaceFolder}` in global `mcp.json`, so the installer never writes that +> literal placeholder. + +> **Claude tool loading.** Claude entries carry `"alwaysLoad": true`, and the +> Explore tool also advertises `_meta["anthropic/alwaysLoad"] = true`. Together +> they keep the primary exploration tool available from the first prompt rather +> than hiding it behind tool search. + +> **OpenCode 2.** The installer writes the native +> `mcp.servers.codegraph` entry with `disabled: false` and `codemode: false`. +> Reinstall migrates the older `mcp.codegraph` + `enabled` shape, and uninstall +> removes either shape while preserving JSONC comments and sibling servers. +> > **The three GitHub Copilot targets.** They share the Copilot MCP surface but > disagree on both the wrapper key and the available locations: > @@ -142,7 +189,8 @@ install). > `mcp.json`, so in the user-level file it would stay literal and point the server > at a nonexistent directory. Run `codegraph init --target=vscode` per project for > live watch. The Copilot CLI entry additionally carries `"tools": ["*"]`, without -> which the CLI registers the server but exposes none of its tools. +> which the CLI registers the server but exposes none of its tools, plus +> `"deferTools": "never"` so Explore is not hidden behind tool search. ```bash codegraph install --yes # auto-detect installed agents, global @@ -168,8 +216,17 @@ confirmation prompt, so it is behavior-neutral. Therefore `codegraph install --yes --init` is safe for unattended setup while retaining all ordinary broad-root and existing-index guards. -**Codex CLI.** Global install remains `~/.codex/config.toml` plus -`~/.codex/AGENTS.md`. Local install writes `/.codex/config.toml`, +**Claude and Codex profile roots.** Global Claude install follows a non-blank +`CLAUDE_CONFIG_DIR`: `.claude.json`, `settings.json`, `CLAUDE.md`, and the skill +directory all live inside that profile; without it the established +`~/.claude.json` plus `~/.claude/` layout remains. Global Codex MCP config and +managed instructions follow a non-blank `CODEX_HOME`, falling back to +`~/.codex/config.toml` plus `~/.codex/AGENTS.md`. Relative overrides resolve +against the install command's working directory. Detection, print-config, +reinstall, skill updates (Claude), and uninstall use the same resolved paths. +Local installs ignore both overrides. + +**Codex CLI.** Local install writes `/.codex/config.toml`, the project-root `/AGENTS.md`, and `/.agents/skills/codegraph`. Detection, `--print-config`, install, and uninstall all honor the selected location. A local uninstall never edits the @@ -202,9 +259,9 @@ installer pins an explicit `--path`: - **Cursor** — `install` injects `--path` automatically (local install pins the project dir; global uses `${workspaceFolder}`, which Cursor expands). -- **Kiro** — install **project-level** only: `--path` is the concrete project dir. - A global Kiro install writes no entry, because Kiro CLI does not expand - `${workspaceFolder}` (see the note above). +- **Kiro** — global install writes a bare read-only entry; local install and + `init --target=kiro` pin the concrete project path for live watch. Kiro does + not expand `${workspaceFolder}` in global configuration. - **Zed** — Zed's global `context_servers` config cannot inject a per-project path (no `${workspaceFolder}` expansion). A global `codegraph install --target=zed` writes a bare entry (read-only off any existing index). To pin a specific project, @@ -345,7 +402,7 @@ versions behind you are. codegraph self-update # update to the latest release codegraph self-update --check # only report whether a newer version exists codegraph self-update --force # reinstall even if already current -codegraph self-update --tag v0.3.0 # pin a specific release tag +codegraph self-update --tag vX.Y.Z # pin a specific release tag ``` If codegraph lives on a root-owned path (e.g. `/usr/local/bin`), run with @@ -507,8 +564,10 @@ user never named. Stdout stays machine-readable and unchanged. ### `--file` — disambiguating same-named definitions -When two files define the same symbol, `callers` / `callees` / `impact` merge -both definitions' relatives into one list. `--file ` keeps only the +When two files define the same symbol, `callers` / `callees` / `impact` render +one section or blast radius per distinct `(filePath, qualifiedName)` definition; +same-definition overloads stay together. The backward-compatible top-level JSON +arrays remain an explicitly labelled union. `--file ` keeps only the definition declared in that file: ```bash @@ -521,17 +580,23 @@ The filter matches the whole project-relative path or any **segment-aligned** trailing suffix, so `other.ts` never selects `my_other.ts`. Windows separators and a leading `./` are normalized. -A filter that matches no definition is an **error** naming the files that do -define the symbol — reporting an empty relative-set instead would read as "this -symbol is dead". In `--json`, the applied filter is echoed as `"file"`. +A filter that matches no definition does not fabricate an empty answer. It +falls back to all definitions and sets `filteredOut: true` plus a human-readable +`note`. JSON also exposes `targets`, `ambiguous`, `aggregation`, and +`definitions[]`; caller/callee definitions carry their own `total`, `limit`, +`truncated`, relation list, and contributing edges. The legacy top-level union +has the same three truncation fields. Human output says `Showing N of M` whenever +`--limit` hides rows. --- ## `codegraph impact` — edge counts in `--json` -`impact --json` emits `symbol`, `depth`, `nodeCount`, `edgeCount`, -`resourceEdgeCount`, `affected`, and `godotDynamic`. The two counts split like -this: +`impact --json` emits `symbol`, `depth`, `targets`, `ambiguous`, `aggregation`, +`file`, `filteredOut`, `note`, `definitions`, `nodeCount`, `edgeCount`, +`resourceEdgeCount`, `affected`, and `godotDynamic`. Each definition contains +its own roots, affected set, edges, and counts; the top-level values remain their +deduplicated union. The two edge counts split like this: - **`edgeCount`** — **all** impact edges: the graph-traversal edges reached from the matched symbols, **plus** the Godot static resource edges (a `.tscn` / @@ -890,12 +955,25 @@ upward probe runs on each relevant request and the bounded child scan runs at most once every five seconds, allowing an index created after startup to be adopted. +The first call for an explicit path to an **existing** index lazily starts or +attaches that project's shared daemon, retains one connection for the MCP +session, and waits for catch-up before returning the tool result. Subsequent +edits use the daemon's watcher. Multiple MCP sessions share the same per-project +daemon, and one session closing does not stop synchronization retained by +another. The session cap is 32 explicit projects; no index is created and no +candidate becomes an implicit default. `CODEGRAPH_NO_DAEMON=1` opts out of this +lazy lifecycle, while `--no-watch` keeps first-access catch-up but disables later +watch events. + ### How `serve --mcp` chooses a run mode The launcher selects a mode in this exact order: 1. `CODEGRAPH_NO_DAEMON=1` is set → **Direct** (foreground, no daemon ever spawned) -2. No `.codegraph/` directory in the project → **Direct** (nothing to share yet) +2. No `.codegraph/` directory in the project → **Direct read-only/no-services** + for that launch root (an explicit `--path` pins queries but never creates + index state; a later per-call path to a different existing index may still + acquire its own lazy shared-daemon services) 3. Otherwise → **SpawnOrProxy**: spawn a new shared detached daemon, or proxy to one already running > `CODEGRAPH_DAEMON_INTERNAL=1` is **internal-only** — it is set automatically on @@ -908,6 +986,19 @@ When the daemon starts, it detaches from the parent process group (Unix: Its stdout and stderr are appended to `.codegraph/daemon.log`. The Unix socket is at `.codegraph/daemon.sock`; the pid/lock file lives alongside it. +The daemon also takes an OS kernel exclusive lock on `.codegraph/writer.pid` +before it publishes the rendezvous. That stable file is never unlink/recreated as +part of ordinary ownership: the lock follows the open handle and is released by +the OS on exit; the JSON payload is only diagnostic. The permanent `index.lock` +still guards each individual read/write operation. Keeping these capabilities +separate means many proxy clients can share one watcher while two long-lived +direct writers cannot alternate syncs. + +Cold-start latency remains bounded: after a successful fire-and-forget daemon +spawn, the first foreground stdio process answers MCP directly but starts no +watcher/catch-up of its own. If the child cannot be spawned, that foreground +process falls back to direct writer mode and takes `writer.pid` itself. + On filesystems that reject binding an `AF_UNIX` socket inside the project directory (ExFAT/FAT, some network mounts, WSL DrvFs), the daemon falls back through a deterministic candidate chain — first the project-dir @@ -928,6 +1019,10 @@ To suppress the daemon entirely in CI or scripted contexts: CODEGRAPH_NO_DAEMON=1 codegraph serve --mcp --path /path/to/project ``` +Only one such direct process may run background services for the same indexed +project. A second exits immediately and names the current holder. Prefer default +daemon mode when several agents or editor windows need the same live index. + ### Live file watch The daemon watches the project for file changes and re-indexes automatically. @@ -980,7 +1075,7 @@ Three escape hatches: | Variable | Default | Clamp range | Meaning | | ---------------------------------- | ------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `CODEGRAPH_NO_DAEMON` | — | — | Force foreground Direct mode; never spawn or proxy a daemon | +| `CODEGRAPH_NO_DAEMON` | — | — | Force foreground Direct mode; one indexed-project writer only, enforced by `writer.pid` | | `CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS` | `300000` | 1000–3600000 | Exit after this long with no connected clients | | `CODEGRAPH_DAEMON_MAX_IDLE_MS` | `1800000` | 1000–3600000 | Hard cap on total daemon lifetime when idle | | `CODEGRAPH_DAEMON_CLIENT_SWEEP_MS` | `30000` | 50–600000 | How often the daemon sweeps for dead clients | @@ -1074,9 +1169,15 @@ codegraph sync /path/to/project # ordinary changes or a supported upgra 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. +Extraction versions 11 → 12 and 12 → 13 are supported `sync` upgrades: `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. + +If a supported grammar reports tree errors and extraction collapses to only the +synthetic file node, `init`/`index`/`sync` still succeed but print and persist +`parse produced no symbols (tree has errors)`. Files with useful surviving +symbols stay quiet even when the grammar tree contains recoverable error nodes. ### Index diagnostics @@ -1091,8 +1192,13 @@ boundary, slow-file watchdog behavior, and the files to attach to a report. `codegraph prompt-hook` is a hidden subcommand (not shown in `--help`). It accepts a query as an argument or reads one from stdin, runs `codegraph_explore` against -the nearest index, and prints structured context. If no index is found it prints a -graceful message and exits cleanly; same if no query is provided. +the nearest index, and prints structured context. HIGH-tier Explore text is +capped at 9,000 UTF-8 bytes so the complete wrapper stays below Claude Code's +10,000-byte inline hook-output limit; truncation never splits a multi-byte +character. If the first non-whitespace content is ``, the +host-generated message is skipped before index work. A user mentioning that tag +later in a real prompt is not suppressed. If no index is found or no query is +provided, the hook exits cleanly and silently. `codegraph install --prompt-hook` writes a `UserPromptSubmit` hook into Claude Code's config that calls `codegraph prompt-hook` before each prompt. This is @@ -1105,23 +1211,12 @@ entry. No other agent configs are touched. ## Supported languages -The language set is the fixed `LANGUAGES` constant, in three extraction tiers. - -**tree-sitter grammars (regular symbol extraction):** TypeScript, TSX, JavaScript, -JSX, Python, Go, Rust, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dart, Pascal, -Scala, Lua, Luau, Objective-C, R. - -**embedded / custom extractors:** Vue, Svelte, Astro, Razor, Liquid, MyBatis XML, -DFM/FMX. - -**file-level-only (0 symbols at the extract stage):** YAML, Twig, Properties. - -`html` / `css` / `json` / `sql` are not in the extraction model and are not -extracted. See [`grammar-manifest.md`](grammar-manifest.md) and -[`embedded-extraction.md`](embedded-extraction.md) for the full grammar manifest -and embedded-language extraction detail. - ---- +The complete source-derived taxonomy, extension map, extraction tiers, and +static-analysis boundaries are maintained in [`languages.md`](languages.md). +Grammar/custom ownership and ABI smoke coverage are in +[`grammar-manifest.md`](grammar-manifest.md). Do not duplicate that inventory in +the CLI reference: `Language::ALL`, `spec_for_language`, embedded detection, and +`builtin_language_for_ext` are the runtime authorities. ## Scope and non-goals @@ -1135,6 +1230,6 @@ golden byte-stable output. constraint, guardrail-enforced; LLM combination happens in the orchestration layer). - No semantic search; search is FTS5 + deterministic scoring only. -- Concrete `FrameworkResolver`s exist for React / Vue / NestJS; other framework - resolution is deferred. +- Concrete `FrameworkResolver`s exist for NestJS, React, Vue, Godot, and Tauri; + other framework resolution remains explicitly deferred. - No languages beyond the fixed `LANGUAGES` set. diff --git a/docs/equivalence.md b/docs/equivalence.md index 09c4e9af..db75fe48 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -1,17 +1,24 @@ # CodeGraph Rust Equivalence Oracle -> AS-BUILT 校核(T27):本文与已提交代码一致。节点 ID 公式 -> (`codegraph_core::node_id::generate_node_id`, -> `format!("{file_path}:{kind}:{name}:{line}")` → sha256 → `{kind}:{hex[..32]}`)、 -> 文件节点字面量 `file:{file_path}`、内容哈希(`hash_content`)以及 -> `FrameworkResolver` 仅扩展点(`crates/codegraph-resolve/src/framework.rs`, -> 零具体实现)均按本文实现。 +> AS-BUILT contract: ordinary symbol IDs use +> `codegraph_core::node_id::generate_node_id`; tree-sitter file nodes use the +> literal `file:{file_path}`; content hashes use `hash_content`. Framework +> resolvers are active extensions, not an empty seam: the registry currently +> contains NestJS, React, Vue, Godot, and Tauri candidates, each gated by project +> detection. Framework nodes with an explicitly documented compatibility ID are +> validated by their own corpora and do not change the ordinary symbol-ID formula. This document defines the byte-level and semantic parity contract between the Rust port and the pinned upstream TypeScript reference. The current authoritative fixture is `crates/codegraph-bench/fixtures/mini/`; the live reference outputs are stored under `reference/golden/mini/`. +The complete committed corpus registry is the set of directories under +`reference/golden/`. Extraction corpora contain canonical `nodes.json`, +`edges.json`, `refs.json`, `files.json`, and `schema.sql`; `mcp/` contains +structural protocol fixtures. Do not maintain an unchecked fixed corpus count in +this document—tests enumerate the committed directories and recipes below. + ## Node ID Formula The symbol-node helper computes: @@ -353,6 +360,65 @@ set when an older binary changes statement ordering. The tests `kotlin_db_is_self_equivalent_to_kotlin_golden` enforce database/artifact self-equivalence. +### Scala fixture + +`reference/golden/scala/` guards the Scala 3 inheritance and companion-object +fixes from upstream #1823 and #1824. Its two-file corpus under +`crates/codegraph-bench/fixtures/scala/` pins all of these as one contract: + +- the `tree-sitter-scala` 0.26.2 grammar accepts a class with multiple + constructor parameter lists whose parent constructor also has multiple lists; +- one `extends A(...) with B with C` clause emits three separate, bare-name + `Extends` edges; +- a Scala `object` is a `module`, while its same-named companion remains a + `trait`; the inheritance edge resolves to the trait even when the object + appears first in the file. + +Regenerate it from the committed source corpus: + +```bash +rm -rf /tmp/cg-fixture-scala +cp -r crates/codegraph-bench/fixtures/scala /tmp/cg-fixture-scala +cargo build --release -p codegraph-rs +CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 \ + ./target/release/codegraph init /tmp/cg-fixture-scala +mkdir -p reference/golden/scala +cp /tmp/cg-fixture-scala/.codegraph/codegraph.db reference/golden/scala/colby.db +cargo run -p codegraph-bench --bin bench -- \ + --gen-golden reference/golden/scala/colby.db reference/golden/scala +``` + +`generated_golden_matches_committed_scala_fixture` and +`scala_db_is_self_equivalent_to_scala_golden` pin the canonical artifacts and +database-to-artifact agreement. + +### Dart fixture + +`reference/golden/dart/` guards upstream #1784 / PR #1865. The focused Dart 3 +corpus at `crates/codegraph-bench/fixtures/dart/extension_type.dart` proves an +`extension type MetersT` is class-like and owns `MetersT::km` and +`MetersT::report` as methods with their complete spans; `Widget::half` is the +ordinary-class control. This prevents either the old top-level-function shape +or the later complete member drop from returning unnoticed. + +Regenerate it from the committed source corpus: + +```bash +rm -rf /tmp/cg-fixture-dart +cp -r crates/codegraph-bench/fixtures/dart /tmp/cg-fixture-dart +cargo build --release -p codegraph-rs +CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 \ + ./target/release/codegraph init /tmp/cg-fixture-dart +mkdir -p reference/golden/dart +cp /tmp/cg-fixture-dart/.codegraph/codegraph.db reference/golden/dart/colby.db +cargo run -p codegraph-bench --bin bench -- \ + --gen-golden reference/golden/dart/colby.db reference/golden/dart +``` + +`generated_golden_matches_committed_dart_fixture` and +`dart_db_is_self_equivalent_to_dart_golden` enforce the same two-layer +self-equivalence as the other re-indexable corpora. + ### C++ fixture A fourth golden fixture, `reference/golden/cpp/`, guards C++ `base_class_clause` @@ -494,6 +560,23 @@ Reg` ref, because no union node existed to bind), with `Ctl c = Ctl();` as the `function:Value`, the WRONG target; the golden pins the union winning, with `Packet p{2};` as the control. Kept in a SEPARATE file from the promotion case on purpose: one file mixing both mechanisms would make either revert-proof ambiguous. +- **C designated-initializer macros** — `designated_macro.c` keeps + `RESET_CONFIG(.field = VALUE)` bounded to its argument list so the following + `after_reset` / `final_value` declarations remain independent functions. +- **C++ raw-string opacity** — `raw_string.cpp` proves annotation/macro text + inside a raw string is never blanked while a genuine annotation whose argument + contains a raw string still balances on its real closer. +- **C++ pure virtual ownership** — `pure_virtual.cpp` pins `= 0` declarations as + abstract methods owned by `AbstractStore`, with overrides, prototypes, data + fields, and function-pointer fields as controls. The same rule adds the four + previously missing abstract `Ping` members to the existing + `com_interface.hpp` controls; no pre-existing node or edge is removed. +- **C++ constructor owner/arity proof** — `constructor_resolution.cpp` emits a + `Calls` edge only when one lexically owned constructor accepts the local + initializer's positional arity. It pins default/braced/value positives plus + aggregate-only, overload-ambiguous, namespace-ambiguous, and explicit-owner + controls. Internal constructor probes are consumed after resolution and never + remain as dangling call references. **Why the union/struct members are named `read_field` and not `get`.** `get` is the only duplicated symbol name in this corpus already (`operators.cpp:5` `Vec2::get` @@ -508,9 +591,10 @@ 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`, `plain_derived.h`, `plain_header_negatives.h`, +`operators.cpp`, `constructor_resolution.cpp`, `plain_derived.h`, `plain_header_negatives.h`, +`pure_virtual.cpp`, `raw_string.cpp`, `designated_macro.c`, `template_method.cpp`, `templated_call.cpp`, `ue_actor.h`, `union_agg.c`, -`union_agg.cpp`, `union_agg.mm` — 19 files). The inheritance base +`union_agg.cpp`, `union_agg.mm` — 23 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 @@ -566,12 +650,15 @@ and `self.field.method()` resolution: 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 +- **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. + unresolved. A direct `self.method()` keeps the receiver and resolves only to + the enclosing method owner's exact qualified member, including split inherent + impls and trait impls. The corpus includes same-named decoys and a missing + owner method; the latter stays in `refs.json` and never falls through to a + global same-name guess. Genuine `self.run()` recursion keeps its self-edge, + now at qualified-name confidence rather than proximity confidence. **The Rust corpus deliberately makes NO instantiation claim.** An `instantiates` edge fires only for the CALL-EXPRESSION construction form: `TupleStruct(2)` yes, a bare @@ -1112,7 +1199,7 @@ The `generated_golden_matches_committed_cfml_fixture` and The dedicated `reference/golden/typescript/` fixture guards TypeScript export aliases, JavaScript-family import resolution, object-literal namespaces, and -inherited path aliases without changing the shared `mini` corpus. It has 14 +inherited path aliases without changing the shared `mini` corpus. It has 16 indexed source files plus `tsconfig.json` and `config/tsconfig.base.json`; source lines are contractual because node IDs include the declaration line. @@ -1151,6 +1238,21 @@ The expanded corpus also pins: `.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. +- `callable_semantics.ts` pins extraction version 13: generator declarations, + TypeScript interface method/property signatures, direct declarator-bound + handlers, React `useCallback`/`useEffectEvent` handlers, and the supported + curried-wrapper shape own their body calls. `useMemo` and array callbacks stay + anonymous negatives. Its helper name is globally unique so adding the fixture + cannot change confidence or target selection in the original 14 files. +- `commonjs_handlers.js` pins `exports.name = function` and + `module.exports.name = function` as exported callables with body ownership; + non-callable assignments and a later `handlers.onSave = …` remain ordinary + values rather than fabricated CommonJS exports. + +The v13 regeneration was accepted only after filtering the two new paths out of +all four canonical JSON artifacts and proving the original 14-file rows +byte-for-byte unchanged. This is the required review shape for future corpus +growth: an additive fixture must not silently perturb old resolution confidence. Regenerate the committed database and canonical artifacts from a clean corpus: @@ -1179,9 +1281,10 @@ Explicit-path pinning in `codegraph_explore`, camel/segment explore seeding, case-insensitive exact-name index seeks, and `[indexing].deprioritize` are query/render-time behavior only. They do not modify extraction, resolution, node IDs, files, nodes, edges, unresolved references, FTS schema, or any -committed golden artifact. This wave therefore keeps extraction version `12` -and has no golden regeneration recipe: a changed file under -`reference/golden/` is a regression, not expected output. +committed golden artifact. That earlier wave required no extraction-version +bump of its own; the current version is `13` because of the later callable +extraction changes above. A golden change attributed only to retrieval or +ranking remains a regression, not expected output. Planner tests lock exact-name hit, miss, and filtered probes to `idx_nodes_lower_name`; search/MCP tests cover Variable/Constant seeds, diff --git a/docs/languages.md b/docs/languages.md index 444f7afd..ceb48675 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -20,39 +20,42 @@ analysis. Internally this is 41 concrete IDs plus the `unknown` fallback (42 ent Tree-sitter parses the file and extracts all symbols (functions, classes, structs, methods, 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 | 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 | +level. Grammar error nodes are common and tolerated when useful symbols survive. If the +parse tree has errors and collapses to only the synthetic file node, indexing still succeeds +but emits and stores `parse produced no symbols (tree has errors)` so an empty graph is never +presented as a clean parse. + +| Language | Extensions | Extraction | Notes | +| ----------- | ------------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TypeScript | `.ts` `.mts` `.cts` | Full tree-sitter | Generator callables, interface members, declarator handlers, supported React hooks, and curried wrappers own body calls. Pure callable aliases forward; receiver-qualified method values emit callable-use `References` edges (not immediate `Calls`); `this.field.method()` and awaited `Promise` receivers require declared, visible project types; untyped call-result/bare-call guesses are refused. Import-only modules with no export are sealed. Config aliases follow bounded inherited `tsconfig`/`jsconfig` chains. | +| TSX | `.tsx` | Full tree-sitter | TypeScript grammar, JSX syntax | +| JavaScript | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib` | Full tree-sitter | Generator/declarator/hook/wrapper ownership and fail-closed receiver/alias rules follow TypeScript; `exports.x = function` and `module.exports.x = function` create exported callables. Classic scripts and real CommonJS/later exports remain visible; an import-only module with no export is sealed. `.xsjs` / `.xsjslib` follow ordinary JS candidates. | +| 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. Receiver-qualified method values emit callable-use `References` edges rather than immediate calls. 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 | Lowercase declarations are package-local (directory-scoped) and cannot become cross-package name targets; local callable parameters/variables also block same-named method guesses. Exported identifiers remain cross-package candidates. | +| 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.method()` binds only to the enclosing impl owner, including split/trait impls. Exact `self.field.method()` resolves through the owning struct field's declared project type. Private items are visible only to their module subtree (trait-impl methods follow trait visibility); absent members, containers, generics, external types, and ambiguity remain unresolved. | +| 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` `.metal` `.cu` `.cuh` | Full tree-sitter | Class/struct inheritance supports access modifiers, `virtual`, qualified bases, and templated-base stripping; pure virtual declarations are abstract owned methods; local object initialization calls one constructor only with unique namespace/arity proof. A free `static` function in another translation unit is not a target, while header `static inline` remains usable. Raw-string contents are opaque to macro blankers. | +| 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. Private definitions never become cross-file targets. | +| Dart | `.dart` | Full tree-sitter | Dart 3 `extension type` declarations are class-like owners; getters and methods qualify under the extension type rather than leaking to file scope. | +| Scala | `.scala` `.sc` | Full tree-sitter | Scala grammar 0.26.2; multiple constructor parameter lists and every `extends`/`with` parent are retained. Companion `object` nodes are Modules, while same-named class/trait types win inheritance resolution. | +| 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 [`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 | --- @@ -73,25 +76,25 @@ and merged back into the parent result. --- -## Tier 3 — File-level only (6 languages) +## Tier 3 — File/framework-level IDs -These files are indexed as file nodes so they appear in the graph and are searchable, but -no symbol extraction is performed at the language level. They contribute to traversal and -impact analysis at the file level. +YAML, Twig, and Properties complete the 38 public code/template language IDs. The three +Godot rows are additional internal resource-format IDs. All six receive file nodes at the +language-extraction layer; the Godot resolver adds project/resource semantics. -| Language | Extensions | Extraction | Notes | -| ------------- | --------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| YAML | `.yml` `.yaml` | File node only | No symbol extraction | -| Twig | `.twig` | File node only | No symbol extraction | -| Properties | `.properties` | File node only | No symbol extraction | -| GodotScene | `.tscn` | File node only | Semantic graph (node tree, signals, scripts, groups, sub-scenes) built by the Godot framework resolver — see [`docs/godot.md`](godot.md) | -| GodotResource | `.tres` | File node only | Resource→script/resource references built by the Godot framework resolver — see [`docs/godot.md`](godot.md) | -| GodotProject | `project.godot` | File node only | Autoload singletons, input actions, plugins parsed by the Godot framework resolver — see [`docs/godot.md`](godot.md) | +| Language | Extensions | Extraction | Notes | +| ------------- | --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| YAML | `.yml` `.yaml` | File node only | No symbol extraction | +| Twig | `.twig` | File node only | No symbol extraction | +| Properties | `.properties` | File node only | No symbol extraction | +| GodotScene | `.tscn` | File node only | Semantic graph (node tree, signals, scripts, groups, sub-scenes) built by the Godot framework resolver — see [`godot.md`](godot.md) | +| GodotResource | `.tres` | File node only | Resource→script/resource references built by the Godot framework resolver — see [`godot.md`](godot.md) | +| GodotProject | `project.godot` | File node only | Autoload singletons, input actions, plugins parsed by the Godot framework resolver — see [`godot.md`](godot.md) | > The three Godot file types carry file nodes only at the language-extraction level. > All Godot-specific symbols, edges, and honesty signals are emitted by the `GodotResolver` > (a `FrameworkResolver` that activates when `project.godot` is present). For the full -> extraction inventory and static-vs-runtime boundary, see [`docs/godot.md`](godot.md). +> extraction inventory and static-vs-runtime boundary, see [`godot.md`](godot.md). --- @@ -117,7 +120,7 @@ inheritance. ## See also -- [`docs/godot.md`](godot.md) — full Godot static-analysis reference: what gets extracted +- [`godot.md`](godot.md) — full Godot static-analysis reference: what gets extracted from `.tscn`/`.tres`/`project.godot`/`.gd`, the static-vs-runtime boundary, honesty signals, and the optional resource DSL hook. - [`docs/grammar-manifest.md`](grammar-manifest.md) — engineering ABI manifest: per-language diff --git a/docs/mcp.md b/docs/mcp.md index 6f428bd9..70476e31 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -325,7 +325,13 @@ CODEGRAPH_MCP_TOOLS=explore,node,search,callers,impact,check codegraph serve --m Every tool is query-only, so each carries MCP tool **annotations** in `tools/list` — `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: false`. Hosts that respect these hints -can call codegraph tools freely without write-confirmation prompts. +can call codegraph tools freely without write-confirmation prompts. The hint +describes the caller's environment: a tool never writes a source file or any +path outside the project's index directory. The first call with an explicit +`projectPath` may start that project's shared daemon and bring its +**existing** derived index up to date (see the explicit-project lifecycle +below); that is cache maintenance inside `.codegraph/`, never the creation of +an index or a change to user files, so the hint stays `true`. --- @@ -376,6 +382,25 @@ and `line` can pin an overloaded symbol to one definition. When given a file path without a symbol it returns the file's source with line numbers, which is a more accurate alternative to a plain `Read` tool call. +File mode also accepts pasted editor/GitHub locations: `src/app.ts:42`, +`src/app.ts:42-80`, `src/app.ts#L42`, and `src/app.ts#L42-L80` (the final `L` is +optional). Resolution tries the literal indexed filename first; only a literal +miss may strip a valid positive selector. A single line supplies the default +`offset`; a closed range supplies the default inclusive `limit`. Explicit MCP +`offset` and `limit` fields override the suffix independently, so +`{"file":"src/app.ts:42-80","offset":5,"limit":2}` reads lines 5–6. Invalid, +zero, reversed, overflowing, and ambiguous Windows-drive spellings stay literal. + +**`codegraph_callers`**, **`codegraph_callees`**, and **`codegraph_impact`** do +not silently merge unrelated same-named definitions. Results are grouped by +`(filePath, qualifiedName)`; same-definition overloads stay together, while two +apps that each define `handle` get separate sections/blast radii. Pass `file` +with an exact project-relative path or suffix to select one definition. A +non-matching filter is disclosed and falls back to all definitions instead of +returning a misleading empty result. Caller/callee limits apply per definition; +when a cap hides rows, the response reports `Showing N of M` and tells the host +to widen `limit` (up to 100). + **`codegraph_impact`** returns the transitive incoming dependency set — every symbol that would break if the queried symbol changed. Use it before a refactor to understand the blast radius instead of walking callers manually. @@ -384,6 +409,12 @@ to understand the blast radius instead of walking callers manually. additive: most projects have zero cycles; run it after a large dependency restructuring to confirm no new cycles were introduced. +**`codegraph_status`** appends a `Pending sync` section when the current source +scope differs from the persisted file inventory. It sees committed-but-unindexed +work as well as working-tree and non-Git changes, applies the same project scope +and content-hash gates as whole-tree sync, and lists each added/modified/removed +path. The check is read-only and holds the engine's existing shared index lease. + **`codegraph_export`** dumps the complete graph as NetworkX node-link JSON. Useful for external visualization tools, custom analysis scripts, or feeding an LLM a high-level structural summary of the entire codebase. @@ -424,6 +455,16 @@ Ranking-only `deprioritize` rules are loaded separately from watcher scope. Each request-scoped engine loads the addressed project's JSON rules followed by its authoritative TOML rules, so a long-lived stdio or HTTP process observes edits on its next search/explore request without changing the graph. + +Exactly one process owns background writes for each indexed project. Besides the +short-lived `index.lock` leases taken by individual mutations, the daemon holds an +OS kernel exclusive lock on `.codegraph/writer.pid` for its whole watcher/catch-up +lifetime. The file's JSON is diagnostic; process death releases the kernel lock +without trusting PID reuse or deleting/recreating the authority path. On a cold +daemon start, the foreground stdio process answers the first handshake directly +but starts no second watcher. When `CODEGRAPH_NO_DAEMON=1` is used, the foreground +process owns this writer lock; a second direct server for the same project exits +with actionable guidance. Read-only/no-default sessions take no writer lock. 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 @@ -450,8 +491,25 @@ most 64 indexed candidates, skips dot/heavy/build/vendor/venv/cache/temp directories, and stops below an indexed child. Exactly one candidate is adopted and gets the full daemon/watcher/catch-up lifecycle. Zero or multiple candidates are never guessed. This scan is forbidden at `$HOME` and filesystem roots. -Streamable HTTP global/no-path mode remains request-scoped and does not adopt a -cwd child. + +Multiple indexed children do not need duplicate MCP registrations. On the first +tool call that supplies an explicit `projectPath`, CodeGraph starts or attaches +that **existing** project's shared daemon, retains one connection for the MCP +session, and waits for a whole-project catch-up before executing the tool. Later +saves are handled by that daemon's single watcher. A second MCP session reuses +the same daemon; closing either session drops only its retained connection, so +the other keeps live sync active. Each session retains at most 32 explicit +projects. A retained service is re-checked on every call: if its daemon has +exited, or the project's index was removed or re-created since the service +attached, the stale connection is released and the next call attaches and +catches up again instead of answering without live synchronization. This never +creates an index, selects a default, or eagerly watches all discovered +children. `CODEGRAPH_NO_DAEMON=1` explicitly opts out of this lazy daemon-backed +lifecycle; `--no-watch` still permits the first catch-up but disables later +file watching. Streamable HTTP global/no-path mode does not adopt a cwd child; +because HTTP requests carry no session, one explicit-project service broker is +shared by the whole server process, so the 32-project bound and the retained +connections last until the server exits. --- @@ -503,10 +561,17 @@ probe runs again on relevant requests, while the downward scan is throttled to once per five seconds so a project initialized after server startup can be adopted without walking the workspace on every call. +For an explicit path that **does** resolve to an existing index, the first call +is also the live-service boundary: it waits for catch-up and retains that +project's shared daemon/watcher as described above. Query routing therefore does +not imply default selection, while synchronization no longer depends on having a +default project. + > **Note:** the home-directory / filesystem-root guard (see [Daemon & live watch] -> above) also skips the normal watcher and catch-up sync for those paths. A real -> project nested under `$HOME` (e.g. `~/projects/myapp`) is unaffected — it is -> resolved via find-up and gets the full daemon and watcher. +> above) also skips the normal watcher and catch-up sync **against those broad +> roots**. A real indexed project nested under `$HOME` (e.g. +> `~/projects/myapp`) is unaffected: find-up/default adoption or an explicit +> `projectPath` gives that project its own daemon and watcher. The daemon exits automatically after all clients disconnect and an idle timeout elapses. Logs are appended to `.codegraph/daemon.log`. A stale lock (e.g. after @@ -521,8 +586,10 @@ for this check because a deliberately daemonized process legitimately reparents to `init`. To disable the daemon entirely and run the MCP server in the foreground, set -`CODEGRAPH_NO_DAEMON=1`. For the full set of env-var knobs — timeouts, sweep -intervals, watch settings — see [`docs/cli.md`](cli.md). +`CODEGRAPH_NO_DAEMON=1`. Run only one such writer per indexed project; additional +clients should leave daemon mode enabled and proxy to the shared process. For the +full set of env-var knobs — timeouts, sweep intervals, watch settings — see +[`docs/cli.md`](cli.md). --- @@ -543,7 +610,8 @@ inject context but _how much_: Latin, Cyrillic, Greek, CJK, Hangul, Arabic, Hebrew, Thai, and Devanagari scripts), OR a code-shaped token (`getUserId`, `get_user`, `Counter()`, `user.login`) that is verified as a real symbol in the index, runs - `codegraph_explore` and injects its full output (capped at 16000 bytes). + `codegraph_explore` and injects its full output (capped at 9,000 UTF-8 bytes, + leaving wrapper headroom under Claude Code's 10,000-byte inline limit). - **Plain words matching indexed symbols → short hint.** When the prompt has no structural keyword or verified token but its prose words match indexed symbol-name segments (e.g. `checkout state machine` → `CheckoutStateMachine`), @@ -560,6 +628,9 @@ telemetry or tracking of any kind**. Set `CODEGRAPH_NO_PROMPT_HOOK=1` (or `CODEGRAPH_PROMPT_HOOK=0`) to disable the hook without editing the config. Every failure path — kill-switch, non-matching prompt, no index, engine error — exits 0 with no output; the hook is degradable by contract and never breaks the prompt. +Host-generated task completions are also discarded before classification when +their first non-whitespace content is ``; the same text later +inside a real user discussion does not match that anchored gate. --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 9d06cd59..7843b5eb 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -175,7 +175,10 @@ codegraph install --target=codex,claude,kiro --yes ``` 不带 `--path` 启动的服务器可通过每次调用显式传入的 `projectPath`、客户端 roots, -或确定性的 workspace adoption 选择已有索引。需要把某项目设为默认值时再固定 `--path`。 +或确定性的 workspace adoption 选择已有索引。首次显式访问已有索引时会先等待 +catch-up,并保留该项目的共享 daemon/watcher;多个子项目仍使用各自独立索引,多个 +MCP 会话则复用每个项目唯一的 writer。需要把某项目设为默认值时再固定 `--path`。 +`CODEGRAPH_NO_DAEMON=1` 会显式关闭这种跨项目的懒加载 daemon 服务。 默认可见 MCP surface 聚焦 explore、文件/符号读取、search 与 callers;其他已知 工具可通过 `CODEGRAPH_MCP_TOOLS` 启用。工具 schema、项目解析、stdio/HTTP 行为与 diff --git a/docs/upstream-sync/KNOWN_DIFFS.md b/docs/upstream-sync/KNOWN_DIFFS.md index 411cd7bc..f2e94308 100644 --- a/docs/upstream-sync/KNOWN_DIFFS.md +++ b/docs/upstream-sync/KNOWN_DIFFS.md @@ -97,6 +97,13 @@ persisted via the literal `unresolved_refs.reference_kind` string `function_ref` (no `.schema` change — the column has no CHECK constraint), read back as `references` + `is_function_ref`, and resolved to a `references` edge. +Upstream #1820 extends that same model to statically receiver-qualified method +values in TypeScript/JavaScript and Python. `obj.method` and proven +`this.field.method` callbacks reuse the ordinary conservative method target +selection, but the persisted edge remains `References` with `fnRef:true`; no +immediate `Calls` edge is fabricated. Call-result, index, literal, missing-type, +and ambiguous receiver shapes remain unresolved. + 15 of the 17 supported languages produce edge sets BYTE-IDENTICAL to colby 1.0.1 for the function_ref feature (TS/JS/TSX/JSX, Python, Go, Rust, C, C++, Java, Kotlin, Ruby, PHP, C#, Swift, Scala, Lua/Luau). The following two carry minor, @@ -124,9 +131,10 @@ TS/Python with no callbacks); they are recorded here as scoped follow-ups. ## Task 22 — MCP tool output (Tier-2 text-formatting differences) -The MCP server (`crates/codegraph-mcp`) renders 8 tools byte-identical to colby -on the mini golden EXCEPT the following text-formatting / relevance-ordering -differences. Six of the eight tools (`codegraph_search`, `codegraph_callers`, +The Task 22 parity corpus compares the 8 upstream tools; the Rust server also +exposes the additive `codegraph_check` and `codegraph_export` tools. Those 8 +shared tools render byte-identically on the mini golden EXCEPT the following +text-formatting / relevance-ordering differences. Six of the eight shared tools (`codegraph_search`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_node` symbol+file modes, `codegraph_files` tree/flat/grouped) are byte-identical; the diffs below are confined to `codegraph_explore` and `codegraph_status`. diff --git a/docs/upstream-sync/POST_V1_6_MAIN_2026-09-15.md b/docs/upstream-sync/POST_V1_6_MAIN_2026-09-15.md index a6e329bb..d6ff7ce6 100644 --- a/docs/upstream-sync/POST_V1_6_MAIN_2026-09-15.md +++ b/docs/upstream-sync/POST_V1_6_MAIN_2026-09-15.md @@ -173,9 +173,56 @@ visibility proof to persist an edge. ## Implementation waves and acceptance -The waves below are the delivery order. Implementation status is recorded here -only when the implementing pull requests land; this audit itself marks nothing as -implemented or LANDED. +### Implementation status (unreleased) + +The following audited PORT items are implemented and verified in the product +integration pull request that follows the engineering baseline (#271). Several +rows were re-checked against the final forms upstream released in `v1.6.1` and +now follow them; each such row says so. This is **not** a shipped or +parity-closeout claim: the release chain and downloaded public-binary proof are +recorded in `UPSTREAM.md` only after release. + +| Item | Local implementation evidence | Delivery state | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| #1631 filesystem fallback containment | store/snapshot contexts reject lexical parent, absolute, drive, UNC/root-relative escapes; in-root symlink behavior retained | implemented + focused tests; unreleased | +| #1524 explicit child index safety | explicit `index ` refuses ancestor retarget; bare `index` retains upward discovery | implemented + CLI integration tests; unreleased | +| #1833/PR #1849 atomic rebinding | whole-file and source-site refresh atomically delete old resolution state and insert recovery refs; SQLite trigger faults prove rollback | implemented + fault injection; unreleased | +| #1740 single long-lived writer | stable `writer.pid` OS kernel lock, daemon multiplexing, cold-start single-writer adaptation, explicit-direct contention and no-implicit-index tests | implemented + multi-process/lifecycle tests; unreleased | +| #1684 quoted Python annotation | quoted forward-reference receiver types resolve with unavailable-type negatives | implemented + resolver/equivalence tests; unreleased | +| #1545 PHP import alias | static alias calls bind only through the imported namespace; missing/ambiguous members stay unresolved and variable receivers remain separate | implemented + resolver/equivalence tests; unreleased | +| #1831/PR #1836 node line selectors | literal path first; `:line`, `:a-b`, `#Lline`, `#La-Lb`; explicit MCP offset/limit wins; Windows-drive negatives | implemented + CLI/MCP tests; unreleased | +| #1694 prompt-hook inline cap | 9,000 UTF-8 byte cap, compile-time host-limit invariant, and multi-byte boundary tests | implemented + full prompt-hook suite; unreleased | +| #1627 profile directories | global Claude/Codex install, detect, print, re-install and uninstall follow `CLAUDE_CONFIG_DIR`/`CODEX_HOME`; local layers remain project-scoped | implemented + module/E2E installer suites; unreleased | +| #1698 OpenCode 2 config | native `mcp.servers.codegraph` with `disabled:false`, `codemode:false`; v1 migration and dual-shape uninstall preserve JSONC/siblings | implemented + module/E2E installer suites; unreleased | +| #1696 installer loading controls | Claude `alwaysLoad:true` and Copilot CLI `deferTools:"never"` on new/reinstalled entries | implemented + installer round-trip tests; unreleased | +| #1832 task notification gate | only a prompt that is entirely a `` envelope is skipped (upstream v1.6.1 final form); a question typed after the envelope or merely mentioning the tag still runs | implemented + prompt-hook E2E test; unreleased | +| #1829/PR #1848 committed pending state | scope-aware full inventory with sync-equivalent stat/hash classification; CLI/MCP report clean committed add/modify/delete paths; status proven byte-read-only | correctness-first implementation + Git E2E; fast-path optimisation deferred pending measurement; unreleased | +| #1741/#1638/#1669/#1675/#1747 callable extraction | extraction version 13 adds generators, TS interface members, nested declarator handlers, CommonJS exports, supported React hooks and curried wrappers; computation callbacks remain negatives | TypeScript corpus grew from 14 to 16 files; old-row proof shows zero drift outside the two new fixtures; unreleased | +| #1861/#1882 Rust `self.method()` | extractor keeps `self`; resolver binds only to the enclosing impl owner across split and trait impls; with same-named owners in several modules the caller's file must pin exactly one (v1.6.1 #1882) | resolver integration plus expanded Rust golden with decoys; unreleased | +| #1838 C/C++ function-like macros | call suppression follows the indexed transitive include closure and rejects raw-string/comment false macro declarations | focused resolver integration; large-corpus performance not yet benchmarked; unreleased | +| #1857 Go receiver-less calls | a call read from source as receiver-less (including `go`/`defer` statements) never binds to a method; exact matching filters methods so a same-named function still wins, fuzzy matching rejects a method survivor (v1.6.1 final form, replacing the earlier local-binding heuristic) | focused resolver tests; unreleased | +| #1512/#1639/#1674 definition grouping | CLI/MCP callers/callees and impact group by `(filePath, qualifiedName)`, support honest `file` narrowing/fallback, and disclose truncation | additive legacy union plus per-definition metadata/sections; integration tests; unreleased | +| #1522 collapsed parse honesty | a supported tree with errors and no surviving symbols stores and prints a warning while indexing/sync still succeed; healthy errors with surviving symbols remain quiet | extractor + real CLI init/index/sync tests; unreleased | +| #1683/#1714/#1496/#1840 JS receiver safety | call-result and receiver-less guesses are refused; `this.field` requires declaration proof; awaited receivers follow the v1.6.1 #1885 inference (brace scopes, shadowing, re-assignment, bare-call tail, import-resolved or reachable local factory, `Promise` naming a project class/interface declared in the resolved type's own file); typed method lookup matches within a language family, so `.tsx` reaches `.ts` | focused extraction/resolution adversarial suites; unreleased | +| #1482 alias forwarding | pure local/imported callable aliases and object members forward to the callable; wrappers and ambiguous cross-file targets stop at the binding | focused resolver tests; unreleased | +| #1719 sealed JS modules | a JS-family file with imports and no ESM/CommonJS/ambient export offers no cross-file name targets; import/export markers are read after blanking comments, strings and templates (v1.6.1 `blankStringContents`), and calls to a JSON `require` constant are rejected | source-masked resolver tests; unreleased | +| #1731 cross-file visibility | C/C++ TU-local statics, private JVM/Swift/Scala/Dart/PHP definitions, Go lowercase package locals, and Rust private module items are rejected after target selection; same-file/header/trait-visible controls remain | integration + visibility matrix tests; unreleased | +| #1729/#1505/#1727 C-family extraction | designated-initializer macros stay bounded; raw strings are opaque to blankers; pure virtual declarations are abstract owned methods | C-family corpus 19→22 files with zero removed/mutated old node/edge rows; unreleased | +| #1823/#1824 Scala | grammar 0.26.2 supports multi-parameter lists and all mixins; companion objects are Modules, never inheritance targets, while they own callable methods and inherit members (v1.6.1) | Scala golden + resolver tests; unreleased | +| #1784 Dart extension type | Dart 3 extension types are class-like owners of their getters/methods | new one-file Dart golden; unreleased | +| #1696 MCP tool metadata | Explore advertises `anthropic/alwaysLoad`; installer round trips retain Claude `alwaysLoad` and Copilot `deferTools:"never"` | schema/RMCP/installer tests; unreleased | +| #1835 explicit-project live sync | first explicit `projectPath` waits for catch-up, retains a session connection to the per-project shared daemon, caps retention at 32 projects, shares one watcher across concurrent MCP sessions, and re-attaches when the daemon exited or the index was re-created | two-project/two-session daemon and watcher E2E plus broker liveness tests; unreleased | +| #1820 method-as-callback | statically receiver-qualified JS/TS/Python method values reuse conservative method resolution and emit fnRef `References`; dynamic receivers remain unresolved and no immediate `Calls` edge is fabricated | extractor + direct/callback target-parity tests; unreleased | +| #1839 C++ constructors | local object initialization resolves to a constructor only with one lexical namespace owner and one overload accepting the recorded arity; aggregates, packs, ambiguous owners/overloads stay edge-free and internal probes are consumed | resolver integration + C-family golden positives/negatives; unreleased | +| #1502 unsupported-only projects | a project whose files are all in unsupported languages reports the extension summary and an inactive state instead of a silent success | CLI integration tests; unreleased | +| #1830 extensionless path caveat | a query naming an extensionless or existing-but-unindexed file gets a path caveat after the containment check, instead of fuzzy full-text noise | query-path and MCP tests; unreleased | +| #1708 fuzzy lexical reachability | the one fuzzy survivor must be lexically reachable (a closure is not callable from outside its container); C/C++ nesting is an extraction artifact and stays reachable; gates judge the survivor, never filter the crowd | name-matcher unit tests; unreleased | +| Resolution scaling (post-audit review) | per-file source facts (line index, blanked views, memoised sealed/local-binding/trait-impl/static decisions) are built once per resolution pass and shared by every chunk context, so no gate rescans a file per reference | snapshot cache-counter and memo unit tests; unreleased | + +CI/release/docs scaffolding and the canonical `AGENTS.md`/`CLAUDE.md` governance +are also implemented and locally gated, but GitHub-hosted settings remain a +separate owner-confirmed action. UI remains deferred to the top-level CodeGraph +Penpot project and owner-selected design direction. 1. Governance/audit documentation (no graph change). 2. Core containment, explicit-index safety, transaction/writer ownership. diff --git a/reference/golden/cpp/colby.db b/reference/golden/cpp/colby.db index cc17bc0e..88ef4de9 100644 Binary files a/reference/golden/cpp/colby.db and b/reference/golden/cpp/colby.db differ diff --git a/reference/golden/cpp/edges.json b/reference/golden/cpp/edges.json index db08624d..21422ed2 100644 --- a/reference/golden/cpp/edges.json +++ b/reference/golden/cpp/edges.json @@ -83,6 +83,18 @@ "source": "function:231cb297b9449e906e38fd66e790340a", "target": "method:533a20574d92cc309a832441bc372dbc" }, + { + "col": 11, + "kind": "calls", + "line": 20, + "metadata": { + "confidence": 0.9, + "resolvedBy": "instance-method" + }, + "provenance": null, + "source": "function:9058c7106252c18a3b40a7ae30b61c61", + "target": "method:6111b5c9721038715cb2fe6785508441" + }, { "col": 11, "kind": "calls", @@ -119,6 +131,18 @@ "source": "struct:418731e230da99cc3237d0e7e35385f1", "target": "class:e436be77a00102552decf9de5d7cc052" }, + { + "col": 17, + "kind": "calls", + "line": 54, + "metadata": { + "confidence": 0.98, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "function:2e69a1a4359c610bb966e14eff66f7c5", + "target": "method:9f2305515d771cca255de0d01797ebbc" + }, { "col": 17, "kind": "extends", @@ -155,6 +179,18 @@ "source": "class:76beb6e8a8a91371f90602d5b477891f", "target": "class:e436be77a00102552decf9de5d7cc052" }, + { + "col": 20, + "kind": "calls", + "line": 34, + "metadata": { + "confidence": 0.98, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "function:56bae27ba6930119038d6978f1409f09", + "target": "method:94063368897cda91446ebcfe2f5a931b" + }, { "col": 20, "kind": "extends", @@ -191,6 +227,42 @@ "source": "function:22779c7ef8b93c5a53baefaca5b875ec", "target": "struct:860b4dfc2d8e87f389dbeb7e3370cac2" }, + { + "col": 25, + "kind": "calls", + "line": 38, + "metadata": { + "confidence": 0.98, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "function:349ccb0ccbe38bfbd1c16594e8c8cf24", + "target": "method:94063368897cda91446ebcfe2f5a931b" + }, + { + "col": 25, + "kind": "calls", + "line": 42, + "metadata": { + "confidence": 0.98, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "function:2853089015338d05eb43de3524353639", + "target": "method:357cb150cd964ac2e38bfe1927c89ec7" + }, + { + "col": 25, + "kind": "extends", + "line": 12, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "class:d764b8bdd68df1978991f1ac288b8731", + "target": "class:0569b166ad8530680e6dec0eb3d66606" + }, { "col": 27, "kind": "instantiates", @@ -287,6 +359,42 @@ "source": "function:8118a321a3f16f7898de2768139b459d", "target": "function:a83447ac43eae44cd787ebbed1f221b2" }, + { + "col": 4, + "kind": "instantiates", + "line": 30, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:b059b1af3af4752dd5cf7a1e8c1dba05", + "target": "struct:ec132ff83138b136c5a605ebbd0e1dc0" + }, + { + "col": 4, + "kind": "instantiates", + "line": 38, + "metadata": { + "confidence": 0.4, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:349ccb0ccbe38bfbd1c16594e8c8cf24", + "target": "class:899a831101ba157f25653c22d0aa096d" + }, + { + "col": 4, + "kind": "instantiates", + "line": 42, + "metadata": { + "confidence": 0.4, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:2853089015338d05eb43de3524353639", + "target": "class:899a831101ba157f25653c22d0aa096d" + }, { "col": 44, "kind": "extends", @@ -311,6 +419,42 @@ "source": "struct:ba394e66f523aafa20ecfd967cbf6f2e", "target": "class:11306345dc1e400be056c1416040177a" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:0569b166ad8530680e6dec0eb3d66606", + "target": "method:36d23835cb8699f15b054c33006bb510" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:0569b166ad8530680e6dec0eb3d66606", + "target": "method:6111b5c9721038715cb2fe6785508441" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:0569b166ad8530680e6dec0eb3d66606", + "target": "method:87836f369d78cf28d87186e7ab99fb7b" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:0569b166ad8530680e6dec0eb3d66606", + "target": "method:f2dbd8d51b9a7d1e954916609e26d5f3" + }, { "col": null, "kind": "contains", @@ -338,6 +482,24 @@ "source": "class:1f9695d61900e851f6609231a60ccd9a", "target": "method:2c00ba8af8cccb7ba6a0a4c0a07098c1" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:37db7d681e3a819cf2676f6e3d3c85fa", + "target": "method:72726111e34e6a64e5aa477fc7ba52a5" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:37db7d681e3a819cf2676f6e3d3c85fa", + "target": "method:ea794a7d5359c3f0347e4925cb16d397" + }, { "col": null, "kind": "contains", @@ -347,6 +509,24 @@ "source": "class:76beb6e8a8a91371f90602d5b477891f", "target": "method:752d570e8d732f0eb4cc536d72123612" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:899a831101ba157f25653c22d0aa096d", + "target": "method:357cb150cd964ac2e38bfe1927c89ec7" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:899a831101ba157f25653c22d0aa096d", + "target": "method:94063368897cda91446ebcfe2f5a931b" + }, { "col": null, "kind": "contains", @@ -392,6 +572,24 @@ "source": "class:c4e79d14ebfa98f8a715418e5d9039ea", "target": "method:39b1fd1d8987dd97a48578bd976bee5a" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:d764b8bdd68df1978991f1ac288b8731", + "target": "method:c73cebcf51bf89a351f9f723998c0e0f" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:d764b8bdd68df1978991f1ac288b8731", + "target": "method:f7e502d803cea6773599d065461241c3" + }, { "col": null, "kind": "contains", @@ -518,6 +716,114 @@ "source": "file:com_interface.hpp", "target": "struct:f8ed8b93742d867912fe2dd218c2819e" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "class:37db7d681e3a819cf2676f6e3d3c85fa" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "class:899a831101ba157f25653c22d0aa096d" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "function:2853089015338d05eb43de3524353639" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "function:2e69a1a4359c610bb966e14eff66f7c5" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "function:349ccb0ccbe38bfbd1c16594e8c8cf24" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "function:56bae27ba6930119038d6978f1409f09" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "function:b059b1af3af4752dd5cf7a1e8c1dba05" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "function:b3423862b958cda406d6e7c066e19f98" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "function:d6347288b6b82f8802096a745fec3986" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "struct:09bdad29b0fbfd6a210a79f303cc4a15" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "struct:54a9715e63c7bca9015243011993fd8c" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:constructor_resolution.cpp", + "target": "struct:ec132ff83138b136c5a605ebbd0e1dc0" + }, { "col": null, "kind": "contains", @@ -572,6 +878,33 @@ "source": "file:derived.cpp", "target": "struct:418731e230da99cc3237d0e7e35385f1" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:designated_macro.c", + "target": "function:31e9ddb0cbaad23f5e67bfff4a33b226" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:designated_macro.c", + "target": "function:98c3fd82bb06a5e9d5b60ca0ca9b69f1" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:designated_macro.c", + "target": "function:e5656cf0c49caffad1139fecf1e2a293" + }, { "col": null, "kind": "contains", @@ -842,6 +1175,51 @@ "source": "file:plain_header_negatives.h", "target": "struct:2bf8f85a163435816a0103679af5fa1d" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:pure_virtual.cpp", + "target": "class:0569b166ad8530680e6dec0eb3d66606" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:pure_virtual.cpp", + "target": "class:d764b8bdd68df1978991f1ac288b8731" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:pure_virtual.cpp", + "target": "function:9058c7106252c18a3b40a7ae30b61c61" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:raw_string.cpp", + "target": "function:1bb19f504ff49e3767cb71a49de06e78" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:raw_string.cpp", + "target": "function:b388ca94000365699dd62214ab7a276d" + }, { "col": null, "kind": "contains", @@ -1004,6 +1382,24 @@ "source": "file:union_agg.mm", "target": "union:e37aaff4aa26c664b0b839456f0024e1" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:09bdad29b0fbfd6a210a79f303cc4a15", + "target": "method:37f8c3a1e77826689592cb86a1d857c3" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:3968c80a1532766ec076463fa574d507", + "target": "method:9eb3c282c31756359e98ed00c09ea4b9" + }, { "col": null, "kind": "contains", @@ -1022,6 +1418,15 @@ "source": "struct:418731e230da99cc3237d0e7e35385f1", "target": "method:eaf644a1dae18b22184dd0d0b882e1bd" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:5009f722baa64c277a025aa26625b3dd", + "target": "method:aaeb76a291398f6d47caebe0b8e5393f" + }, { "col": null, "kind": "contains", @@ -1031,6 +1436,24 @@ "source": "struct:5009f722baa64c277a025aa26625b3dd", "target": "method:d039853bf1b1d17f861ce23c69804568" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:54a9715e63c7bca9015243011993fd8c", + "target": "method:9f2305515d771cca255de0d01797ebbc" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:7c06a13aa42a9d9b3bc5b16852f42d10", + "target": "method:5567c9d10c1d8d3e257c9f0b9aeed25e" + }, { "col": null, "kind": "contains", @@ -1058,6 +1481,15 @@ "source": "struct:a79c39917f9987d43250fce7bde68405", "target": "method:533a20574d92cc309a832441bc372dbc" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:f8ed8b93742d867912fe2dd218c2819e", + "target": "method:45404abdc07be0b32b4758b8527ff102" + }, { "col": null, "kind": "contains", diff --git a/reference/golden/cpp/files.json b/reference/golden/cpp/files.json index d1b01459..7dd87ba5 100644 --- a/reference/golden/cpp/files.json +++ b/reference/golden/cpp/files.json @@ -22,10 +22,19 @@ "errors": null, "generated": 0, "language": "cpp", - "node_count": 7, + "node_count": 11, "path": "com_interface.hpp", "size": 294 }, + { + "content_hash": "d15e2f0f74ca060f2739db7aba1fa9799bbab59eaef3eb336d27949ae18b1a3f", + "errors": null, + "generated": 0, + "language": "cpp", + "node_count": 19, + "path": "constructor_resolution.cpp", + "size": 760 + }, { "content_hash": "7866bdbba877acfa0b4b8e7e4be58875893da925ac91aa333a4c3658bea64e69", "errors": null, @@ -35,6 +44,15 @@ "path": "derived.cpp", "size": 363 }, + { + "content_hash": "23ecaace76f5fc36b4126a4e37c0ea5d099d2098c530c5520ffbdc8aa4f980c5", + "errors": null, + "generated": 0, + "language": "c", + "node_count": 4, + "path": "designated_macro.c", + "size": 211 + }, { "content_hash": "e3331a39e14456236ae24d3d0cc31562b65008bf1027a065d8f403066d74a46e", "errors": null, @@ -116,6 +134,24 @@ "path": "plain_header_negatives.h", "size": 260 }, + { + "content_hash": "d68692863901bd7bd192db10fc580becc6d1533c734f74abf8596eaaf8c0e5f5", + "errors": null, + "generated": 0, + "language": "cpp", + "node_count": 10, + "path": "pure_virtual.cpp", + "size": 524 + }, + { + "content_hash": "f95f1fbfc5b7cf252c5baac6791d675054eb8e99cc2c6a787f95ee9594f71806", + "errors": null, + "generated": 0, + "language": "cpp", + "node_count": 3, + "path": "raw_string.cpp", + "size": 365 + }, { "content_hash": "0527736aa5807318919d35bb62e2c3f8dc4be0e333cf2e27553b89dc59006b74", "errors": null, diff --git a/reference/golden/cpp/nodes.json b/reference/golden/cpp/nodes.json index 75ec9966..c50a6754 100644 --- a/reference/golden/cpp/nodes.json +++ b/reference/golden/cpp/nodes.json @@ -1,4 +1,26 @@ [ + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 10, + "file_path": "pure_virtual.cpp", + "id": "class:0569b166ad8530680e6dec0eb3d66606", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "cpp", + "name": "AbstractStore", + "qualified_name": "AbstractStore", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -87,6 +109,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 15, + "file_path": "constructor_resolution.cpp", + "id": "class:37db7d681e3a819cf2676f6e3d3c85fa", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "cpp", + "name": "Defaults", + "qualified_name": "Defaults", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 11, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -153,6 +197,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 9, + "file_path": "constructor_resolution.cpp", + "id": "class:899a831101ba157f25653c22d0aa096d", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "cpp", + "name": "WithConstructor", + "qualified_name": "WithConstructor", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 5, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -263,6 +329,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 16, + "file_path": "pure_virtual.cpp", + "id": "class:d764b8bdd68df1978991f1ac288b8731", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "cpp", + "name": "DiskStore", + "qualified_name": "DiskStore", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 12, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -351,6 +439,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 56, + "file_path": "constructor_resolution.cpp", + "id": "file:constructor_resolution.cpp", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "cpp", + "name": "constructor_resolution.cpp", + "qualified_name": "constructor_resolution.cpp", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -373,6 +483,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 17, + "file_path": "designated_macro.c", + "id": "file:designated_macro.c", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "c", + "name": "designated_macro.c", + "qualified_name": "designated_macro.c", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -571,6 +703,50 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 22, + "file_path": "pure_virtual.cpp", + "id": "file:pure_virtual.cpp", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "cpp", + "name": "pure_virtual.cpp", + "qualified_name": "pure_virtual.cpp", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 22, + "file_path": "raw_string.cpp", + "id": "file:raw_string.cpp", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "cpp", + "name": "raw_string.cpp", + "qualified_name": "raw_string.cpp", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -747,6 +923,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 21, + "file_path": "raw_string.cpp", + "id": "function:1bb19f504ff49e3767cb71a49de06e78", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "final_after_raw_string", + "qualified_name": "final_after_raw_string", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 18, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -813,6 +1011,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 43, + "file_path": "constructor_resolution.cpp", + "id": "function:2853089015338d05eb43de3524353639", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "constructor_value", + "qualified_name": "constructor_value", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 41, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -835,6 +1055,72 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 55, + "file_path": "constructor_resolution.cpp", + "id": "function:2e69a1a4359c610bb966e14eff66f7c5", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "constructor_explicit_owner", + "qualified_name": "constructor_explicit_owner", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 53, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 11, + "file_path": "designated_macro.c", + "id": "function:31e9ddb0cbaad23f5e67bfff4a33b226", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "c", + "name": "after_reset", + "qualified_name": "after_reset", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 9, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 39, + "file_path": "constructor_resolution.cpp", + "id": "function:349ccb0ccbe38bfbd1c16594e8c8cf24", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "constructor_braced", + "qualified_name": "constructor_braced", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 37, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -901,6 +1187,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 35, + "file_path": "constructor_resolution.cpp", + "id": "function:56bae27ba6930119038d6978f1409f09", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "constructor_default", + "qualified_name": "constructor_default", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 33, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1059,21 +1367,21 @@ "decorators": null, "docstring": null, "end_column": 1, - "end_line": 6, - "file_path": "templated_call.cpp", - "id": "function:906472bb253d72948b69406881d785bf", + "end_line": 21, + "file_path": "pure_virtual.cpp", + "id": "function:9058c7106252c18a3b40a7ae30b61c61", "is_abstract": 0, "is_async": 0, "is_exported": 0, "is_static": 0, "kind": "function", "language": "cpp", - "name": "run_templated", - "qualified_name": "run_templated", + "name": "fetch_store", + "qualified_name": "fetch_store", "return_type": null, "signature": null, "start_column": 0, - "start_line": 4, + "start_line": 18, "type_parameters": null, "visibility": null }, @@ -1081,11 +1389,33 @@ "decorators": null, "docstring": null, "end_column": 1, - "end_line": 22, - "file_path": "operators.cpp", - "id": "function:977be8a56f253290743386f93328db8a", - "is_abstract": 0, - "is_async": 0, + "end_line": 6, + "file_path": "templated_call.cpp", + "id": "function:906472bb253d72948b69406881d785bf", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "run_templated", + "qualified_name": "run_templated", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 4, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 22, + "file_path": "operators.cpp", + "id": "function:977be8a56f253290743386f93328db8a", + "is_abstract": 0, + "is_async": 0, "is_exported": 0, "is_static": 0, "kind": "function", @@ -1099,6 +1429,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 16, + "file_path": "designated_macro.c", + "id": "function:98c3fd82bb06a5e9d5b60ca0ca9b69f1", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "c", + "name": "final_value", + "qualified_name": "final_value", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 13, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1165,6 +1517,72 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 31, + "file_path": "constructor_resolution.cpp", + "id": "function:b059b1af3af4752dd5cf7a1e8c1dba05", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "aggregate_only", + "qualified_name": "aggregate_only", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 29, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 47, + "file_path": "constructor_resolution.cpp", + "id": "function:b3423862b958cda406d6e7c066e19f98", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "constructor_ambiguous_overload", + "qualified_name": "constructor_ambiguous_overload", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 45, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 16, + "file_path": "raw_string.cpp", + "id": "function:b388ca94000365699dd62214ab7a276d", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "after_raw_string", + "qualified_name": "after_raw_string", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 13, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1187,6 +1605,50 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 51, + "file_path": "constructor_resolution.cpp", + "id": "function:d6347288b6b82f8802096a745fec3986", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "cpp", + "name": "constructor_ambiguous_owner", + "qualified_name": "constructor_ambiguous_owner", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 49, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 7, + "file_path": "designated_macro.c", + "id": "function:e5656cf0c49caffad1139fecf1e2a293", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "c", + "name": "reset_profile", + "qualified_name": "reset_profile", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1429,6 +1891,72 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 42, + "end_line": 8, + "file_path": "constructor_resolution.cpp", + "id": "method:357cb150cd964ac2e38bfe1927c89ec7", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "WithConstructor", + "qualified_name": "WithConstructor::WithConstructor", + "return_type": null, + "signature": "(int value)", + "start_column": 4, + "start_line": 8, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 64, + "end_line": 5, + "file_path": "pure_virtual.cpp", + "id": "method:36d23835cb8699f15b054c33006bb510", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "operator=", + "qualified_name": "AbstractStore::operator=", + "return_type": "AbstractStore", + "signature": null, + "start_column": 4, + "start_line": 5, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 15, + "end_line": 25, + "file_path": "constructor_resolution.cpp", + "id": "method:37f8c3a1e77826689592cb86a1d857c3", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Widget", + "qualified_name": "right::Widget::Widget", + "return_type": null, + "signature": "()", + "start_column": 4, + "start_line": 25, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1451,6 +1979,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 28, + "end_line": 2, + "file_path": "com_interface.hpp", + "id": "method:45404abdc07be0b32b4758b8527ff102", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Ping", + "qualified_name": "SControl::Ping", + "return_type": null, + "signature": null, + "start_column": 4, + "start_line": 2, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1473,6 +2023,50 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 28, + "end_line": 17, + "file_path": "com_interface.hpp", + "id": "method:5567c9d10c1d8d3e257c9f0b9aeed25e", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Ping", + "qualified_name": "INewlineBrace::Ping", + "return_type": null, + "signature": null, + "start_column": 4, + "start_line": 17, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 34, + "end_line": 3, + "file_path": "pure_virtual.cpp", + "id": "method:6111b5c9721038715cb2fe6785508441", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "read", + "qualified_name": "AbstractStore::read", + "return_type": null, + "signature": null, + "start_column": 4, + "start_line": 3, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -1495,6 +2089,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 39, + "end_line": 14, + "file_path": "constructor_resolution.cpp", + "id": "method:72726111e34e6a64e5aa477fc7ba52a5", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Defaults", + "qualified_name": "Defaults::Defaults", + "return_type": null, + "signature": "(int value = 0)", + "start_column": 4, + "start_line": 14, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -1517,6 +2133,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 39, + "end_line": 4, + "file_path": "pure_virtual.cpp", + "id": "method:87836f369d78cf28d87186e7ab99fb7b", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "clone", + "qualified_name": "AbstractStore::clone", + "return_type": "AbstractStore", + "signature": null, + "start_column": 4, + "start_line": 4, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -1539,6 +2177,72 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 24, + "end_line": 7, + "file_path": "constructor_resolution.cpp", + "id": "method:94063368897cda91446ebcfe2f5a931b", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "WithConstructor", + "qualified_name": "WithConstructor::WithConstructor", + "return_type": null, + "signature": "()", + "start_column": 4, + "start_line": 7, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 28, + "end_line": 12, + "file_path": "com_interface.hpp", + "id": "method:9eb3c282c31756359e98ed00c09ea4b9", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Ping", + "qualified_name": "IPlain::Ping", + "return_type": null, + "signature": null, + "start_column": 4, + "start_line": 12, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 15, + "end_line": 19, + "file_path": "constructor_resolution.cpp", + "id": "method:9f2305515d771cca255de0d01797ebbc", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Widget", + "qualified_name": "left::Widget::Widget", + "return_type": null, + "signature": "()", + "start_column": 4, + "start_line": 19, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1561,6 +2265,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 28, + "end_line": 7, + "file_path": "com_interface.hpp", + "id": "method:aaeb76a291398f6d47caebe0b8e5393f", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Ping", + "qualified_name": "IWidget::Ping", + "return_type": null, + "signature": null, + "start_column": 4, + "start_line": 7, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1649,6 +2375,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 52, + "end_line": 15, + "file_path": "pure_virtual.cpp", + "id": "method:c73cebcf51bf89a351f9f723998c0e0f", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "clone", + "qualified_name": "DiskStore::clone", + "return_type": "AbstractStore", + "signature": null, + "start_column": 4, + "start_line": 15, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -1693,6 +2441,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 17, + "end_line": 13, + "file_path": "constructor_resolution.cpp", + "id": "method:ea794a7d5359c3f0347e4925cb16d397", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "Defaults", + "qualified_name": "Defaults::Defaults", + "return_type": null, + "signature": "()", + "start_column": 4, + "start_line": 13, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -1737,6 +2507,72 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 29, + "end_line": 6, + "file_path": "pure_virtual.cpp", + "id": "method:f2dbd8d51b9a7d1e954916609e26d5f3", + "is_abstract": 1, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "inherited_pure", + "qualified_name": "AbstractStore::inherited_pure", + "return_type": null, + "signature": null, + "start_column": 4, + "start_line": 6, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 46, + "end_line": 14, + "file_path": "pure_virtual.cpp", + "id": "method:f7e502d803cea6773599d065461241c3", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cpp", + "name": "read", + "qualified_name": "DiskStore::read", + "return_type": null, + "signature": null, + "start_column": 4, + "start_line": 14, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 26, + "file_path": "constructor_resolution.cpp", + "id": "struct:09bdad29b0fbfd6a210a79f303cc4a15", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "struct", + "language": "cpp", + "name": "Widget", + "qualified_name": "right::Widget", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 24, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1869,6 +2705,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 20, + "file_path": "constructor_resolution.cpp", + "id": "struct:54a9715e63c7bca9015243011993fd8c", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "struct", + "language": "cpp", + "name": "Widget", + "qualified_name": "left::Widget", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 18, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -2045,6 +2903,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 3, + "file_path": "constructor_resolution.cpp", + "id": "struct:ec132ff83138b136c5a605ebbd0e1dc0", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "struct", + "language": "cpp", + "name": "AggregateOnly", + "qualified_name": "AggregateOnly", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, diff --git a/reference/golden/cpp/refs.json b/reference/golden/cpp/refs.json index fdde11e7..7b07aec3 100644 --- a/reference/golden/cpp/refs.json +++ b/reference/golden/cpp/refs.json @@ -28,5 +28,15 @@ "line": 3, "reference_kind": "extends", "reference_name": "UObject" + }, + { + "candidates": null, + "col": 4, + "file_path": "designated_macro.c", + "from_node_id": "function:e5656cf0c49caffad1139fecf1e2a293", + "language": "c", + "line": 3, + "reference_kind": "calls", + "reference_name": "RESET_CONFIG" } ] diff --git a/reference/golden/dart/colby.db b/reference/golden/dart/colby.db new file mode 100644 index 00000000..4cbe92a9 Binary files /dev/null and b/reference/golden/dart/colby.db differ diff --git a/reference/golden/dart/edges.json b/reference/golden/dart/edges.json new file mode 100644 index 00000000..9dc98102 --- /dev/null +++ b/reference/golden/dart/edges.json @@ -0,0 +1,60 @@ +[ + { + "col": 10, + "kind": "references", + "line": 5, + "metadata": { + "confidence": 0.95, + "fnRef": true, + "resolvedBy": "function-ref" + }, + "provenance": null, + "source": "method:92e64d057dc21e59be0578ff025bbc66", + "target": "method:05192e5077270147247cb766102ea8f1" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:8e751eea10d61643a8aa244715e82664", + "target": "method:05192e5077270147247cb766102ea8f1" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:8e751eea10d61643a8aa244715e82664", + "target": "method:92e64d057dc21e59be0578ff025bbc66" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:db1c1649eba5ed312e7fe91d279f2695", + "target": "method:c8de892aa59dcfa863960de73ca95d53" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:extension_type.dart", + "target": "class:8e751eea10d61643a8aa244715e82664" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:extension_type.dart", + "target": "class:db1c1649eba5ed312e7fe91d279f2695" + } +] diff --git a/reference/golden/dart/files.json b/reference/golden/dart/files.json new file mode 100644 index 00000000..5b0b4151 --- /dev/null +++ b/reference/golden/dart/files.json @@ -0,0 +1,11 @@ +[ + { + "content_hash": "213d1695ba4038b7bb079532890a89c3c5e7088caefed35f764dcbce24b48ec2", + "errors": null, + "generated": 0, + "language": "dart", + "node_count": 6, + "path": "extension_type.dart", + "size": 156 + } +] diff --git a/reference/golden/dart/nodes.json b/reference/golden/dart/nodes.json new file mode 100644 index 00000000..3a791808 --- /dev/null +++ b/reference/golden/dart/nodes.json @@ -0,0 +1,134 @@ +[ + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 7, + "file_path": "extension_type.dart", + "id": "class:8e751eea10d61643a8aa244715e82664", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "dart", + "name": "MetersT", + "qualified_name": "MetersT", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 11, + "file_path": "extension_type.dart", + "id": "class:db1c1649eba5ed312e7fe91d279f2695", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "dart", + "name": "Widget", + "qualified_name": "Widget", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 9, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 12, + "file_path": "extension_type.dart", + "id": "file:extension_type.dart", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "dart", + "name": "extension_type.dart", + "qualified_name": "extension_type.dart", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 32, + "end_line": 2, + "file_path": "extension_type.dart", + "id": "method:05192e5077270147247cb766102ea8f1", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "dart", + "name": "km", + "qualified_name": "MetersT::km", + "return_type": null, + "signature": null, + "start_column": 2, + "start_line": 2, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 3, + "end_line": 6, + "file_path": "extension_type.dart", + "id": "method:92e64d057dc21e59be0578ff025bbc66", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "dart", + "name": "report", + "qualified_name": "MetersT::report", + "return_type": null, + "signature": "()", + "start_column": 2, + "start_line": 4, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 25, + "end_line": 10, + "file_path": "extension_type.dart", + "id": "method:c8de892aa59dcfa863960de73ca95d53", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "dart", + "name": "half", + "qualified_name": "Widget::half", + "return_type": null, + "signature": null, + "start_column": 2, + "start_line": 10, + "type_parameters": null, + "visibility": "public" + } +] diff --git a/reference/golden/dart/refs.json b/reference/golden/dart/refs.json new file mode 100644 index 00000000..2b933abe --- /dev/null +++ b/reference/golden/dart/refs.json @@ -0,0 +1,12 @@ +[ + { + "candidates": null, + "col": 4, + "file_path": "extension_type.dart", + "from_node_id": "method:92e64d057dc21e59be0578ff025bbc66", + "language": "dart", + "line": 5, + "reference_kind": "calls", + "reference_name": "print" + } +] diff --git a/reference/golden/dart/schema.sql b/reference/golden/dart/schema.sql new file mode 100644 index 00000000..e528b87c --- /dev/null +++ b/reference/golden/dart/schema.sql @@ -0,0 +1,119 @@ +CREATE TABLE schema_versions ( +version INTEGER PRIMARY KEY, +applied_at INTEGER NOT NULL, +description TEXT +); +CREATE TABLE nodes ( +id TEXT PRIMARY KEY, +kind TEXT NOT NULL, +name TEXT NOT NULL, +qualified_name TEXT NOT NULL, +file_path TEXT NOT NULL, +language TEXT NOT NULL, +start_line INTEGER NOT NULL, +end_line INTEGER NOT NULL, +start_column INTEGER NOT NULL, +end_column INTEGER NOT NULL, +docstring TEXT, +signature TEXT, +visibility TEXT, +is_exported INTEGER DEFAULT 0, +is_async INTEGER DEFAULT 0, +is_static INTEGER DEFAULT 0, +is_abstract INTEGER DEFAULT 0, +decorators TEXT, -- JSON array +type_parameters TEXT, -- JSON array +return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference) +updated_at INTEGER NOT NULL +); +CREATE TABLE edges ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +source TEXT NOT NULL, +target TEXT NOT NULL, +kind TEXT NOT NULL, +metadata TEXT, -- JSON object +line INTEGER, +col INTEGER, +provenance TEXT DEFAULT NULL, +FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE, +FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE +); +CREATE TABLE sqlite_sequence(name,seq); +CREATE TABLE files ( +path TEXT PRIMARY KEY, +content_hash TEXT NOT NULL, +language TEXT NOT NULL, +size INTEGER NOT NULL, +modified_at INTEGER NOT NULL, +indexed_at INTEGER NOT NULL, +node_count INTEGER DEFAULT 0, +errors TEXT, -- JSON array +generated INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE unresolved_refs ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +from_node_id TEXT NOT NULL, +reference_name TEXT NOT NULL, +reference_kind TEXT NOT NULL, +line INTEGER NOT NULL, +col INTEGER NOT NULL, +candidates TEXT, -- JSON array +file_path TEXT NOT NULL DEFAULT '', +language TEXT NOT NULL DEFAULT 'unknown', +reference_subkind TEXT, +FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE +); +CREATE INDEX idx_nodes_kind ON nodes(kind); +CREATE INDEX idx_nodes_name ON nodes(name); +CREATE INDEX idx_nodes_qualified_name ON nodes(qualified_name); +CREATE INDEX idx_nodes_file_path ON nodes(file_path); +CREATE INDEX idx_nodes_language ON nodes(language); +CREATE INDEX idx_nodes_file_line ON nodes(file_path, start_line); +CREATE INDEX idx_nodes_lower_name ON nodes(lower(name)); +CREATE VIRTUAL TABLE nodes_fts USING fts5( +id, +name, +qualified_name, +docstring, +signature, +content='nodes', +content_rowid='rowid' +) +/* nodes_fts(id,name,qualified_name,docstring,signature) */; +CREATE TABLE 'nodes_fts_data'(id INTEGER PRIMARY KEY, block BLOB); +CREATE TABLE 'nodes_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; +CREATE TABLE 'nodes_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); +CREATE TABLE 'nodes_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; +CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN +INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) +VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; +CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN +INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) +VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +END; +CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN +INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) +VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) +VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; +CREATE INDEX idx_edges_kind ON edges(kind); +CREATE INDEX idx_edges_source_kind ON edges(source, kind); +CREATE INDEX idx_edges_target_kind ON edges(target, kind); +CREATE UNIQUE INDEX idx_edges_identity +ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1)); +CREATE INDEX idx_files_language ON files(language); +CREATE INDEX idx_files_modified_at ON files(modified_at); +CREATE INDEX idx_files_generated ON files(path) WHERE generated = 1; +CREATE INDEX idx_unresolved_from_node ON unresolved_refs(from_node_id); +CREATE INDEX idx_unresolved_name ON unresolved_refs(reference_name); +CREATE INDEX idx_unresolved_file_path ON unresolved_refs(file_path); +CREATE INDEX idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name); +CREATE INDEX idx_edges_provenance ON edges(provenance); +CREATE TABLE project_metadata ( +key TEXT PRIMARY KEY, +value TEXT NOT NULL, +updated_at INTEGER NOT NULL +); +CREATE TABLE sqlite_stat1(tbl,idx,stat); diff --git a/reference/golden/mcp/tools_list.json b/reference/golden/mcp/tools_list.json index 280002d6..f04864f3 100644 --- a/reference/golden/mcp/tools_list.json +++ b/reference/golden/mcp/tools_list.json @@ -70,6 +70,10 @@ "description": "Maximum number of callers to return (default: 20)", "default": 20 }, + "file": { + "type": "string", + "description": "Narrow same-named definitions by exact project-relative path or path suffix. A miss is disclosed and falls back to all definitions." + }, "projectPath": { "type": "string", "description": "Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases." diff --git a/reference/golden/rust/colby.db b/reference/golden/rust/colby.db index 9ed1e211..a3a3120e 100644 Binary files a/reference/golden/rust/colby.db and b/reference/golden/rust/colby.db differ diff --git a/reference/golden/rust/edges.json b/reference/golden/rust/edges.json index c2311337..9f5501da 100644 --- a/reference/golden/rust/edges.json +++ b/reference/golden/rust/edges.json @@ -16,8 +16,8 @@ "kind": "calls", "line": 89, "metadata": { - "confidence": 0.4, - "resolvedBy": "exact-match" + "confidence": 0.9, + "resolvedBy": "qualified-name" }, "provenance": null, "source": "method:e3a6ddebdcc8fea332bc9fd28acd3c7b", @@ -59,6 +59,18 @@ "source": "struct:280f7daaeca096946aa3484658c88063", "target": "trait:822b89fd17ee8e49a50fbe03e4fe72de" }, + { + "col": 5, + "kind": "implements", + "line": 131, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "struct:10ae8eba9644fb15110032de62f5074b", + "target": "trait:7885ef3f957141ad762ca16f1afc580a" + }, { "col": 5, "kind": "implements", @@ -131,6 +143,30 @@ "source": "struct:00fb5a02fc6889409e039fb6dcf358a0", "target": "trait:d8605a7e20ab75ad54290c9e4502ad66" }, + { + "col": 8, + "kind": "calls", + "line": 110, + "metadata": { + "confidence": 0.9, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "method:1c949c796cae4f133f48ba2603ba82b7", + "target": "method:6e1e7eb1897ffb7d6281af7959155dde" + }, + { + "col": 8, + "kind": "calls", + "line": 133, + "metadata": { + "confidence": 0.9, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "method:0f4640288266b6d082378f92d5216de4", + "target": "method:90df49b6bc4206e7aaec991f720e1732" + }, { "col": 8, "kind": "calls", @@ -503,6 +539,15 @@ "source": "file:self_field.rs", "target": "method:0b83d189b22fe05494538afcb6a66fd4" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "method:0f4640288266b6d082378f92d5216de4" + }, { "col": null, "kind": "contains", @@ -521,6 +566,15 @@ "source": "file:self_field.rs", "target": "method:1957267ebf94a1b5a3d96556ced4faca" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "method:1c949c796cae4f133f48ba2603ba82b7" + }, { "col": null, "kind": "contains", @@ -548,6 +602,24 @@ "source": "file:self_field.rs", "target": "method:3b296c4a5cc83673fdc59a30ac709fbd" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "method:5f36c0113db17e9515d3bc25a8e6c05a" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "method:634b614d0e02aa92d10ee65727574f28" + }, { "col": null, "kind": "contains", @@ -557,6 +629,33 @@ "source": "file:self_field.rs", "target": "method:6b1aec63e0cff57c30774c728e61e843" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "method:6e1e7eb1897ffb7d6281af7959155dde" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "method:90df49b6bc4206e7aaec991f720e1732" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "method:d4ac1e2a071762b4692a5826527d6d8c" + }, { "col": null, "kind": "contains", @@ -575,6 +674,15 @@ "source": "file:self_field.rs", "target": "method:fbf14162f990c64466ebf52588d5e3a6" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "struct:10ae8eba9644fb15110032de62f5074b" + }, { "col": null, "kind": "contains", @@ -584,6 +692,15 @@ "source": "file:self_field.rs", "target": "struct:19fcccf3e085823e5f2e65e3a41576ae" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "struct:25cc800d94ce63b910b6fd528599a25d" + }, { "col": null, "kind": "contains", @@ -593,6 +710,15 @@ "source": "file:self_field.rs", "target": "struct:2e679781b98a6308aa8511ef6b4fae19" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "struct:3044babf327ea10cf62bed16c2f469c1" + }, { "col": null, "kind": "contains", @@ -620,6 +746,15 @@ "source": "file:self_field.rs", "target": "struct:a2deb552393969ddffca8b8b8b3b6776" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "struct:a4f78f063a09975857997e494dd8553f" + }, { "col": null, "kind": "contains", @@ -656,6 +791,15 @@ "source": "file:self_field.rs", "target": "struct:f25376677417afce92200d49eee69c84" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:self_field.rs", + "target": "trait:7885ef3f957141ad762ca16f1afc580a" + }, { "col": null, "kind": "contains", @@ -665,6 +809,24 @@ "source": "struct:00fb5a02fc6889409e039fb6dcf358a0", "target": "method:d2dfb67ceb37326f45f8cd1641fe3ba6" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:10ae8eba9644fb15110032de62f5074b", + "target": "method:0f4640288266b6d082378f92d5216de4" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:10ae8eba9644fb15110032de62f5074b", + "target": "method:90df49b6bc4206e7aaec991f720e1732" + }, { "col": null, "kind": "contains", @@ -683,6 +845,24 @@ "source": "struct:1ef30d9d3f298b426fbbbd5910139ecc", "target": "method:15074a95e078da927a780dccd5ed2448" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:25cc800d94ce63b910b6fd528599a25d", + "target": "method:5f36c0113db17e9515d3bc25a8e6c05a" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:25cc800d94ce63b910b6fd528599a25d", + "target": "method:634b614d0e02aa92d10ee65727574f28" + }, { "col": null, "kind": "contains", @@ -710,6 +890,24 @@ "source": "struct:2e679781b98a6308aa8511ef6b4fae19", "target": "method:fbf14162f990c64466ebf52588d5e3a6" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:3044babf327ea10cf62bed16c2f469c1", + "target": "method:1c949c796cae4f133f48ba2603ba82b7" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:3044babf327ea10cf62bed16c2f469c1", + "target": "method:6e1e7eb1897ffb7d6281af7959155dde" + }, { "col": null, "kind": "contains", @@ -755,6 +953,15 @@ "source": "struct:a2deb552393969ddffca8b8b8b3b6776", "target": "method:1664cb1c390283048a9ac640a8797063" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "struct:a4f78f063a09975857997e494dd8553f", + "target": "method:d4ac1e2a071762b4692a5826527d6d8c" + }, { "col": null, "kind": "contains", @@ -836,6 +1043,15 @@ "source": "trait:76fe46fe3128f1980f0bda9a06155993", "target": "method:9765edc3e460e1296020dc1ddf2531af" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "trait:7885ef3f957141ad762ca16f1afc580a", + "target": "method:d2274655d99f1b81b0b65055cdfbff4d" + }, { "col": null, "kind": "contains", diff --git a/reference/golden/rust/files.json b/reference/golden/rust/files.json index 6dab8be3..d0e89bd7 100644 --- a/reference/golden/rust/files.json +++ b/reference/golden/rust/files.json @@ -27,12 +27,12 @@ "size": 767 }, { - "content_hash": "30f4bab3e50f2460cce057b7701539c5e102f261366e9267fc5077df7aebed41", + "content_hash": "278055c1c308236d4e3c8f32a6f2e3ff33f5fff3b193c8a36b3029f7691f8207", "errors": null, "generated": 0, "language": "rust", - "node_count": 20, + "node_count": 33, "path": "self_field.rs", - "size": 1222 + "size": 1768 } ] diff --git a/reference/golden/rust/nodes.json b/reference/golden/rust/nodes.json index 63ec5a0f..e3407fcf 100644 --- a/reference/golden/rust/nodes.json +++ b/reference/golden/rust/nodes.json @@ -69,7 +69,7 @@ "decorators": null, "docstring": null, "end_column": 0, - "end_line": 93, + "end_line": 136, "file_path": "self_field.rs", "id": "file:self_field.rs", "is_abstract": 0, @@ -329,6 +329,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 5, + "end_line": 134, + "file_path": "self_field.rs", + "id": "method:0f4640288266b6d082378f92d5216de4", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "go", + "qualified_name": "Doer::go", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 132, + "type_parameters": null, + "visibility": "private" + }, { "decorators": null, "docstring": null, @@ -439,6 +461,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 5, + "end_line": 112, + "file_path": "self_field.rs", + "id": "method:1c949c796cae4f133f48ba2603ba82b7", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "drive", + "qualified_name": "SplitTarget::drive", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 109, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -549,6 +593,50 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 30, + "end_line": 103, + "file_path": "self_field.rs", + "id": "method:5f36c0113db17e9515d3bc25a8e6c05a", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "reset", + "qualified_name": "SplitDecoy::reset", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 103, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 32, + "end_line": 105, + "file_path": "self_field.rs", + "id": "method:634b614d0e02aa92d10ee65727574f28", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "missing", + "qualified_name": "SplitDecoy::missing", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 105, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -593,6 +681,28 @@ "type_parameters": null, "visibility": "private" }, + { + "decorators": null, + "docstring": null, + "end_column": 30, + "end_line": 97, + "file_path": "self_field.rs", + "id": "method:6e1e7eb1897ffb7d6281af7959155dde", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "reset", + "qualified_name": "SplitTarget::reset", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 97, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -637,6 +747,28 @@ "type_parameters": null, "visibility": "private" }, + { + "decorators": null, + "docstring": null, + "end_column": 29, + "end_line": 128, + "file_path": "self_field.rs", + "id": "method:90df49b6bc4206e7aaec991f720e1732", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "step", + "qualified_name": "Doer::step", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 128, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -659,6 +791,28 @@ "type_parameters": null, "visibility": "private" }, + { + "decorators": null, + "docstring": null, + "end_column": 21, + "end_line": 116, + "file_path": "self_field.rs", + "id": "method:d2274655d99f1b81b0b65055cdfbff4d", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "go", + "qualified_name": "Drive::go", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 116, + "type_parameters": null, + "visibility": "private" + }, { "decorators": null, "docstring": null, @@ -681,6 +835,28 @@ "type_parameters": null, "visibility": "private" }, + { + "decorators": null, + "docstring": null, + "end_column": 29, + "end_line": 122, + "file_path": "self_field.rs", + "id": "method:d4ac1e2a071762b4692a5826527d6d8c", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "rust", + "name": "step", + "qualified_name": "StepDecoy::step", + "return_type": null, + "signature": "(&mut self)", + "start_column": 4, + "start_line": 122, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -835,6 +1011,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 16, + "end_line": 125, + "file_path": "self_field.rs", + "id": "struct:10ae8eba9644fb15110032de62f5074b", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "struct", + "language": "rust", + "name": "Doer", + "qualified_name": "Doer", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 125, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -879,6 +1077,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 22, + "end_line": 100, + "file_path": "self_field.rs", + "id": "struct:25cc800d94ce63b910b6fd528599a25d", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "struct", + "language": "rust", + "name": "SplitDecoy", + "qualified_name": "SplitDecoy", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 100, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -923,6 +1143,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 23, + "end_line": 94, + "file_path": "self_field.rs", + "id": "struct:3044babf327ea10cf62bed16c2f469c1", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "struct", + "language": "rust", + "name": "SplitTarget", + "qualified_name": "SplitTarget", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 94, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -1033,6 +1275,28 @@ "type_parameters": null, "visibility": "public" }, + { + "decorators": null, + "docstring": null, + "end_column": 21, + "end_line": 119, + "file_path": "self_field.rs", + "id": "struct:a4f78f063a09975857997e494dd8553f", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "struct", + "language": "rust", + "name": "StepDecoy", + "qualified_name": "StepDecoy", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 119, + "type_parameters": null, + "visibility": "public" + }, { "decorators": null, "docstring": null, @@ -1231,6 +1495,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 117, + "file_path": "self_field.rs", + "id": "trait:7885ef3f957141ad762ca16f1afc580a", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "trait", + "language": "rust", + "name": "Drive", + "qualified_name": "Drive", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 115, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, diff --git a/reference/golden/rust/refs.json b/reference/golden/rust/refs.json index cf38a987..6b73ef3b 100644 --- a/reference/golden/rust/refs.json +++ b/reference/golden/rust/refs.json @@ -49,6 +49,16 @@ "reference_kind": "calls", "reference_name": "self.items.next" }, + { + "candidates": null, + "col": 8, + "file_path": "self_field.rs", + "from_node_id": "method:1c949c796cae4f133f48ba2603ba82b7", + "language": "rust", + "line": 111, + "reference_kind": "calls", + "reference_name": "self.missing" + }, { "candidates": null, "col": 8, diff --git a/reference/golden/scala/colby.db b/reference/golden/scala/colby.db new file mode 100644 index 00000000..1e7ef0a5 Binary files /dev/null and b/reference/golden/scala/colby.db differ diff --git a/reference/golden/scala/edges.json b/reference/golden/scala/edges.json new file mode 100644 index 00000000..2138a416 --- /dev/null +++ b/reference/golden/scala/edges.json @@ -0,0 +1,101 @@ +[ + { + "col": 10, + "kind": "extends", + "line": 2, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "class:627a216b0cc8c0c9e5e61a855c967dd4", + "target": "class:29d4ea01650c7344733d923a81d0516b" + }, + { + "col": 51, + "kind": "extends", + "line": 2, + "metadata": { + "confidence": 0.4, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "class:627a216b0cc8c0c9e5e61a855c967dd4", + "target": "trait:82c543978cf8541c3f7a0c088d5521f3" + }, + { + "col": 69, + "kind": "extends", + "line": 2, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "class:627a216b0cc8c0c9e5e61a855c967dd4", + "target": "trait:5fe9b04e2c5042b6b532f8a4d92e1bc5" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:627a216b0cc8c0c9e5e61a855c967dd4", + "target": "method:9fd814c66467ec06ba0f3dc046d0a650" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:agreements.scala", + "target": "class:29d4ea01650c7344733d923a81d0516b" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:agreements.scala", + "target": "module:bd1ee126d046e3b49d2f34ed9d713fc7" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:agreements.scala", + "target": "trait:5fe9b04e2c5042b6b532f8a4d92e1bc5" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:agreements.scala", + "target": "trait:82c543978cf8541c3f7a0c088d5521f3" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:m_ext_agreement.scala", + "target": "class:627a216b0cc8c0c9e5e61a855c967dd4" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "trait:82c543978cf8541c3f7a0c088d5521f3", + "target": "method:71607374878addf68c9584ca659d0dd5" + } +] diff --git a/reference/golden/scala/files.json b/reference/golden/scala/files.json new file mode 100644 index 00000000..124e94b8 --- /dev/null +++ b/reference/golden/scala/files.json @@ -0,0 +1,20 @@ +[ + { + "content_hash": "7324352515604b9d2fac88358395643dfa87fee0bb726ba0437f617f74c0c84d", + "errors": null, + "generated": 0, + "language": "scala", + "node_count": 6, + "path": "agreements.scala", + "size": 160 + }, + { + "content_hash": "c7b2a4154d23f77fbd135bc5d0bf84b90f53626fb5ff2c80c978816cb31a22e7", + "errors": null, + "generated": 0, + "language": "scala", + "node_count": 3, + "path": "m_ext_agreement.scala", + "size": 199 + } +] diff --git a/reference/golden/scala/nodes.json b/reference/golden/scala/nodes.json new file mode 100644 index 00000000..9e271e62 --- /dev/null +++ b/reference/golden/scala/nodes.json @@ -0,0 +1,200 @@ +[ + { + "decorators": null, + "docstring": null, + "end_column": 63, + "end_line": 9, + "file_path": "agreements.scala", + "id": "class:29d4ea01650c7344733d923a81d0516b", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "scala", + "name": "MAgreement", + "qualified_name": "MAgreement", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 9, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 4, + "file_path": "m_ext_agreement.scala", + "id": "class:627a216b0cc8c0c9e5e61a855c967dd4", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "scala", + "name": "MExtAgreement", + "qualified_name": "MExtAgreement", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 10, + "file_path": "agreements.scala", + "id": "file:agreements.scala", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "scala", + "name": "agreements.scala", + "qualified_name": "agreements.scala", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 5, + "file_path": "m_ext_agreement.scala", + "id": "file:m_ext_agreement.scala", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "scala", + "name": "m_ext_agreement.scala", + "qualified_name": "m_ext_agreement.scala", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 25, + "end_line": 4, + "file_path": "agreements.scala", + "id": "method:71607374878addf68c9584ca659d0dd5", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "scala", + "name": "extId", + "qualified_name": "ExtAgreement::extId", + "return_type": "String", + "signature": ": String", + "start_column": 2, + "start_line": 4, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 30, + "end_line": 3, + "file_path": "m_ext_agreement.scala", + "id": "method:9fd814c66467ec06ba0f3dc046d0a650", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "scala", + "name": "render", + "qualified_name": "MExtAgreement::render", + "return_type": "String", + "signature": "(): String", + "start_column": 2, + "start_line": 3, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 22, + "end_line": 1, + "file_path": "agreements.scala", + "id": "module:bd1ee126d046e3b49d2f34ed9d713fc7", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "module", + "language": "scala", + "name": "ExtAgreement", + "qualified_name": "ExtAgreement", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 20, + "end_line": 7, + "file_path": "agreements.scala", + "id": "trait:5fe9b04e2c5042b6b532f8a4d92e1bc5", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "trait", + "language": "scala", + "name": "OtherAgreement", + "qualified_name": "OtherAgreement", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 7, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 5, + "file_path": "agreements.scala", + "id": "trait:82c543978cf8541c3f7a0c088d5521f3", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "trait", + "language": "scala", + "name": "ExtAgreement", + "qualified_name": "ExtAgreement", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 3, + "type_parameters": null, + "visibility": "public" + } +] diff --git a/reference/golden/scala/refs.json b/reference/golden/scala/refs.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/reference/golden/scala/refs.json @@ -0,0 +1 @@ +[] diff --git a/reference/golden/scala/schema.sql b/reference/golden/scala/schema.sql new file mode 100644 index 00000000..e528b87c --- /dev/null +++ b/reference/golden/scala/schema.sql @@ -0,0 +1,119 @@ +CREATE TABLE schema_versions ( +version INTEGER PRIMARY KEY, +applied_at INTEGER NOT NULL, +description TEXT +); +CREATE TABLE nodes ( +id TEXT PRIMARY KEY, +kind TEXT NOT NULL, +name TEXT NOT NULL, +qualified_name TEXT NOT NULL, +file_path TEXT NOT NULL, +language TEXT NOT NULL, +start_line INTEGER NOT NULL, +end_line INTEGER NOT NULL, +start_column INTEGER NOT NULL, +end_column INTEGER NOT NULL, +docstring TEXT, +signature TEXT, +visibility TEXT, +is_exported INTEGER DEFAULT 0, +is_async INTEGER DEFAULT 0, +is_static INTEGER DEFAULT 0, +is_abstract INTEGER DEFAULT 0, +decorators TEXT, -- JSON array +type_parameters TEXT, -- JSON array +return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference) +updated_at INTEGER NOT NULL +); +CREATE TABLE edges ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +source TEXT NOT NULL, +target TEXT NOT NULL, +kind TEXT NOT NULL, +metadata TEXT, -- JSON object +line INTEGER, +col INTEGER, +provenance TEXT DEFAULT NULL, +FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE, +FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE +); +CREATE TABLE sqlite_sequence(name,seq); +CREATE TABLE files ( +path TEXT PRIMARY KEY, +content_hash TEXT NOT NULL, +language TEXT NOT NULL, +size INTEGER NOT NULL, +modified_at INTEGER NOT NULL, +indexed_at INTEGER NOT NULL, +node_count INTEGER DEFAULT 0, +errors TEXT, -- JSON array +generated INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE unresolved_refs ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +from_node_id TEXT NOT NULL, +reference_name TEXT NOT NULL, +reference_kind TEXT NOT NULL, +line INTEGER NOT NULL, +col INTEGER NOT NULL, +candidates TEXT, -- JSON array +file_path TEXT NOT NULL DEFAULT '', +language TEXT NOT NULL DEFAULT 'unknown', +reference_subkind TEXT, +FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE +); +CREATE INDEX idx_nodes_kind ON nodes(kind); +CREATE INDEX idx_nodes_name ON nodes(name); +CREATE INDEX idx_nodes_qualified_name ON nodes(qualified_name); +CREATE INDEX idx_nodes_file_path ON nodes(file_path); +CREATE INDEX idx_nodes_language ON nodes(language); +CREATE INDEX idx_nodes_file_line ON nodes(file_path, start_line); +CREATE INDEX idx_nodes_lower_name ON nodes(lower(name)); +CREATE VIRTUAL TABLE nodes_fts USING fts5( +id, +name, +qualified_name, +docstring, +signature, +content='nodes', +content_rowid='rowid' +) +/* nodes_fts(id,name,qualified_name,docstring,signature) */; +CREATE TABLE 'nodes_fts_data'(id INTEGER PRIMARY KEY, block BLOB); +CREATE TABLE 'nodes_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; +CREATE TABLE 'nodes_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); +CREATE TABLE 'nodes_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; +CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN +INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) +VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; +CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN +INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) +VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +END; +CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN +INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) +VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) +VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; +CREATE INDEX idx_edges_kind ON edges(kind); +CREATE INDEX idx_edges_source_kind ON edges(source, kind); +CREATE INDEX idx_edges_target_kind ON edges(target, kind); +CREATE UNIQUE INDEX idx_edges_identity +ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1)); +CREATE INDEX idx_files_language ON files(language); +CREATE INDEX idx_files_modified_at ON files(modified_at); +CREATE INDEX idx_files_generated ON files(path) WHERE generated = 1; +CREATE INDEX idx_unresolved_from_node ON unresolved_refs(from_node_id); +CREATE INDEX idx_unresolved_name ON unresolved_refs(reference_name); +CREATE INDEX idx_unresolved_file_path ON unresolved_refs(file_path); +CREATE INDEX idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name); +CREATE INDEX idx_edges_provenance ON edges(provenance); +CREATE TABLE project_metadata ( +key TEXT PRIMARY KEY, +value TEXT NOT NULL, +updated_at INTEGER NOT NULL +); +CREATE TABLE sqlite_stat1(tbl,idx,stat); diff --git a/reference/golden/typescript/colby.db b/reference/golden/typescript/colby.db index b4697dff..88a1101c 100644 Binary files a/reference/golden/typescript/colby.db and b/reference/golden/typescript/colby.db differ diff --git a/reference/golden/typescript/edges.json b/reference/golden/typescript/edges.json index dc6bbaf3..6707ffe1 100644 --- a/reference/golden/typescript/edges.json +++ b/reference/golden/typescript/edges.json @@ -131,6 +131,30 @@ "source": "file:consumer.ts", "target": "import:58ba30c67d247ca76af614a60b572c1c" }, + { + "col": 10, + "kind": "calls", + "line": 23, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:f011bc96eb9f06f761a520a2284304d4", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, + { + "col": 11, + "kind": "calls", + "line": 20, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:f51493c01c858b8d930eebb10d598b30", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, { "col": 14, "kind": "calls", @@ -263,6 +287,18 @@ "source": "function:30d56685f42c695610703f36bc7f5de8", "target": "function:11b0f33a34cdbb367d1c2e84ed0677d2" }, + { + "col": 2, + "kind": "calls", + "line": 5, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:1cf01dcf9c6ece584761379d8a60af90", + "target": "function:99d722e851cc816b0deaccc26b98c8a9" + }, { "col": 2, "kind": "calls", @@ -299,6 +335,18 @@ "source": "function:bec301d43eb73165b2bd3a9f7ba1f7da", "target": "function:80a62e168be79a592fe0e5bc10062a24" }, + { + "col": 2, + "kind": "calls", + "line": 9, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:a7a038450c8b3a6dd5f2397dc782b408", + "target": "function:404422494787139e3c9ff50ac9702e72" + }, { "col": 21, "kind": "imports", @@ -335,6 +383,30 @@ "source": "file:consumer.ts", "target": "function:d9eba2bae70ed18576ba5ed96bc2b644" }, + { + "col": 24, + "kind": "calls", + "line": 16, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "file:commonjs_handlers.js", + "target": "function:99d722e851cc816b0deaccc26b98c8a9" + }, + { + "col": 26, + "kind": "calls", + "line": 27, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:babb3e6c322e0536d55254aed669e164" + }, { "col": 26, "kind": "references", @@ -360,6 +432,18 @@ "source": "file:consumer.ts", "target": "function:a6d594ffd5d5f1daf20b2b6a38aa9d8a" }, + { + "col": 29, + "kind": "calls", + "line": 17, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:7ed411c680dd4dc85e7dc937bdcfe3c6", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, { "col": 32, "kind": "imports", @@ -372,6 +456,30 @@ "source": "file:consumer.ts", "target": "function:82178a7ebdc9958e76b3d6014789f799" }, + { + "col": 34, + "kind": "calls", + "line": 25, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, + { + "col": 36, + "kind": "calls", + "line": 26, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, { "col": 4, "kind": "calls", @@ -384,6 +492,30 @@ "source": "function:11b0f33a34cdbb367d1c2e84ed0677d2", "target": "function:881216817d6d02e528335a807d20c174" }, + { + "col": 44, + "kind": "calls", + "line": 18, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:babb3e6c322e0536d55254aed669e164", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, + { + "col": 46, + "kind": "calls", + "line": 27, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:f51493c01c858b8d930eebb10d598b30" + }, { "col": 7, "kind": "imports", @@ -396,6 +528,30 @@ "source": "file:consumer.ts", "target": "file:exports.ts" }, + { + "col": 8, + "kind": "calls", + "line": 13, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:efc26b98460e38d61956ab45ccdbd41c", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, + { + "col": 9, + "kind": "calls", + "line": 27, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:7ed411c680dd4dc85e7dc937bdcfe3c6" + }, { "col": 9, "kind": "imports", @@ -480,6 +636,51 @@ "source": "file:alias_consumer.ts", "target": "import:09ce996766fd26d9e6998db066851b9d" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:callable_semantics.ts", + "target": "function:00799fe30ca74b5008645c296de83d5a" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:callable_semantics.ts", + "target": "function:4b0317e2a093194d05c58f73d1e5b715" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:callable_semantics.ts", + "target": "function:efc26b98460e38d61956ab45ccdbd41c" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:callable_semantics.ts", + "target": "interface:36f9e8421df698344df2891d2ee6835d" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:callable_semantics.ts", + "target": "type_alias:d6fbd103defa892b963e74486a061a7d" + }, { "col": null, "kind": "contains", @@ -498,6 +699,51 @@ "source": "file:collision.ts", "target": "function:a6d594ffd5d5f1daf20b2b6a38aa9d8a" }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:commonjs_handlers.js", + "target": "constant:4abc3da71dfc9451e06a38289f81ef5d" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:commonjs_handlers.js", + "target": "function:1cf01dcf9c6ece584761379d8a60af90" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:commonjs_handlers.js", + "target": "function:404422494787139e3c9ff50ac9702e72" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:commonjs_handlers.js", + "target": "function:99d722e851cc816b0deaccc26b98c8a9" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:commonjs_handlers.js", + "target": "function:a7a038450c8b3a6dd5f2397dc782b408" + }, { "col": null, "kind": "contains", @@ -740,5 +986,59 @@ "provenance": null, "source": "file:src/aliased.ts", "target": "function:11f52a047f284a61ac9210724034a4d5" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:7ed411c680dd4dc85e7dc937bdcfe3c6" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:babb3e6c322e0536d55254aed669e164" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:f011bc96eb9f06f761a520a2284304d4" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "function:4b0317e2a093194d05c58f73d1e5b715", + "target": "function:f51493c01c858b8d930eebb10d598b30" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "interface:36f9e8421df698344df2891d2ee6835d", + "target": "method:39f6bda50dc637243a66e8c89d24fac7" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "interface:36f9e8421df698344df2891d2ee6835d", + "target": "property:2595e2e8197a10dc7446440a4ced1bc7" } ] diff --git a/reference/golden/typescript/files.json b/reference/golden/typescript/files.json index c18d14a5..07853436 100644 --- a/reference/golden/typescript/files.json +++ b/reference/golden/typescript/files.json @@ -8,6 +8,15 @@ "path": "alias_consumer.ts", "size": 101 }, + { + "content_hash": "94c7c95e28d1f77ebf4addcd681e8ed5445938facba2b853db54a2ec0aa6cead", + "errors": null, + "generated": 0, + "language": "typescript", + "node_count": 12, + "path": "callable_semantics.ts", + "size": 924 + }, { "content_hash": "5d91d0b374e6bbe30c01d7932f5554fbf02f1a3d1eea2f0aee046ae6a891c182", "errors": null, @@ -26,6 +35,15 @@ "path": "collision.ts", "size": 37 }, + { + "content_hash": "1d69b93cbb14ab8e0323ae07ca1b8997d40381621d6a0e14d23cc89c67a4ced2", + "errors": null, + "generated": 0, + "language": "javascript", + "node_count": 6, + "path": "commonjs_handlers.js", + "size": 275 + }, { "content_hash": "417ddd5bafc0993f53e32c05d91eef694f7ef6b3d99fda239ae893be0fcc7c9b", "errors": null, diff --git a/reference/golden/typescript/nodes.json b/reference/golden/typescript/nodes.json index e7279a95..797b0cfd 100644 --- a/reference/golden/typescript/nodes.json +++ b/reference/golden/typescript/nodes.json @@ -1,4 +1,26 @@ [ + { + "decorators": null, + "docstring": null, + "end_column": 19, + "end_line": 15, + "file_path": "commonjs_handlers.js", + "id": "constant:4abc3da71dfc9451e06a38289f81ef5d", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "constant", + "language": "javascript", + "name": "handlers", + "qualified_name": "handlers", + "return_type": null, + "signature": "= {}", + "start_column": 6, + "start_line": 15, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -65,6 +87,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 29, + "file_path": "callable_semantics.ts", + "id": "file:callable_semantics.ts", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "typescript", + "name": "callable_semantics.ts", + "qualified_name": "callable_semantics.ts", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -109,6 +153,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 17, + "file_path": "commonjs_handlers.js", + "id": "file:commonjs_handlers.js", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "javascript", + "name": "commonjs_handlers.js", + "qualified_name": "commonjs_handlers.js", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -351,6 +417,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 10, + "file_path": "callable_semantics.ts", + "id": "function:00799fe30ca74b5008645c296de83d5a", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "typescript", + "name": "callableSemanticsUniqueHelper", + "qualified_name": "callableSemanticsUniqueHelper", + "return_type": null, + "signature": "(): number", + "start_column": 0, + "start_line": 8, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -417,6 +505,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 6, + "file_path": "commonjs_handlers.js", + "id": "function:1cf01dcf9c6ece584761379d8a60af90", + "is_abstract": 0, + "is_async": 1, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "javascript", + "name": "getItems", + "qualified_name": "getItems", + "return_type": null, + "signature": "()", + "start_column": 19, + "start_line": 4, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -483,6 +593,50 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 24, + "end_line": 2, + "file_path": "commonjs_handlers.js", + "id": "function:404422494787139e3c9ff50ac9702e72", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "javascript", + "name": "removeItem", + "qualified_name": "removeItem", + "return_type": null, + "signature": "()", + "start_column": 0, + "start_line": 2, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 28, + "file_path": "callable_semantics.ts", + "id": "function:4b0317e2a093194d05c58f73d1e5b715", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "typescript", + "name": "Screen", + "qualified_name": "Screen", + "return_type": null, + "signature": "()", + "start_column": 7, + "start_line": 16, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -505,6 +659,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 60, + "end_line": 17, + "file_path": "callable_semantics.ts", + "id": "function:7ed411c680dd4dc85e7dc937bdcfe3c6", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "typescript", + "name": "plainHandler", + "qualified_name": "Screen::plainHandler", + "return_type": null, + "signature": "()", + "start_column": 23, + "start_line": 17, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -593,6 +769,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 23, + "end_line": 1, + "file_path": "commonjs_handlers.js", + "id": "function:99d722e851cc816b0deaccc26b98c8a9", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "function", + "language": "javascript", + "name": "findItems", + "qualified_name": "findItems", + "return_type": null, + "signature": "()", + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -637,6 +835,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 10, + "file_path": "commonjs_handlers.js", + "id": "function:a7a038450c8b3a6dd5f2397dc782b408", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "javascript", + "name": "deleteItem", + "qualified_name": "deleteItem", + "return_type": null, + "signature": "()", + "start_column": 28, + "start_line": 8, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -659,6 +879,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 75, + "end_line": 18, + "file_path": "callable_semantics.ts", + "id": "function:babb3e6c322e0536d55254aed669e164", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "typescript", + "name": "callbackHandler", + "qualified_name": "Screen::callbackHandler", + "return_type": null, + "signature": "()", + "start_column": 38, + "start_line": 18, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -769,6 +1011,50 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 14, + "file_path": "callable_semantics.ts", + "id": "function:efc26b98460e38d61956ab45ccdbd41c", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "typescript", + "name": "generatorTask", + "qualified_name": "generatorTask", + "return_type": null, + "signature": "()", + "start_column": 7, + "start_line": 12, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 3, + "end_line": 24, + "file_path": "callable_semantics.ts", + "id": "function:f011bc96eb9f06f761a520a2284304d4", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "typescript", + "name": "wrappedHandler", + "qualified_name": "Screen::wrappedHandler", + "return_type": null, + "signature": "()", + "start_column": 53, + "start_line": 22, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -791,6 +1077,28 @@ "type_parameters": null, "visibility": null }, + { + "decorators": null, + "docstring": null, + "end_column": 3, + "end_line": 21, + "file_path": "callable_semantics.ts", + "id": "function:f51493c01c858b8d930eebb10d598b30", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "function", + "language": "typescript", + "name": "eventHandler", + "qualified_name": "Screen::eventHandler", + "return_type": null, + "signature": "()", + "start_column": 38, + "start_line": 19, + "type_parameters": null, + "visibility": null + }, { "decorators": null, "docstring": null, @@ -1032,5 +1340,93 @@ "start_line": 1, "type_parameters": null, "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 4, + "file_path": "callable_semantics.ts", + "id": "interface:36f9e8421df698344df2891d2ee6835d", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "interface", + "language": "typescript", + "name": "PlatformApi", + "qualified_name": "PlatformApi", + "return_type": null, + "signature": null, + "start_column": 7, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 40, + "end_line": 2, + "file_path": "callable_semantics.ts", + "id": "method:39f6bda50dc637243a66e8c89d24fac7", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "typescript", + "name": "fetchPage", + "qualified_name": "PlatformApi::fetchPage", + "return_type": null, + "signature": "(id: string): Promise", + "start_column": 2, + "start_line": 2, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 17, + "end_line": 3, + "file_path": "callable_semantics.ts", + "id": "property:2595e2e8197a10dc7446440a4ced1bc7", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "property", + "language": "typescript", + "name": "version", + "qualified_name": "PlatformApi::version", + "return_type": null, + "signature": "version version", + "start_column": 2, + "start_line": 3, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 53, + "end_line": 6, + "file_path": "callable_semantics.ts", + "id": "type_alias:d6fbd103defa892b963e74486a061a7d", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "type_alias", + "language": "typescript", + "name": "Handle", + "qualified_name": "Handle", + "return_type": null, + "signature": null, + "start_column": 7, + "start_line": 6, + "type_parameters": null, + "visibility": null } ] diff --git a/reference/golden/typescript/refs.json b/reference/golden/typescript/refs.json index df3225ab..134ef53f 100644 --- a/reference/golden/typescript/refs.json +++ b/reference/golden/typescript/refs.json @@ -29,6 +29,76 @@ "reference_kind": "calls", "reference_name": "nested.run" }, + { + "candidates": null, + "col": 20, + "file_path": "callable_semantics.ts", + "from_node_id": "function:4b0317e2a093194d05c58f73d1e5b715", + "language": "typescript", + "line": 25, + "reference_kind": "calls", + "reference_name": "useMemo" + }, + { + "candidates": null, + "col": 23, + "file_path": "callable_semantics.ts", + "from_node_id": "function:4b0317e2a093194d05c58f73d1e5b715", + "language": "typescript", + "line": 19, + "reference_kind": "calls", + "reference_name": "useEffectEvent" + }, + { + "candidates": null, + "col": 25, + "file_path": "callable_semantics.ts", + "from_node_id": "function:4b0317e2a093194d05c58f73d1e5b715", + "language": "typescript", + "line": 22, + "reference_kind": "calls", + "reference_name": "Effect.fn" + }, + { + "candidates": null, + "col": 25, + "file_path": "callable_semantics.ts", + "from_node_id": "function:4b0317e2a093194d05c58f73d1e5b715", + "language": "typescript", + "line": 22, + "reference_kind": "calls", + "reference_name": "Effect.fn(\"Screen.wrapped\")" + }, + { + "candidates": null, + "col": 25, + "file_path": "callable_semantics.ts", + "from_node_id": "method:39f6bda50dc637243a66e8c89d24fac7", + "language": "typescript", + "line": 2, + "reference_kind": "references", + "reference_name": "Promise" + }, + { + "candidates": null, + "col": 25, + "file_path": "callable_semantics.ts", + "from_node_id": "method:39f6bda50dc637243a66e8c89d24fac7", + "language": "typescript", + "line": 2, + "reference_kind": "references", + "reference_name": "Promise" + }, + { + "candidates": null, + "col": 26, + "file_path": "callable_semantics.ts", + "from_node_id": "function:4b0317e2a093194d05c58f73d1e5b715", + "language": "typescript", + "line": 18, + "reference_kind": "calls", + "reference_name": "useCallback" + }, { "candidates": null, "col": 26,