diff --git a/Cargo.lock b/Cargo.lock index 7f45d2bc..c5d1375f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3227,7 +3227,7 @@ dependencies = [ [[package]] name = "zerograph" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "ast-grep-language", diff --git a/Cargo.toml b/Cargo.toml index 7b9f9330..34a051e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerograph" -version = "0.6.3" +version = "0.6.4" edition = "2021" rust-version = "1.98" description = "A project knowledge graph built and queried at zero API tokens" diff --git a/docs/commands.md b/docs/commands.md index eaa89d9c..a3bb05af 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,7 @@ ## Status -0.6.3 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, +0.6.4 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, C#, Rust, Python, Dart, Swift, GraphQL, SQL, Bicep, HCL, Shell, Vue, Lua, Go, C, C++, PHP, Ruby, Scala, Elixir and OCaml; every command below is implemented rather than planned: diff --git a/docs/releases/0.6.4.md b/docs/releases/0.6.4.md new file mode 100644 index 00000000..85643cda --- /dev/null +++ b/docs/releases/0.6.4.md @@ -0,0 +1,12 @@ +# zerograph 0.6.4 + +The release notes for the GitHub release page. The why behind each change is in the docs each line +links to. + +## Fixed + +- **`install-agent --claude` keeps a matcher you narrowed.** A re-run replaced zerograph's hook + entries whole, so a `matcher` a repository had changed on purpose (PreToolUse without `Bash`, say) + went back to the default on every upgrade. The hook script now records the version and the + matchers it was installed with; a re-run moves a matcher it wrote to the current default and keeps + any other. diff --git a/npm/zerograph/package.json b/npm/zerograph/package.json index 515d3599..b8a17c34 100644 --- a/npm/zerograph/package.json +++ b/npm/zerograph/package.json @@ -1,6 +1,6 @@ { "name": "zerograph", - "version": "0.6.3", + "version": "0.6.4", "description": "Knowledge graph of a repository's requirement docs and code in 24 languages, built and refreshed at zero model tokens, asked by id, keyword or paraphrase", "license": "MIT", "repository": { diff --git a/src/install_agent.rs b/src/install_agent.rs index fcb1e1be..04abbedd 100644 --- a/src/install_agent.rs +++ b/src/install_agent.rs @@ -56,7 +56,16 @@ fn with_command(template: &str, command: &str) -> String { template.replace("{{c const HOOK_COMMAND: &str = "const COMMAND = \"zerograph\";"; fn hook(command: &str) -> String { - HOOK.replacen(HOOK_COMMAND, &format!("const COMMAND = {};", serde_json::Value::from(command)), 1) + let script = HOOK.replacen(HOOK_COMMAND, &format!("const COMMAND = {};", serde_json::Value::from(command)), 1); + let (shebang, rest) = script.split_once('\n').unwrap(); + format!("{shebang}\n{}{rest}", stamp()) +} + +/// The line under the shebang records the matchers this release wrote, so a re-run can tell one it +/// wrote (moved to the new default) from one a person changed (kept). +fn stamp() -> String { + let wrote: Vec = hook_entries("").iter().map(|(e, m, _)| format!("{e}={m}")).collect(); + format!("// zerograph {} wrote: {}\n", env!("CARGO_PKG_VERSION"), wrote.join(" ")) } const BEGIN: &str = ""; @@ -135,7 +144,9 @@ fn hook_entries(hook_path: &str) -> Vec<(&'static str, &'static str, serde_json: /// dropped from. That script is the previous surface and this one replaces it, but it is a file /// the repository owns and a person put in that list: removing it without a word would be the one /// silent edit in an installer that otherwise touches nothing it did not write. -pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec)> { +/// +/// `prior_hook` is the hook script on disk before this run; its stamp says which matchers we wrote. +pub fn merged_settings(existing: &str, hook_path: &str, prior_hook: &str) -> Result<(String, Vec)> { let mut root: serde_json::Value = match existing.trim().is_empty() { true => serde_json::json!({}), false => serde_json::from_str(existing).context("the settings file is not JSON")?, @@ -144,12 +155,17 @@ pub fn merged_settings(existing: &str, hook_path: &str) -> Result<(String, Vec Result { let mut report = Report::default(); let base: PathBuf = root.join(target.dir()); let hook_file = base.join("hooks").join("zerograph-hook.mjs"); + let prior_hook = std::fs::read_to_string(&hook_file).unwrap_or_default(); write_if_changed(&hook_file, &hook(command), &mut report)?; write_if_changed(&base.join("hooks").join("rule.txt"), &with_command(RULE, command), &mut report)?; write_if_changed(&base.join("skills").join("repo-query").join("SKILL.md"), &with_command(SKILL, command), &mut report)?; @@ -251,7 +268,7 @@ pub fn install(root: &Path, target: Target, command: &str) -> Result { if target.hooks_are_repository_scoped() { let settings = base.join("settings.json"); let existing = std::fs::read_to_string(&settings).unwrap_or_default(); - let (merged, retired) = merged_settings(&existing, &hook_path)?; + let (merged, retired) = merged_settings(&existing, &hook_path, &prior_hook)?; if !retired.is_empty() { report.notes.push(format!( "removed the retired `repograph-notice` hook from {} — this hook replaces it; \ @@ -1129,6 +1146,35 @@ mod tests { assert!(arr[0]["hooks"][0]["command"].as_str().unwrap().contains("CLAUDE_PROJECT_DIR")); } + /// A narrowed matcher is a choice someone made, such as keeping the hook off Bash calls; a + /// re-run that put the default back would undo it on every upgrade. + #[test] + fn a_matcher_set_on_our_entry_survives_a_re_run() { + let dir = root(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + std::fs::write(dir.path().join(".claude/settings.json"), r#"{"hooks":{"PreToolUse":[ + {"matcher":"Grep|Glob|Agent","hooks":[{"type":"command","command":"node /old/path/zerograph-hook.mjs"}]} + ]}}"#).unwrap(); + install(dir.path(), Target::Claude, "zerograph").unwrap(); + let v: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap()).unwrap(); + let arr = v["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(arr.len(), 1, "{arr:?}"); + assert_eq!(arr[0]["matcher"], "Grep|Glob|Agent"); + assert!(arr[0]["hooks"][0]["command"].as_str().unwrap().contains("CLAUDE_PROJECT_DIR"), "the rest of the entry is still ours to update"); + assert_eq!(v["hooks"]["SessionStart"][0]["matcher"], "startup|resume|clear|compact", "a fresh entry gets the default"); + } + + /// A matcher the stamp says we wrote is ours to move when the default changes; any other stays. + #[test] + fn a_matcher_the_stamp_records_moves_to_the_new_default() { + let settings = r#"{"hooks":{"PreToolUse":[{"matcher":"Bash|Grep","hooks":[{"type":"command","command":"node zerograph-hook.mjs"}]}]}}"#; + let ours = merged_settings(settings, "h", "#!/usr/bin/env node\n// zerograph 0.6.4 wrote: PreToolUse=Bash|Grep\n").unwrap().0; + assert!(ours.contains("\"Bash|Grep|Glob|Agent\""), "{ours}"); + let theirs = merged_settings(settings, "h", "#!/usr/bin/env node\n// zerograph 0.6.4 wrote: PreToolUse=Bash\n").unwrap().0; + assert!(theirs.contains("\"Bash|Grep\""), "{theirs}"); + } + #[test] fn the_stanza_lands_in_a_root_instructions_file_when_there_is_one_and_replaces_only_itself() { let dir = root(); @@ -1209,7 +1255,7 @@ mod tests { #[test] fn the_hook_takes_the_command_as_one_json_string_and_the_default_as_it_ships() { assert_eq!(HOOK.matches(HOOK_COMMAND).count(), 1, "the declaration the installer replaces is in the hook once"); - assert_eq!(hook("zerograph"), HOOK, "the default installs the template byte for byte"); + assert_eq!(hook("zerograph").replacen(&stamp(), "", 1), HOOK, "the default installs the template byte for byte, under the stamp"); let script = hook(r#"pnpm exec "re'po\graph" `x` ${y}"#); assert!(script.contains(r#"const COMMAND = "pnpm exec \"re'po\\graph\" `x` ${y}";"#), "the command is one string literal"); assert_eq!(script.matches("const COMMAND = ").count(), 1);