Fix six open issues: stale imports, MCP validation, import-graph gaps, dup importers, I/O errors, scope docs

- pipeline: re-resolve imports for every file on update (not just changed),
  so add/remove/rename of a target fixes unchanged importers; propagate hash
  and read errors instead of treating unreadable files as empty
- discovery: report unreadable walk entries to stderr instead of dropping them
- imports: strip `pub` visibility so `pub use` re-exports resolve; share
  extension/module-entry probe lists (adds cc,cxx,hpp,hh,hxx,mts,cts,jsx,mjs,cjs)
- store: DISTINCT + ORDER in importers_of to dedup repeated edges
- mcp: validate required tool args (explicit isError), emit JSON-RPC -32700
  parse errors and -32600 invalid-request instead of silently skipping
- README: document the 13-tool surface vs toktoken's 27 and known gaps
- tests: regression coverage for update re-resolution, pub-use re-exports,
  importer dedup, and MCP error reporting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
endergebnisandCopilot committed 2026-09-04 18:49:06 +02:00
1 parent 6875998ab6
commit 4338e28ce0
7 files changed
+271 -49

No files matched your search

+131 -1
View File
@@ -144,7 +144,10 @@ fn inspect_blast_radius_finds_affected_files() {
.collect();
// lib.rs imports util.rs directly; main.rs reaches it via the crate name.
assert!(affected.contains(&"src/lib.rs"), "lib.rs imports util.rs");
assert!(affected.contains(&"src/main.rs"), "main.rs → lib.rs → util.rs");
assert!(
affected.contains(&"src/main.rs"),
"main.rs → lib.rs → util.rs"
);
}
#[test]
@@ -496,3 +499,130 @@ fn index_update_reindexes_changed_files() {
"index update picks up the new symbol"
);
}
#[test]
fn index_update_reimports_unchanged_file_and_dedups() {
let tmp = cache_dir().join("update-reimport");
let _ = std::fs::remove_dir_all(&tmp);
let src = tmp.join("src");
std::fs::create_dir_all(&src).unwrap();
// Two `use` lines targeting the same (not-yet-existing) module.
std::fs::write(
src.join("main.rs"),
"use util::hello;\nuse util::world;\n\nfn main() {}\n",
)
.unwrap();
run_json(&["index", "create", tmp.to_str().unwrap()]);
std::fs::write(
src.join("util.rs"),
"pub fn hello() {}\npub fn world() {}\n",
)
.unwrap();
run_json(&["index", "update", tmp.to_str().unwrap()]);
let after = run_json(&[
"--project",
tmp.to_str().unwrap(),
"find",
"importers",
"src/util.rs",
]);
let names: Vec<&str> = after
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
names,
vec!["src/main.rs"],
"unchanged main.rs re-resolves to the new util.rs, deduplicated"
);
}
#[test]
fn rust_pub_use_re_exports_resolve() {
let tmp = cache_dir().join("pub-use");
let _ = std::fs::remove_dir_all(&tmp);
let src = tmp.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("inner.rs"), "pub fn x() {}\n").unwrap();
std::fs::write(src.join("re.rs"), "pub use inner;\n").unwrap();
run_json(&["index", "create", tmp.to_str().unwrap()]);
let importers = run_json(&[
"--project",
tmp.to_str().unwrap(),
"find",
"importers",
"src/inner.rs",
]);
let names: Vec<&str> = importers
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
names.contains(&"src/re.rs"),
"pub use in re.rs re-exports inner.rs"
);
}
#[test]
fn mcp_reports_invalid_params_and_parse_errors() {
let src = fixture("rustdemo");
let tmp = cache_dir().join("mcp-errors");
let _ = std::fs::remove_dir_all(&tmp);
copy_dir(&src, &tmp);
run_json(&["index", "create", tmp.to_str().unwrap()]);
let mut child = Command::new(bin())
.args(["--project", tmp.to_str().unwrap(), "serve"])
.env("XDG_CACHE_HOME", cache_dir())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn serve");
{
let mut stdin = child.stdin.take().unwrap();
// Missing required `query` argument -> tool-level isError.
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"search_symbols","arguments":{{}}}}}}"#
)
.unwrap();
// Malformed JSON -> JSON-RPC parse error (id null, code -32700).
writeln!(stdin, "this is not json").unwrap();
}
let out = child.wait_with_output().unwrap();
let responses: Vec<Value> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let call = responses
.iter()
.find(|r| r["id"] == 2)
.expect("tools/call response");
assert_eq!(call["result"]["isError"], true, "missing arg is an error");
assert!(
call["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("query"),
"error names the missing argument"
);
let parse = responses
.iter()
.find(|r| r["error"]["code"] == -32700)
.expect("parse error response");
assert_eq!(parse["error"]["message"], "parse error");
}