diff --git a/crates/sem-cli/src/commands/query.rs b/crates/sem-cli/src/commands/query.rs index 0700e516..bb95a827 100644 --- a/crates/sem-cli/src/commands/query.rs +++ b/crates/sem-cli/src/commands/query.rs @@ -252,12 +252,10 @@ pub(crate) fn is_file_stale(idx: &QueryIndex, root: &Path, path: &str) -> bool { /// *corpus-shaped* index answer needs (: `sem impact --all/--tests`' /// transitive walk, `sem graph`'s whole-repo dump, `sem context`'s subgraph). /// -/// The entity-scoped verbs (`find`/`callers`/`refs`/`impact --deps`) get away -/// with proving only the files their own answer touches, because an edit -/// anywhere else cannot change *their* answer. A transitive walk has no such -/// boundary: an edit in a file the walk never visits can add an edge *into* -/// the closure, so the only honest gate is the one the SQL path already -/// used — the whole corpus, membership and content both. +/// Name lookup must also inspect changed files that did not previously match: +/// an edit can introduce a new name. It repairs definition rows locally rather +/// than rebuilding topology. A transitive walk additionally needs fresh edges +/// from the whole corpus, so its gate proves membership and content together. /// /// - membership: `index::complete_check` (`Complete` tier), which needs /// `DIRS`; an image without it is refused outright rather than trusted, @@ -368,6 +366,32 @@ fn index_answer_verified( let registry = super::create_registry(&opts.cwd); let mut defs = resolve_defs(idx, &opts.query, opts.file.as_deref()); + if verb == Verb::Find { + use rayon::prelude::*; + // A rename or a new declaration in an existing file is absent from + // NAMES. Checking only files of existing hits silently misses it. + // Stat the indexed corpus, but parse only changed files: no topology + // rebuild or whole-graph serialization is needed for definitions. + let files = idx.all_file_paths(); + let stale: Vec<_> = files + .par_iter() + .copied() + .filter(|path| opts.file.as_deref().is_none_or(|file| file == *path)) + .filter(|path| file_is_stale(idx, root, path)) + .collect(); + defs.retain(|entity| !stale.iter().any(|path| entity.file_path == *path)); + let fresh: Vec = stale + .par_iter() + .flat_map_iter(|path| reextract_file(®istry, root, path)) + .filter(|entity| matches_query(entity, &opts.query)) + .collect(); + defs.extend(fresh); + return Some(Answer { + defs, + related: Vec::new(), + }); + } + // Definition-side freshness: content-local, repaired in place. let def_files: Vec = defs.iter().map(|e| e.file_path.clone()).collect(); for path in dedup(def_files) { diff --git a/crates/sem-cli/tests/index_membership.rs b/crates/sem-cli/tests/index_membership.rs index 9030d4d6..f62db97a 100644 --- a/crates/sem-cli/tests/index_membership.rs +++ b/crates/sem-cli/tests/index_membership.rs @@ -54,6 +54,84 @@ fn prime_index(repo: &Path, cache: &Path) { ); } +#[test] +fn find_repairs_renames_and_added_duplicates_without_refresh() { + let repo = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + fs::write(repo.path().join("a.ts"), "export function original() {}\n").unwrap(); + fs::write(repo.path().join("b.ts"), "export function existing() {}\n").unwrap(); + prime_index(repo.path(), cache.path()); + fs::write(repo.path().join("a.ts"), + "export function newlyRenamed() { return 123; }\nexport function existing() { return 456; }\n").unwrap(); + for (query, expected) in [("newlyRenamed", 1), ("original", 0), ("existing", 2)] { + let out = assert_success( + sem(repo.path(), cache.path(), &["find", query, "--json"]), + query, + ); + let rows: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + rows.as_array().unwrap().len(), + expected, + "{query}: {}", + output_text(&out) + ); + } + // A second edit must also be visible; no eager graph refresh is involved. + fs::write( + repo.path().join("a.ts"), + "export function finalNameAfterSecondEdit() {}\n", + ) + .unwrap(); + let out = assert_success( + sem( + repo.path(), + cache.path(), + &["find", "finalNameAfterSecondEdit", "--json"], + ), + "second edit", + ); + let rows: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(rows.as_array().unwrap().len(), 1); +} + +#[test] +fn find_repairs_existing_files_across_languages() { + for (file, before, after) in [ + ( + "a.py", + "def old_name():\n return 1\n", + "def newly_added_name():\n return 123\n", + ), + ( + "a.rs", + "fn old_name() {}\n", + "fn newly_added_name() { let _x = 123; }\n", + ), + ] { + let repo = TempDir::new().unwrap(); + let cache = TempDir::new().unwrap(); + fs::write(repo.path().join(file), before).unwrap(); + prime_index(repo.path(), cache.path()); + fs::write(repo.path().join(file), after).unwrap(); + let out = assert_success( + sem( + repo.path(), + cache.path(), + &["find", "newly_added_name", "--json"], + ), + file, + ); + let rows: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!( + rows.as_array().unwrap().len(), + 1, + "{file}: {}", + output_text(&out) + ); + assert_eq!(rows[0]["file"], file); + } +} + /// Wait until `dir`'s mtime has visibly moved past `before` — the POSIX /// signal `Complete` freshness leans on: creating, /// deleting, or renaming a directory entry bumps the directory's own mtime.