From ebc1f3f083d003f61912c806fdbeaa4aa8fae8cb Mon Sep 17 00:00:00 2001 From: Akio Jinsenji Date: Mon, 11 May 2026 23:33:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(reference):=20Phase=202=20-=20reference=20?= =?UTF-8?q?find-symbol=20=E3=81=A8=20Phase=201=20=E6=8C=AF=E3=82=8A?= =?UTF-8?q?=E8=BF=94=E3=82=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC #187 (Phase 2 シンボル lookup インデックス) を land し、Phase 1 で得た知見を tasks/lessons.md に追記する。Phase 1 は PR #186 で develop 統合済みのため、本 PR の diff は Phase 2 と lessons のみ。 採用方針 B (独自 ReferenceSymbolEntry) と Symbol scope (型のみ) は gwt-discussion で確定済み。member-level lookup は Phase 2.5 以降の 拡張余地として残し、validate_kind の受理リストには method/property/ field も含めておく。 主な変更: - src/reference/index.rs (新規): ReferenceSymbolEntry と ReferenceSymbolIndex 構造、build_or_update_index で size+mtime ベースの incremental 更新、find_symbol で kind / namespace フィルタ - src/reference/mod.rs: maybe_execute_reference_tool に reference_find_symbol 分岐と execute_find_symbol を追加、 dispatcher 経由テスト 6 件 - src/cli.rs: ReferenceCommand::FindSymbol variant - src/app/runner.rs: build_reference_call に FindSymbol 分岐と 2 件のテスト - src/tooling/tool_catalog.rs: reference_find_symbol を TOOL_NAMES / executor / read_only / params_schema / parity count(125) に登録 - .claude-plugin/.../unity-csharp-reference/SKILL.md: Preferred Flow に find-symbol 例とフォールバック説明 - references/symbol-lookup-playbook.md: workflow step 1 を反映 - docs/tools.md: Reference Cache (6 -> 7 tools) - tasks/lessons.md: SPEC #185 振り返り 3 件 (clippy 1.95 差、 coverage 90% 維持、SPEC section markers) ローカル検証: - cargo fmt -- --check: clean - cargo clippy --all-targets -- -D warnings: clean - cargo test --bin unity-cli: 356 passed / 0 failed - cargo llvm-cov --all-targets: TOTAL line coverage 90.69% - unity-cli skills lint --severity error: 15 skills / 0 violations Refs #187 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/unity-csharp-reference/SKILL.md | 3 + .../references/symbol-lookup-playbook.md | 5 +- docs/tools.md | 4 +- src/app/runner.rs | 50 +++ src/cli.rs | 11 + src/reference/index.rs | 423 ++++++++++++++++++ src/reference/mod.rs | 127 ++++++ src/tooling/tool_catalog.rs | 18 +- tasks/lessons.md | 21 + 9 files changed, 657 insertions(+), 5 deletions(-) create mode 100644 src/reference/index.rs diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/SKILL.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/SKILL.md index 51dbfab..dd7ed38 100644 --- a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/SKILL.md +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/SKILL.md @@ -46,11 +46,14 @@ Browse Unity Technologies' official UnityCsReference C# source as a read-only lo ```bash unity-cli reference fetch --accept-license unity-cli reference status --output json +unity-cli reference find-symbol --name Animator --kind class unity-cli reference grep "class Animator " --context 3 unity-cli reference view Runtime/Export/Animation/Animator.bindings.cs --start-line 100 --max-lines 60 unity-cli reference clean --keep 1 --dry-run ``` +Use `reference find-symbol` first when you already know the type name (class / interface / struct / enum). It is backed by an on-disk index per Unity version (`~/.unity/cache/UnityCsReference//.unity-cli-index/symbols.json`) and is faster than `grep` for repeated lookups. Drop back to `grep` for free-text patterns or member-level matches. + ## Examples - "Show me how Unity implements `Animator.Play` so I can predict whether it allocates." diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/symbol-lookup-playbook.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/symbol-lookup-playbook.md index dceeffc..635720a 100644 --- a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/symbol-lookup-playbook.md +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/symbol-lookup-playbook.md @@ -13,12 +13,13 @@ The canonical reference → navigate → edit workflow when validating LLM-sugge ### 1. Locate the symbol in the official source ```bash -unity-cli reference grep "class Animator " --context 0 +unity-cli reference find-symbol --name Animator --kind class +unity-cli reference find-symbol --name AssetDatabase --kind class --namespace UnityEditor unity-cli reference grep "void Play\(" --file-glob "Animator*.cs" --context 2 unity-cli reference search "AssetDatabase.Refresh" --max-results 10 ``` -`grep` emits `{ path, line, text, context_before, context_after }` so the surrounding context survives a follow-up `view`. +`find-symbol` reads (and lazily builds) a per-version index of type definitions and returns `{ path, name, kind, line, namespace?, container?, fqn? }`. Use it when the type name is already known; the index is regenerated incrementally based on file size and mtime, so repeated lookups within the same Unity version are O(1) over the index. Fall back to `grep` when the pattern targets methods, properties, or free text — those are intentionally outside the Phase 2 index scope. ### 2. Read the relevant span diff --git a/docs/tools.md b/docs/tools.md index cc0de32..0fab736 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -249,7 +249,7 @@ unity-cli tool schema input_keyboard --output json unity-cli tool schema package_manager --output json ``` -## Reference Cache (6 tools) +## Reference Cache (7 tools) The `unity-cli reference *` family provides a local read-only mirror of the official [UnityCsReference](https://github.com/Unity-Technologies/UnityCsReference) @@ -267,12 +267,14 @@ fetch via `--accept-license` or `UNITY_CLI_ACCEPT_LICENSE=1`. | `reference_grep` | Grep the cached reference source line-by-line with optional file glob and context lines. | | `reference_view` | Display a slice of a file in the cached reference source by line range. | | `reference_clean` | Remove old UnityCsReference snapshots, keeping the newest entries. | +| `reference_find_symbol` | Look up type definitions (class / interface / struct / enum) in the cached reference source via a per-version on-disk index. | Typed CLI equivalents: ```bash unity-cli reference fetch --accept-license unity-cli reference status --output json +unity-cli reference find-symbol --name Animator --kind class unity-cli reference grep "class Animator " --context 3 unity-cli reference view Runtime/Export/Animation/Animator.bindings.cs --start-line 100 --max-lines 60 unity-cli reference clean --keep 1 --dry-run diff --git a/src/app/runner.rs b/src/app/runner.rs index 4c51047..ca475ea 100644 --- a/src/app/runner.rs +++ b/src/app/runner.rs @@ -327,6 +327,24 @@ fn build_reference_call(command: &ReferenceCommand) -> (&'static str, Value) { } "reference_view" } + ReferenceCommand::FindSymbol { + name, + kind, + namespace, + version, + } => { + params.insert("name".to_string(), Value::String(name.clone())); + if let Some(k) = kind { + params.insert("kind".to_string(), Value::String(k.clone())); + } + if let Some(ns) = namespace { + params.insert("namespace".to_string(), Value::String(ns.clone())); + } + if let Some(v) = version { + params.insert("version".to_string(), Value::String(v.clone())); + } + return ("reference_find_symbol", Value::Object(params)); + } ReferenceCommand::Clean { keep, version, @@ -2142,6 +2160,38 @@ mod tests { assert_eq!(params["dryRun"], true); } + #[test] + fn build_reference_call_find_symbol_with_filters() { + let cmd = ReferenceCommand::FindSymbol { + name: "Animator".to_string(), + kind: Some("class".to_string()), + namespace: Some("UnityEngine".to_string()), + version: Some("2023.2.20f1".to_string()), + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_find_symbol"); + assert_eq!(params["name"], "Animator"); + assert_eq!(params["kind"], "class"); + assert_eq!(params["namespace"], "UnityEngine"); + assert_eq!(params["version"], "2023.2.20f1"); + } + + #[test] + fn build_reference_call_find_symbol_minimal() { + let cmd = ReferenceCommand::FindSymbol { + name: "Foo".to_string(), + kind: None, + namespace: None, + version: None, + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_find_symbol"); + assert_eq!(params["name"], "Foo"); + assert!(params.get("kind").is_none()); + assert!(params.get("namespace").is_none()); + assert!(params.get("version").is_none()); + } + #[test] fn build_reference_call_clean_defaults() { let cmd = ReferenceCommand::Clean { diff --git a/src/cli.rs b/src/cli.rs index cdba1a2..5705c4c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -271,6 +271,17 @@ pub enum ReferenceCommand { #[arg(long)] max_lines: Option, }, + /// Find a symbol (class/interface/struct/enum) in the cached reference index. + FindSymbol { + #[arg(long)] + name: String, + #[arg(long)] + kind: Option, + #[arg(long)] + namespace: Option, + #[arg(long)] + version: Option, + }, /// Remove old UnityCsReference snapshots, keeping the newest entries. Clean { #[arg(long, default_value_t = 1)] diff --git a/src/reference/index.rs b/src/reference/index.rs new file mode 100644 index 0000000..56494e4 --- /dev/null +++ b/src/reference/index.rs @@ -0,0 +1,423 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +pub const INDEX_REL_PATH: &str = ".unity-cli-index/symbols.json"; +pub const INDEX_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReferenceSymbolEntry { + pub path: String, + pub name: String, + pub kind: String, + pub line: u32, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub namespace: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub container: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub fqn: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct IndexedFile { + pub signature: String, + pub symbols: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReferenceSymbolIndex { + pub version: u32, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub unity_version: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub branch: Option, + pub generated_at_epoch_ms: u64, + pub files: BTreeMap, +} + +impl Default for ReferenceSymbolIndex { + fn default() -> Self { + Self { + version: INDEX_VERSION, + unity_version: None, + branch: None, + generated_at_epoch_ms: 0, + files: BTreeMap::new(), + } + } +} + +pub fn extract_symbols_from_text(content: &str, rel_path: &str) -> Vec { + static NAMESPACE_RE: OnceLock = OnceLock::new(); + static TYPE_RE: OnceLock = OnceLock::new(); + let ns_re = NAMESPACE_RE.get_or_init(|| { + Regex::new(r"(?m)^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)") + .expect("namespace regex compiles") + }); + let type_re = TYPE_RE.get_or_init(|| { + Regex::new( + r"(?m)^[ \t]*(?:\[[^\]]*\][ \t]*)*(?:(?:public|internal|protected|private|static|abstract|sealed|partial|readonly)[ \t]+)*(class|interface|struct|enum)[ \t]+([A-Za-z_][A-Za-z0-9_]*)", + ) + .expect("type regex compiles") + }); + + let namespace = ns_re + .captures(content) + .and_then(|c| c.get(1).map(|m| m.as_str().to_string())); + + let mut symbols = Vec::new(); + for cap in type_re.captures_iter(content) { + let kind = cap + .get(1) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + let name = cap + .get(2) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + if name.is_empty() { + continue; + } + let full = cap.get(0).expect("full match exists"); + let line = (content[..full.start()].matches('\n').count() as u32) + 1; + let fqn = namespace.as_ref().map(|ns| format!("{ns}.{name}")); + symbols.push(ReferenceSymbolEntry { + path: rel_path.to_string(), + name, + kind, + line, + namespace: namespace.clone(), + container: None, + fqn, + }); + } + symbols +} + +pub fn file_signature(metadata: &fs::Metadata) -> String { + let mtime = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("{}:{}", metadata.len(), mtime) +} + +pub fn build_or_update_index(version_dir: &Path) -> Result { + let index_path = version_dir.join(INDEX_REL_PATH); + let mut index = load_existing_index(&index_path).unwrap_or_default(); + if index.version != INDEX_VERSION { + index = ReferenceSymbolIndex::default(); + } + let mut seen = std::collections::BTreeSet::new(); + for entry in WalkDir::new(version_dir) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("cs") { + continue; + } + let rel = match path.strip_prefix(version_dir) { + Ok(p) => p.to_string_lossy().to_string(), + Err(_) => continue, + }; + if rel.starts_with(".unity-cli-index") || rel.starts_with(".git") { + continue; + } + seen.insert(rel.clone()); + let metadata = match fs::metadata(path) { + Ok(m) => m, + Err(_) => continue, + }; + let sig = file_signature(&metadata); + if let Some(existing) = index.files.get(&rel) { + if existing.signature == sig { + continue; + } + } + let contents = match fs::read_to_string(path) { + Ok(s) => s, + Err(_) => continue, + }; + let symbols = extract_symbols_from_text(&contents, &rel); + index.files.insert( + rel, + IndexedFile { + signature: sig, + symbols, + }, + ); + } + index.files.retain(|k, _| seen.contains(k)); + index.generated_at_epoch_ms = now_epoch_ms(); + save_index(&index_path, &index)?; + Ok(index) +} + +pub fn find_symbol( + index: &ReferenceSymbolIndex, + name: &str, + kind: Option<&str>, + namespace: Option<&str>, +) -> Vec { + let mut hits = Vec::new(); + for file in index.files.values() { + for sym in &file.symbols { + if sym.name != name { + continue; + } + if let Some(k) = kind { + if sym.kind != k { + continue; + } + } + if let Some(ns) = namespace { + if sym.namespace.as_deref() != Some(ns) { + continue; + } + } + hits.push(sym.clone()); + } + } + hits.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line))); + hits +} + +fn load_existing_index(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + serde_json::from_str(&contents).ok() +} + +fn save_index(path: &Path, index: &ReferenceSymbolIndex) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let contents = serde_json::to_string_pretty(index) + .with_context(|| format!("failed to serialize symbol index for {}", path.display()))?; + fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn now_epoch_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +pub fn validate_kind(kind: &str) -> Result<()> { + const ALLOWED: &[&str] = &[ + "class", + "interface", + "struct", + "enum", + "method", + "property", + "field", + ]; + if ALLOWED.contains(&kind) { + Ok(()) + } else { + Err(anyhow!( + "kind '{}' is not allowed (expected one of: {})", + kind, + ALLOWED.join(", ") + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use tempfile::TempDir; + + #[test] + fn extract_symbols_from_text_finds_class() { + let text = "namespace UnityEngine {\n public class Animator : Behaviour {\n }\n}\n"; + let symbols = extract_symbols_from_text(text, "Runtime/Animator.cs"); + assert!( + symbols + .iter() + .any(|s| s.name == "Animator" && s.kind == "class"), + "expected to find class Animator: {symbols:?}" + ); + } + + #[test] + fn extract_symbols_from_text_captures_namespace() { + let text = "namespace UnityEditor {\n public class AnimatorInspector { }\n}\n"; + let symbols = extract_symbols_from_text(text, "Editor/AnimatorInspector.cs"); + let hit = symbols + .iter() + .find(|s| s.name == "AnimatorInspector") + .expect("AnimatorInspector should be indexed"); + assert_eq!(hit.namespace.as_deref(), Some("UnityEditor")); + assert_eq!(hit.kind, "class"); + assert!(hit.line >= 1); + } + + #[test] + fn extract_symbols_from_text_finds_struct_and_interface() { + let text = "public interface IBar { }\npublic struct Vec3 { }\n"; + let symbols = extract_symbols_from_text(text, "Runtime/Types.cs"); + assert!(symbols + .iter() + .any(|s| s.name == "IBar" && s.kind == "interface")); + assert!(symbols + .iter() + .any(|s| s.name == "Vec3" && s.kind == "struct")); + } + + #[test] + fn build_or_update_index_persists_signature() { + let tmp = TempDir::new().unwrap(); + let version_dir = tmp.path(); + let src_dir = version_dir.join("Runtime"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write( + src_dir.join("Foo.cs"), + "namespace UnityEngine {\n public class Foo { }\n}\n", + ) + .unwrap(); + let index = build_or_update_index(version_dir).unwrap(); + assert_eq!(index.version, INDEX_VERSION); + assert_eq!(index.files.len(), 1); + let entry = index + .files + .iter() + .find(|(p, _)| p.contains("Foo.cs")) + .unwrap(); + assert!(!entry.1.signature.is_empty()); + assert!(entry.1.symbols.iter().any(|s| s.name == "Foo")); + assert!(version_dir.join(INDEX_REL_PATH).exists()); + } + + #[test] + fn find_symbol_filters_by_kind_and_namespace() { + let mut index = ReferenceSymbolIndex::default(); + index.files.insert( + "Runtime/A.cs".to_string(), + IndexedFile { + signature: "1:1".to_string(), + symbols: vec![ + ReferenceSymbolEntry { + path: "Runtime/A.cs".to_string(), + name: "Foo".to_string(), + kind: "class".to_string(), + line: 2, + namespace: Some("UnityEngine".to_string()), + container: None, + fqn: Some("UnityEngine.Foo".to_string()), + }, + ReferenceSymbolEntry { + path: "Runtime/A.cs".to_string(), + name: "Foo".to_string(), + kind: "method".to_string(), + line: 10, + namespace: Some("UnityEngine".to_string()), + container: Some("Animator".to_string()), + fqn: Some("UnityEngine.Animator.Foo".to_string()), + }, + ], + }, + ); + let hits = find_symbol(&index, "Foo", Some("class"), None); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].kind, "class"); + let ns_hits = find_symbol(&index, "Foo", None, Some("UnityEngine")); + assert_eq!(ns_hits.len(), 2); + let nothing = find_symbol(&index, "Bar", None, None); + assert!(nothing.is_empty()); + } + + #[test] + fn validate_kind_accepts_known_and_rejects_unknown() { + validate_kind("class").unwrap(); + validate_kind("method").unwrap(); + let err = validate_kind("alien").unwrap_err(); + assert!(format!("{err:#}").contains("not allowed")); + } + + #[test] + fn file_signature_changes_when_content_changes() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("a.cs"); + fs::write(&path, b"short").unwrap(); + let sig1 = file_signature(&fs::metadata(&path).unwrap()); + std::thread::sleep(std::time::Duration::from_millis(15)); + fs::write(&path, b"longer content").unwrap(); + let sig2 = file_signature(&fs::metadata(&path).unwrap()); + assert_ne!(sig1, sig2); + } + + #[test] + fn build_or_update_index_skips_already_indexed_files_until_change() { + let tmp = TempDir::new().unwrap(); + let version_dir = tmp.path(); + fs::create_dir_all(version_dir.join("Runtime")).unwrap(); + fs::write(version_dir.join("Runtime/X.cs"), "public class X { }\n").unwrap(); + let first = build_or_update_index(version_dir).unwrap(); + let first_sig = first + .files + .values() + .next() + .map(|f| f.signature.clone()) + .unwrap(); + let second = build_or_update_index(version_dir).unwrap(); + let second_sig = second + .files + .values() + .next() + .map(|f| f.signature.clone()) + .unwrap(); + assert_eq!(first_sig, second_sig); + } + + fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/reference-cache") + } + + #[test] + fn build_or_update_index_handles_existing_fixture() { + let tmp = TempDir::new().unwrap(); + let version_dir = tmp.path(); + // Copy fixture into version_dir + for entry in WalkDir::new(fixture_root()) + .into_iter() + .filter_map(|e| e.ok()) + { + let src = entry.path(); + let rel = src.strip_prefix(fixture_root()).unwrap(); + let dst = version_dir.join(rel); + if entry.file_type().is_dir() { + fs::create_dir_all(&dst).unwrap(); + } else if entry.file_type().is_file() { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::copy(src, &dst).unwrap(); + } + } + let index = build_or_update_index(version_dir).unwrap(); + assert!(index.files.len() >= 2); + let hits = find_symbol(&index, "Animator", Some("class"), None); + assert!(!hits.is_empty(), "Animator class should be discovered"); + } +} diff --git a/src/reference/mod.rs b/src/reference/mod.rs index 82812a5..2679126 100644 --- a/src/reference/mod.rs +++ b/src/reference/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod fetcher; +pub mod index; pub mod search; pub mod version; @@ -20,6 +21,7 @@ pub fn maybe_execute_reference_tool(tool_name: &str, params: &Value) -> Option Some(execute_grep(params)), "reference_view" => Some(execute_view(params)), "reference_clean" => Some(execute_clean(params)), + "reference_find_symbol" => Some(execute_find_symbol(params)), _ => None, } } @@ -198,6 +200,33 @@ fn execute_view(params: &Value) -> Result { })) } +fn execute_find_symbol(params: &Value) -> Result { + let name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("find_symbol requires `name`"))?; + let kind = params.get("kind").and_then(Value::as_str); + if let Some(k) = kind { + index::validate_kind(k)?; + } + let namespace = params.get("namespace").and_then(Value::as_str); + let version = resolve_version(params)?; + let dir = cache::version_dir(&version)?; + if !dir.exists() { + return Err(anyhow!( + "reference cache for version '{}' does not exist; run `unity-cli reference fetch` first", + version + )); + } + let symbol_index = index::build_or_update_index(&dir)?; + let hits = index::find_symbol(&symbol_index, name, kind, namespace); + Ok(json!({ + "ok": true, + "version": version, + "hits": hits, + })) +} + fn execute_clean(params: &Value) -> Result { let keep = params.get("keep").and_then(Value::as_u64).unwrap_or(1) as usize; let dry_run = params @@ -670,4 +699,102 @@ mod tests { ); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn execute_find_symbol_requires_name() { + let err = maybe_execute_reference_tool("reference_find_symbol", &json!({})) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("name")); + } + + #[test] + fn execute_find_symbol_rejects_unknown_kind() { + let err = maybe_execute_reference_tool( + "reference_find_symbol", + &json!({"name": "Foo", "kind": "alien"}), + ) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("not allowed")); + } + + #[test] + fn execute_find_symbol_returns_error_when_cache_missing() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("find-missing"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let err = maybe_execute_reference_tool( + "reference_find_symbol", + &json!({"name": "Foo", "version": "missing"}), + ) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("does not exist")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_find_symbol_returns_hits_from_fixture() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("find-hits"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_find_symbol", + &json!({"name": "Animator", "kind": "class", "version": version}), + ) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + let hits = value["hits"].as_array().unwrap(); + assert!(!hits.is_empty(), "Animator class should be discovered"); + assert!(hits.iter().all(|h| h["kind"] == "class")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_find_symbol_filters_namespace() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("find-ns"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_find_symbol", + &json!({ + "name": "AnimatorInspector", + "kind": "class", + "namespace": "UnityEditor", + "version": version, + }), + ) + .unwrap() + .unwrap(); + let hits = value["hits"].as_array().unwrap(); + assert!(!hits.is_empty()); + assert_eq!(hits[0]["namespace"], "UnityEditor"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_find_symbol_empty_hits_for_unknown_name() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("find-unknown"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_find_symbol", + &json!({"name": "Nonexistent", "version": version}), + ) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + assert!(value["hits"].as_array().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/src/tooling/tool_catalog.rs b/src/tooling/tool_catalog.rs index b4e55bb..4051b2e 100644 --- a/src/tooling/tool_catalog.rs +++ b/src/tooling/tool_catalog.rs @@ -126,6 +126,7 @@ pub const TOOL_NAMES: &[&str] = &[ "reference_grep", "reference_view", "reference_clean", + "reference_find_symbol", ]; #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -228,7 +229,8 @@ fn tool_executor(name: &str) -> ToolExecutor { | "reference_search" | "reference_grep" | "reference_view" - | "reference_clean" => ToolExecutor::Local, + | "reference_clean" + | "reference_find_symbol" => ToolExecutor::Local, _ => ToolExecutor::Remote, } } @@ -280,6 +282,7 @@ fn is_read_only_tool(name: &str) -> bool { | "reference_search" | "reference_grep" | "reference_view" + | "reference_find_symbol" ) } @@ -2292,6 +2295,17 @@ fn tool_params_schema(name: &str) -> Value { &[], false, ), + "reference_find_symbol" => object_schema( + &[ + ("name", string_schema()), + ("kind", string_schema()), + ("namespace", string_schema()), + ("version", string_schema()), + ("projectRoot", string_schema()), + ], + &["name"], + false, + ), _ => default_params_schema(), } } @@ -2406,7 +2420,7 @@ mod tests { #[test] fn tool_catalog_keeps_manifest_parity_count() { - assert_eq!(TOOL_NAMES.len(), 124); + assert_eq!(TOOL_NAMES.len(), 125); } #[test] diff --git a/tasks/lessons.md b/tasks/lessons.md index e945d79..0d0edc2 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -23,3 +23,24 @@ - Mistake: 完了判定の一次情報を Issue 本文ではなく、自分の縮約した内部チェックリストに置いてしまった。 - Rule: `gwt-spec` Issue の完了判定は必ず Issue 本文の `Tasks` / 受け入れ基準 / PR本文 / 作業ツリー状態を同期させた上で行う。1つでも未同期なら「完了」と言わない。 - Checkpoint: 1. Issue 本文の `Tasks` を更新 2. 検証結果を Issue/PR に反映 3. `git status --short` を確認 4. ignore すべきローカル生成物が残っていれば `.gitignore` か cleanup を先に行う + +### 2026-05-11 (SPEC #185 振り返り) + +- Context: SPEC #185 (UnityCsReference 参照キャッシュ Phase 1) を実装した PR #186 が、CI で `Rust Format & Lint` 失敗を 3 回繰り返した。原因はローカル clippy が 0.1.94、CI clippy が 1.95.0 で、新規 lint (`collapsible_match`、`unnecessary_sort_by`、`await_holding_lock`) と `unused_variables` を見逃していた。 +- Mistake: ローカルでの `cargo clippy --all-targets -- -D warnings` が clean だったため push 前検証を完了と見なした。 +- Rule: 大きな PR を出す前に、CI と同じ rust toolchain で clippy を回す。`rustup show active-toolchain` を CI の `dtolnay/rust-toolchain@stable` 指定と突き合わせ、ずれていたら `rustup update stable` してから再検証。最低限 `cargo +stable clippy --all-targets -- -D warnings` を必ず通す。 +- Checkpoint: 1. `rustup show active-toolchain` を実行 2. CI の rust-toolchain 指定と一致するか確認 3. 不一致なら `rustup update` 4. clippy 再実行 + +### 2026-05-11 (coverage gate) + +- Context: SPEC #185 で新規 module `src/reference/*` を ~1,000 行追加した結果、Phase 1 第 1 commit 時点の line coverage が 89.15% まで下がり、CI `Rust Coverage >= 90% (required)` が 2 連続で fail した。 +- Mistake: 新規 module の test を「主要パスを cover していれば十分」と簡略化したため、dispatcher / wiring / error paths が未 cover で全体閾値を割った。 +- Rule: 大規模な新規 module を追加する PR では、追加直後に `cargo llvm-cov --all-targets --summary-only -- --test-threads=1` をローカルで 1 回実行して 90% を確認する。reference / dispatcher / CLI 配線 / error path も TDD の中で test を書く。 +- Checkpoint: 1. 新規 module 着手時に test stub を先に書く (RED) 2. 実装ごとに対応する test を増やす 3. PR push 前にローカル llvm-cov で全体 90% を確認 4. 90% 未達なら不足 module を識別し、small test を集中投入 + +### 2026-05-11 (SPEC section markers) + +- Context: SPEC #185 を `gwtd issue spec create --title ... -f ` で plain markdown 本文として作成したため、`` コメントが付かず、`gwtd issue spec 185 --section spec` / `--edit tasks -f ` などの section 操作が `section 'spec' not found` で失敗した。 +- Mistake: section 構造が必要であることを起票時に意識せず、`gwt-build-spec` の completion gate で tasks セクション更新ができなくなった。 +- Rule: SPEC を起票するときは body 内に `## Spec` / `## Plan` / `## Tasks` / `## TDD` の見出しを揃え、必要なら `gwtd issue spec --edit
` を初回投入時から使う。`--edit` 経由なら gwtd 側が `` コメントを管理してくれる。 +- Checkpoint: 1. spec create 直後に `gwtd issue spec ` を実行して `` が埋まっているか確認 2. 空ならその場で `--edit spec` / `--edit plan` / `--edit tasks` / `--edit tdd` で投入し直す