diff --git a/AGENTS.md b/AGENTS.md index eb30253..e7f0510 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,9 @@ Protocol) stdio server. No AI / vector / LLM anywhere in the binary — output i - **Golden `.schema` byte-stability** — verified by `crates/codegraph-bench/tests/equivalence.rs` against the fixed golden artifacts under `reference/golden/`. Fixtures: the existing upstream corpus plus `reference/golden/godot/` (corpus `crates/codegraph-bench/fixtures/godot/`; - guards F1 autoload-call edges + F2 signal-handler edges byte-for-byte) and + guards F1 autoload-call edges + F2 signal-handler edges + UID-form autoloads + — a sidecar-UID script autoload (`*.gd.uid`) and a header-UID scene autoload + (`.tscn` `uid=`) — byte-for-byte) and `reference/golden/ruby/` (corpus `crates/codegraph-bench/fixtures/ruby/`; guards #1110 Ruby `receiver.method` extraction — instance/class-method Calls, `Const.new` Instantiates, bare `include` Implements — byte-for-byte) and `reference/golden/cpp/` diff --git a/crates/codegraph-bench/fixtures/godot/combo_ui.tscn b/crates/codegraph-bench/fixtures/godot/combo_ui.tscn new file mode 100644 index 0000000..f665e09 --- /dev/null +++ b/crates/codegraph-bench/fixtures/godot/combo_ui.tscn @@ -0,0 +1,3 @@ +[gd_scene format=3 uid="uid://bddoi8q2q2olp"] + +[node name="ComboUI" type="Node"] diff --git a/crates/codegraph-bench/fixtures/godot/effect_manager.gd b/crates/codegraph-bench/fixtures/godot/effect_manager.gd new file mode 100644 index 0000000..30a66b3 --- /dev/null +++ b/crates/codegraph-bench/fixtures/godot/effect_manager.gd @@ -0,0 +1,4 @@ +extends Node + +func apply_effect() -> void: + pass diff --git a/crates/codegraph-bench/fixtures/godot/effect_manager.gd.uid b/crates/codegraph-bench/fixtures/godot/effect_manager.gd.uid new file mode 100644 index 0000000..168ae14 --- /dev/null +++ b/crates/codegraph-bench/fixtures/godot/effect_manager.gd.uid @@ -0,0 +1 @@ +uid://bb1ewunp0bc47 diff --git a/crates/codegraph-bench/fixtures/godot/project.godot b/crates/codegraph-bench/fixtures/godot/project.godot index 168b0cd..0e184c0 100644 --- a/crates/codegraph-bench/fixtures/godot/project.godot +++ b/crates/codegraph-bench/fixtures/godot/project.godot @@ -7,3 +7,5 @@ config/name="CodeGraph Godot Fixture" [autoload] GameFlow="*res://game_flow.gd" +EffectManager="*uid://bb1ewunp0bc47" +ComboUi="*uid://bddoi8q2q2olp" diff --git a/crates/codegraph-bench/fixtures/godot/stage_manager.gd b/crates/codegraph-bench/fixtures/godot/stage_manager.gd index 5483d90..2649f9b 100644 --- a/crates/codegraph-bench/fixtures/godot/stage_manager.gd +++ b/crates/codegraph-bench/fixtures/godot/stage_manager.gd @@ -7,6 +7,7 @@ func _ready() -> void: func _goto_map() -> void: GameFlow.return_to_map() + EffectManager.apply_effect() func _on_pressed(source) -> void: pass diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index e0ba566..814598e 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -176,6 +176,9 @@ enum Command { kind: Option, #[arg(short = 'j', long = "json")] json: bool, + /// Exit non-zero when no result is found (default exits 0). + #[arg(long)] + strict: bool, }, // Upstream flags/output shape: upstream bin/codegraph.ts:903-911, 939-1013. Files { @@ -240,6 +243,9 @@ enum Command { limit: usize, #[arg(short = 'j', long = "json")] json: bool, + /// Exit non-zero when no callers are found (default exits 0). + #[arg(long)] + strict: bool, }, // Upstream flags/output shape: upstream bin/codegraph.ts:1280-1284, 1298-1345. Callees { @@ -250,6 +256,9 @@ enum Command { limit: usize, #[arg(short = 'j', long = "json")] json: bool, + /// Exit non-zero when no callees are found (default exits 0). + #[arg(long)] + strict: bool, }, // Upstream flags/output shape: upstream bin/codegraph.ts:1358-1362, 1374-1439. Impact { @@ -260,6 +269,9 @@ enum Command { depth: usize, #[arg(short = 'j', long = "json")] json: bool, + /// Exit non-zero when the symbol is not found (default exits 0). + #[arg(long)] + strict: bool, }, // Upstream flags/output shape: upstream bin/codegraph.ts:1462-1469, 1479-1582. Affected { @@ -268,7 +280,8 @@ enum Command { path: Option, #[arg(short, long, default_value_t = 5)] depth: usize, - #[arg(short, long)] + /// glob used to classify affectedTests; does NOT filter affectedFiles. Example: tests/* + #[arg(short, long, value_name = "GLOB")] filter: Option, }, // New analysis surface (not in the v1.0.1 pin): forward file-dependency @@ -345,6 +358,9 @@ enum Command { symbols_only: bool, #[arg(short = 'j', long = "json")] json: bool, + /// Exit non-zero when the symbol/file is not found (default exits 0). + #[arg(long)] + strict: bool, }, // Upstream flags/output: upstream bin/codegraph.ts:1864-1870, 1871-1920. // `--global`/`--local` are convenience aliases for `--location` (task spec). @@ -512,7 +528,8 @@ fn run(cli: Cli) -> Result<()> { limit, kind, json, - } => cmd_query(search, path, limit, kind, json), + strict, + } => cmd_query(search, path, limit, kind, json, strict), Command::Files { path, filter, @@ -536,19 +553,22 @@ fn run(cli: Cli) -> Result<()> { path, limit, json, - } => cmd_callers(symbol, path, limit, json), + strict, + } => cmd_callers(symbol, path, limit, json, strict), Command::Callees { symbol, path, limit, json, - } => cmd_callees(symbol, path, limit, json), + strict, + } => cmd_callees(symbol, path, limit, json, strict), Command::Impact { symbol, path, depth, json, - } => cmd_impact(symbol, path, depth, json), + strict, + } => cmd_impact(symbol, path, depth, json, strict), Command::Affected { files, path, @@ -591,7 +611,8 @@ fn run(cli: Cli) -> Result<()> { path, symbols_only, json, - } => cmd_node(target, path, symbols_only, json), + strict, + } => cmd_node(target, path, symbols_only, json, strict), Command::Install { target, location, @@ -1340,6 +1361,7 @@ fn cmd_query( limit: i64, kind: Option, json_output: bool, + strict: bool, ) -> Result<()> { let project = resolve_required_project(path)?; let store = open_store(&project)?; @@ -1368,12 +1390,11 @@ fn cmd_query( .map(|node| SearchResult { node, score: 1.0 }) .collect(); } + let is_empty = results.is_empty(); if json_output { let output = results.iter().map(SearchOutput::from).collect::>(); print_json_pretty(&output)?; - return Ok(()); - } - if results.is_empty() { + } else if is_empty { println!("No results found for \"{search}\""); } else { println!("\nSearch Results for \"{search}\":\n"); @@ -1386,6 +1407,9 @@ fn cmd_query( println!(); } } + if strict && is_empty { + bail!("codegraph query: no results found for \"{search}\""); + } Ok(()) } @@ -2679,6 +2703,7 @@ fn cmd_callers( path: Option, limit: usize, json_output: bool, + strict: bool, ) -> Result<()> { let project = resolve_required_project(path)?; let store = open_store(&project)?; @@ -2694,6 +2719,9 @@ fn cmd_callers( print_related("Callers", &symbol, &nodes); godot.print_cli(nodes.is_empty()); } + if strict && nodes.is_empty() { + bail!("codegraph callers: no callers found for \"{symbol}\""); + } Ok(()) } @@ -2702,6 +2730,7 @@ fn cmd_callees( path: Option, limit: usize, json_output: bool, + strict: bool, ) -> Result<()> { let project = resolve_required_project(path)?; let store = open_store(&project)?; @@ -2711,14 +2740,33 @@ fn cmd_callees( } else { print_related("Callees", &symbol, &nodes); } + if strict && nodes.is_empty() { + bail!("codegraph callees: no callees found for \"{symbol}\""); + } Ok(()) } +/// The shared Godot reverse-referrer fan-out for `impact` and `affected`. A +/// `.tscn`/`.tres`/`project.godot` target owns no `file:` node, so its +/// referrers live only in the path-keyed reverse lanes. Fanning out to all +/// three — the 6-subkind `unresolved_refs` lane, the untagged `main_scene` +/// lane, and the loader `import:`-name lane — behind ONE helper keeps `impact` +/// and `affected` from diverging. Sorted + deduped; deterministic. +fn godot_reverse_referrers(store: &Store, file: &str) -> Result> { + let mut referrers = store.dependent_file_paths_unresolved(file)?; + referrers.extend(store.dependent_file_paths_via_import_name(file)?); + referrers.extend(store.dependent_file_paths_main_scene(file)?); + referrers.sort(); + referrers.dedup(); + Ok(referrers) +} + fn cmd_impact( symbol: String, path: Option, depth: usize, json_output: bool, + strict: bool, ) -> Result<()> { let project = resolve_required_project(path)?; let store = open_store(&project)?; @@ -2726,6 +2774,9 @@ fn cmd_impact( let matches = symbol_matches(&store, &project, &symbol)?; if matches.is_empty() { println!("Symbol \"{symbol}\" not found"); + if strict { + bail!("codegraph impact: symbol \"{symbol}\" not found"); + } return Ok(()); } let traverser = GraphTraverser::new(&store); @@ -2740,17 +2791,14 @@ fn cmd_impact( for edge in impact.edges { edge_keys.insert((edge.source, edge.target, edge.kind)); } - if node.kind == NodeKind::File - && is_godot_resource_path(&node.file_path) - && !godot_files.contains(&node.file_path) - { + if is_godot_resource_target_node(node) && !godot_files.contains(&node.file_path) { godot_files.push(node.file_path.clone()); } } let mut affected = nodes.values().map(NodeSummary::from).collect::>(); let mut godot_referrers: Vec = Vec::new(); for file in &godot_files { - godot_referrers.extend(store.dependent_file_paths_unresolved(file)?); + godot_referrers.extend(godot_reverse_referrers(&store, file)?); } godot_referrers.sort(); godot_referrers.dedup(); @@ -2797,7 +2845,7 @@ fn cmd_explore( args["maxFiles"] = json!(max_files); } let result = engine.execute("codegraph_explore", &args); - print_engine_result("explore", &query, &result, json_output) + print_engine_result("explore", &query, &result, json_output, false) } fn cmd_node( @@ -2805,6 +2853,7 @@ fn cmd_node( path: Option, symbols_only: bool, json_output: bool, + strict: bool, ) -> Result<()> { let project = resolve_required_project(path)?; let engine = codegraph_mcp::CodeGraphEngine::open(&project)?; @@ -2814,7 +2863,7 @@ fn cmd_node( json!({ "symbol": target, "includeCode": true }) }; let result = engine.execute("codegraph_node", &args); - print_engine_result("node", &target, &result, json_output) + print_engine_result("node", &target, &result, json_output, strict) } /// Decide whether a `codegraph node ` argument names an indexed FILE (→ @@ -2843,6 +2892,7 @@ fn print_engine_result( query: &str, result: &codegraph_mcp::protocol::ToolResult, json_output: bool, + strict: bool, ) -> Result<()> { let text = result .content @@ -2864,6 +2914,9 @@ fn print_engine_result( if is_error { bail!("codegraph {command} failed: {text}"); } + if strict && result.not_found.unwrap_or(false) { + bail!("codegraph {command}: \"{query}\" not found"); + } Ok(()) } @@ -2893,8 +2946,7 @@ fn cmd_affected( continue; } let mut dependents = store.dependent_file_paths(¤t)?; - dependents.extend(store.dependent_file_paths_unresolved(¤t)?); - dependents.extend(store.dependent_file_paths_via_import_name(¤t)?); + dependents.extend(godot_reverse_referrers(&store, ¤t)?); dependents.sort(); dependents.dedup(); for dependent in dependents { @@ -3168,6 +3220,25 @@ fn is_godot_resource_path(path: &str) -> bool { || lower.ends_with(".gd") } +/// Whether a matched `impact` node names a Godot RESOURCE whose reverse +/// referrers should fold into the impact set — as opposed to a symbol that +/// merely lives in a Godot file. A `.tscn`/`.tres`/`.res` owns no `file:` node, +/// so its ONLY node is a scene-level `Constant` (the whole-resource target); a +/// `.gd` folds only when the matched node is its `file:` node (a whole-file +/// target), never when it is a symbol (function/class) inside that script — that +/// would wrongly attach the script's scene/autoload referrers to a symbol-level +/// query. +fn is_godot_resource_target_node(node: &codegraph_core::types::Node) -> bool { + if !is_godot_resource_path(&node.file_path) { + return false; + } + let lower = node.file_path.to_ascii_lowercase(); + if lower.ends_with(".gd") { + return node.kind == NodeKind::File; + } + true +} + fn print_audit_orphans(orphans: &[codegraph_graph::graph::OrphanResource]) { if orphans.is_empty() { println!("No orphan resources found"); diff --git a/crates/codegraph-cli/tests/affected.rs b/crates/codegraph-cli/tests/affected.rs index 6d87da8..90caeb2 100644 --- a/crates/codegraph-cli/tests/affected.rs +++ b/crates/codegraph-cli/tests/affected.rs @@ -96,6 +96,31 @@ fn affected_json(p: &str, file: &str, extra: &[&str]) -> serde_json::Value { serde_json::from_str(&stdout).expect("affected emits valid JSON on stdout") } +#[test] +fn impact_and_affected_of_main_scene_list_project_godot() { + // Given the indexed godot_audit fixture, where project.godot declares + // `run/main_scene="res://main.tscn"` (an untagged main_scene ref). + let (_dir, project) = indexed_project("main-scene"); + let p = project.to_str().unwrap(); + + // When impact runs on the main scene, project.godot is surfaced as a + // referrer (P2 — the untagged main_scene reverse lane). + let (impact_out, err, ok) = cli(&["impact", "main.tscn", "-p", p]); + assert!(ok, "impact failed: stdout={impact_out} stderr={err}"); + assert!( + impact_out.contains("project.godot"), + "impact main.tscn must list project.godot: {impact_out}" + ); + + // And affected agrees at its own entrypoint (both route the shared helper). + let value = affected_json(p, "main.tscn", &["--depth", "5"]); + let affected_files = string_array(&value, "affectedFiles"); + assert!( + affected_files.contains(&"project.godot".to_string()), + "affected main.tscn must list project.godot: {affected_files:?}" + ); +} + fn string_array(value: &serde_json::Value, key: &str) -> Vec { value[key] .as_array() diff --git a/crates/codegraph-cli/tests/cli_text_and_errors.rs b/crates/codegraph-cli/tests/cli_text_and_errors.rs index e222fa3..35431bc 100644 --- a/crates/codegraph-cli/tests/cli_text_and_errors.rs +++ b/crates/codegraph-cli/tests/cli_text_and_errors.rs @@ -314,6 +314,127 @@ fn affected_with_filter_treats_matches_as_tests() { ); } +#[test] +fn node_missing_is_zero_but_strict_is_nonzero() { + let dir = TestDir::new("strict-node"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + let lax = run_in(dir.path(), &["node", "no_such_symbol_zzz", "-p", p]); + assert!(lax.ok, "node of a missing symbol must exit 0 by default"); + + let strict = run_in( + dir.path(), + &["node", "no_such_symbol_zzz", "-p", p, "--strict"], + ); + assert!( + !strict.ok, + "node --strict of a missing symbol must exit non-zero: {}", + strict.stdout + ); + + let hit = run_in(dir.path(), &["node", "add", "-p", p, "--strict"]); + assert!( + hit.ok, + "node --strict of an existing symbol must exit 0: {}", + hit.stderr + ); +} + +#[test] +fn query_and_impact_strict_flags_gate_exit_code() { + let dir = TestDir::new("strict-query-impact"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + let q_lax = run_in(dir.path(), &["query", "no_such_symbol_zzz", "-p", p]); + assert!(q_lax.ok, "query of a missing symbol must exit 0 by default"); + let q_strict = run_in( + dir.path(), + &["query", "no_such_symbol_zzz", "-p", p, "--strict"], + ); + assert!( + !q_strict.ok, + "query --strict must exit non-zero on no results" + ); + + let i_lax = run_in(dir.path(), &["impact", "no_such_symbol_zzz", "-p", p]); + assert!( + i_lax.ok, + "impact of a missing symbol must exit 0 by default" + ); + let i_strict = run_in( + dir.path(), + &["impact", "no_such_symbol_zzz", "-p", p, "--strict"], + ); + assert!( + !i_strict.ok, + "impact --strict must exit non-zero when not found" + ); +} + +#[test] +fn node_strict_found_symbol_with_sentinel_body_exits_zero() { + let dir = TestDir::new("strict-sentinel-body"); + let project = dir.path().join("mini"); + copy_tree(&mini_fixture(), &project); + std::fs::write( + project.join("sentinel.ts"), + "export function sentinelCarrier(): string {\n return \"No results found and not found in the codebase\";\n}\n", + ) + .unwrap(); + let p = project.to_str().unwrap(); + let init = run_in(dir.path(), &["init", p]); + assert!(init.ok, "init failed: {} {}", init.stdout, init.stderr); + let idx = run_in(dir.path(), &["index", "--force", p]); + assert!(idx.ok, "index failed: {} {}", idx.stdout, idx.stderr); + + let strict = run_in( + dir.path(), + &["node", "sentinelCarrier", "-p", p, "--strict"], + ); + assert!( + strict.ok, + "node --strict of a FOUND symbol must exit 0 even when its source body contains a not-found sentinel phrase: {}", + strict.stdout + ); +} + +#[test] +fn node_strict_missing_file_exits_nonzero() { + let dir = TestDir::new("strict-missing-file"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + + let run = run_in( + dir.path(), + &["node", "some/missing_file.rs", "-p", p, "--strict"], + ); + assert!( + !run.ok, + "node --strict of a missing file must exit non-zero (the missing-file sentinel is now flagged): {}", + run.stdout + ); +} + +#[test] +fn affected_help_documents_the_filter_glob() { + let dir = TestDir::new("affected-help"); + let run = run_in(dir.path(), &["affected", "--help"]); + assert!(run.ok, "affected --help must succeed: {}", run.stderr); + assert!( + run.stdout.contains("GLOB"), + "affected --help must show the GLOB value name: {}", + run.stdout + ); + assert!( + run.stdout.contains("affectedTests") + && run.stdout.contains("does NOT filter affectedFiles"), + "affected --help must document that --filter classifies affectedTests only: {}", + run.stdout + ); +} + #[test] fn index_without_init_errors() { let dir = TestDir::new("index-noinit"); diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs index 1e834e5..3a8729b 100644 --- a/crates/codegraph-extract/src/engine.rs +++ b/crates/codegraph-extract/src/engine.rs @@ -877,6 +877,18 @@ mod tests { assert_eq!(detect_language("src/lib.rs"), Language::Rust); } + #[test] + fn gd_uid_sidecar_never_becomes_file_record() { + assert_eq!( + detect_language("Scripts/effect_manager.gd.uid"), + Language::Unknown + ); + assert!( + !is_extractable_source_path("Scripts/effect_manager.gd.uid"), + ".gd.uid sidecar must never be scanned into a file record" + ); + } + #[test] fn metal_cu_cuh_map_to_cpp() { assert_eq!(detect_language("s.metal"), Language::Cpp); diff --git a/crates/codegraph-mcp/src/engine.rs b/crates/codegraph-mcp/src/engine.rs index 9913e13..1e93d79 100644 --- a/crates/codegraph-mcp/src/engine.rs +++ b/crates/codegraph-mcp/src/engine.rs @@ -191,7 +191,7 @@ impl CodeGraphEngine { }; let results = search_nodes(&self.store, &query, &options, &self.project_name_tokens())?; if results.is_empty() { - return Ok(ToolResult::text(format!( + return Ok(ToolResult::not_found_text(format!( "No results found for \"{query}\"" ))); } @@ -223,7 +223,7 @@ impl CodeGraphEngine { let all_matches = self.find_all_symbols(&symbol)?; if all_matches.nodes.is_empty() { - return Ok(ToolResult::text(format!( + return Ok(ToolResult::not_found_text(format!( "Symbol \"{symbol}\" not found in the codebase" ))); } @@ -253,7 +253,7 @@ impl CodeGraphEngine { CallDir::Callers => "callers", CallDir::Callees => "callees", }; - return Ok(ToolResult::text(format!( + return Ok(ToolResult::not_found_text(format!( "No {label} found for \"{symbol}\"{}{}", all_matches.note, godot.annotation(true) @@ -287,7 +287,7 @@ impl CodeGraphEngine { let all_matches = self.find_all_symbols(&symbol)?; if all_matches.nodes.is_empty() { - return Ok(ToolResult::text(format!( + return Ok(ToolResult::not_found_text(format!( "Symbol \"{symbol}\" not found in the codebase" ))); } @@ -354,7 +354,7 @@ impl CodeGraphEngine { let matches = self.find_symbol_matches(symbol)?; if matches.is_empty() { - return Ok(ToolResult::text(format!( + return Ok(ToolResult::not_found_text(format!( "Symbol \"{symbol}\" not found in the codebase" ))); } @@ -507,7 +507,7 @@ impl CodeGraphEngine { let files = self.store.all_files()?; if files.is_empty() { - return Ok(ToolResult::text( + return Ok(ToolResult::not_found_text( "No files indexed. Run `codegraph index` first.", )); } @@ -516,7 +516,7 @@ impl CodeGraphEngine { let file_path = match candidates.as_slice() { [one] => one.clone(), [] => { - return Ok(ToolResult::text(format!( + return Ok(ToolResult::not_found_text(format!( "No indexed file matches \"{file_arg}\". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly." ))); } diff --git a/crates/codegraph-mcp/src/protocol.rs b/crates/codegraph-mcp/src/protocol.rs index a8f64a0..4763d52 100644 --- a/crates/codegraph-mcp/src/protocol.rs +++ b/crates/codegraph-mcp/src/protocol.rs @@ -91,6 +91,14 @@ pub struct ToolResult { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "isError")] pub is_error: Option, + /// Structured "the lookup found nothing" signal for a non-error result — set + /// at the engine's genuine not-found/empty branches so callers can gate on + /// it (`--strict`) WITHOUT substring-matching the rendered text, which would + /// misfire on a matched node whose source body contains a sentinel phrase. + /// Skipped when None, so the MCP wire shape is unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "notFound")] + pub not_found: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -109,6 +117,17 @@ impl ToolResult { text: text.into(), }], is_error: None, + not_found: None, + } + } + + /// A non-error result whose text is a genuine "found nothing" sentinel; + /// flags `not_found` so `--strict` callers gate on it without inspecting the + /// text. Wire shape matches [`Self::text`] (the flag is skipped when None). + pub fn not_found_text(text: impl Into) -> Self { + Self { + not_found: Some(true), + ..Self::text(text) } } @@ -121,6 +140,7 @@ impl ToolResult { text: format!("Error: {message}"), }], is_error: Some(true), + not_found: None, } } } diff --git a/crates/codegraph-resolve/src/frameworks/godot.rs b/crates/codegraph-resolve/src/frameworks/godot.rs index 28686c6..b2782f7 100644 --- a/crates/codegraph-resolve/src/frameworks/godot.rs +++ b/crates/codegraph-resolve/src/frameworks/godot.rs @@ -384,10 +384,13 @@ fn resolve_class_member( /// same-named `func` in the receiver's bound script (F1). Returns `None` — no /// edge — unless every determinism rule holds: /// -/// 1. Single binding source: the target script is ONLY the `res://` path bound to -/// `Receiver` in `project.godot`'s `[autoload]` section (via -/// [`autoload_script_bindings`]). A receiver that is not a real `res://`-bound -/// autoload (a built-in like `Vector2`, a `uid://`-bound autoload, a class +/// 1. Single binding source: the target script is the path bound to `Receiver` +/// in `project.godot`'s `[autoload]` section (via +/// [`autoload_script_bindings`]) — a `res://` `.gd` path or a sidecar-UID +/// (`*.gd.uid`) script autoload, both of which get full F1 binding. A +/// scene-header-UID autoload is registration-only (bound to a `.tscn`, no F1 +/// binding to its attached script). A receiver that is not a real script-bound +/// autoload (a built-in like `Vector2`, a scene-backed autoload, a class /// global) has no binding here → `None`. No global cross-file matching. /// 2. Unique-candidate-only: `member` is searched for as a GDScript `Function` /// ONLY inside that one bound script. Exactly one match → resolve to it; zero @@ -451,7 +454,9 @@ fn autoload_script_bindings( let Some(content) = context.read_file(&file) else { continue; }; - for (name, path) in godot_project::autoload_script_paths(&content) { + for (name, path) in + godot_project::autoload_script_paths(&content, context.get_project_root()) + { out.entry(name).or_insert(path); } } diff --git a/crates/codegraph-resolve/src/frameworks/godot_project.rs b/crates/codegraph-resolve/src/frameworks/godot_project.rs index b26d1ec..b0bb837 100644 --- a/crates/codegraph-resolve/src/frameworks/godot_project.rs +++ b/crates/codegraph-resolve/src/frameworks/godot_project.rs @@ -48,11 +48,13 @@ //! header), an unterminated value, or an unknown section is skipped, never //! panics. An empty or sectionless file yields an empty result. +use std::collections::BTreeMap; + use codegraph_core::node_id::generate_node_id; use codegraph_core::types::{EdgeKind, Language, Node, NodeKind, ReferenceSubkind}; use super::framework_node; -use super::godot_common::{map_res_path, map_res_path_inner, quoted_strings}; +use super::godot_common::{map_res_path, map_res_path_inner, quoted_strings, strip_quotes}; use crate::types::{FrameworkResolverExtractionResult, RefView}; /// The marker basename this parser handles. @@ -79,11 +81,13 @@ pub(crate) fn parse_project_godot( let mut nodes: Vec = Vec::new(); let mut references: Vec = Vec::new(); - // `run/main_scene` may be a `uid://…` (Godot 4.x default) rather than a - // `res://…` path. Resolving it needs the project-wide `uid → .tscn path` - // map, built lazily on first `uid://` main_scene so a project without one - // pays nothing. - let mut scene_uids: Option> = None; + // `run/main_scene` and `[autoload]` values may be a `uid://…` (Godot 4.x + // default) rather than a `res://…` path. A scene uid resolves through the + // project-wide `uid → .tscn path` map; an autoload SCRIPT uid resolves + // through the `uid → .gd path` sidecar map. Both are built lazily on first + // `uid://` need so a project without one pays nothing. + let mut scene_uids: Option> = None; + let mut script_uids: Option> = None; let mut section: Option
= None; // Track whether we are inside a multi-line `key={ ... }` value so its inner @@ -121,9 +125,17 @@ pub(crate) fn parse_project_godot( }; match section { - Section::Autoload => { - emit_autoload(file_path, line_no, key, value, &mut nodes, &mut references) - } + Section::Autoload => emit_autoload( + file_path, + line_no, + key, + value, + project_root, + &mut scene_uids, + &mut script_uids, + &mut nodes, + &mut references, + ), Section::Application => { if key == "run/main_scene" { emit_main_scene( @@ -161,10 +173,11 @@ pub(crate) fn parse_project_godot( /// [`parse_project_godot`] uses, but yields only `(name, path)` pairs (no nodes /// / no clock), so it is pure and deterministic. An entry whose value is not a /// `res://` path is skipped. First-write-wins on a duplicate name. -pub(crate) fn autoload_script_paths(content: &str) -> Vec<(String, String)> { +pub(crate) fn autoload_script_paths(content: &str, project_root: &str) -> Vec<(String, String)> { let mut out: Vec<(String, String)> = Vec::new(); let mut section: Option
= None; let mut brace_depth: i32 = 0; + let mut script_uids: Option> = None; for raw_line in content.lines() { let line = raw_line.trim(); @@ -188,7 +201,8 @@ pub(crate) fn autoload_script_paths(content: &str) -> Vec<(String, String)> { match section { Section::Autoload => { if !key.is_empty() { - if let Some(path) = map_res_path(value) { + if let Some(path) = autoload_script_path(value, project_root, &mut script_uids) + { if !out.iter().any(|(n, _)| n == key) { out.push((key.to_string(), path)); } @@ -202,6 +216,29 @@ pub(crate) fn autoload_script_paths(content: &str) -> Vec<(String, String)> { out } +/// Resolve an `[autoload]` value to a repo-relative SCRIPT path for F1 binding. +/// A `res://…` value maps via [`map_res_path`]; a `uid://…` value resolves ONLY +/// through the `.gd.uid` sidecar map (a script autoload). A scene-backed uid +/// (`.tscn` header) yields `None` here — F1 binds to a script's own `func`s, and +/// a scene autoload has no such backing script (registration-only per the plan). +fn autoload_script_path( + value: &str, + project_root: &str, + script_uids: &mut Option>, +) -> Option { + if let Some(path) = map_res_path(value) { + return Some(path); + } + let unquoted = strip_quotes(value).trim(); + let uid = unquoted.strip_prefix('*').unwrap_or(unquoted); + if !uid.starts_with("uid://") { + return None; + } + let scripts = + script_uids.get_or_insert_with(|| super::godot_scene::gd_uid_sidecar_map(project_root)); + scripts.get(uid).cloned() +} + /// The sections L1 understands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Section { @@ -225,11 +262,23 @@ impl Section { } /// Emit a singleton node + a `References` edge to its target script/scene. +/// +/// A `res://…` value maps directly via [`map_res_path`]. A `uid://…` value +/// (Godot 4.x default) resolves through a combined uid lookup: the `.gd.uid` +/// SIDECAR map FIRST (a SCRIPT autoload), then the scene `uid → .tscn` map (a +/// scene-backed autoload). Either resolution emits the SAME +/// `Autoload`-subkind reference the `res://` form emits, so it enters the +/// reverse-consume lanes with zero query change. An unresolvable uid emits the +/// singleton node but NO reference (unknown-uid → no guess). +#[allow(clippy::too_many_arguments)] fn emit_autoload( file_path: &str, line_no: i64, name: &str, value: &str, + project_root: &str, + scene_uids: &mut Option>, + script_uids: &mut Option>, nodes: &mut Vec, references: &mut Vec, ) { @@ -240,11 +289,38 @@ fn emit_autoload( let node_id = node.id.clone(); nodes.push(node); - if let Some(target) = map_res_path(value) { + if let Some(target) = resolve_autoload_target(value, project_root, scene_uids, script_uids) { references.push(autoload_reference(node_id, target, line_no, file_path)); } } +/// Resolve an `[autoload]` value to a repo-relative target path. A `res://…` +/// value maps via the shared [`map_res_path`]; a `uid://…` value is resolved +/// through the `.gd.uid` sidecar map (script autoload) first, then the scene +/// `uid → .tscn` map (scene-backed autoload). Any other form yields `None`. +fn resolve_autoload_target( + value: &str, + project_root: &str, + scene_uids: &mut Option>, + script_uids: &mut Option>, +) -> Option { + if let Some(target) = map_res_path(value) { + return Some(target); + } + let unquoted = strip_quotes(value).trim(); + let uid = unquoted.strip_prefix('*').unwrap_or(unquoted); + if !uid.starts_with("uid://") { + return None; + } + let scripts = + script_uids.get_or_insert_with(|| super::godot_scene::gd_uid_sidecar_map(project_root)); + if let Some(path) = scripts.get(uid) { + return Some(path.clone()); + } + let scenes = scene_uids.get_or_insert_with(|| super::godot_scene::scene_uid_map(project_root)); + scenes.get(uid).cloned() +} + /// Emit a main-scene marker node + a `References` edge to the scene path. /// /// `run/main_scene` is either a `res://…` path (mapped directly) or, in Godot @@ -433,7 +509,7 @@ mod tests { fn autoload_script_paths_maps_name_to_repo_relative() { let content = "[autoload]\nGameState=\"*res://globals/state.gd\"\nMusic=\"res://audio/m.gd\"\n"; - let got = autoload_script_paths(content); + let got = autoload_script_paths(content, ""); assert_eq!( got, vec![ @@ -446,14 +522,14 @@ mod tests { #[test] fn autoload_script_paths_first_write_wins_on_dup_name() { let content = "[autoload]\nX=\"res://a.gd\"\nX=\"res://b.gd\"\n"; - let got = autoload_script_paths(content); + let got = autoload_script_paths(content, ""); assert_eq!(got, vec![("X".to_string(), "a.gd".to_string())]); } #[test] fn autoload_script_paths_skips_non_res_value() { let content = "[autoload]\nX=\"user://a.gd\"\n"; - assert!(autoload_script_paths(content).is_empty()); + assert!(autoload_script_paths(content, "").is_empty()); } #[test] @@ -471,14 +547,14 @@ run/main_scene=\"res://main.tscn\" X=\"res://x.gd\" "; assert_eq!( - autoload_script_paths(content), + autoload_script_paths(content, ""), vec![("X".to_string(), "x.gd".to_string())] ); } #[test] fn autoload_script_paths_empty_when_no_autoload() { - assert!(autoload_script_paths("; comment\nconfig_version=5\n").is_empty()); + assert!(autoload_script_paths("; comment\nconfig_version=5\n", "").is_empty()); } #[test] @@ -550,4 +626,148 @@ X=\"res://x.gd\" ); std::fs::remove_dir_all(&dir).ok(); } + + fn autoload_temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "cg-autoload-uid-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir"); + dir + } + + #[test] + fn emit_autoload_uid_sidecar_form_emits_reference() { + let dir = autoload_temp_dir("sidecar"); + std::fs::create_dir_all(dir.join("Scripts/Autoloads")).unwrap(); + std::fs::write( + dir.join("Scripts/Autoloads/effect_manager.gd"), + "extends Node\n", + ) + .unwrap(); + std::fs::write( + dir.join("Scripts/Autoloads/effect_manager.gd.uid"), + "uid://bb1ewunp0bc47\n", + ) + .unwrap(); + let root = dir.to_string_lossy().to_string(); + + let content = "[autoload]\nEffectManager=\"*uid://bb1ewunp0bc47\"\n"; + let result = parse_project_godot("project.godot", content, &root); + + assert_eq!(result.references.len(), 1, "one autoload ref emitted"); + let r = &result.references[0]; + assert_eq!(r.reference_name, "Scripts/Autoloads/effect_manager.gd"); + assert_eq!(r.reference_subkind, Some(ReferenceSubkind::Autoload)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn emit_autoload_uid_scene_form_emits_reference() { + let dir = autoload_temp_dir("scene"); + std::fs::create_dir_all(dir.join("Entities/UI")).unwrap(); + std::fs::write( + dir.join("Entities/UI/ComboUI.tscn"), + "[gd_scene format=3 uid=\"uid://bddoi8q2q2olp\"]\n", + ) + .unwrap(); + let root = dir.to_string_lossy().to_string(); + + let content = "[autoload]\nComboUi=\"*uid://bddoi8q2q2olp\"\n"; + let result = parse_project_godot("project.godot", content, &root); + + assert_eq!(result.references.len(), 1, "one autoload ref emitted"); + let r = &result.references[0]; + assert_eq!(r.reference_name, "Entities/UI/ComboUI.tscn"); + assert_eq!(r.reference_subkind, Some(ReferenceSubkind::Autoload)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn gd_uid_collision_sidecar_wins_over_scene() { + let dir = autoload_temp_dir("collision"); + std::fs::create_dir_all(dir.join("Scripts")).unwrap(); + std::fs::write(dir.join("Scripts/effect_manager.gd"), "extends Node\n").unwrap(); + std::fs::write(dir.join("Scripts/effect_manager.gd.uid"), "uid://collide\n").unwrap(); + std::fs::write( + dir.join("Scripts/ComboUI.tscn"), + "[gd_scene format=3 uid=\"uid://collide\"]\n", + ) + .unwrap(); + let root = dir.to_string_lossy().to_string(); + + let content = "[autoload]\nShared=\"*uid://collide\"\n"; + let result = parse_project_godot("project.godot", content, &root); + + assert_eq!(result.references.len(), 1, "one autoload ref emitted"); + assert_eq!( + result.references[0].reference_name, "Scripts/effect_manager.gd", + "on a uid present in BOTH the sidecar and the scene map, the sidecar .gd wins" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn emit_autoload_unknown_uid_no_reference() { + let dir = autoload_temp_dir("miss"); + let root = dir.to_string_lossy().to_string(); + + let content = "[autoload]\nGhost=\"*uid://nope\"\n"; + let result = parse_project_godot("project.godot", content, &root); + + assert_eq!(result.nodes.len(), 1, "singleton node still emitted"); + assert!( + result.references.is_empty(), + "unknown uid emits no reference, got: {:?}", + result.references + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn autoload_script_paths_resolves_uid_sidecar_form() { + let dir = autoload_temp_dir("scriptpaths"); + std::fs::create_dir_all(dir.join("Scripts")).unwrap(); + std::fs::write(dir.join("Scripts/effect_manager.gd"), "extends Node\n").unwrap(); + std::fs::write( + dir.join("Scripts/effect_manager.gd.uid"), + "uid://bb1ewunp0bc47\n", + ) + .unwrap(); + let root = dir.to_string_lossy().to_string(); + + let content = "[autoload]\nEffectManager=\"*uid://bb1ewunp0bc47\"\n"; + let got = autoload_script_paths(content, &root); + assert_eq!( + got, + vec![( + "EffectManager".to_string(), + "Scripts/effect_manager.gd".to_string() + )] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn autoload_script_paths_skips_scene_backed_uid() { + let dir = autoload_temp_dir("scriptpaths-scene"); + std::fs::create_dir_all(dir.join("Entities")).unwrap(); + std::fs::write( + dir.join("Entities/ComboUI.tscn"), + "[gd_scene format=3 uid=\"uid://bddoi8q2q2olp\"]\n", + ) + .unwrap(); + let root = dir.to_string_lossy().to_string(); + + let content = "[autoload]\nComboUi=\"*uid://bddoi8q2q2olp\"\n"; + assert!( + autoload_script_paths(content, &root).is_empty(), + "scene-backed uid autoload has no F1 script binding" + ); + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/codegraph-resolve/src/frameworks/godot_scene.rs b/crates/codegraph-resolve/src/frameworks/godot_scene.rs index 7d29e2f..699407d 100644 --- a/crates/codegraph-resolve/src/frameworks/godot_scene.rs +++ b/crates/codegraph-resolve/src/frameworks/godot_scene.rs @@ -240,6 +240,72 @@ pub(crate) fn scene_uid_map(project_root: &str) -> BTreeMap { /// `.godot/` cache and any hidden dir (`.git`, etc.). Errors are ignored /// (tolerant walk); ordering is imposed by the caller's sort. fn collect_tscn_files(dir: &Path, out: &mut Vec) { + collect_files_with_suffix(dir, ".tscn", out); +} + +/// Build a deterministic `uid://` → repo-relative `.gd` script-path map by +/// reading every `