diff --git a/Cargo.lock b/Cargo.lock index ee81d1a9..137d9f1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -592,6 +592,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tree-sitter", "unicode-normalization", ] @@ -1143,7 +1144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1820,7 +1821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2384,7 +2385,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2702,7 +2703,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2771,7 +2772,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3217,7 +3218,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3572,9 +3573,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" +checksum = "a5129de2ccc97808c4583d48f1fb44576d015cf241e432800572bda11ef30c3b" dependencies = [ "cc", "regex", @@ -3615,9 +3616,9 @@ dependencies = [ [[package]] name = "tree-sitter-cfml" -version = "0.26.42" +version = "0.26.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1c57e5c21ce03a08338c67441b40125227066634297ea0c2c8621372541e95" +checksum = "68ebd7091acf7ec18caf8e99c6b95116bc03b04173244d8ef26c0c185631bb9a" dependencies = [ "cc", "tree-sitter-language", @@ -3655,9 +3656,9 @@ dependencies = [ [[package]] name = "tree-sitter-erlang" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3107efc581d335488dc38d2f51ca8b7a6a98e36f07f95a8e963f031edda6f568" +checksum = "8079a9cfe733c3b6749bb3c683c0e997ee68a2305f7a210800bbbc53e82d3f64" dependencies = [ "cc", "tree-sitter-language", @@ -3745,9 +3746,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" [[package]] name = "tree-sitter-lua" @@ -3801,9 +3802,9 @@ dependencies = [ [[package]] name = "tree-sitter-php" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +checksum = "d9779e392d38af4254f48be0e57b7eed4d50ec7a6e55a7e5e2e74df8f8f032d3" dependencies = [ "cc", "tree-sitter-language", @@ -3861,9 +3862,9 @@ dependencies = [ [[package]] name = "tree-sitter-scala" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24e0ab4505990bfe30051761d40a7bf4033ce5a81c9eda9e20e987a5cdc84826" +checksum = "c554730d29306d9c7cdd26912638d2401378c26961e12d161a071df43f49b81b" dependencies = [ "cc", "tree-sitter-language", @@ -4233,7 +4234,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index e07c5635..027afa00 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -2152,6 +2152,69 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { } return Ok(()); } + // A database a newer codegraph wrote (OD-19) is unreadable here, and + // nothing may write it; report which versions disagree instead of + // failing like a read. + Err( + error @ codegraph_store::StoreError::FutureSchema { + found, + supported, + discard_with_uninit, + .. + }, + ) => { + if json_output { + let mut status = json!({ + "initialized": false, + "version": VERSION, + "projectPath": project, + "indexPath": index_root, + "lastIndexed": null, + "dbPath": db, + "dbExists": db_exists, + "dbSizeBytes": db_size, + "extractionStatus": "current", + "extractionStatusDetail": error.to_string(), + "recoveryCommand": null, + "legacyIndexPresent": legacy_index_present, + "legacyIndexPaths": legacy_index_paths, + "daemonRunning": daemon_running, + "daemonPidPath": daemon_pid_path, + "daemonSocketPath": daemon_socket_path, + "daemonLogPath": daemon_log_path, + }); + if wal_size > 0 { + status["walSizeBytes"] = json!(wal_size); + } + print_json(&status)?; + } else { + println!("\nCodeGraph Status\n"); + println!("Project: {}\n", project.display()); + println!( + "State: current (schema {found} is newer than this binary's {supported})" + ); + println!("Index Statistics:"); + println!(" DB Size: {:.2} MB", db_size as f64 / 1024.0 / 1024.0); + print_wal_status(wal_size, db_size); + println!("\n DB Path: {}", db.display()); + println!( + " Daemon: {}", + if daemon_running { "running" } else { "stopped" } + ); + if discard_with_uninit { + println!( + "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph, or run `codegraph uninit --force {}` and then `codegraph init {}` to discard and rebuild it.", + project.display(), + project.display() + ); + } else { + println!( + "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph to use it." + ); + } + } + return Ok(()); + } Err(error) => return Err(error.into()), }; if status_open.rebuilding { @@ -6506,7 +6569,7 @@ fn index_project_inner( codegraph_watch::control_inputs::write_index_rows( &store, project, - &resolver.framework_names(), + &resolver.framework_signature(), )?; if resolver.has_framework_resolvers() { let relative_files = store diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md new file mode 100644 index 00000000..730ae02c --- /dev/null +++ b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md @@ -0,0 +1,20 @@ +# Index written by codegraph v0.55.2 + +`codegraph.db` is the database the released v0.55.2 binary (schema 10, extraction +version 22) wrote for `crates/codegraph-bench/fixtures/mini/`. The upgrade tests +open it with the current binary to prove that an index from the previous release +migrates: the outdated extraction version triggers one full rebuild. + +Provenance: + +- Binary: `codegraph 0.55.2` from `codegraph-0.55.2-x86_64-unknown-linux-musl.tar.gz` + (checked against the release's `SHA256SUMS` and `gh attestation verify`), sha256 + `6c8f0093905b08d17848dfcc05b6d04f88256bca88a89186a71c052b6165d57c`. +- Command: `CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 codegraph init `, then `.codegraph/codegraph.db` copied + here. +- `codegraph.db` sha256: + `12cfc45a0c157873f75567fcd85d8ef0522eeead31bdfe2043513d9cecf16a0b`. + +Do not regenerate it with a newer binary: its value is that an old release +wrote it. diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db new file mode 100644 index 00000000..5131f3ea Binary files /dev/null and b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db differ diff --git a/crates/codegraph-cli/tests/future_schema_cli.rs b/crates/codegraph-cli/tests/future_schema_cli.rs new file mode 100644 index 00000000..d040caaf --- /dev/null +++ b/crates/codegraph-cli/tests/future_schema_cli.rs @@ -0,0 +1,419 @@ +//! An index a newer codegraph wrote (OD-19), through the commands a user runs: +//! `status` reports it, reads, `sync` and `init` refuse it without changing a +//! byte, MCP answers with the refusal, a daemon does not start on it, and +//! `uninit --force` followed by `init` discards and rebuilds it. + +mod upgrade_support; + +use std::collections::BTreeMap; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use codegraph_core::IndexPaths; +use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; +use codegraph_store::{ExtractionStatus, Store}; +use serde_json::{Value, json}; +use upgrade_support::*; + +const NEWER: i64 = CURRENT_SCHEMA_VERSION + 1; + +fn sidecar(db: &Path, suffix: &str) -> PathBuf { + PathBuf::from(format!("{}{suffix}", db.display())) +} + +/// The mini corpus indexed by this binary. +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + project +} + +/// Record schema `NEWER` in the project's database, checkpointed. +fn record_newer_schema(paths: &IndexPaths) { + let db = paths.current_db(); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .unwrap(); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .unwrap(); + drop(conn); + for suffix in ["-wal", "-shm"] { + let _ = fs::remove_file(sidecar(&db, suffix)); + } +} + +/// Record schema `NEWER` in the database's log only, as a newer binary killed +/// before its checkpoint leaves it. +fn record_newer_schema_in_the_log_only(paths: &IndexPaths) { + let db = paths.current_db(); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch("PRAGMA wal_autocheckpoint = 0;") + .unwrap(); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .unwrap(); + let main = fs::read(&db).unwrap(); + let wal = fs::read(sidecar(&db, "-wal")).unwrap(); + drop(conn); + fs::write(&db, main).unwrap(); + fs::write(sidecar(&db, "-wal"), wal).unwrap(); + let _ = fs::remove_file(sidecar(&db, "-shm")); +} + +/// The database, its sidecars and the state slots, byte for byte (`None` when +/// absent). The daemon's own log, pid and socket files are not part of it. +fn index_bytes(paths: &IndexPaths) -> BTreeMap>> { + let db = paths.current_db(); + let mut files = BTreeMap::new(); + for (label, path) in [ + ("db", db.clone()), + ("wal", sidecar(&db, "-wal")), + ("shm", sidecar(&db, "-shm")), + ("slot0", paths.state_slots()[0].clone()), + ("slot1", paths.state_slots()[1].clone()), + ("tombstone", paths.tombstone()), + ] { + files.insert(label.to_string(), fs::read(path).ok()); + } + files +} + +fn assert_unchanged(paths: &IndexPaths, before: &BTreeMap>>, what: &str) { + let after = index_bytes(paths); + for (label, bytes) in before { + assert!( + after.get(label) == Some(bytes), + "{what} changed the index's {label}" + ); + } +} + +#[test] +fn status_reports_an_index_a_newer_codegraph_wrote() { + let dir = TestDir::new("future-schema-status"); + let project = indexed_project(&dir); + record_newer_schema(&index_paths(&project)); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["initialized"], false, "{}", status.stdout); + assert_eq!(json["extractionStatus"], "current", "{}", status.stdout); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && detail.contains("uninit --force"), + "{detail}" + ); + assert_eq!(json["recoveryCommand"], Value::Null); + + let human = cli(&["status", path_str(&project)]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&format!( + "State: current (schema {NEWER} is newer than this binary's {CURRENT_SCHEMA_VERSION})" + )), + "{}", + human.stdout + ); + assert!( + human.stdout.contains("codegraph uninit --force"), + "{}", + human.stdout + ); +} + +#[test] +fn reads_sync_and_init_refuse_a_newer_schema_and_uninit_discards_it() { + let dir = TestDir::new("future-schema-commands"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let search = cli(&["search", "add", "-p", path_str(&project)]); + assert!(!search.ok, "search read a newer codegraph's index"); + assert!( + search.stderr.contains("newer CodeGraph") && search.stderr.contains("uninit --force"), + "{}", + search.stderr + ); + assert_unchanged(&paths, &before, "search"); + + let sync = cli(&["sync", path_str(&project)]); + assert!(!sync.ok, "sync wrote a newer codegraph's index"); + assert!(sync.stderr.contains("newer CodeGraph"), "{}", sync.stderr); + assert_unchanged(&paths, &before, "sync"); + + let init = cli(&["init", path_str(&project)]); + assert!(!init.ok, "init replaced a newer codegraph's index"); + assert!(init.stderr.contains("newer CodeGraph"), "{}", init.stderr); + assert_unchanged(&paths, &before, "init"); + + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + assert!(!paths.current_db().exists()); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init after uninit failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); +} + +/// The same index beside the tombstone a newer codegraph's `init` could not +/// remove: `init` refuses it unchanged and names `uninit --force`, which +/// discards it, and `init` then rebuilds it. +#[test] +fn a_tombstoned_index_a_newer_codegraph_wrote_is_discarded_by_uninit() { + let dir = TestDir::new("future-schema-tombstone"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + fs::write(paths.tombstone(), b"remove failed").unwrap(); + let before = index_bytes(&paths); + + // `status` reports the newer schema, not the tombstone's `init` recovery. + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["extractionStatus"], "current", "{}", status.stdout); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && detail.contains("uninit --force"), + "{detail}" + ); + assert_eq!(json["recoveryCommand"], Value::Null, "{}", status.stdout); + let human = cli(&["status", path_str(&project)]); + assert!( + human.stdout.contains(&format!("schema {NEWER} is newer")) + && human.stdout.contains("codegraph uninit --force") + && !human.stdout.contains("blocked by the uninit tombstone"), + "{}", + human.stdout + ); + let search = cli(&["search", "add", "-p", path_str(&project)]); + assert!( + !search.ok && search.stderr.contains("newer CodeGraph"), + "{}", + search.stderr + ); + assert_unchanged(&paths, &before, "status and search"); + + let init = cli(&["init", path_str(&project)]); + assert!(!init.ok, "init replaced a newer codegraph's index"); + assert!( + init.stderr.contains("newer CodeGraph") && init.stderr.contains("uninit --force"), + "{}", + init.stderr + ); + assert_unchanged(&paths, &before, "init"); + + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + assert!(!paths.current_db().exists()); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init after uninit failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!(!paths.tombstone().exists(), "init removed the tombstone"); +} + +/// Where `uninit --force` would fail (a tombstoned newer-schema index whose +/// extraction stamp is missing), `status` reports the newer schema without +/// offering it, in JSON and in its human output. +#[test] +fn status_offers_no_uninit_where_uninit_would_fail() { + let dir = TestDir::new("future-schema-no-discard"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + { + let conn = rusqlite::Connection::open(paths.current_db()).unwrap(); + conn.execute( + "DELETE FROM project_metadata WHERE key = 'indexed_with_extraction_version'", + [], + ) + .unwrap(); + } + record_newer_schema(&paths); + fs::write(paths.tombstone(), b"remove failed").unwrap(); + let before = index_bytes(&paths); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && !detail.contains("uninit"), + "{detail}" + ); + let human = cli(&["status", path_str(&project)]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&format!("schema {NEWER} is newer")) + && !human.stdout.contains("uninit"), + "{}", + human.stdout + ); + assert!( + !cli(&["uninit", "--force", path_str(&project)]).ok, + "uninit refuses the missing stamp" + ); + assert_unchanged(&paths, &before, "status and uninit"); +} + +#[test] +fn sync_leaves_a_newer_schema_in_the_log_untouched() { + let dir = TestDir::new("future-schema-log-sync"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema_in_the_log_only(&paths); + let before = index_bytes(&paths); + assert!(before["wal"].is_some() && before["shm"].is_none()); + + let sync = cli(&["sync", path_str(&project)]); + assert!(!sync.ok, "sync wrote a newer codegraph's index"); + assert!(sync.stderr.contains("newer CodeGraph"), "{}", sync.stderr); + assert_unchanged(&paths, &before, "sync"); +} + +/// A daemon's catch-up runs `codegraph_watch::sync_project_once`: it returns +/// the refusal and changes no byte, whether the newer version is checkpointed +/// or only in the log. +#[test] +fn the_catch_up_sync_refuses_a_newer_schema() { + type Record = fn(&IndexPaths); + let record: [(&str, Record); 2] = [ + ("checkpointed", record_newer_schema), + ("log", record_newer_schema_in_the_log_only), + ]; + for (label, record) in record { + let dir = TestDir::new(&format!("future-schema-catch-up-{label}")); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record(&paths); + let before = index_bytes(&paths); + let error = codegraph_watch::sync_project_once(&project) + .err() + .unwrap_or_else(|| panic!("{label}: the catch-up wrote a newer codegraph's index")); + assert!( + format!("{error:#}").contains("newer CodeGraph"), + "{label}: {error:#}" + ); + assert_unchanged(&paths, &before, "the catch-up"); + } +} + +/// The MCP server answers a tool call with the refusal (direct mode). +#[test] +fn an_mcp_tool_call_answers_with_the_refusal() { + let dir = TestDir::new("future-schema-mcp"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(["serve", "--mcp", "--path"]) + .arg(&project) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env( + "CODEGRAPH_MCP_REGISTRY_DIR", + dir.path().join("mcp-registry"), + ) + .env_remove("CODEGRAPH_DIR") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve --mcp"); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut send = |value: Value| { + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + }; + let mut read_id = |want: i64| -> Value { + loop { + let mut line = String::new(); + assert!( + stdout.read_line(&mut line).unwrap() > 0, + "serve closed stdout" + ); + let Ok(value) = serde_json::from_str::(line.trim()) else { + continue; + }; + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + }; + send(json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": { "name": "future-schema", "version": "0" } } + })); + read_id(1); + send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + send(json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "codegraph_search", "arguments": { "query": "add" } } + })); + let response = read_id(2).to_string(); + assert!(response.contains("newer CodeGraph"), "{response}"); + let _ = child.kill(); + let _ = child.wait(); + assert_unchanged(&paths, &before, "the MCP session"); +} + +/// A cold MCP session in the default daemon mode on `project`: wait until the +/// project's daemon refuses to start, then return a tool call's answer. +#[cfg(unix)] +fn cold_session_answer(dir: &TestDir, project: &Path, paths: &IndexPaths) -> String { + let mut session = cold::Session::start(project, dir.path()); + let log = cold::wait_for_daemon_log(paths, "refusing to start a daemon for", "", &session); + assert!( + !cold::daemon_live(project), + "no daemon may serve it:\n{log}" + ); + let answer = session.search("add"); + session.close(); + answer +} + +/// In the default daemon mode the project's daemon does not start on a newer +/// codegraph's index, and a tool call answers with the refusal. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_answers_with_the_refusal() { + let dir = TestDir::new("future-schema-cold"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let answer = cold_session_answer(&dir, &project, &paths); + assert!(answer.contains("newer CodeGraph"), "{answer}"); + assert_unchanged(&paths, &before, "the cold session"); +} + +/// The same when the newer schema is only in the log: the daemon folds nothing +/// into the index, and a read refuses the live log as it refuses any. +#[cfg(unix)] +#[test] +fn a_daemon_does_not_start_on_a_newer_schema_in_the_log() { + let dir = TestDir::new("future-schema-log-daemon"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema_in_the_log_only(&paths); + let before = index_bytes(&paths); + + let answer = cold_session_answer(&dir, &project, &paths); + assert!(answer.contains("Failed to open project"), "{answer}"); + assert_unchanged(&paths, &before, "the daemon start"); +} diff --git a/crates/codegraph-cli/tests/probes/main.rs b/crates/codegraph-cli/tests/probes/main.rs index 2238cb2b..c2c4b779 100644 --- a/crates/codegraph-cli/tests/probes/main.rs +++ b/crates/codegraph-cli/tests/probes/main.rs @@ -10547,6 +10547,70 @@ mod swift { "{report}" ); } + + /// Upstream #2683: a call written after `try!` links what it calls. Calls + /// after `try?` and `try` are the control. Upstream's TortureTry.swift, + /// reduced to calls whose callees the project declares. + /// + /// Not yet on Windows: the held tree-sitter-swift 0.7.3 builds the `try!` + /// entry of its scanner's 64-bit mask as `1UL << 32`, which MSVC folds to + /// 0, so there `try! Message2(…)` parses as a call of `! Message2` and + /// `try! Settings.Defaults.load()` loses its receiver. 0.7.4 fixes it but + /// regenerates the grammar, which needs a walker change first + /// (docs/grammar-manifest.md); drop the `ignore` with that upgrade. + #[test] + #[cfg_attr( + windows, + ignore = "tree-sitter-swift 0.7.3 misparses `try!` on MSVC (upstream #2683)" + )] + fn pr2683_a_call_after_try_bang_links_what_it_calls() { + let project = Project::new() + .file( + "App/Models.swift", + "import Foundation\n\nstruct Message2 {\n init(data: Data?) throws {}\n}\n\nfinal class Store {\n init(path: String) throws {}\n}\n\nenum Settings {\n enum Defaults {\n static func load(from name: String) throws -> Settings.Defaults { .init() }\n }\n}\n", + ) + .file( + "App/Repository.swift", + "import Foundation\n\nfinal class Repository {\n func load(data: Data?) throws -> Message2 {\n let msg2 = try! Message2(data: data!)\n let msg3 = try? Message2(data: data!)\n let msg4 = try Message2(data: data!)\n let store = try! Store(path: \"x\")\n let settings = try! Settings.Defaults.load(from: \"defaults\")\n try! validate(msg2)\n _ = (msg3, msg4, store, settings)\n return try! Message2(data: data!)\n }\n\n func validate(_ m: Message2) throws {}\n}\n", + ) + .index(); + let graph = project.graph(); + let lines = |kind: &str, target: &str| -> Vec { + let mut lines: Vec = graph + .edges_matching("method Repository::load", kind, target) + .iter() + .filter_map(|edge| edge.line) + .collect(); + lines.sort_unstable(); + lines + }; + let report = graph.report(); + assert_eq!( + lines("instantiates", "struct Message2"), + [5, 6, 7, 12], + "{report}" + ); + assert_eq!(lines("instantiates", "class Store"), [8], "{report}"); + assert_eq!( + lines("calls", "method Settings::Defaults::load"), + [9], + "{report}" + ); + assert_eq!( + lines("calls", "method Repository::validate"), + [10], + "{report}" + ); + // The misparse left references named after the `!`. + assert!( + graph + .unresolved + .iter() + .all(|reference| !reference.reference_name.starts_with('!')), + "{}", + graph.unresolved_list() + ); + } } mod objc { @@ -12818,7 +12882,7 @@ mod go { } mod rust { - use super::support::{Graph, Project}; + use super::support::{Graph, Project, assert_edge, assert_no_edge, assert_unresolved}; /// `kind:qualifiedName` of every `calls` / `references` / `instantiates` /// target out of the functions named `name` (upstream's `targets`). @@ -12852,6 +12916,33 @@ mod rust { .collect() } + /// The member-path std gate deferred on 2026-10-03 needs no change: since + /// the #2169 port, a receiver whose declared type the project does not + /// declare (`PathBuf`, `&Path`, `std::path::PathBuf`) binds no project + /// method by name, so `index_root.join(..)` no longer reaches the project's + /// `LineIndex::join`; a project-typed receiver keeps its method. + #[test] + fn pr2169_a_std_typed_rust_receiver_binds_no_project_method_by_name() { + let project = Project::new() + .file("Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n") + .file( + "src/lib.rs", + "use std::path::{Path, PathBuf};\n\npub struct LineIndex;\nimpl LineIndex {\n pub fn join(&self, _x: &str) -> usize { 0 }\n}\n\npub struct EnvGuard;\nimpl EnvGuard {\n pub fn set(&self) {}\n}\npub fn env_guard() -> EnvGuard { EnvGuard }\n\npub fn annotated(index_root: PathBuf) -> PathBuf {\n index_root.join(\"x\")\n}\n\npub fn let_typed() -> PathBuf {\n let base: PathBuf = PathBuf::from(\"a\");\n base.join(\"b\")\n}\n\npub fn path_typed(p: &Path) -> PathBuf {\n p.join(\"c\")\n}\n\npub fn std_path(q: std::path::PathBuf) -> PathBuf {\n q.join(\"d\")\n}\n\npub fn project_typed() {\n let mut env = env_guard();\n env.set();\n}\n", + ) + .index(); + + for (function, call) in [ + ("annotated", "index_root.join"), + ("let_typed", "base.join"), + ("path_typed", "p.join"), + ("std_path", "q.join"), + ] { + assert_no_edge!(project, function => calls => "method LineIndex::join"); + assert_unresolved!(project, function => calls => call); + } + assert_edge!(project, "project_typed" => calls => "method EnvGuard::set"); + } + /// Upstream #2124: a bare Rust name reaches only what is in scope — /// `Some(x)` / `Ok(x)` the prelude's, not a project variant or a struct /// another file declares; a variant through a `use` of its enum's `*`; a @@ -18670,3 +18761,842 @@ mod pascal_includes { ); } } + +/// What framework resolvers record on the edges they make (W7). +mod frameworks { + use super::support::Project; + + /// Upstream #2159: a link a framework or bridge resolver made names that + /// resolver in `metadata.framework`, as a synthesized channel names + /// itself with `synthesizedBy`: a Tauri `invoke('greet')` names `tauri`. + #[test] + fn pr2159_a_framework_resolved_edge_names_its_resolver() { + let project = Project::new() + .file("src-tauri/tauri.conf.json", "{}\n") + .file( + "src/main.ts", + "import { invoke } from '@tauri-apps/api/core';\n\nexport function run(): Promise {\n return invoke('greet');\n}\n", + ) + .file( + "src-tauri/src/main.rs", + "#[tauri::command]\npub fn greet() -> String {\n String::new()\n}\n\nfn main() {\n greet();\n}\n", + ) + .index(); + let graph = project.graph(); + let bridged = graph.edges_matching("file:src/main.ts", "calls", "function greet"); + assert_eq!(bridged.len(), 1, "{}", graph.report()); + assert_eq!(bridged[0].resolved_by(), Some("framework")); + assert_eq!( + bridged[0] + .metadata + .get("framework") + .and_then(|v| v.as_str()), + Some("tauri") + ); + // A call no framework resolved names none. + let direct = graph.edges_matching("function main", "calls", "function greet"); + assert_eq!(direct.len(), 1, "{}", graph.report()); + assert_eq!(direct[0].metadata.get("framework"), None); + } + + /// The kinds of every node named `name`, in order. + fn kinds_of(graph: &super::support::Graph, name: &str) -> Vec { + let mut kinds: Vec = graph + .nodes + .iter() + .filter(|node| node.name == name) + .map(|node| node.kind.clone()) + .collect(); + kinds.sort(); + kinds + } + + /// Upstream #841 and #2429: a const a wrapper declares as a component — + /// `forwardRef`, `memo`, `styled.x\`…\``, `styled(Base)\`…\``, typed or not + /// — is a `component` node, whose inline render function's calls are its + /// own; a lowercase memo, another template tag, a comparison and any + /// other initializer stay constants. + #[test] + fn pr0841_a_wrapper_declared_component_is_a_component() { + let project = Project::new() + .file("package.json", "{\"dependencies\":{\"react\":\"^18.0.0\"}}\n") + .file( + "ui.tsx", + "import * as React from 'react';\nimport styled from 'styled-components';\nexport const Button = React.forwardRef((props, ref) =>