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
7 changes: 7 additions & 0 deletions crates/codegraph-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1804,6 +1804,13 @@ fn cmd_status(path: Option<PathBuf>, 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");
Expand Down
34 changes: 34 additions & 0 deletions crates/codegraph-cli/tests/batch_m_outdated_migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
43 changes: 43 additions & 0 deletions crates/codegraph-cli/tests/cli_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
82 changes: 78 additions & 4 deletions crates/codegraph-graph/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -25,7 +26,8 @@ pub fn node_link_graph(store: &Store) -> anyhow::Result<Value> {
/// then carry no `god_score`/`pagerank`/`in_degree`/`out_degree`.
pub fn node_link_graph_opts(store: &Store, with_centrality: bool) -> anyhow::Result<Value> {
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)
Expand Down Expand Up @@ -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<i64>,
col: Option<i64>,
provenance: Option<String>,
metadata: Option<String>,
}

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"
Expand Down Expand Up @@ -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::<Vec<_>>();
let reverse_row_order = reverse
.all_edges()
.expect("read reverse edges")
.into_iter()
.map(|edge| (edge.source, edge.target))
.collect::<Vec<_>>();
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);
}
}
Loading