Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 34 additions & 53 deletions crates/compass-cli/src/history_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,33 +109,11 @@ impl HistoryBuildOptions {
})
}

pub(crate) fn from_compatible_profile(mut profile: BuildProfile) -> Result<Self, HistoryError> {
let persisted = profile.value("compass_version").ok_or_else(|| {
HistoryError::InvalidFingerprint(
pub(crate) fn from_rebuild_profile(mut profile: BuildProfile) -> Result<Self, HistoryError> {
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,
Expand Down Expand Up @@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}

Expand Down
115 changes: 97 additions & 18 deletions crates/compass-cli/src/history_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
Expand All @@ -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)
Expand Down Expand Up @@ -211,7 +239,7 @@ pub(crate) fn resolve_or_materialize(
fn configured_build_options(repository: &Repository) -> Result<HistoryBuildOptions, String> {
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())
Expand Down Expand Up @@ -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)
}
};
Expand Down Expand Up @@ -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)?
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 20 additions & 30 deletions crates/compass-cli/tests/history_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,29 @@ fn current_history_profile() -> Result<compass_history::BuildProfile, compass_hi
("program_analyzer_version", "1"),
("enabled_features", "workspace-default"),
("direction", "native-source-semantics"),
("semantic_prompt_sha256", "fixture-prompt"),
("cluster_algorithm", "seeded-louvain/v1"),
("cluster_seed", "42"),
("gitignore", "true"),
("code_only", "true"),
("cargo", "false"),
("dedup_llm", "false"),
("semantic_mode", "standard"),
("provider", "none"),
("model", "none"),
("resolution", "1"),
("exclude_hubs", "none"),
("token_budget", "default"),
("provider_endpoint", "none"),
("provider_temperature", "none"),
("provider_max_output_tokens", "none"),
("provider_region", "none"),
] {
profile.insert(key, value)?;
}
profile.insert(
"semantic_prompt_sha256",
&compass_semantic::extraction_prompt_sha256(false),
)?;
Ok(profile)
}

Expand Down Expand Up @@ -1464,35 +1483,6 @@ fn diff_emits_semantic_text_json_html_and_rejects_removed_flags()

let empty = run(compass, directory.path(), &["diff", "HEAD", "HEAD"])?;
assert!(String::from_utf8_lossy(&empty.stdout).contains("0 likely breaks"));
let history = HistoryStore::open_existing(&repository)?.ok_or("missing history store")?;
let mut incompatible_profile = current_history_profile()?;
incompatible_profile.insert("compass_version", "incompatible")?;
let incompatible = history.publish(PublishRequest {
commit: new_commit,
parents: repository.parents(&repository.resolve("HEAD")?)?,
profile: incompatible_profile,
fingerprint: std::iter::repeat_n('d', 64)
.collect::<String>()
.parse::<ExtractionFingerprint>()?,
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(&current.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(())
}

Expand Down
Loading
Loading