From 1bcf5856940178388bb217e62d4cb6dff8bb9ede Mon Sep 17 00:00:00 2001 From: sunerpy Date: Tue, 1 Sep 2026 03:11:30 +0800 Subject: [PATCH] fix(cli): stabilize export and outdated status guidance --- crates/codegraph-cli/src/main.rs | 7 ++ .../tests/batch_m_outdated_migration.rs | 34 ++++++++ crates/codegraph-cli/tests/cli_commands.rs | 43 ++++++++++ crates/codegraph-graph/src/export.rs | 82 ++++++++++++++++++- 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index fc209218..8988cf09 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -1804,6 +1804,13 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { ExtractionStatus::Missing if lockless_missing => { println!("{extraction_status_detail}"); } + ExtractionStatus::Outdated { .. } => { + println!("Index was built by an older extraction version."); + println!( + "Recovery: run `codegraph sync {}` to migrate it.", + project.display() + ); + } _ => { println!("Not initialized"); println!("Run \"codegraph init\" to initialize"); diff --git a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs index 6003c739..75342c67 100644 --- a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs +++ b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs @@ -303,6 +303,40 @@ fn namespace_snapshot_detects_equal_length_byte_mutation() { ); } +#[test] +fn status_on_outdated_namespace_recommends_sync() { + let dir = TestDir::new("outdated-status-sync"); + let project = init_project("outdated-status-sync", &dir); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + + let run = cli(&["status", project.to_str().unwrap()]); + assert!( + run.ok, + "status must report an outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + assert!( + run.stdout.contains("State: outdated") + && run + .stdout + .contains(&format!("codegraph sync {}", project.display())), + "outdated status must prescribe ordinary sync: {}", + run.stdout + ); + assert!( + !run.stdout.contains("Not initialized") && !run.stdout.contains("codegraph init"), + "outdated status must not prescribe initialization: {}", + run.stdout + ); +} + /// Plan test 9: an `Outdated` namespace forces EVERY file through migration — /// zero unchanged skips even though every source byte is identical to the /// outdated database — and the result equals a fresh v2 `index --force`. diff --git a/crates/codegraph-cli/tests/cli_commands.rs b/crates/codegraph-cli/tests/cli_commands.rs index 75f2f7ab..b81b49bd 100644 --- a/crates/codegraph-cli/tests/cli_commands.rs +++ b/crates/codegraph-cli/tests/cli_commands.rs @@ -287,6 +287,49 @@ fn export_to_stdout_and_file() { ); } +#[test] +fn export_is_byte_stable_after_sync_and_force_rebuild() { + let dir = TestDir::new("export-sync-force-stable"); + let project = indexed_project(&dir); + let p = project.to_str().unwrap(); + let app = project.join("src/app.ts"); + let mut source = std::fs::read_to_string(&app).expect("read app fixture"); + source.push_str("\n// Force an incremental reindex without changing the extracted graph.\n"); + std::fs::write(&app, source).expect("edit app fixture"); + + let sync = run_in(dir.path(), &["sync", p]); + assert!( + sync.ok, + "sync must succeed: {} {}", + sync.stdout, sync.stderr + ); + let incremental = run_in(dir.path(), &["export", "-p", p]); + assert!( + incremental.ok, + "incremental export must succeed: {}", + incremental.stderr + ); + + let rebuild = run_in(dir.path(), &["index", "--force", p]); + assert!( + rebuild.ok, + "index --force must succeed: {} {}", + rebuild.stdout, rebuild.stderr + ); + let rebuilt = run_in(dir.path(), &["export", "-p", p]); + assert!( + rebuilt.ok, + "rebuilt export must succeed: {}", + rebuilt.stderr + ); + + assert_eq!( + incremental.stdout.as_bytes(), + rebuilt.stdout.as_bytes(), + "incremental sync and index --force must emit byte-identical graph JSON" + ); +} + #[test] fn unlock_on_clean_project_reports_nothing_to_do() { let dir = TestDir::new("unlock-clean"); diff --git a/crates/codegraph-graph/src/export.rs b/crates/codegraph-graph/src/export.rs index 25a5bdbb..63ab98fc 100644 --- a/crates/codegraph-graph/src/export.rs +++ b/crates/codegraph-graph/src/export.rs @@ -4,12 +4,13 @@ //! `agents_md_generator`) consume: `{nodes:[{id,label,file_type,source_file, //! kind,...}], links:[{source,target,relation,...}], edges:[...], directed, //! multigraph, graph}`. `links` is the canonical NetworkX `node_link_data` key; -//! `edges` is a duplicate alias for readers that expect it. Deterministic -//! (nodes/edges read in id order). +//! `edges` is a duplicate alias for readers that expect it. Deterministic: +//! nodes are read in stable id order and edges are sorted by their semantic +//! fields rather than SQLite insertion rowid. use std::collections::HashMap; -use codegraph_core::types::{Node, NodeKind}; +use codegraph_core::types::{Edge, Node, NodeKind}; use codegraph_store::Store; use serde_json::{Map, Value, json}; @@ -25,7 +26,8 @@ pub fn node_link_graph(store: &Store) -> anyhow::Result { /// then carry no `god_score`/`pagerank`/`in_degree`/`out_degree`. pub fn node_link_graph_opts(store: &Store, with_centrality: bool) -> anyhow::Result { let nodes = store.all_nodes()?; - let edges = store.all_edges()?; + let mut edges = store.all_edges()?; + edges.sort_by_cached_key(edge_export_sort_key); let scores = if with_centrality { centrality::compute(&nodes, &edges) @@ -65,6 +67,32 @@ pub fn node_link_graph_opts(store: &Store, with_centrality: bool) -> anyhow::Res })) } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct EdgeExportSortKey { + source: String, + target: String, + kind: &'static str, + line: Option, + col: Option, + provenance: Option, + metadata: Option, +} + +fn edge_export_sort_key(edge: &Edge) -> EdgeExportSortKey { + EdgeExportSortKey { + source: edge.source.clone(), + target: edge.target.clone(), + kind: edge.kind.as_str(), + line: edge.line, + col: edge.col, + provenance: edge.provenance.clone(), + metadata: edge + .metadata + .as_ref() + .map(|metadata| serde_json::to_string(metadata).expect("JSON values always serialize")), + } +} + fn node_to_link_node(node: &Node, centrality: Option<&Centrality>) -> Value { let file_type = if node.kind == NodeKind::File { "file" @@ -253,4 +281,50 @@ mod tests { assert_eq!(doc["links"].as_array().expect("links").len(), 0); assert_eq!(doc["edges"].as_array().expect("edges").len(), 0); } + + #[test] + fn node_link_graph_is_byte_stable_across_edge_insertion_order() { + let nodes = [ + node("file:src/a.ts", NodeKind::File, "a.ts", "src/a.ts"), + node("function:caller", NodeKind::Function, "caller", "src/a.ts"), + node("function:callee", NodeKind::Function, "callee", "src/a.ts"), + ]; + let edges = [ + edge("file:src/a.ts", "function:caller", EdgeKind::Contains), + edge("function:caller", "function:callee", EdgeKind::Calls), + ]; + + let mut forward = Store::open(&temp_db_path("stable-forward")).expect("open forward store"); + forward.upsert_nodes(&nodes).expect("insert forward nodes"); + forward.insert_edges(&edges).expect("insert forward edges"); + + let mut reverse = Store::open(&temp_db_path("stable-reverse")).expect("open reverse store"); + reverse.upsert_nodes(&nodes).expect("insert reverse nodes"); + reverse + .insert_edges(&[edges[1].clone(), edges[0].clone()]) + .expect("insert reverse edges"); + + let forward_row_order = forward + .all_edges() + .expect("read forward edges") + .into_iter() + .map(|edge| (edge.source, edge.target)) + .collect::>(); + let reverse_row_order = reverse + .all_edges() + .expect("read reverse edges") + .into_iter() + .map(|edge| (edge.source, edge.target)) + .collect::>(); + assert_ne!( + forward_row_order, reverse_row_order, + "fixture must exercise different SQLite insertion rowids" + ); + + let forward_json = + serde_json::to_vec(&node_link_graph(&forward).expect("export forward")).unwrap(); + let reverse_json = + serde_json::to_vec(&node_link_graph(&reverse).expect("export reverse")).unwrap(); + assert_eq!(forward_json, reverse_json); + } }