diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af81fd..50f2fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- `forge drift --target ` verifies a module's assembled `build/` against where it was deployed, scoped to the module's own files. It mirrors `forge install --target`: each provider's `build/` is compared to `/`, so files built but not yet deployed surface as local-only, deployment edits surface as frontmatter/body drift, and this module's deployed files (per the target `.manifest` plus provenance attribution) that are no longer built surface as drift. Unlike `--upstream` against a multi-module tree such as `~/.claude`, other modules' files are never reported. `--target` and `--upstream` are mutually exclusive. (#61) - `forge validate` sanity-checks Claude Code plugin scaffolding when `.claude-plugin/plugin.json` is present: each manifest (`plugin.json`, `.claude-plugin/marketplace.json`, `hooks/hooks.json`) must be valid JSON, and every hook script referenced via `${CLAUDE_PLUGIN_ROOT}` must exist and be executable (the most common cause of hooks silently not firing). It deliberately makes no assertions about the plugin or marketplace field schema, so it does not break when Claude Code's plugin format changes. Non-plugin modules are unaffected: the check runs only when `plugin.json` exists. (#59) - `.forge` consumer manifests accept `git:` sources pinned to a 40-hex commit SHA. `forge install` clones the remote via `gix` into a content-addressed cache at `~/.cache/forge/git////`, materializes the pinned tree, and feeds it through the standard assemble + deploy pipeline. HTTPS-only; `ssh://`, `git://`, `git@host:` shorthand, and userinfo URLs are rejected at parse time. Branch names, tags, and abbreviated SHAs are rejected in favor of explicit 40-char commit hashes. Cache hits are instant; the bare clone is reused across SHA pins within the same repository. (#53) diff --git a/src/cli/deploy/mod.rs b/src/cli/deploy/mod.rs index d34a4d3..3ff9324 100644 --- a/src/cli/deploy/mod.rs +++ b/src/cli/deploy/mod.rs @@ -424,7 +424,9 @@ fn validate_target_boundary(target_path: &Path, base_directory: &Path) -> Result } /// Load the previously deployed `.manifest` from a provider's target directory. -fn load_deployed_manifest(target_base: &Path) -> HashMap { +pub(crate) fn load_deployed_manifest( + target_base: &Path, +) -> HashMap { let manifest_path = target_base.join(".manifest"); let Ok(content) = fs::read_to_string(&manifest_path) else { return HashMap::new(); @@ -517,7 +519,7 @@ fn collect_files_recursive(dir: &Path) -> Result, Error> /// inputs fail to parse as URLs. This prevents two modules named `forge-core` /// at different repositories from incorrectly pruning each other's deployed /// files, and stops `Prompts` from matching `PublishPrompts` via a substring. -fn is_owned_by_module( +pub(crate) fn is_owned_by_module( entry: &manifest::ManifestEntry, target_base: &Path, module_name: Option<&str>, diff --git a/src/cli/drift/mod.rs b/src/cli/drift/mod.rs index 7cdf9cf..59df915 100644 --- a/src/cli/drift/mod.rs +++ b/src/cli/drift/mod.rs @@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fs; use std::path::Path; +mod scope; + const BODY_KEY: &str = "body"; // --- Types --- @@ -44,7 +46,32 @@ pub struct DriftResult { // --- Execution --- +/// Route to upstream comparison (`--upstream`) or manifest-scoped deployment +/// verification (`--target`). Exactly one of the two must be provided. pub fn execute( + module_path: &str, + upstream_path: Option<&str>, + target_path: Option<&str>, + ignore_keys: &[String], + json_output: bool, +) -> Result { + match (upstream_path, target_path) { + (Some(upstream), None) => { + execute_upstream(module_path, upstream, ignore_keys, json_output) + } + (None, Some(target)) => scope::execute(module_path, target, ignore_keys, json_output), + (Some(_), Some(_)) => Err(Error::new( + ErrorKind::Config, + "--upstream and --target are mutually exclusive".to_string(), + )), + (None, None) => Err(Error::new( + ErrorKind::Config, + "provide --upstream (compare two module trees) or --target (verify build against a deployment)".to_string(), + )), + } +} + +fn execute_upstream( module_path: &str, upstream_path: &str, ignore_keys: &[String], diff --git a/src/cli/drift/scope.rs b/src/cli/drift/scope.rs new file mode 100644 index 0000000..fe91291 --- /dev/null +++ b/src/cli/drift/scope.rs @@ -0,0 +1,190 @@ +//! Manifest-scoped drift verification: compare a module's assembled `build/` +//! against where it was deployed, limited to this module's own files. +//! +//! `forge drift --upstream ` compares two module trees by name and, run +//! against a multi-module deployment like `~/.claude`, floods the report with +//! every other module's files as "upstream only". This mode instead walks +//! `build/` (this module's output) and compares each file to its +//! deployed counterpart, then consults the deployed `.manifest` + provenance +//! attribution to flag this module's deployed files that are no longer built. + +use commands::error::{Error, ErrorKind}; +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::Path; + +use super::{DriftEntry, DriftResult, DriftStatus, compare_file_content, print_drift_result}; +use crate::cli::config; +use crate::cli::deploy::{is_owned_by_module, load_deployed_manifest}; + +const CONTENT_PREFIXES: [&str; 3] = ["agents/", "skills/", "rules/"]; + +/// Verify each provider's `build/` against `/` (mirroring `forge install --target`), scoped to this module. +pub fn execute( + source: &str, + target_base: &str, + ignore: &[String], + json_output: bool, +) -> Result { + let module_root = Path::new(source); + if !module_root.join("module.yaml").is_file() { + return Err(Error::new( + ErrorKind::Io, + format!( + "{source} is not a module root (no module.yaml); --target verify compares its build/ against a deployment" + ), + )); + } + + let source_uri = config::load_source_uri(module_root); + let module_name = (!source_uri.is_empty()).then_some(source_uri.as_str()); + + let merged_config = config::load_merged_config(module_root)?; + let providers = config::load_providers(&merged_config)?; + + let ignored: HashSet<&str> = ignore.iter().map(String::as_str).collect(); + let base = Path::new(target_base); + let mut result = DriftResult::default(); + let mut compared_any = false; + + for (provider_name, provider_config) in &providers { + let build_dir = module_root.join("build").join(provider_name); + if !build_dir.is_dir() { + continue; + } + compared_any = true; + compare_provider( + &mut result, + &build_dir, + &base.join(&provider_config.target), + provider_name, + module_name, + &ignored, + ); + } + + if !compared_any { + return Err(Error::new( + ErrorKind::Io, + format!( + "no build/ output under {source}; run `forge assemble` or `forge install` first" + ), + )); + } + + if json_output { + match serde_json::to_string_pretty(&result) { + Ok(json) => println!("{json}"), + Err(error) => eprintln!("failed to serialize drift result: {error}"), + } + } else { + print_drift_result(&result); + } + + let has_drift = result.entries.iter().any(|entry| { + matches!( + entry.status, + DriftStatus::FrontmatterOnly + | DriftStatus::BodyOnly + | DriftStatus::Both + | DriftStatus::UpstreamOnly + ) + }); + Ok(i32::from(has_drift)) +} + +fn compare_provider( + result: &mut DriftResult, + build_dir: &Path, + deployed_base: &Path, + provider_name: &str, + module_name: Option<&str>, + ignored: &HashSet<&str>, +) { + let build_files = collect_content_files(build_dir); + + for (relative, build_content) in &build_files { + let deployed_path = deployed_base.join(relative); + match fs::read_to_string(&deployed_path) { + Ok(deployed_content) => { + result.entries.push(compare_file_content( + relative, + build_content, + &deployed_content, + provider_name, + ignored, + )); + } + Err(_) => { + result + .entries + .push(only_entry(relative, DriftStatus::LocalOnly, provider_name)); + } + } + } + + // This module's deployed files (per the target manifest + provenance) that + // are no longer built — stale deployments that should be pruned. + for (key, entry) in load_deployed_manifest(deployed_base) { + if build_files.contains_key(&key) || !is_content_key(&key) { + continue; + } + if is_owned_by_module(&entry, deployed_base, module_name) { + result + .entries + .push(only_entry(&key, DriftStatus::UpstreamOnly, provider_name)); + } + } +} + +fn only_entry(name: &str, status: DriftStatus, category: &str) -> DriftEntry { + DriftEntry { + name: name.to_string(), + status, + category: category.to_string(), + changed_keys: Vec::new(), + renamed_from: None, + source_uri: None, + } +} + +fn is_content_key(key: &str) -> bool { + CONTENT_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) +} + +/// Collect content files under a provider build directory, keyed by their path +/// relative to it. Sidecar directories, `.manifest`, and dotfiles are skipped. +fn collect_content_files(build_dir: &Path) -> BTreeMap { + let mut files = BTreeMap::new(); + collect_recursive(build_dir, build_dir, &mut files); + files +} + +fn collect_recursive(base: &Path, current: &Path, files: &mut BTreeMap) { + let Ok(entries) = fs::read_dir(current) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name.starts_with('.') { + continue; + } + if path.is_dir() { + collect_recursive(base, &path, files); + } else if let Ok(content) = fs::read_to_string(&path) { + let relative = path + .strip_prefix(base) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + files.insert(relative, content); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/cli/drift/scope/tests.rs b/src/cli/drift/scope/tests.rs new file mode 100644 index 0000000..1b1e6ff --- /dev/null +++ b/src/cli/drift/scope/tests.rs @@ -0,0 +1,150 @@ +use super::super::{DriftResult, DriftStatus}; +use super::*; +use commands::manifest; +use tempfile::TempDir; + +fn write(path: &std::path::Path, content: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); +} + +/// Deploy a file plus its `.manifest` entry and a provenance sidecar that +/// attributes it to `source_uri`, mimicking a real `forge install`. +fn deploy_owned_file( + deployed_base: &std::path::Path, + relative: &str, + content: &str, + source_uri: &str, +) { + write(&deployed_base.join(relative), content); + + let artifact = std::path::Path::new(relative); + let stem = artifact.file_stem().unwrap().to_string_lossy(); + let provenance_relative = format!( + "{}/.provenance/{stem}.yaml", + artifact.parent().unwrap().to_string_lossy() + ); + let sidecar = format!( + "provenance:\n _type: https://in-toto.io/Statement/v1\n subject:\n - name: {relative}\n digest:\n sha256: {digest}\n predicate:\n buildDefinition:\n buildType: https://forge-cli/assemble/v1\n externalParameters:\n source: {source_uri}\n resolvedDependencies: []\n runDetails:\n builder:\n id: forge-cli\n version:\n forge: 0.0.0\n metadata:\n startedOn: \"2026-01-01T00:00:00Z\"\n", + digest = manifest::content_sha256(content) + ); + write(&deployed_base.join(&provenance_relative), &sidecar); + + let manifest_path = deployed_base.join(".manifest"); + let mut manifest_yaml = std::fs::read_to_string(&manifest_path).unwrap_or_default(); + manifest_yaml.push_str(&format!( + "{relative}:\n fingerprint: {digest}\n provenance: {provenance_relative}\n", + digest = manifest::content_sha256(content) + )); + std::fs::write(&manifest_path, manifest_yaml).unwrap(); +} + +fn run(build: &std::path::Path, deployed: &std::path::Path, module: &str) -> DriftResult { + let mut result = DriftResult::default(); + compare_provider( + &mut result, + build, + deployed, + "claude", + Some(module), + &HashSet::new(), + ); + result +} + +const MODULE: &str = "https://github.com/example/mymod"; + +#[test] +fn identical_build_and_deployed_is_not_drift() { + let build = TempDir::new().unwrap(); + let deployed = TempDir::new().unwrap(); + let body = "---\nname: Foo\n---\nBody.\n"; + write(&build.path().join("rules/Foo.md"), body); + deploy_owned_file(deployed.path(), "rules/Foo.md", body, MODULE); + + let result = run(build.path(), deployed.path(), MODULE); + let entry = result + .entries + .iter() + .find(|e| e.name == "rules/Foo.md") + .unwrap(); + assert_eq!(entry.status, DriftStatus::Identical); +} + +#[test] +fn edited_deployment_reports_body_drift() { + let build = TempDir::new().unwrap(); + let deployed = TempDir::new().unwrap(); + write( + &build.path().join("rules/Foo.md"), + "---\nname: Foo\n---\nOriginal.\n", + ); + deploy_owned_file( + deployed.path(), + "rules/Foo.md", + "---\nname: Foo\n---\nUser edited this.\n", + MODULE, + ); + + let result = run(build.path(), deployed.path(), MODULE); + let entry = result + .entries + .iter() + .find(|e| e.name == "rules/Foo.md") + .unwrap(); + assert_eq!(entry.status, DriftStatus::BodyOnly); +} + +#[test] +fn built_but_not_deployed_is_local_only() { + let build = TempDir::new().unwrap(); + let deployed = TempDir::new().unwrap(); + write( + &build.path().join("rules/New.md"), + "---\nname: New\n---\nBody.\n", + ); + + let result = run(build.path(), deployed.path(), MODULE); + let entry = result + .entries + .iter() + .find(|e| e.name == "rules/New.md") + .unwrap(); + assert_eq!(entry.status, DriftStatus::LocalOnly); +} + +#[test] +fn deployed_but_not_built_owned_is_drift() { + let build = TempDir::new().unwrap(); + let deployed = TempDir::new().unwrap(); + // Module deployed a file previously; the current build no longer contains it. + deploy_owned_file(deployed.path(), "rules/Removed.md", "old body\n", MODULE); + + let result = run(build.path(), deployed.path(), MODULE); + let entry = result + .entries + .iter() + .find(|e| e.name == "rules/Removed.md") + .expect("stale deployed file must be reported"); + assert_eq!(entry.status, DriftStatus::UpstreamOnly); +} + +#[test] +fn other_modules_deployed_files_are_ignored() { + let build = TempDir::new().unwrap(); + let deployed = TempDir::new().unwrap(); + // A file deployed by a DIFFERENT module must not appear in this module's report. + deploy_owned_file( + deployed.path(), + "rules/Foreign.md", + "foreign body\n", + "https://github.com/other/theirmod", + ); + + let result = run(build.path(), deployed.path(), MODULE); + assert!( + result.entries.iter().all(|e| e.name != "rules/Foreign.md"), + "another module's file must be out of scope: {:?}", + result.entries.iter().map(|e| &e.name).collect::>() + ); +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5e07601..df6fad6 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -171,15 +171,24 @@ enum Command { show_orphans: bool, }, - /// Compare module content against an upstream reference + /// Compare module content against an upstream reference, or verify a build + /// against where it was deployed Drift { /// Module root to compare. Defaults to `.`. #[arg(long, value_name = "DIR", default_value = ".")] source: String, - /// Upstream reference module to compare against. + /// Upstream reference module to compare against (compares two module + /// trees by name). Mutually exclusive with --target. #[arg(long, value_name = "DIR")] - upstream: String, + upstream: Option, + + /// Deploy base to verify against (e.g. `~` or `.`), mirroring `forge + /// install --target`. Diffs each `build/` against + /// `/`, scoped to this module's files. Mutually + /// exclusive with --upstream. + #[arg(long, value_name = "DIR")] + target: Option, /// Comma-separated keys to ignore (use "body" to ignore body drift) #[arg(long, value_delimiter = ',')] @@ -323,8 +332,17 @@ pub fn run() -> i32 { Command::Drift { source, upstream, + target, ignore, - } => return exit_code(drift::execute(&source, &upstream, &ignore, args.json)), + } => { + return exit_code(drift::execute( + &source, + upstream.as_deref(), + target.as_deref(), + &ignore, + args.json, + )); + } Command::Clean { source, target } => ( deploy::execute(&source, target.as_deref(), &[], false, true, false, false), "cleaned", diff --git a/tests/drift_target.rs b/tests/drift_target.rs new file mode 100644 index 0000000..398eb1c --- /dev/null +++ b/tests/drift_target.rs @@ -0,0 +1,119 @@ +//! Integration test for `forge drift --target`: verify a module's assembled +//! build against where it was deployed, scoped to the module's own files. + +use assert_cmd::Command; +use std::fs; +use std::path::Path; + +fn forge() -> Command { + Command::cargo_bin("forge").unwrap() +} + +fn scaffold_module(root: &Path) { + fs::write( + root.join("module.yaml"), + "name: drift-fixture\nversion: 0.1.0\ndescription: drift target fixture\nevents: []\nrepository: https://github.com/example/drift-fixture\n", + ) + .unwrap(); + fs::write(root.join("defaults.yaml"), "").unwrap(); + let rules = root.join("rules"); + fs::create_dir_all(&rules).unwrap(); + fs::write( + rules.join("KeepDeployed.md"), + "---\nname: KeepDeployed\ndescription: a rule\n---\n\nOriginal body.\n", + ) + .unwrap(); +} + +fn install(module: &Path, target: &Path) { + forge() + .args([ + "install", + "--source", + module.to_str().unwrap(), + "--target", + target.to_str().unwrap(), + ]) + .assert() + .success(); +} + +#[test] +fn drift_target_clean_after_install() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + scaffold_module(module.path()); + install(module.path(), target.path()); + + forge() + .args([ + "drift", + "--source", + module.path().to_str().unwrap(), + "--target", + target.path().to_str().unwrap(), + ]) + .assert() + .success(); +} + +#[test] +fn drift_target_detects_edited_deployment() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + scaffold_module(module.path()); + install(module.path(), target.path()); + + // Edit the deployed rule out from under the build. + let deployed_rule = target.path().join(".claude/rules/KeepDeployed.md"); + let edited = fs::read_to_string(&deployed_rule).unwrap() + "\nUser appended a line.\n"; + fs::write(&deployed_rule, edited).unwrap(); + + forge() + .args([ + "drift", + "--source", + module.path().to_str().unwrap(), + "--target", + target.path().to_str().unwrap(), + ]) + .assert() + .failure(); +} + +#[test] +fn drift_target_ignores_other_modules_in_deployment() { + let module = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + scaffold_module(module.path()); + install(module.path(), target.path()); + + // A foreign file with no provenance attribution to this module. Scoped + // verify must not flag it; the result stays clean. + fs::write( + target.path().join(".claude/rules/Foreign.md"), + "---\nname: Foreign\n---\nfrom another module\n", + ) + .unwrap(); + + forge() + .args([ + "drift", + "--source", + module.path().to_str().unwrap(), + "--target", + target.path().to_str().unwrap(), + ]) + .assert() + .success(); +} + +#[test] +fn drift_requires_a_mode() { + let module = tempfile::tempdir().unwrap(); + scaffold_module(module.path()); + forge() + .args(["drift", "--source", module.path().to_str().unwrap()]) + .assert() + .failure(); +}