diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ec1497..65c318ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Allow `compass review` on `0.3.x` to rebuild comparable realizations from + any repository profile or preferred realization whose persisted user-option + shape remains reconstructable, including when both compared revisions are + already materialized. Rebuilding does not order or allowlist Compass release + numbers: it preserves matching user-selected options, replaces engine-owned + fingerprint fields, and keeps original historical realizations immutable. + ## 0.3.13 - 2026-08-14 - Make `compass review` recover automatically when one compared revision still diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 73be20bf..dfb9d5cc 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -330,11 +330,17 @@ integer rubric is version 1; each deterministic gate has its own rule version. Presentation formats and the reusable GitHub Action consume this report and do not redefine its semantics. -When only one side of a review has a preferred realization from an older patch -release in the same supported `0.3.x` line, Compass advances engine-owned -profile fields and materializes both revisions with the running binary. The -older realization remains immutable and queryable. Newer profiles and profiles -from another release line still fail explicitly rather than being downgraded. +When either side of a review has a preferred realization or repository history +profile with noncurrent engine fields, Compass replaces every engine-owned +field with the running contract and then validates the complete reconstructed +profile before materializing both revisions. The persisted `compass_version` +is provenance, not a compatibility gate: review does not parse, order, or +allowlist release numbers. This also applies when both revisions already have +preferred realizations. Reconciliation proceeds only when their user-selected +options are identical after reconstruction. Historical realizations remain +immutable and queryable. Malformed or unsupported profile shapes and different +user options fail explicitly. When the supported profile shape changes, Compass +uses a hard cutover rather than accumulating release-specific migrations. Dependency findings in `compass.semantic_diff.report/1` may now carry the optional strict `dependency_topology` object. It records source/target community diff --git a/crates/compass-cli/src/history_build.rs b/crates/compass-cli/src/history_build.rs index 78ca60c4..6f051fc1 100644 --- a/crates/compass-cli/src/history_build.rs +++ b/crates/compass-cli/src/history_build.rs @@ -109,33 +109,11 @@ impl HistoryBuildOptions { }) } - pub(crate) fn from_compatible_profile(mut profile: BuildProfile) -> Result { - let persisted = profile.value("compass_version").ok_or_else(|| { - HistoryError::InvalidFingerprint( + pub(crate) fn from_rebuild_profile(mut profile: BuildProfile) -> Result { + if profile.value("compass_version").is_none() { + return Err(HistoryError::InvalidFingerprint( "persisted compass_version is missing from build profile".to_owned(), - ) - })?; - if persisted == env!("CARGO_PKG_VERSION") { - return Self::from_profile(profile); - } - let persisted = semver::Version::parse(persisted).map_err(|_| { - HistoryError::InvalidFingerprint( - "persisted compass_version is not a semantic version".to_owned(), - ) - })?; - let current = semver::Version::parse(env!("CARGO_PKG_VERSION")).map_err(|_| { - HistoryError::InvalidFingerprint( - "running compass_version is not a semantic version".to_owned(), - ) - })?; - if persisted.major != current.major - || persisted.minor != current.minor - || persisted >= current - { - return Err(HistoryError::InvalidFingerprint(format!( - "persisted compass_version {persisted} cannot be upgraded by {}", - env!("CARGO_PKG_VERSION") - ))); + )); } let deep = match profile.value("semantic_mode") { Some("standard") => false, @@ -1589,36 +1567,39 @@ mod tests { } #[test] - fn compatible_patch_profiles_advance_engine_fields_and_preserve_user_options() + fn rebuild_profiles_replace_engine_identity_without_release_comparison() -> Result<(), Box> { - let mut profile = HistoryBuildOptions::defaults()?.profile(); - let current = semver::Version::parse(env!("CARGO_PKG_VERSION"))?; - let mut previous = current.clone(); - previous.patch = previous - .patch - .checked_sub(1) - .ok_or("patch release fixture")?; - profile.insert("compass_version", &previous.to_string())?; - profile.insert("pipeline_version", "compass-core/older")?; - profile.insert("resolution", "2")?; - - let upgraded = HistoryBuildOptions::from_compatible_profile(profile)?.profile(); + for persisted_engine in ["historical-engine", "999.0.0", env!("CARGO_PKG_VERSION")] { + let mut profile = HistoryBuildOptions::defaults()?.profile(); + profile.insert("compass_version", persisted_engine)?; + profile.insert("pipeline_version", "compass-core/other")?; + profile.insert("resolution", "2")?; - assert_eq!( - upgraded.value("compass_version"), - Some(env!("CARGO_PKG_VERSION")) - ); - assert_eq!(upgraded.value("pipeline_version"), Some("compass-core/v1")); - assert_eq!(upgraded.value("resolution"), Some("2")); - - let mut future_profile = upgraded; - let mut future = current; - future.patch = future.patch.saturating_add(1); - future_profile.insert("compass_version", &future.to_string())?; - let error = HistoryBuildOptions::from_compatible_profile(future_profile) + let rebuilt = HistoryBuildOptions::from_rebuild_profile(profile)?.profile(); + + assert_eq!( + rebuilt.value("compass_version"), + Some(env!("CARGO_PKG_VERSION")) + ); + assert_eq!(rebuilt.value("pipeline_version"), Some("compass-core/v1")); + assert_eq!(rebuilt.value("resolution"), Some("2")); + } + Ok(()) + } + + #[test] + fn rebuild_profiles_hard_fail_unsupported_shapes() -> Result<(), Box> { + let mut unsupported = HistoryBuildOptions::defaults()?.profile(); + unsupported.insert("compass_version", "historical-engine")?; + unsupported.insert("future_option", "enabled")?; + let error = HistoryBuildOptions::from_rebuild_profile(unsupported) .err() - .ok_or("future profile unexpectedly accepted")?; - assert!(error.to_string().contains("cannot be upgraded")); + .ok_or("unsupported profile unexpectedly accepted")?; + assert!( + error + .to_string() + .contains("unsupported persisted build-profile field") + ); Ok(()) } diff --git a/crates/compass-cli/src/history_commands.rs b/crates/compass-cli/src/history_commands.rs index 7b806c07..8a0c307c 100644 --- a/crates/compass-cli/src/history_commands.rs +++ b/crates/compass-cli/src/history_commands.rs @@ -144,10 +144,38 @@ pub(crate) fn resolve_or_materialize( rebuild: bool, replace_corrupt: bool, ) -> Result<(HistoryStore, PublishedVersion), String> { + resolve_or_materialize_inner(repository, commit, options, rebuild, replace_corrupt, false) +} + +fn resolve_or_materialize_matching_profile( + repository: &Repository, + commit: CommitId, + options: &HistoryBuildOptions, + rebuild: bool, + replace_corrupt: bool, +) -> Result<(HistoryStore, PublishedVersion), String> { + resolve_or_materialize_inner(repository, commit, options, rebuild, replace_corrupt, true) +} + +fn resolve_or_materialize_inner( + repository: &Repository, + commit: CommitId, + options: &HistoryBuildOptions, + rebuild: bool, + replace_corrupt: bool, + require_profile_match: bool, +) -> Result<(HistoryStore, PublishedVersion), String> { + let requested_profile = options.profile(); let existing = HistoryStore::open_existing(repository).map_err(|error| error.to_string())?; if !rebuild && let Some(history) = existing { match history.preferred(&commit) { - Ok(Some(preferred)) => return Ok((history, preferred)), + Ok(Some(preferred)) + if !require_profile_match + || preferred.version.build_profile == requested_profile => + { + return Ok((history, preferred)); + } + Ok(Some(_)) => {} Ok(None) => {} Err(error) => return Err(error.to_string()), } @@ -160,7 +188,7 @@ pub(crate) fn resolve_or_materialize( let queue = HistoryQueue::for_repository(repository).map_err(|error| error.to_string())?; let request = JobRequest { commit: commit.clone(), - profile: options.profile(), + profile: requested_profile, }; let job_id = if rebuild { queue.enqueue_rebuild(request, replace_corrupt) @@ -211,7 +239,7 @@ pub(crate) fn resolve_or_materialize( fn configured_build_options(repository: &Repository) -> Result { let config = HistoryConfig::load(repository).map_err(|error| error.to_string())?; if let Some(profile) = config.profile { - return HistoryBuildOptions::from_compatible_profile(profile) + return HistoryBuildOptions::from_rebuild_profile(profile) .map_err(|error| error.to_string()); } HistoryBuildOptions::defaults().map_err(|error| error.to_string()) @@ -258,43 +286,94 @@ pub(crate) fn resolve_comparable_pair( return Err("the requested fingerprint is not materialized at both commits".to_owned()); } let (history, old, new) = match (old, new) { - (Some(old), Some(new)) => ( + (Some(old), Some(new)) if required_fingerprint.is_some() => ( existing.ok_or_else(|| "history store disappeared".to_owned())?, old, new, ), + (Some(old), Some(new)) => { + let old_options = + HistoryBuildOptions::from_rebuild_profile(old.version.build_profile.clone()) + .map_err(|error| error.to_string())?; + let new_options = + HistoryBuildOptions::from_rebuild_profile(new.version.build_profile.clone()) + .map_err(|error| error.to_string())?; + if old_options.profile() == new_options.profile() { + let (_, old) = resolve_or_materialize_matching_profile( + repository, + old_commit, + &old_options, + false, + false, + )?; + let (history, new) = resolve_or_materialize_matching_profile( + repository, + new_commit, + &new_options, + false, + false, + )?; + (history, old, new) + } else { + return Err(format!( + "realizations retain different user-selected build options after current-engine reconstruction\n\nOLD {} ({}) profile: {}\nNEW {} ({}) profile: {}\n\nBuild a comparable realization:\n compass history build {} --profile-from {}", + old.version.git_commit, + old.id, + old.version.profile_digest, + new.version.git_commit, + new.id, + new.version.profile_digest, + new.version.git_commit, + old.version.git_commit, + )); + } + } (Some(old), None) => { let options = - HistoryBuildOptions::from_compatible_profile(old.version.build_profile.clone()) + HistoryBuildOptions::from_rebuild_profile(old.version.build_profile.clone()) .map_err(|error| error.to_string())?; let old = if old.version.build_profile == options.profile() { old } else { - resolve_or_materialize(repository, old_commit, &options, true, false)?.1 + resolve_or_materialize_matching_profile( + repository, old_commit, &options, true, false, + )? + .1 }; - let (history, new) = - resolve_or_materialize(repository, new_commit, &options, false, false)?; + let (history, new) = resolve_or_materialize_matching_profile( + repository, new_commit, &options, false, false, + )?; (history, old, new) } (None, Some(new)) => { let options = - HistoryBuildOptions::from_compatible_profile(new.version.build_profile.clone()) + HistoryBuildOptions::from_rebuild_profile(new.version.build_profile.clone()) .map_err(|error| error.to_string())?; let new = if new.version.build_profile == options.profile() { new } else { - resolve_or_materialize(repository, new_commit, &options, true, false)?.1 + resolve_or_materialize_matching_profile( + repository, new_commit, &options, true, false, + )? + .1 }; - let (history, old) = - resolve_or_materialize(repository, old_commit, &options, false, false)?; + let (history, old) = resolve_or_materialize_matching_profile( + repository, old_commit, &options, false, false, + )?; (history, old, new) } (None, None) => { let options = configured_build_options(repository)?; - let (_, old) = - resolve_or_materialize(repository, old_commit.clone(), &options, false, false)?; - let (history, new) = - resolve_or_materialize(repository, new_commit, &options, false, false)?; + let (_, old) = resolve_or_materialize_matching_profile( + repository, + old_commit.clone(), + &options, + false, + false, + )?; + let (history, new) = resolve_or_materialize_matching_profile( + repository, new_commit, &options, false, false, + )?; (history, old, new) } }; @@ -1866,7 +1945,7 @@ fn execute_build( let parsed = parse_build_command(command, args).map_err(usage)?; let commit = repository.resolve(&parsed.revision).map_err(runtime)?; let options = if let Some(source) = &parsed.profile_from { - HistoryBuildOptions::from_compatible_profile( + HistoryBuildOptions::from_rebuild_profile( stored_profile(repository, source).map_err(runtime)?, ) .map_err(runtime)? @@ -1909,7 +1988,7 @@ fn execute_build( } else { false }; - let (_history, published) = resolve_or_materialize( + let (_history, published) = resolve_or_materialize_matching_profile( repository, commit, &options, diff --git a/crates/compass-cli/tests/history_cli.rs b/crates/compass-cli/tests/history_cli.rs index fab7b33b..53913ccc 100644 --- a/crates/compass-cli/tests/history_cli.rs +++ b/crates/compass-cli/tests/history_cli.rs @@ -51,10 +51,29 @@ fn current_history_profile() -> Result() - .parse::()?, - artifacts: new_artifacts, - completion: CompletionEvidence { - extraction_succeeded: true, - allow_partial: false, - semantic_files_expected: 0, - semantic_files_completed: 0, - failed_chunks: 0, - }, - make_preferred: true, - })?; - let head = repository.resolve("HEAD")?; - let current = history - .preferred(&head)? - .ok_or("missing current preferred realization")?; - assert!(history.compare_and_set_preferred(&head, Some(¤t.id), &incompatible.id)?); - drop(history); - let mismatch = run(compass, directory.path(), &["diff", "HEAD~1", "HEAD"])?; - assert_eq!(mismatch.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&mismatch.stderr).contains("incompatible graph engines")); Ok(()) } diff --git a/crates/compass-cli/tests/review_cli.rs b/crates/compass-cli/tests/review_cli.rs index 8b90cc19..040cad91 100644 --- a/crates/compass-cli/tests/review_cli.rs +++ b/crates/compass-cli/tests/review_cli.rs @@ -1,8 +1,12 @@ use std::path::Path; use std::process::{Command, Output}; -use compass_history::{ExtractionFingerprint, HistoryStore, PublishRequest, Repository}; -use compass_pr_intelligence::{GateState, PullRequestReport, RiskBand}; +use compass_history::{ + ExtractionFingerprint, HistoryConfig, HistoryStore, PublishRequest, Repository, +}; +use compass_pr_intelligence::{GateState, MergeOutcome, PullRequestReport, RiskBand}; + +const SYNTHETIC_ENGINE_IDENTITY: &str = "historical-engine"; fn git(root: &Path, arguments: &[&str]) -> Result> { let output = Command::new("git") @@ -48,7 +52,7 @@ fn publish_historical_base(root: &Path, commit: &str) -> Result<(), Box Result<(), Box Result<(), Box> { + let enabled = run(root, &["history", "enable", "--code-only"])?; + if !enabled.status.success() { + return Err(format!( + "could not enable history: {}", + String::from_utf8_lossy(&enabled.stderr) + ) + .into()); + } + let repository = Repository::discover(root)?; + let mut profile = HistoryConfig::load(&repository)? + .profile + .ok_or("enabled profile")?; + profile.insert("compass_version", SYNTHETIC_ENGINE_IDENTITY)?; + HistoryConfig::enable(&repository, profile)?; + Ok(()) +} + #[test] fn local_review_writes_round_trippable_exact_report() -> Result<(), Box> { let directory = tempfile::tempdir()?; @@ -142,7 +164,7 @@ fn local_review_writes_round_trippable_exact_report() -> Result<(), Box Result<(), Box> { let directory = tempfile::tempdir()?; initialize(directory.path())?; @@ -182,11 +204,162 @@ fn local_review_rebuilds_a_comparable_pair_after_a_compass_patch_upgrade() Some(env!("CARGO_PKG_VERSION")) ); assert!(history.list(Some(&base))?.iter().any(|realization| { - realization.version.build_profile.value("compass_version") == Some("0.3.9") + realization.version.build_profile.value("compass_version") + == Some(SYNTHETIC_ENGINE_IDENTITY) })); Ok(()) } +#[test] +fn local_review_rebuilds_a_persisted_profile_after_a_current_graph_build() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + initialize(directory.path())?; + let base = git(directory.path(), &["rev-parse", "HEAD"])?; + git(directory.path(), &["checkout", "--quiet", "-b", "feature"])?; + std::fs::write( + directory.path().join("feature.rs"), + "pub fn feature() -> u8 { 2 }\n", + )?; + git(directory.path(), &["add", "feature.rs"])?; + git(directory.path(), &["commit", "--quiet", "-m", "feature"])?; + let head = git(directory.path(), &["rev-parse", "HEAD"])?; + persist_historical_repository_profile(directory.path())?; + + let built = run( + directory.path(), + &["extract", ".", "--code-only", "--no-viz"], + )?; + assert!( + built.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&built.stdout), + String::from_utf8_lossy(&built.stderr) + ); + let repository = Repository::discover(directory.path())?; + assert_eq!( + HistoryConfig::load(&repository)? + .profile + .and_then(|profile| profile.value("compass_version").map(str::to_owned)) + .as_deref(), + Some(SYNTHETIC_ENGINE_IDENTITY) + ); + + let reviewed = run( + directory.path(), + &[ + "review", "--base", &base, "--head", &head, "--format", "json", + ], + )?; + assert!( + reviewed.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&reviewed.stdout), + String::from_utf8_lossy(&reviewed.stderr) + ); + let report = PullRequestReport::from_json(&reviewed.stdout)?; + assert_eq!(report.identity.revisions.target_head, base); + assert_eq!(report.identity.revisions.pull_request_head, head); + let comparison = report + .identity + .revisions + .merge_result + .object_id() + .unwrap_or(&report.identity.revisions.pull_request_head) + .to_owned(); + + let history = HistoryStore::open_existing(&repository)?.ok_or("history store")?; + for revision in [base, comparison] { + let commit = repository.resolve(&revision)?; + let preferred = history.preferred(&commit)?.ok_or("preferred realization")?; + assert_eq!( + preferred.version.build_profile.value("compass_version"), + Some(env!("CARGO_PKG_VERSION")) + ); + } + Ok(()) +} + +fn assert_review_reconciles_existing_realizations( + noncurrent_head: bool, +) -> Result<(), Box> { + let directory = tempfile::tempdir()?; + initialize(directory.path())?; + git(directory.path(), &["checkout", "--quiet", "-b", "feature"])?; + std::fs::write( + directory.path().join("lib.rs"), + "pub fn shared() -> u8 { 2 }\n", + )?; + git(directory.path(), &["add", "lib.rs"])?; + git(directory.path(), &["commit", "--quiet", "-m", "feature"])?; + let head = git(directory.path(), &["rev-parse", "HEAD"])?; + git(directory.path(), &["checkout", "--quiet", "main"])?; + std::fs::write( + directory.path().join("lib.rs"), + "pub fn shared() -> u8 { 3 }\n", + )?; + git(directory.path(), &["add", "lib.rs"])?; + git(directory.path(), &["commit", "--quiet", "-m", "target"])?; + let base = git(directory.path(), &["rev-parse", "HEAD"])?; + publish_historical_base(directory.path(), &base)?; + if noncurrent_head { + publish_historical_base(directory.path(), &head)?; + } else { + let built = run( + directory.path(), + &["history", "build", &head, "--code-only"], + )?; + assert!( + built.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&built.stdout), + String::from_utf8_lossy(&built.stderr) + ); + } + + let reviewed = run( + directory.path(), + &[ + "review", "--base", &base, "--head", &head, "--format", "json", + ], + )?; + assert!( + reviewed.status.success(), + "stdout={} stderr={}", + String::from_utf8_lossy(&reviewed.stdout), + String::from_utf8_lossy(&reviewed.stderr) + ); + let report = PullRequestReport::from_json(&reviewed.stdout)?; + assert!(matches!( + report.identity.revisions.merge_result, + MergeOutcome::Conflicted { .. } + )); + + let repository = Repository::discover(directory.path())?; + let history = HistoryStore::open_existing(&repository)?.ok_or("history store")?; + for revision in [base, head] { + let commit = repository.resolve(&revision)?; + let preferred = history.preferred(&commit)?.ok_or("preferred realization")?; + assert_eq!( + preferred.version.build_profile.value("compass_version"), + Some(env!("CARGO_PKG_VERSION")) + ); + } + Ok(()) +} + +#[test] +fn local_review_reconciles_existing_different_engine_profiles() +-> Result<(), Box> { + assert_review_reconciles_existing_realizations(false) +} + +#[test] +fn local_review_hard_cuts_over_matching_noncurrent_engine_profiles() +-> Result<(), Box> { + assert_review_reconciles_existing_realizations(true) +} + #[test] fn conflicted_review_is_unavailable_without_false_clean_gate() -> Result<(), Box> { diff --git a/docs/guides/versioned-history.md b/docs/guides/versioned-history.md index c7978616..dd5592e7 100644 --- a/docs/guides/versioned-history.md +++ b/docs/guides/versioned-history.md @@ -355,11 +355,16 @@ There is no profile-mismatch override: unlike profiles do not produce a semantic or exact report. Compass checks graph-engine compatibility explicitly before comparing the complete build profiles. -`compass review` handles an older preferred realization from a compatible -`0.3.x` patch release automatically when it must materialize the other side. -It preserves user-selected profile options, rebuilds a current-version pair, -and leaves the older immutable realization intact. A profile from a newer -binary or another release line remains an explicit compatibility error. +`compass review` handles a preferred realization or repository history profile +with noncurrent engine fields automatically when its persisted user-option +shape remains reconstructable. This includes comparisons where both revisions +already have preferred realizations. Compass replaces engine-owned fields with +the running contract, validates the complete reconstructed profile, and rebuilds +a current pair only when both sides retain identical user-selected options. It +does not parse, order, or allowlist the persisted Compass release number, and it +leaves historical realizations intact. A malformed or unsupported profile shape +or genuinely different user options remains an explicit compatibility error. +Profile-shape changes use a hard cutover instead of release-specific migrations. ## 7. Export a realization