From 7b90a6b142f7df58d7cd19dac472dda105977f40 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Thu, 9 Jul 2026 09:22:38 +0200 Subject: [PATCH] fix: forge install freshness warning + qualifier-aware prune Closes #73. Before deploying, forge install checks whether the source git working copy is behind its trunk (origin/main, no network fetch); if behind it refuses with a prominent warning unless --allow-stale is passed. A git-read failure is advisory, never fatal. Prune is now qualifier-aware: a deployed base file whose source counterpart exists only under a qualifier directory (rules/harness/model) is pruned, while a correctly-resolved qualifier deployment is kept. --- CHANGELOG.md | 2 + src/cli/assemble/mod.rs | 68 ++++++++++++--- src/cli/assemble/tests.rs | 82 +++++++++++++++--- src/cli/install/mod.rs | 105 +++++++++++++++++++++++ src/cli/install/tests.rs | 5 ++ src/cli/mod.rs | 8 ++ src/cli/release/mod.rs | 1 + tests/install_freshness.rs | 171 +++++++++++++++++++++++++++++++++++++ tests/prune.rs | 100 ++++++++++++++++++++-- 9 files changed, 511 insertions(+), 31 deletions(-) create mode 100644 tests/install_freshness.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9669b07..263878f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - The `agentskills` provider installs Agent Skills-compatible `SKILL.md` files under `.agents/skills//SKILL.md`, with `agents` as an alias for `--provider agents` and an Agent Skills frontmatter whitelist. - `forge adopt ` fetches an upstream HTTPS artifact (or `file://` fixture), applies the `align` transform into a module skill or companion file, and writes an `adopt/v1` provenance sidecar with the upstream digest pin. - `forge find ""` scans local modules, discovered repos, and already-cached watchlist sources for skills, agents, and rules, ranking matches by name, trigger text, and description with optional JSON output. +- `forge install` now warns prominently and refuses by default when the source git checkout is confirmed behind local `origin/main` or `origin/master`; `--allow-stale` overrides the refusal while still printing the warning. Prune is qualifier-aware: a deployed base file whose source counterpart exists only under a qualifier directory is pruned, while a correctly-resolved qualifier deployment is kept. (#73) - `forge config` prints the resolved forge ontology from `~/.config/forge/config.yaml`, with `FORGE_*` environment overrides taking precedence over file values and built-in defaults. The legacy `project.yaml` file remains as a deprecated fallback for one release. - `forge exec ` runs skill-bundled scripts through a small synchronous runtime table (`uv run`, `bash`, `deno run`, `node`), injects `FORGE_*`/`INPUT_*` environment, supports JSON stdin/stdout wrapping, dry-runs, and output schema validation. - Unknown `forge ` commands now dispatch to external `forge-` scripts from the module `commands/` directory, configured extension directories, or `PATH`, keeping new capabilities out of the Rust kernel. @@ -33,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- Prune is qualifier-aware, so base files are removed when the only remaining source match is an inactive qualifier variant while correctly resolved qualifier output is kept. (closes #73) - `forge provenance` verifies source-side `.provenance/` sidecars, not just deployed targets. Pointed at a source repository or an artifact subdirectory (detected by a `module.yaml` at or above the path), it walks `.provenance/*.yaml`, resolves each `subject.name` to a repo-relative file, recomputes its SHA-256 against the recorded digest, and verifies the digests of in-repo `resolvedDependencies` (the remote `upstream` dependency is left to its recorded pin). Previously the strict typed sidecar model rejected `adopt/v1` sidecars (which carry `upstream_url` rather than `source`) and the directory walk only iterated provider kind roots, so `forge provenance skills/Foo` reported "No provenance found" and source-side drift went undetected. The model now tolerates both `assemble/v1` and `adopt/v1` schemas without changing generated sidecar output, and `--json` emits a machine-readable per-sidecar report. (#44) - Assembly preserves Claude-native `SKILL.md` frontmatter for the claude provider. `claude.keep_fields.skills` previously kept only `name`, `description`, and `version`, so `forge assemble` stripped `allowed-tools` (and the rest of the Claude Code optional skill fields) from deployed skills, breaking tool pre-approval and gating dynamic context injection (`!` command lines). The claude skill keep-list now mirrors the skill mdschema whitelist (`allowed-tools`, `argument-hint`, `arguments`, `disable-model-invocation`, `user-invocable`, `model`, `effort`, `context`, `agent`, `paths`, `shell`). The frontmatter stripper also retains multi-line block values for kept fields, so a list-form `allowed-tools:` survives intact instead of collapsing to a valueless key. (#69) diff --git a/src/cli/assemble/mod.rs b/src/cli/assemble/mod.rs index 321ed96..6580a1c 100644 --- a/src/cli/assemble/mod.rs +++ b/src/cli/assemble/mod.rs @@ -158,11 +158,7 @@ fn assemble_source_for_provider( models: &std::collections::HashMap>, source_uri: &str, ) -> Result, Error> { - if source - .qualifier - .as_ref() - .is_some_and(|qualifier| !qualifier_matches_provider(qualifier, provider_name, models)) - { + if !source_qualifier_matches_provider(source, provider_name, active_model, models) { return Ok(None); } @@ -245,24 +241,72 @@ fn assemble_source_for_provider( })) } -/// Check whether a qualifier directory matches a given provider. +/// Check whether a qualifier-only source applies to a provider/model target. /// -/// A qualifier matches if it is either the provider name itself, or if -/// any model ID for that provider contains the qualifier as a substring. -fn qualifier_matches_provider( - qualifier: &str, +/// Provider-only files such as `rules/claude/Foo.md` apply to that provider +/// for every model. Model-only files such as +/// `rules/claude/claude-sonnet-4-6/Foo.md` apply only when the provider and +/// active model both match, so a non-active model variant cannot keep a stale +/// deployed base file alive during prune. +fn source_qualifier_matches_provider( + source: &sources::SourceFile, provider_name: &str, + active_model: Option<&str>, models: &std::collections::HashMap>, ) -> bool { - if qualifier == provider_name { + if source.qualifier.is_none() { return true; } + + let segments = qualifier_segments(source); + match segments.as_slice() { + [provider] => { + provider == provider_name + || active_model_matches(provider, active_model, provider_name, models) + } + [provider, model, ..] => provider == provider_name && active_model == Some(model.as_str()), + _ => false, + } +} + +fn qualifier_segments(source: &sources::SourceFile) -> Vec { + let stripped_kind = source + .relative_path + .strip_prefix(&format!("{}/", source.kind)) + .unwrap_or(&source.relative_path); + let mut segments: Vec = stripped_kind.split('/').map(str::to_string).collect(); + let _ = segments.pop(); + segments +} + +fn active_model_matches( + qualifier: &str, + active_model: Option<&str>, + provider_name: &str, + models: &std::collections::HashMap>, +) -> bool { + if active_model != Some(qualifier) { + return false; + } if let Some(model_ids) = models.get(provider_name) { - return model_ids.iter().any(|id| id.contains(qualifier)); + return model_ids.iter().any(|id| id == qualifier); } false } +#[cfg(test)] +fn qualifier_matches_provider( + qualifier: &str, + provider_name: &str, + active_model: Option<&str>, + models: &std::collections::HashMap>, +) -> bool { + if qualifier == provider_name { + return true; + } + active_model_matches(qualifier, active_model, provider_name, models) +} + /// Apply kebab-case transformation to each segment of a path. #[cfg(test)] fn apply_kebab_case_to_path(path: &str) -> String { diff --git a/src/cli/assemble/tests.rs b/src/cli/assemble/tests.rs index fdb84b6..2363a01 100644 --- a/src/cli/assemble/tests.rs +++ b/src/cli/assemble/tests.rs @@ -21,41 +21,97 @@ fn make_models() -> HashMap> { #[test] fn direct_provider_name_matches() { let models = make_models(); - assert!(qualifier_matches_provider("claude", "claude", &models)); - assert!(qualifier_matches_provider("codex", "codex", &models)); + assert!(qualifier_matches_provider( + "claude", "claude", None, &models + )); + assert!(qualifier_matches_provider("codex", "codex", None, &models)); } #[test] -fn model_tier_matches_provider_with_that_model() { +fn active_model_matches_provider_with_that_model() { let models = make_models(); - assert!(qualifier_matches_provider("sonnet", "claude", &models)); - assert!(qualifier_matches_provider("opus", "claude", &models)); + assert!(qualifier_matches_provider( + "claude-sonnet-4-6", + "claude", + Some("claude-sonnet-4-6"), + &models + )); + assert!(qualifier_matches_provider( + "claude-opus-4-6", + "claude", + Some("claude-opus-4-6"), + &models + )); } #[test] -fn model_tier_matches_across_providers() { +fn active_model_matches_across_providers_that_list_it() { let models = make_models(); - assert!(qualifier_matches_provider("sonnet", "opencode", &models)); + assert!(qualifier_matches_provider( + "claude-sonnet-4-6", + "opencode", + Some("claude-sonnet-4-6"), + &models + )); } #[test] -fn model_tier_does_not_match_unrelated_provider() { +fn inactive_model_does_not_match_provider() { let models = make_models(); - assert!(!qualifier_matches_provider("sonnet", "codex", &models)); - assert!(!qualifier_matches_provider("opus", "codex", &models)); + assert!(!qualifier_matches_provider( + "claude-sonnet-4-6", + "claude", + Some("claude-opus-4-6"), + &models + )); + assert!(!qualifier_matches_provider( + "claude-opus-4-6", + "claude", + None, + &models + )); +} + +#[test] +fn model_does_not_match_unrelated_provider() { + let models = make_models(); + assert!(!qualifier_matches_provider( + "claude-sonnet-4-6", + "codex", + Some("claude-sonnet-4-6"), + &models + )); + assert!(!qualifier_matches_provider( + "claude-opus-4-6", + "codex", + Some("claude-opus-4-6"), + &models + )); } #[test] fn unknown_qualifier_does_not_match() { let models = make_models(); - assert!(!qualifier_matches_provider("gpt5", "claude", &models)); + assert!(!qualifier_matches_provider( + "gpt5", + "claude", + Some("gpt5"), + &models + )); } #[test] fn provider_not_in_models_only_matches_by_name() { let models = make_models(); - assert!(qualifier_matches_provider("gemini", "gemini", &models)); - assert!(!qualifier_matches_provider("sonnet", "gemini", &models)); + assert!(qualifier_matches_provider( + "gemini", "gemini", None, &models + )); + assert!(!qualifier_matches_provider( + "claude-sonnet-4-6", + "gemini", + Some("claude-sonnet-4-6"), + &models + )); } #[test] diff --git a/src/cli/install/mod.rs b/src/cli/install/mod.rs index 12ad7f0..be38ca6 100644 --- a/src/cli/install/mod.rs +++ b/src/cli/install/mod.rs @@ -1,9 +1,17 @@ use commands::error::Error; +use commands::error::ErrorKind; use commands::result::ActionResult; +use std::path::Path; use super::assemble; +use super::config; use super::deploy; +struct StaleSource { + trunk: String, + commits_behind: usize, +} + /// Assemble and deploy module content to provider directories. /// /// ```text @@ -22,7 +30,9 @@ pub fn execute( interactive: bool, dry_run: bool, model: Option<&str>, + allow_stale: bool, ) -> Result { + warn_or_block_stale_source(Path::new(path), allow_stale)?; assemble::execute_with_model(path, model)?; deploy::execute( path, @@ -35,5 +45,100 @@ pub fn execute( ) } +fn warn_or_block_stale_source(module_root: &Path, allow_stale: bool) -> Result<(), Error> { + let module_label = module_label(module_root); + let stale = match detect_stale_source(module_root) { + Ok(Some(stale)) => stale, + Ok(None) => return Ok(()), + Err(error) => { + eprintln!( + "warning: cannot determine git freshness for {module_label}: {error}; continuing" + ); + return Ok(()); + } + }; + + let count = match stale.commits_behind { + 1 => "1 commit".to_string(), + count => format!("{count} commits"), + }; + let warning = format!( + "WARNING: source module {module_label} is {count} behind {}; deploying it may resurrect stale content", + stale.trunk + ); + eprintln!("{warning}"); + + if allow_stale { + return Ok(()); + } + + Err(Error::new( + ErrorKind::Config, + format!("{warning}. Refusing to install; pass --allow-stale to continue."), + )) +} + +fn module_label(module_root: &Path) -> String { + let source_uri = config::load_source_uri(module_root); + if source_uri.is_empty() { + module_root.display().to_string() + } else { + source_uri + } +} + +fn detect_stale_source(module_root: &Path) -> Result, String> { + if !module_root.join(".git").exists() { + return Ok(None); + } + + let repo = gix::open(module_root).map_err(|error| error.to_string())?; + let head_id = repo.head_id().map_err(|error| error.to_string())?.detach(); + + // Deliberately do not fetch here. `forge install` compares against the + // last-known origin trunk ref so the check stays cheap and offline-safe. + let Some((trunk_name, trunk_ref)) = trunk_reference(&repo)? else { + return Ok(None); + }; + let trunk_id = trunk_ref + .into_fully_peeled_id() + .map_err(|error| error.to_string())?; + + if trunk_id == head_id { + return Ok(None); + } + + for (commits_behind, item) in trunk_id + .ancestors() + .all() + .map_err(|error| error.to_string())? + .enumerate() + { + let info = item.map_err(|error| error.to_string())?; + if info.id == head_id { + return Ok(Some(StaleSource { + trunk: trunk_name.to_string(), + commits_behind, + })); + } + } + + Ok(None) +} + +fn trunk_reference( + repo: &gix::Repository, +) -> Result)>, String> { + for name in ["refs/remotes/origin/main", "refs/remotes/origin/master"] { + if let Some(reference) = repo + .try_find_reference(name) + .map_err(|error| error.to_string())? + { + return Ok(Some((name, reference))); + } + } + Ok(None) +} + #[cfg(test)] mod tests; diff --git a/src/cli/install/tests.rs b/src/cli/install/tests.rs index cc47e9c..252eec5 100644 --- a/src/cli/install/tests.rs +++ b/src/cli/install/tests.rs @@ -20,6 +20,7 @@ fn execute_errors_on_missing_module() { false, false, None, + false, ); assert!(result.is_err()); } @@ -36,6 +37,7 @@ fn execute_errors_on_directory_without_module_yaml() { false, false, None, + false, ); let error = result.expect_err("expected install to refuse non-module directory"); let message = error.to_string(); @@ -69,6 +71,7 @@ fn execute_succeeds_on_empty_module() { false, false, None, + false, ); assert!(result.is_ok()); } @@ -88,6 +91,7 @@ fn execute_unknown_provider_lists_available_choices() { false, false, None, + false, ); let error = result.expect_err("unknown provider must error"); let message = error.to_string(); @@ -123,6 +127,7 @@ fn execute_provider_filter_skips_unrequested_providers() { false, false, None, + false, ) .expect("install should succeed for known provider"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index eb12792..6ebbbc9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -102,6 +102,12 @@ enum Command { #[arg(long)] dry_run: bool, + /// Continue even when the source git checkout is behind origin/main or + /// origin/master. The freshness check uses local refs only and never + /// fetches. + #[arg(long)] + allow_stale: bool, + /// Override each provider's default model when selecting /// `provider//` qualifier variants (exact model ID from /// config/models.yaml; ignored for providers that lack it). @@ -365,6 +371,7 @@ pub fn run() -> i32 { interactive, no_prune, dry_run, + allow_stale, model, } => ( install::execute( @@ -376,6 +383,7 @@ pub fn run() -> i32 { interactive, dry_run, model.as_deref(), + allow_stale, ), "deployed", ), diff --git a/src/cli/release/mod.rs b/src/cli/release/mod.rs index aa719fd..8904fcf 100644 --- a/src/cli/release/mod.rs +++ b/src/cli/release/mod.rs @@ -45,6 +45,7 @@ pub fn execute(path: &str, embed: bool) -> Result { false, false, None, + true, )?; result.installed.clear(); result.skipped.clear(); diff --git a/tests/install_freshness.rs b/tests/install_freshness.rs new file mode 100644 index 0000000..079103d --- /dev/null +++ b/tests/install_freshness.rs @@ -0,0 +1,171 @@ +//! Integration tests for `forge install` source git freshness checks. + +use assert_cmd::Command; +use std::fs; +use std::path::Path; +use std::process::Command as StdCommand; + +fn forge() -> Command { + Command::cargo_bin("forge").unwrap() +} + +fn git(args: &[&str], cwd: &Path) -> std::process::Output { + let mut full_args: Vec<&str> = vec![ + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + "-c", + "gpg.format=openpgp", + ]; + full_args.extend_from_slice(args); + let output = StdCommand::new("git") + .args(&full_args) + .current_dir(cwd) + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_DIR") + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .output() + .expect("git command must run"); + assert!( + output.status.success(), + "git {args:?} in {} failed: stdout={} stderr={}", + cwd.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + output +} + +fn scaffold_module(root: &Path) { + fs::write( + root.join("module.yaml"), + "name: freshness-fixture\nversion: 0.1.0\ndescription: freshness fixture\nevents: []\n", + ) + .unwrap(); + fs::write(root.join("defaults.yaml"), "").unwrap(); + let rules = root.join("rules"); + fs::create_dir_all(&rules).unwrap(); + fs::write(rules.join("Freshness.md"), "base rule\n").unwrap(); +} + +fn install(source: &Path, target: &Path, extra_args: &[&str]) -> assert_cmd::assert::Assert { + let mut args = vec![ + "install", + "--source", + source.to_str().unwrap(), + "--target", + target.to_str().unwrap(), + ]; + args.extend(extra_args); + forge().args(args).assert() +} + +fn init_git_module(root: &Path) { + git(&["init", "--initial-branch=main"], root); + scaffold_module(root); + git(&["add", "."], root); + git(&["commit", "-m", "base"], root); +} + +fn head_sha(root: &Path) -> String { + let output = git(&["rev-parse", "HEAD"], root); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +fn make_origin_main_match_head(root: &Path) { + let head = head_sha(root); + git(&["update-ref", "refs/remotes/origin/main", &head], root); +} + +fn make_origin_main_ahead(root: &Path) { + let base = head_sha(root); + fs::write(root.join("rules/Freshness.md"), "trunk rule\n").unwrap(); + git(&["add", "."], root); + git(&["commit", "-m", "trunk"], root); + let trunk = head_sha(root); + git(&["update-ref", "refs/remotes/origin/main", &trunk], root); + git(&["reset", "--hard", &base], root); +} + +#[test] +fn stale_git_source_refuses_install_without_allow_stale() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + init_git_module(module.path()); + make_origin_main_ahead(module.path()); + + let output = install(module.path(), target.path(), &[]).failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr).into_owned(); + + assert!( + stderr.contains("WARNING"), + "stale install must print a prominent warning: {stderr}" + ); + assert!( + stderr.contains("1 commit behind refs/remotes/origin/main"), + "warning must name how far behind origin/main it is: {stderr}" + ); + assert!( + stderr.contains("--allow-stale"), + "refusal must explain the override flag: {stderr}" + ); + assert!( + !target.path().join(".claude/rules/Freshness.md").exists(), + "refused install must not deploy stale content" + ); +} + +#[test] +fn stale_git_source_allows_install_with_allow_stale() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + init_git_module(module.path()); + make_origin_main_ahead(module.path()); + + let output = install(module.path(), target.path(), &["--allow-stale"]).success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr).into_owned(); + + assert!( + stderr.contains("WARNING"), + "--allow-stale must still show the freshness warning: {stderr}" + ); + assert!( + target.path().join(".claude/rules/Freshness.md").is_file(), + "--allow-stale should continue into deployment" + ); +} + +#[test] +fn non_git_source_skips_freshness_check_silently() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + scaffold_module(module.path()); + + let output = install(module.path(), target.path(), &[]).success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr).into_owned(); + + assert!( + !stderr.contains("freshness") && !stderr.contains("WARNING"), + "non-git sources should not warn about freshness: {stderr}" + ); +} + +#[test] +fn up_to_date_git_source_does_not_warn() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + init_git_module(module.path()); + make_origin_main_match_head(module.path()); + + let output = install(module.path(), target.path(), &[]).success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr).into_owned(); + + assert!( + !stderr.contains("behind") && !stderr.contains("WARNING"), + "up-to-date source should not warn: {stderr}" + ); +} diff --git a/tests/prune.rs b/tests/prune.rs index ff92173..4f7c5fb 100644 --- a/tests/prune.rs +++ b/tests/prune.rs @@ -521,13 +521,18 @@ fn prune_ignores_files_not_in_manifest() { // --- rule removal (separate code branch from skills) --- fn create_rule(root: &Path, name: &str) { + create_rule_at( + root, + &format!("{name}.md"), + &format!("Rule body for {name}.\n"), + ); +} + +fn create_rule_at(root: &Path, relative: &str, body: &str) { let rules_dir = root.join("rules"); - fs::create_dir_all(&rules_dir).unwrap(); - fs::write( - rules_dir.join(format!("{name}.md")), - format!("Rule body for {name}.\n"), - ) - .unwrap(); + let rule_path = rules_dir.join(relative); + fs::create_dir_all(rule_path.parent().unwrap()).unwrap(); + fs::write(rule_path, body).unwrap(); } #[test] @@ -571,6 +576,89 @@ fn prune_removes_stale_rule() { ); } +#[test] +fn prune_removes_base_rule_when_source_only_has_inactive_model_variant() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + + scaffold_module_with_repo( + module.path(), + "inactive-model-module", + "https://github.com/example/inactive-model-module", + ); + create_rule(module.path(), "KeepThis"); + create_rule(module.path(), "Foo"); + + install(module.path(), target.path(), &[]).success(); + let deployed = target.path().join(".claude/rules/Foo.md"); + assert!(deployed.is_file(), "base Foo rule must deploy first"); + + fs::remove_file(module.path().join("rules/Foo.md")).unwrap(); + create_rule_at( + module.path(), + "claude/claude-sonnet-4-6/Foo.md", + "SONNET ONLY BODY\n", + ); + + install(module.path(), target.path(), &[]).success(); + + assert!( + target.path().join(".claude/rules/KeepThis.md").is_file(), + "real base rule must survive" + ); + assert!( + !deployed.exists(), + "inactive model-only Foo must not keep the deployed base Foo alive" + ); + let trash = list_trash(target.path(), ".claude"); + assert_eq!(trash.len(), 1, "exactly one .trash entry expected"); + assert!( + trash[0].join("rules/Foo.md").is_file(), + "stale base Foo must be quarantined" + ); +} + +#[test] +fn prune_keeps_base_rule_when_active_model_variant_resolves_to_target() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + + scaffold_module_with_repo( + module.path(), + "active-model-module", + "https://github.com/example/active-model-module", + ); + create_rule(module.path(), "KeepThis"); + create_rule(module.path(), "Foo"); + + install(module.path(), target.path(), &[]).success(); + let deployed = target.path().join(".claude/rules/Foo.md"); + assert!(deployed.is_file(), "base Foo rule must deploy first"); + + fs::remove_file(module.path().join("rules/Foo.md")).unwrap(); + create_rule_at( + module.path(), + "claude/claude-opus-4-6/Foo.md", + "OPUS ONLY BODY\n", + ); + + install(module.path(), target.path(), &[]).success(); + + assert!( + deployed.is_file(), + "active model-only Foo is the correct deployed file for this target" + ); + let content = fs::read_to_string(&deployed).unwrap(); + assert!( + content.contains("OPUS ONLY BODY"), + "deployed Foo should be refreshed from the active model variant: {content}" + ); + assert!( + list_trash(target.path(), ".claude").is_empty(), + "active model deployment must not be pruned" + ); +} + // --- bare-name module identity --- #[test]