Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions crates/sem-cli/src/commands/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<EntityInfo> = stale
.par_iter()
.flat_map_iter(|path| reextract_file(&registry, 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<String> = defs.iter().map(|e| e.file_path.clone()).collect();
for path in dedup(def_files) {
Expand Down
78 changes: 78 additions & 0 deletions crates/sem-cli/tests/index_membership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading