From b77156a8679a494722b0f61a0d8bfeb814f8687b Mon Sep 17 00:00:00 2001 From: sunerpy Date: Mon, 22 Jun 2026 10:33:57 +0800 Subject: [PATCH 1/3] fix(installer): preserve comments and key order when editing agent configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer rewrote agent config files with serde_json::to_string_pretty, which dropped all JSONC comments and re-sorted keys alphabetically — scrambling users' hand-maintained configs even when only adding the codegraph entry. Edit existing configs surgically with jsonc-parser's CST API (upsert_nested_key_jsonc / remove_nested_key_jsonc): only the codegraph property is inserted, updated, or removed, leaving every comment, key order, and the file's formatting intact. Re-runs short-circuit to Unchanged (no churn). Fresh files are still seeded with the canonical shape. All six targets and the uninstall path go through the surgical helpers. --- Cargo.lock | 30 ++- Cargo.toml | 3 + crates/codegraph-cli/Cargo.toml | 1 + crates/codegraph-cli/src/installer/shared.rs | 179 ++++++++++++++++++ .../src/installer/targets/antigravity.rs | 43 ++--- .../src/installer/targets/claude.rs | 45 ++--- .../src/installer/targets/cursor.rs | 44 ++--- .../src/installer/targets/gemini.rs | 45 ++--- .../src/installer/targets/kiro.rs | 43 ++--- .../src/installer/targets/opencode.rs | 101 +++------- 10 files changed, 331 insertions(+), 203 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52a3a9a2..03fe4a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,7 +209,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codegraph-bench" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "clap", @@ -224,7 +224,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "serde", @@ -240,7 +240,7 @@ dependencies = [ [[package]] name = "codegraph-daemon" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "codegraph-core", @@ -254,7 +254,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "codegraph-core", @@ -294,7 +294,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "codegraph-core", @@ -306,7 +306,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "codegraph-core", @@ -322,7 +322,7 @@ dependencies = [ [[package]] name = "codegraph-resolve" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "codegraph-core", @@ -335,7 +335,7 @@ dependencies = [ [[package]] name = "codegraph-rs" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "clap", @@ -348,6 +348,7 @@ dependencies = [ "codegraph-resolve", "codegraph-store", "codegraph-watch", + "jsonc-parser", "serde", "serde_json", "time", @@ -358,7 +359,7 @@ dependencies = [ [[package]] name = "codegraph-store" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "codegraph-core", @@ -370,7 +371,7 @@ dependencies = [ [[package]] name = "codegraph-watch" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "codegraph-core", @@ -685,6 +686,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonc-parser" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6d80e6d70e7911a29f3cf3f44f452df85d06f73572b494ca99a2cad3fcf8f4" +dependencies = [ + "serde_json", +] + [[package]] name = "kqueue" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index f8efc44c..65036f3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ notify = "6" # CLI clap = { version = "4.5", features = ["derive"] } +# JSONC config editing (preserve comments + key order on agent config writes) +jsonc-parser = { version = "0.26", features = ["cst", "serde"] } + # Benchmarking criterion = "0.7" diff --git a/crates/codegraph-cli/Cargo.toml b/crates/codegraph-cli/Cargo.toml index 93ca4da4..54f76b81 100644 --- a/crates/codegraph-cli/Cargo.toml +++ b/crates/codegraph-cli/Cargo.toml @@ -27,6 +27,7 @@ codegraph-store = { path = "../codegraph-store", version = "0.1.0" } codegraph-watch = { path = "../codegraph-watch", version = "0.1.0" } serde = { workspace = true } serde_json = { workspace = true } +jsonc-parser = { workspace = true } toml = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } diff --git a/crates/codegraph-cli/src/installer/shared.rs b/crates/codegraph-cli/src/installer/shared.rs index 28eccd39..85969ac2 100644 --- a/crates/codegraph-cli/src/installer/shared.rs +++ b/crates/codegraph-cli/src/installer/shared.rs @@ -6,6 +6,8 @@ use std::fs; use std::path::Path; +use jsonc_parser::cst::{CstInputValue, CstRootNode}; +use jsonc_parser::ParseOptions; use serde_json::{Map, Value}; use super::types::{FileAction, FileWrite}; @@ -237,6 +239,114 @@ pub fn to_upstream_json(value: &Value) -> String { serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string()) } +fn to_cst_input(value: &Value) -> CstInputValue { + match value { + Value::Null => CstInputValue::Null, + Value::Bool(b) => CstInputValue::Bool(*b), + Value::Number(n) => CstInputValue::Number(n.to_string()), + Value::String(s) => CstInputValue::String(s.clone()), + Value::Array(arr) => CstInputValue::Array(arr.iter().map(to_cst_input).collect()), + Value::Object(map) => CstInputValue::Object( + map.iter() + .map(|(k, v)| (k.clone(), to_cst_input(v))) + .collect(), + ), + } +} + +/// Surgically upsert `. = value` in JSONC, preserving the +/// file's comments, key order, and formatting. `Unchanged` when the leaf already +/// equals `value` (no write — avoids comment churn). Caller must pre-confirm the +/// file parses (see `read_config_file`); a parse failure returns an error so the +/// caller skips rather than clobbers. +pub fn upsert_nested_key_jsonc( + path: &Path, + parent_key: &str, + leaf_key: &str, + value: &Value, + schema_url: Option<&str>, +) -> std::io::Result { + let text = fs::read_to_string(path)?; + let existed = !text.trim().is_empty(); + + let parsed = parse_json_object(&text); + if let Some(map) = &parsed { + let current = map.get(parent_key).and_then(|p| p.get(leaf_key)); + if current == Some(value) && (schema_url.is_none() || map.contains_key("$schema")) { + return Ok(FileAction::Unchanged); + } + } + + let root = CstRootNode::parse(&text, &ParseOptions::default()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + let root_obj = root.object_value_or_set(); + let parent = root_obj.object_value_or_create(parent_key).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("`{parent_key}` exists but is not an object"), + ) + })?; + match parent.get(leaf_key) { + Some(prop) => prop.set_value(to_cst_input(value)), + None => { + parent.append(leaf_key, to_cst_input(value)); + } + } + if let Some(schema) = schema_url { + if root_obj.get("$schema").is_none() { + root_obj.insert(0, "$schema", CstInputValue::String(schema.to_string())); + } + } + + let mut out = root.to_string(); + if text.ends_with('\n') && !out.ends_with('\n') { + out.push('\n'); + } + atomic_write_file(path, &out)?; + Ok(if existed { + FileAction::Updated + } else { + FileAction::Created + }) +} + +/// Surgically remove `.` from a JSONC file, preserving +/// surrounding comments/formatting. Drops the now-empty parent object too. +/// Returns `Removed` if the key was present, `NotFound` otherwise. +pub fn remove_nested_key_jsonc( + path: &Path, + parent_key: &str, + leaf_key: &str, +) -> std::io::Result { + let Ok(text) = fs::read_to_string(path) else { + return Ok(FileAction::NotFound); + }; + let present = parse_json_object(&text) + .and_then(|m| m.get(parent_key).and_then(|p| p.get(leaf_key)).cloned()) + .is_some(); + if !present { + return Ok(FileAction::NotFound); + } + let root = CstRootNode::parse(&text, &ParseOptions::default()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + if let Some(parent) = root.object_value().and_then(|o| o.object_value(parent_key)) { + if let Some(prop) = parent.get(leaf_key) { + prop.remove(); + } + if parent.properties().is_empty() { + if let Some(parent_prop) = root.object_value().and_then(|o| o.get(parent_key)) { + parent_prop.remove(); + } + } + } + let mut out = root.to_string(); + if text.ends_with('\n') && !out.ends_with('\n') { + out.push('\n'); + } + atomic_write_file(path, &out)?; + Ok(FileAction::Removed) +} + /// Replace or append a marker-delimited section. Ports /// `replaceOrAppendMarkedSection` (shared.ts:141). pub fn replace_or_append_marked_section( @@ -517,4 +627,73 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + + fn tmp_path(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "cg-jsonc-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + #[test] + fn upsert_preserves_comments_and_key_order() { + let p = tmp_path("opencode.json"); + let original = "{\n // lead comment\n \"$schema\": \"https://x\",\n \"theme\": \"dark\", // trailing\n \"mcp\": {\n \"existing\": { \"enabled\": true }\n },\n \"zzz\": \"last\"\n}\n"; + fs::write(&p, original).unwrap(); + let value = serde_json::json!({"type": "local", "enabled": true}); + let action = + upsert_nested_key_jsonc(&p, "mcp", "codegraph", &value, Some("https://x")).unwrap(); + assert_eq!(action, FileAction::Updated); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.contains("// lead comment"), "lead comment preserved"); + assert!(out.contains("// trailing"), "trailing comment preserved"); + assert!(out.contains("\"existing\""), "sibling preserved"); + assert!(out.contains("\"codegraph\""), "codegraph inserted"); + let theme_at = out.find("\"theme\"").unwrap(); + let zzz_at = out.find("\"zzz\"").unwrap(); + assert!(theme_at < zzz_at, "original key order preserved"); + let _ = fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn upsert_is_idempotent() { + let p = tmp_path("conf.json"); + fs::write(&p, "{\n // keep\n \"mcpServers\": {}\n}\n").unwrap(); + let value = serde_json::json!({"command": "codegraph"}); + let first = upsert_nested_key_jsonc(&p, "mcpServers", "codegraph", &value, None).unwrap(); + assert_eq!(first, FileAction::Updated); + let after_first = fs::read_to_string(&p).unwrap(); + let second = upsert_nested_key_jsonc(&p, "mcpServers", "codegraph", &value, None).unwrap(); + assert_eq!(second, FileAction::Unchanged); + assert_eq!( + fs::read_to_string(&p).unwrap(), + after_first, + "no churn on re-run" + ); + assert!(after_first.contains("// keep")); + let _ = fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn remove_preserves_comments() { + let p = tmp_path("conf.json"); + fs::write( + &p, + "{\n // keep me\n \"mcpServers\": {\n \"other\": 1,\n \"codegraph\": { \"x\": 1 }\n }\n}\n", + ) + .unwrap(); + let action = remove_nested_key_jsonc(&p, "mcpServers", "codegraph").unwrap(); + assert_eq!(action, FileAction::Removed); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.contains("// keep me"), "comment preserved on remove"); + assert!(out.contains("\"other\""), "sibling preserved"); + assert!(!out.contains("\"codegraph\""), "codegraph removed"); + let _ = fs::remove_dir_all(p.parent().unwrap()); + } } diff --git a/crates/codegraph-cli/src/installer/targets/antigravity.rs b/crates/codegraph-cli/src/installer/targets/antigravity.rs index 8ead1b1e..58997093 100644 --- a/crates/codegraph-cli/src/installer/targets/antigravity.rs +++ b/crates/codegraph-cli/src/installer/targets/antigravity.rs @@ -15,7 +15,7 @@ use serde_json::{json, Map, Value}; use super::super::shared::{ read_config_file, read_json_file, remove_codegraph_from_mcp_servers, to_upstream_json, - write_json_file, ConfigRead, + upsert_nested_key_jsonc, write_json_file, ConfigRead, }; use super::super::types::{ AgentTarget, DetectionResult, FileAction, FileWrite, InstallContext, InstallOptions, Location, @@ -173,32 +173,27 @@ fn write_mcp_entry(ctx: &InstallContext) -> FileWrite { if let Some(dir) = file.parent() { let _ = fs::create_dir_all(dir); } - let mut existing = match read_config_file(&file) { - ConfigRead::Missing => Map::new(), - ConfigRead::Parsed(map) => map, - ConfigRead::Unparseable => { - return FileWrite { - path: file, - action: FileAction::Skipped, - }; - } - }; - let before = existing.get("mcpServers").and_then(|s| s.get("codegraph")); let after = build_antigravity_entry(); - if before == Some(&after) { - return FileWrite { + match read_config_file(&file) { + ConfigRead::Unparseable => FileWrite { path: file, - action: FileAction::Unchanged, - }; + action: FileAction::Skipped, + }, + ConfigRead::Missing => { + let mut config = Map::new(); + upsert_mcp_server(&mut config, "codegraph", after); + let _ = write_json_file(&file, &config); + FileWrite { + path: file, + action: FileAction::Created, + } + } + ConfigRead::Parsed(_) => { + let action = upsert_nested_key_jsonc(&file, "mcpServers", "codegraph", &after, None) + .unwrap_or(FileAction::Skipped); + FileWrite { path: file, action } + } } - let action = if before.is_some() || file.exists() { - FileAction::Updated - } else { - FileAction::Created - }; - upsert_mcp_server(&mut existing, "codegraph", after); - let _ = write_json_file(&file, &existing); - FileWrite { path: file, action } } // Ports cleanupLegacyEntry (antigravity.ts:261). diff --git a/crates/codegraph-cli/src/installer/targets/claude.rs b/crates/codegraph-cli/src/installer/targets/claude.rs index 54ade086..e387e570 100644 --- a/crates/codegraph-cli/src/installer/targets/claude.rs +++ b/crates/codegraph-cli/src/installer/targets/claude.rs @@ -12,7 +12,8 @@ use serde_json::{json, Map, Value}; use super::super::shared::{ self, codegraph_permissions, mcp_server_config, read_config_file, read_json_file, remove_codegraph_from_mcp_servers, to_upstream_json, upsert_instructions_entry, - write_json_file, ConfigRead, CODEGRAPH_SECTION_END, CODEGRAPH_SECTION_START, + upsert_nested_key_jsonc, write_json_file, ConfigRead, CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, }; use super::super::types::{ AgentTarget, DetectionResult, FileAction, FileWrite, InstallContext, InstallOptions, Location, @@ -133,33 +134,27 @@ impl AgentTarget for ClaudeCodeTarget { // Ports writeMcpEntry (claude.ts:214). fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { let file = mcp_json_path(ctx, loc); - let mut existing = match read_config_file(&file) { - ConfigRead::Missing => Map::new(), - ConfigRead::Parsed(map) => map, - ConfigRead::Unparseable => { - return FileWrite { - path: file, - action: FileAction::Skipped, - }; - } - }; - let before = existing.get("mcpServers").and_then(|s| s.get("codegraph")); let after = mcp_server_config(); - - if before == Some(&after) { - return FileWrite { + match read_config_file(&file) { + ConfigRead::Unparseable => FileWrite { path: file, - action: FileAction::Unchanged, - }; + action: FileAction::Skipped, + }, + ConfigRead::Missing => { + let mut config = Map::new(); + upsert_mcp_server(&mut config, "codegraph", after); + let _ = write_json_file(&file, &config); + FileWrite { + path: file, + action: FileAction::Created, + } + } + ConfigRead::Parsed(_) => { + let action = upsert_nested_key_jsonc(&file, "mcpServers", "codegraph", &after, None) + .unwrap_or(FileAction::Skipped); + FileWrite { path: file, action } + } } - let action = if before.is_some() || file.exists() { - FileAction::Updated - } else { - FileAction::Created - }; - upsert_mcp_server(&mut existing, "codegraph", after); - let _ = write_json_file(&file, &existing); - FileWrite { path: file, action } } /// Insert/replace `mcpServers.` in a JSON object, creating the wrapper. diff --git a/crates/codegraph-cli/src/installer/targets/cursor.rs b/crates/codegraph-cli/src/installer/targets/cursor.rs index 5f30ac71..516032fa 100644 --- a/crates/codegraph-cli/src/installer/targets/cursor.rs +++ b/crates/codegraph-cli/src/installer/targets/cursor.rs @@ -14,7 +14,8 @@ use serde_json::{json, Map, Value}; use super::super::shared::{ mcp_server_config, read_config_file, read_json_file, remove_codegraph_from_mcp_servers, - to_upstream_json, write_json_file, ConfigRead, CODEGRAPH_SECTION_END, CODEGRAPH_SECTION_START, + to_upstream_json, upsert_nested_key_jsonc, write_json_file, ConfigRead, CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, }; use super::super::types::{ AgentTarget, DetectionResult, FileAction, FileWrite, InstallContext, InstallOptions, Location, @@ -133,32 +134,27 @@ impl AgentTarget for CursorTarget { // Ports writeMcpEntry (cursor.ts:177). fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { let file = mcp_json_path(ctx, loc); - let mut existing = match read_config_file(&file) { - ConfigRead::Missing => Map::new(), - ConfigRead::Parsed(map) => map, - ConfigRead::Unparseable => { - return FileWrite { - path: file, - action: FileAction::Skipped, - }; - } - }; - let before = existing.get("mcpServers").and_then(|s| s.get("codegraph")); let after = build_cursor_mcp_config(ctx, loc); - if before == Some(&after) { - return FileWrite { + match read_config_file(&file) { + ConfigRead::Unparseable => FileWrite { path: file, - action: FileAction::Unchanged, - }; + action: FileAction::Skipped, + }, + ConfigRead::Missing => { + let mut config = Map::new(); + upsert_mcp_server(&mut config, "codegraph", after); + let _ = write_json_file(&file, &config); + FileWrite { + path: file, + action: FileAction::Created, + } + } + ConfigRead::Parsed(_) => { + let action = upsert_nested_key_jsonc(&file, "mcpServers", "codegraph", &after, None) + .unwrap_or(FileAction::Skipped); + FileWrite { path: file, action } + } } - let action = if before.is_some() || file.exists() { - FileAction::Updated - } else { - FileAction::Created - }; - upsert_mcp_server(&mut existing, "codegraph", after); - let _ = write_json_file(&file, &existing); - FileWrite { path: file, action } } // Ports removeRulesEntry (cursor.ts:208). diff --git a/crates/codegraph-cli/src/installer/targets/gemini.rs b/crates/codegraph-cli/src/installer/targets/gemini.rs index 88f2ab5d..3c235200 100644 --- a/crates/codegraph-cli/src/installer/targets/gemini.rs +++ b/crates/codegraph-cli/src/installer/targets/gemini.rs @@ -11,8 +11,8 @@ use serde_json::{json, Map}; use super::super::shared::{ self, mcp_server_config, read_config_file, read_json_file, remove_codegraph_from_mcp_servers, - to_upstream_json, upsert_instructions_entry, write_json_file, ConfigRead, - CODEGRAPH_SECTION_END, CODEGRAPH_SECTION_START, + to_upstream_json, upsert_instructions_entry, upsert_nested_key_jsonc, write_json_file, + ConfigRead, CODEGRAPH_SECTION_END, CODEGRAPH_SECTION_START, }; use super::super::types::{ AgentTarget, DetectionResult, FileAction, FileWrite, InstallContext, InstallOptions, Location, @@ -114,32 +114,27 @@ fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { if let Some(dir) = file.parent() { let _ = std::fs::create_dir_all(dir); } - let mut existing = match read_config_file(&file) { - ConfigRead::Missing => Map::new(), - ConfigRead::Parsed(map) => map, - ConfigRead::Unparseable => { - return FileWrite { - path: file, - action: FileAction::Skipped, - }; - } - }; - let before = existing.get("mcpServers").and_then(|s| s.get("codegraph")); let after = mcp_server_config(); - if before == Some(&after) { - return FileWrite { + match read_config_file(&file) { + ConfigRead::Unparseable => FileWrite { path: file, - action: FileAction::Unchanged, - }; + action: FileAction::Skipped, + }, + ConfigRead::Missing => { + let mut config = Map::new(); + upsert_mcp_server(&mut config, "codegraph", after); + let _ = write_json_file(&file, &config); + FileWrite { + path: file, + action: FileAction::Created, + } + } + ConfigRead::Parsed(_) => { + let action = upsert_nested_key_jsonc(&file, "mcpServers", "codegraph", &after, None) + .unwrap_or(FileAction::Skipped); + FileWrite { path: file, action } + } } - let action = if before.is_some() || file.exists() { - FileAction::Updated - } else { - FileAction::Created - }; - upsert_mcp_server(&mut existing, "codegraph", after); - let _ = write_json_file(&file, &existing); - FileWrite { path: file, action } } // Ports removeInstructionsEntry (gemini.ts:156). diff --git a/crates/codegraph-cli/src/installer/targets/kiro.rs b/crates/codegraph-cli/src/installer/targets/kiro.rs index 0599fd19..b844b7df 100644 --- a/crates/codegraph-cli/src/installer/targets/kiro.rs +++ b/crates/codegraph-cli/src/installer/targets/kiro.rs @@ -12,7 +12,7 @@ use serde_json::{json, Map}; use super::super::shared::{ mcp_server_config, read_config_file, read_json_file, remove_codegraph_from_mcp_servers, - to_upstream_json, write_json_file, ConfigRead, + to_upstream_json, upsert_nested_key_jsonc, write_json_file, ConfigRead, }; use super::super::types::{ AgentTarget, DetectionResult, FileAction, FileWrite, InstallContext, InstallOptions, Location, @@ -116,32 +116,27 @@ fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { if let Some(dir) = file.parent() { let _ = fs::create_dir_all(dir); } - let mut existing = match read_config_file(&file) { - ConfigRead::Missing => Map::new(), - ConfigRead::Parsed(map) => map, - ConfigRead::Unparseable => { - return FileWrite { - path: file, - action: FileAction::Skipped, - }; - } - }; - let before = existing.get("mcpServers").and_then(|s| s.get("codegraph")); let after = mcp_server_config(); - if before == Some(&after) { - return FileWrite { + match read_config_file(&file) { + ConfigRead::Unparseable => FileWrite { path: file, - action: FileAction::Unchanged, - }; + action: FileAction::Skipped, + }, + ConfigRead::Missing => { + let mut config = Map::new(); + upsert_mcp_server(&mut config, "codegraph", after); + let _ = write_json_file(&file, &config); + FileWrite { + path: file, + action: FileAction::Created, + } + } + ConfigRead::Parsed(_) => { + let action = upsert_nested_key_jsonc(&file, "mcpServers", "codegraph", &after, None) + .unwrap_or(FileAction::Skipped); + FileWrite { path: file, action } + } } - let action = if before.is_some() || file.exists() { - FileAction::Updated - } else { - FileAction::Created - }; - upsert_mcp_server(&mut existing, "codegraph", after); - let _ = write_json_file(&file, &existing); - FileWrite { path: file, action } } // Ports removeSteeringEntry (kiro.ts:158). diff --git a/crates/codegraph-cli/src/installer/targets/opencode.rs b/crates/codegraph-cli/src/installer/targets/opencode.rs index add79f2f..bafeb224 100644 --- a/crates/codegraph-cli/src/installer/targets/opencode.rs +++ b/crates/codegraph-cli/src/installer/targets/opencode.rs @@ -6,11 +6,10 @@ //! `mcp.` wrapper with a string-array `command` and an `enabled` flag — //! not `mcpServers`. //! -//! DIVERGENCE FROM UPSTREAM: the upstream edits through `jsonc-parser` to preserve `//` -//! comments on idempotent re-runs. This port uses serde_json (the task's JSON -//! tool of choice): the written keys/shape are byte-faithful, but a user's -//! hand-added JSONC comments are not preserved across an install rewrite. The -//! emitted config remains valid opencode JSON. +//! Existing configs are edited surgically via `jsonc-parser` (see +//! `shared::upsert_nested_key_jsonc`), preserving the user's comments, key +//! order, and formatting; only the `codegraph` entry changes. Fresh files are +//! seeded with the canonical shape. use std::fs; use std::path::{Path, PathBuf}; @@ -18,8 +17,9 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; use super::super::shared::{ - self, parse_json_object, read_config_file, to_upstream_json, upsert_instructions_entry, - write_json_file, ConfigRead, CODEGRAPH_SECTION_END, CODEGRAPH_SECTION_START, + self, parse_json_object, read_config_file, remove_nested_key_jsonc, to_upstream_json, + upsert_instructions_entry, upsert_nested_key_jsonc, write_json_file, ConfigRead, + CODEGRAPH_SECTION_END, CODEGRAPH_SECTION_START, }; use super::super::types::{ AgentTarget, DetectionResult, FileAction, FileWrite, InstallContext, InstallOptions, Location, @@ -162,80 +162,39 @@ impl AgentTarget for OpencodeTarget { // Ports writeMcpEntry (opencode.ts:189). fn write_mcp_entry(ctx: &InstallContext, loc: Location) -> FileWrite { let file = config_path(ctx, loc); - let existed = file.exists(); - let mut config = match read_config_file(&file) { - ConfigRead::Missing => Map::new(), - ConfigRead::Parsed(map) => map, - ConfigRead::Unparseable => { - return FileWrite { - path: file, - action: FileAction::Skipped, - }; - } - }; - let before = config.get("mcp").and_then(|m| m.get("codegraph")); let after = opencode_server_entry(); - if before == Some(&after) { - return FileWrite { + match read_config_file(&file) { + ConfigRead::Unparseable => FileWrite { path: file, - action: FileAction::Unchanged, - }; - } - if !config.contains_key("$schema") { - // Insert $schema first so it leads the file, matching the seeded shape. - let mut reordered = Map::new(); - reordered.insert("$schema".to_string(), json!(SCHEMA_URL)); - for (k, v) in config { - reordered.insert(k, v); - } - config = reordered; - } - let mcp = config - .entry("mcp") - .or_insert_with(|| Value::Object(Map::new())); - if !mcp.is_object() { - *mcp = Value::Object(Map::new()); - } - if let Value::Object(map) = mcp { - map.insert("codegraph".to_string(), after); - } - let _ = write_json_file(&file, &config); - FileWrite { - path: file, - action: if existed { - FileAction::Updated - } else { - FileAction::Created + action: FileAction::Skipped, }, + ConfigRead::Missing => { + let mut config = Map::new(); + config.insert("$schema".to_string(), json!(SCHEMA_URL)); + let mut mcp = Map::new(); + mcp.insert("codegraph".to_string(), after); + config.insert("mcp".to_string(), Value::Object(mcp)); + let _ = write_json_file(&file, &config); + FileWrite { + path: file, + action: FileAction::Created, + } + } + ConfigRead::Parsed(_) => { + let action = + upsert_nested_key_jsonc(&file, "mcp", "codegraph", &after, Some(SCHEMA_URL)) + .unwrap_or(FileAction::Skipped); + FileWrite { path: file, action } + } } } // Ports removeMcpEntryAt (opencode.ts:233). fn remove_mcp_entry_at(file: &Path) -> FileWrite { - if !file.exists() { - return FileWrite { - path: file.to_path_buf(), - action: FileAction::NotFound, - }; - } - let text = fs::read_to_string(file).unwrap_or_default(); - let mut config = parse_config(&text); - if config.get("mcp").and_then(|m| m.get("codegraph")).is_none() { - return FileWrite { - path: file.to_path_buf(), - action: FileAction::NotFound, - }; - } - if let Some(Value::Object(mcp)) = config.get_mut("mcp") { - mcp.remove("codegraph"); - if mcp.is_empty() { - config.remove("mcp"); - } - } - let _ = write_json_file(file, &config); + let action = remove_nested_key_jsonc(file, "mcp", "codegraph").unwrap_or(FileAction::NotFound); FileWrite { path: file.to_path_buf(), - action: FileAction::Removed, + action, } } From fc1a1cb1161f9c32d9ce391f70cac57419396b3f Mon Sep 17 00:00:00 2001 From: sunerpy Date: Mon, 22 Jun 2026 10:39:26 +0800 Subject: [PATCH 2/3] chore: move KNOWN_DIFFS.md to internal docs/upstream-sync ledger KNOWN_DIFFS.md is an internal upstream-diff ledger (deferred colby resolvers, Tier-3 allowed diffs), not user-facing. No code loads it at runtime. Move it under docs/upstream-sync/ (already gitignored, alongside UPSTREAM.md) so it stays local and out of the public repo. --- KNOWN_DIFFS.md | 184 ------------------------------------------------- README.md | 2 +- 2 files changed, 1 insertion(+), 185 deletions(-) delete mode 100644 KNOWN_DIFFS.md diff --git a/KNOWN_DIFFS.md b/KNOWN_DIFFS.md deleted file mode 100644 index 411cd7bc..00000000 --- a/KNOWN_DIFFS.md +++ /dev/null @@ -1,184 +0,0 @@ -# Known CodeGraph Equivalence Differences - -This file is parsed by `codegraph-bench::oracle::diff::KnownDiffs` for Tier-3 -differences only. Tier-1 and Tier-2 differences are never allowlisted by this -file. - -## Rule format - -One grep-able rule per line: - -```text -RULE tier=3 surface= key= justification= -``` - -- `tier`: currently only `3` can allow a diff. -- `surface`: presentation or behavioral surface name reported by the differ. -- `key`: substring matched against the diff key, or `*` for the whole surface. -- `justification`: short no-spaces reason; expand with prose nearby when needed. - -## Current rules - -No Tier-3 rules are active yet. - -## Canonicalized by design, not allowlisted - -Timestamps are stripped before comparison and are therefore not represented as -Tier-3 rules: - -- `nodes.updated_at` -- `files.modified_at` -- `files.indexed_at` - -## Deferred colby resolution behavior (Task 20, codegraph-resolve) - -The v1 Rust port of `reference/colby/src/resolution/` ships the two CORE -deterministic strategies — import resolution (`import-resolver.ts`) and name -matching (`name-matcher.ts`) — orchestrated by `ReferenceResolver` -(`index.ts`), plus THREE concrete `FrameworkResolver` implementations behind the -`FrameworkResolver` trait EXTENSION POINT (`crates/codegraph-resolve/src/framework.rs`): -React/Next.js, Vue/Nuxt, and NestJS (`crates/codegraph-resolve/src/frameworks/`), -detected per-project by `detect_frameworks`. The remaining ~19 colby resolvers -stay deferred. The following colby behaviors are intentionally NOT ported in v1: - -- **The other framework-specific resolvers** — every concrete `FrameworkResolver` - in `reference/colby/src/resolution/frameworks/**` EXCEPT react/vue/nestjs: - Spring/Java, Drupal `routing.yml`, Express, Svelte, React Native / Expo / Fabric - bridges, Swift↔ObjC bridging, the Go-module / Python / Ruby / C# / Play route - extractors, and the Astro resolver - (`frameworks/astro.ts`: `astro:*` module imports + the `Astro` global resolved - as framework-provided, and `src/pages` file-based route-node mapping). These - are an additive heuristic layer; on a project where none of react/vue/nestjs is - detected the orchestrator holds an empty resolver list, so behavior matches - colby with zero frameworks detected. The Astro - EXTRACTION half (frontmatter + `