diff --git a/crates/core/src/lockfile.rs b/crates/core/src/lockfile.rs index 35b5c2a..1e47817 100644 --- a/crates/core/src/lockfile.rs +++ b/crates/core/src/lockfile.rs @@ -121,11 +121,41 @@ pub struct LockfileFeature { /// SHA256 digest for integrity checking (e.g., "sha256:...") pub integrity: String, - /// Optional feature dependencies - #[serde(skip_serializing_if = "Option::is_none")] + /// Optional feature dependencies. Spec emits this field as `dependsOn` + /// (camelCase) — see upstream `generateLockfile` in + /// `devcontainers/cli` `src/spec-configuration/lockfile.ts`. + #[serde(rename = "dependsOn", skip_serializing_if = "Option::is_none", default)] pub depends_on: Option>, } +impl LockfileFeature { + /// Construct a lockfile entry in the upstream canonical form. + /// + /// Mirrors `generateLockfile` in `devcontainers/cli` + /// `src/spec-configuration/lockfile.ts`: + /// resolved = `{registry}/{repository}@{digest}` + /// integrity = `{digest}` + /// The `digest` argument MUST be in `sha256:<64-hex>` form (the manifest + /// digest returned by the OCI fetcher). + /// + /// `depends_on` should be an alphabetically-sorted vec of feature IDs, or + /// `None` for features with no dependencies. + pub fn from_resolved( + registry: &str, + repository: &str, + digest: &str, + version: String, + depends_on: Option>, + ) -> Self { + Self { + version, + resolved: format!("{}/{}@{}", registry, repository, digest), + integrity: digest.to_string(), + depends_on, + } + } +} + /// Get lockfile path adjacent to config file /// /// Implements the lockfile naming convention: @@ -274,9 +304,13 @@ pub fn write_lockfile(path: &Path, lockfile: &Lockfile, force_init: bool) -> Res // Sort all object keys recursively for stable JSON output sort_json_object(&mut value); - // Serialize with pretty printing (2-space indentation) - let json = + // Serialize with pretty printing (2-space indentation) and a trailing + // newline to match upstream `devcontainers/cli`'s `writeLockfile` output + // (`JSON.stringify(..., 2) + '\n'`). Byte-identical output keeps the + // `--frozen-lockfile` content comparison stable across implementations. + let mut json = serde_json::to_string_pretty(&value).context("Failed to serialize lockfile to JSON")?; + json.push('\n'); // Atomic write: write to temp file in same directory, then rename // Using same directory ensures same filesystem for atomic rename on all platforms @@ -615,31 +649,35 @@ impl LockfileValidationResult { /// Format the validation result as an error message. /// /// Returns a user-friendly error message describing the mismatch, - /// including actionable guidance on how to resolve the issue. + /// including actionable guidance on how to resolve the issue. The leading + /// summary line mirrors the canonical upstream `devcontainers/cli` strings + /// (`"Lockfile does not exist."` / `"Lockfile does not match."`) so + /// existing CI scripts that match on those messages continue to work. pub fn format_error(&self) -> String { match self { LockfileValidationResult::Matched => "Lockfile validation passed".to_string(), LockfileValidationResult::Missing { expected_path } => { format!( - "Frozen lockfile mode requires a lockfile, but none found at '{}'.\n\ - Run without --experimental-frozen-lockfile to generate a lockfile, \ - or create one with `deacon build --experimental-lockfile`.", + "Lockfile does not exist.\nExpected at '{}'.\n\ + Run without --frozen-lockfile to generate a lockfile, or \ + generate one with `deacon upgrade`.", expected_path.display() ) } LockfileValidationResult::MissingFromLockfile { features } => { format!( - "Frozen lockfile mismatch: features declared in config but missing from lockfile:\n \ + "Lockfile does not match.\nFeatures declared in config but missing from lockfile:\n \ - {}\n\ - Update the lockfile or remove --experimental-frozen-lockfile to allow resolution.", + Run without --frozen-lockfile to update the lockfile, or run `deacon upgrade`.", features.join("\n - ") ) } LockfileValidationResult::ExtraInLockfile { features } => { format!( - "Frozen lockfile mismatch: features in lockfile but not declared in config:\n \ + "Lockfile does not match.\nFeatures in lockfile but not declared in config:\n \ - {}\n\ - Update the lockfile to remove stale entries, or add these features to your config.", + Update the lockfile to remove stale entries (e.g. via `deacon upgrade`), \ + or add these features to your config.", features.join("\n - ") ) } @@ -648,10 +686,10 @@ impl LockfileValidationResult { extra_in_lockfile, } => { format!( - "Frozen lockfile mismatch:\n\ + "Lockfile does not match.\n\ Features declared in config but missing from lockfile:\n - {}\n\ Features in lockfile but not declared in config:\n - {}\n\ - Update the lockfile or remove --experimental-frozen-lockfile to allow resolution.", + Run without --frozen-lockfile to update the lockfile, or run `deacon upgrade`.", missing_from_lockfile.join("\n - "), extra_in_lockfile.join("\n - ") ) @@ -1450,14 +1488,90 @@ mod tests { assert_eq!(result.format_error(), "Lockfile validation passed"); } + // ========================================================================= + // Upstream wire-format parity tests + // ========================================================================= + + /// The `dependsOn` field MUST serialize as camelCase per upstream + /// `devcontainers/cli` `generateLockfile`. Deserialization must accept + /// the camelCase form too. + #[test] + fn test_depends_on_serializes_as_camel_case() { + let entry = LockfileFeature { + version: "1.0.0".to_string(), + resolved: "ghcr.io/x/y@sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(), + integrity: "sha256:1111111111111111111111111111111111111111111111111111111111111111".to_string(), + depends_on: Some(vec!["dep-a".to_string()]), + }; + let json = serde_json::to_string(&entry).unwrap(); + assert!( + json.contains("\"dependsOn\""), + "expected camelCase `dependsOn` on the wire, got: {}", + json + ); + assert!( + !json.contains("\"depends_on\""), + "snake_case `depends_on` must not be emitted, got: {}", + json + ); + + // Round-trip both wire forms. + let from_camel: LockfileFeature = serde_json::from_str(&json).unwrap(); + assert_eq!(from_camel, entry); + } + + /// Writer emits a trailing newline to match upstream + /// `JSON.stringify(..., 2) + '\n'`. + #[test] + fn test_write_lockfile_emits_trailing_newline() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("lf.json"); + let lockfile = Lockfile { + features: HashMap::new(), + }; + write_lockfile(&path, &lockfile, true).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!( + bytes.last().copied(), + Some(b'\n'), + "lockfile output must end with a single trailing newline" + ); + } + + /// `LockfileFeature::from_resolved` constructs entries in the upstream + /// canonical form: `{registry}/{repository}@{digest}` for `resolved`, + /// digest alone for `integrity`. + #[test] + fn test_from_resolved_constructs_upstream_form() { + let entry = LockfileFeature::from_resolved( + "ghcr.io", + "devcontainers/features/node", + "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "1.2.3".to_string(), + None, + ); + assert_eq!(entry.version, "1.2.3"); + assert_eq!( + entry.resolved, + "ghcr.io/devcontainers/features/node@sha256:1111111111111111111111111111111111111111111111111111111111111111" + ); + assert_eq!( + entry.integrity, + "sha256:1111111111111111111111111111111111111111111111111111111111111111" + ); + assert!(entry.depends_on.is_none()); + } + #[test] fn test_validation_result_format_error_missing() { let result = LockfileValidationResult::Missing { expected_path: PathBuf::from("/path/to/lockfile.json"), }; let error = result.format_error(); - assert!(error.contains("Frozen lockfile mode requires a lockfile")); + // Upstream-aligned strings (devcontainers/cli writeLockfile errors). + assert!(error.contains("Lockfile does not exist.")); assert!(error.contains("/path/to/lockfile.json")); + assert!(error.contains("--frozen-lockfile")); } #[test] @@ -1466,7 +1580,8 @@ mod tests { features: vec!["feature-a".to_string(), "feature-b".to_string()], }; let error = result.format_error(); - assert!(error.contains("features declared in config but missing from lockfile")); + assert!(error.contains("Lockfile does not match.")); + assert!(error.contains("Features declared in config but missing from lockfile")); assert!(error.contains("feature-a")); assert!(error.contains("feature-b")); } @@ -1477,7 +1592,8 @@ mod tests { features: vec!["stale-feature".to_string()], }; let error = result.format_error(); - assert!(error.contains("features in lockfile but not declared in config")); + assert!(error.contains("Lockfile does not match.")); + assert!(error.contains("Features in lockfile but not declared in config")); assert!(error.contains("stale-feature")); } @@ -1488,7 +1604,7 @@ mod tests { extra_in_lockfile: vec!["old-feature".to_string()], }; let error = result.format_error(); - assert!(error.contains("Frozen lockfile mismatch")); + assert!(error.contains("Lockfile does not match.")); assert!(error.contains("new-feature")); assert!(error.contains("old-feature")); } diff --git a/crates/deacon/src/cli.rs b/crates/deacon/src/cli.rs index fafdbec..97554f6 100644 --- a/crates/deacon/src/cli.rs +++ b/crates/deacon/src/cli.rs @@ -228,11 +228,18 @@ pub enum Commands { /// Skip feature auto-mapping (hidden testing flag) #[arg(long, hide = true)] skip_feature_auto_mapping: bool, - /// Path to feature lockfile for validation (experimental, hidden) + /// Disable lockfile generation and verification. Mutually exclusive with --frozen-lockfile. + #[arg(long)] + no_lockfile: bool, + /// Require an up-to-date lockfile; fail if resolution would change it. + /// Mutually exclusive with --no-lockfile. + #[arg(long)] + frozen_lockfile: bool, + /// DEPRECATED: use --frozen-lockfile (and pass a path via --config if needed). + /// Kept as a hidden alias through the 1.x line; emits a WARN when used. #[arg(long, hide = true)] experimental_lockfile: Option, - /// Require lockfile to exist and match config features exactly (experimental, hidden) - /// Implies --experimental-lockfile if not specified; uses default lockfile location. + /// DEPRECATED alias for --frozen-lockfile (graduated in 1.0). Hidden; emits a WARN. #[arg(long, hide = true)] experimental_frozen_lockfile: bool, /// Dotfiles repository URL @@ -380,10 +387,17 @@ pub enum Commands { /// Do not persist customizations from features into image metadata #[arg(long, hide = true)] skip_persisting_customizations_from_features: bool, - /// Write feature lockfile (experimental) + /// Disable lockfile generation and verification. Mutually exclusive with --frozen-lockfile. + #[arg(long)] + no_lockfile: bool, + /// Require an up-to-date lockfile; fail if resolution would change it. + /// Mutually exclusive with --no-lockfile. + #[arg(long)] + frozen_lockfile: bool, + /// DEPRECATED: lockfile is now written by default. Hidden alias kept through 1.x; emits a WARN. #[arg(long, hide = true)] experimental_lockfile: bool, - /// Fail if lockfile changes would occur (experimental) + /// DEPRECATED alias for --frozen-lockfile (graduated in 1.0). Hidden; emits a WARN. #[arg(long, hide = true)] experimental_frozen_lockfile: bool, /// Omit Dockerfile syntax directive workaround @@ -959,6 +973,8 @@ impl Cli { prefer_cli_features, feature_install_order, skip_feature_auto_mapping, + no_lockfile, + frozen_lockfile, experimental_lockfile, experimental_frozen_lockfile, dotfiles_repository, @@ -979,6 +995,29 @@ impl Cli { }) => { use crate::commands::up::{execute_up, UpArgs}; + // Mutual exclusivity check (mirrors devcontainers/cli). + if no_lockfile && (frozen_lockfile || experimental_frozen_lockfile) { + anyhow::bail!("--no-lockfile and --frozen-lockfile are mutually exclusive."); + } + // Emit deprecation WARN for the hidden experimental aliases. + if experimental_lockfile.is_some() { + tracing::warn!( + target: "deacon::lockfile", + "--experimental-lockfile is deprecated and will be removed in 2.0. \ + Lockfile generation is now the default; pass --no-lockfile to disable. \ + The custom-path form has no replacement (the lockfile lives next to the config)." + ); + } + if experimental_frozen_lockfile { + tracing::warn!( + target: "deacon::lockfile", + "--experimental-frozen-lockfile is deprecated and will be removed in 2.0; \ + use --frozen-lockfile." + ); + } + // effective_frozen = either flag (matches upstream's effectiveFrozenLockfile) + let effective_frozen_lockfile = frozen_lockfile || experimental_frozen_lockfile; + let args = UpArgs { id_label, remove_existing_container, @@ -997,6 +1036,8 @@ impl Cli { cache_to, buildkit, skip_feature_auto_mapping, + no_lockfile, + frozen_lockfile: effective_frozen_lockfile, experimental_lockfile, experimental_frozen_lockfile, dotfiles_repository, @@ -1137,12 +1178,34 @@ impl Cli { output, skip_feature_auto_mapping, skip_persisting_customizations_from_features, + no_lockfile, + frozen_lockfile, experimental_lockfile, experimental_frozen_lockfile, omit_syntax_directive, }) => { use crate::commands::build::{execute_build, BuildArgs}; + // Mutual exclusivity check (mirrors devcontainers/cli). + if no_lockfile && (frozen_lockfile || experimental_frozen_lockfile) { + anyhow::bail!("--no-lockfile and --frozen-lockfile are mutually exclusive."); + } + if experimental_lockfile { + tracing::warn!( + target: "deacon::lockfile", + "--experimental-lockfile is deprecated and will be removed in 2.0; \ + lockfile generation is now the default. Pass --no-lockfile to disable." + ); + } + if experimental_frozen_lockfile { + tracing::warn!( + target: "deacon::lockfile", + "--experimental-frozen-lockfile is deprecated and will be removed in 2.0; \ + use --frozen-lockfile." + ); + } + let effective_frozen_lockfile = frozen_lockfile || experimental_frozen_lockfile; + let args = BuildArgs { no_cache, platform, @@ -1177,6 +1240,8 @@ impl Cli { output, skip_feature_auto_mapping, skip_persisting_customizations_from_features, + no_lockfile, + frozen_lockfile: effective_frozen_lockfile, experimental_lockfile, experimental_frozen_lockfile, omit_syntax_directive, diff --git a/crates/deacon/src/commands/build/mod.rs b/crates/deacon/src/commands/build/mod.rs index 650810f..86f2459 100644 --- a/crates/deacon/src/commands/build/mod.rs +++ b/crates/deacon/src/commands/build/mod.rs @@ -66,11 +66,21 @@ pub struct BuildArgs { /// Do not persist customizations from features into image metadata #[allow(dead_code)] // Reserved for future feature implementation pub skip_persisting_customizations_from_features: bool, - /// Write feature lockfile (experimental) - #[allow(dead_code)] // Reserved for future feature implementation + /// Skip lockfile generation and verification (graduated 1.0). + #[allow(dead_code)] // Wired to behavior in PR-4b (writer integration follow-up) + pub no_lockfile: bool, + /// Require an up-to-date lockfile; fail if resolution would change it + /// (graduated 1.0). Effective value (CLI ORs the deprecated + /// `--experimental-frozen-lockfile` alias into this). + #[allow(dead_code)] // Wired to behavior in PR-4b + pub frozen_lockfile: bool, + /// DEPRECATED hidden alias for the legacy --experimental-lockfile (bool form). + /// CLI layer emits a WARN when set; kept through 1.x for backward compat. + #[allow(dead_code)] // Will be removed in 2.0 pub experimental_lockfile: bool, - /// Fail if lockfile changes would occur (experimental) - #[allow(dead_code)] // Reserved for future feature implementation + /// DEPRECATED hidden alias for --frozen-lockfile. + /// CLI layer ORs into `frozen_lockfile` and emits a WARN. + #[allow(dead_code)] // Will be removed in 2.0 pub experimental_frozen_lockfile: bool, /// Omit Dockerfile syntax directive workaround #[allow(dead_code)] // Reserved for future feature implementation @@ -113,6 +123,8 @@ impl Default for BuildArgs { output: None, skip_feature_auto_mapping: false, skip_persisting_customizations_from_features: false, + no_lockfile: false, + frozen_lockfile: false, experimental_lockfile: false, experimental_frozen_lockfile: false, omit_syntax_directive: false, diff --git a/crates/deacon/src/commands/up/args.rs b/crates/deacon/src/commands/up/args.rs index 8351bfb..8304781 100644 --- a/crates/deacon/src/commands/up/args.rs +++ b/crates/deacon/src/commands/up/args.rs @@ -365,10 +365,22 @@ pub struct UpArgs { pub dotfiles_install_command: Option, pub dotfiles_target_path: Option, - // Lockfile control (experimental) - /// Path to feature lockfile for validation (experimental) + // Lockfile control (graduated in 1.0; default behavior is read + write) + /// Skip lockfile generation and verification. + pub no_lockfile: bool, + /// Require an up-to-date lockfile; fail if resolution would change it. + /// This is the effective value (already OR'd with the deprecated + /// `experimental_frozen_lockfile` alias in the CLI layer). + pub frozen_lockfile: bool, + /// DEPRECATED hidden alias for the legacy `--experimental-lockfile ` + /// form. Kept through the 1.x line so existing CI scripts keep working; + /// the CLI layer emits a WARN when set. The custom-path semantics have + /// no replacement in the graduated flag surface. pub experimental_lockfile: Option, - /// Require lockfile to exist and match config features exactly (experimental) + /// DEPRECATED hidden alias for `--frozen-lockfile`. The CLI layer ORs + /// this into `frozen_lockfile` and emits a WARN; downstream code should + /// read `frozen_lockfile` only. + #[allow(dead_code)] // Plumbed for shape parity; CLI layer ORs into frozen_lockfile. pub experimental_frozen_lockfile: bool, // Metadata and output control @@ -448,6 +460,8 @@ impl Default for UpArgs { dotfiles_repository: None, dotfiles_install_command: None, dotfiles_target_path: None, + no_lockfile: false, + frozen_lockfile: false, experimental_lockfile: None, experimental_frozen_lockfile: false, omit_config_remote_env_from_metadata: false, diff --git a/crates/deacon/src/commands/up/mod.rs b/crates/deacon/src/commands/up/mod.rs index 88e99a7..eb962d2 100644 --- a/crates/deacon/src/commands/up/mod.rs +++ b/crates/deacon/src/commands/up/mod.rs @@ -203,18 +203,19 @@ pub(crate) async fn execute_up_with_runtime( check_for_disallowed_features(&config.features)?; debug!("Validated features - no disallowed features found"); - // T012: Enforce lockfile/frozen validation pre-build - // T013: User-facing error handling for lockfile/frozen enforcement - // If frozen mode is enabled, or lockfile path is explicitly provided, validate before build - if args.experimental_frozen_lockfile || args.experimental_lockfile.is_some() { - // Determine lockfile path: use explicit path if provided, otherwise derive from config + // Frozen-lockfile pre-build validation (graduated in 1.0). + // `args.frozen_lockfile` is the effective value (CLI layer ORs the + // deprecated --experimental-frozen-lockfile alias into it). + // Skip lockfile interaction entirely when --no-lockfile is set. + if !args.no_lockfile && (args.frozen_lockfile || args.experimental_lockfile.is_some()) { + // Determine lockfile path: use explicit path if provided (deprecated), + // otherwise derive from config. let lockfile_path = args .experimental_lockfile .clone() .unwrap_or_else(|| get_lockfile_path(&config_path)); - // Use info-level log so users can see lockfile validation is active - if args.experimental_frozen_lockfile { + if args.frozen_lockfile { info!( "Frozen lockfile mode enabled: validating features against '{}'", lockfile_path.display() @@ -231,7 +232,7 @@ pub(crate) async fn execute_up_with_runtime( format!( "Failed to read lockfile at '{}'. \ The file may be corrupted or contain invalid JSON. \ - To regenerate, remove the file and run without --experimental-frozen-lockfile.", + To regenerate, remove the file and run without --frozen-lockfile.", lockfile_path.display() ) })?; @@ -242,7 +243,7 @@ pub(crate) async fn execute_up_with_runtime( match &validation_result { LockfileValidationResult::Matched => { - if args.experimental_frozen_lockfile { + if args.frozen_lockfile { info!( "Lockfile validation passed: all features match '{}'", lockfile_path.display() @@ -254,7 +255,7 @@ pub(crate) async fn execute_up_with_runtime( _ => { let error_message = validation_result.format_error(); - if args.experimental_frozen_lockfile { + if args.frozen_lockfile { // Frozen mode: fail immediately on any mismatch (exit code 1) return Err(DeaconError::Config( deacon_core::errors::ConfigError::Validation { diff --git a/crates/deacon/tests/up_lockfile_frozen.rs b/crates/deacon/tests/up_lockfile_frozen.rs index d450eec..d79b3fb 100644 --- a/crates/deacon/tests/up_lockfile_frozen.rs +++ b/crates/deacon/tests/up_lockfile_frozen.rs @@ -265,16 +265,17 @@ fn test_frozen_lockfile_missing_fails() { ), } - // Verify error message content + // Verify error message content matches upstream-aligned format + // ("Lockfile does not exist." / "--frozen-lockfile" — see lockfile.rs format_error) let error_msg = validation_result.format_error(); assert!( - error_msg.contains("Frozen lockfile mode requires a lockfile"), - "Error message should indicate lockfile is required. Got: {}", + error_msg.contains("Lockfile does not exist."), + "Error message should match upstream missing-lockfile string. Got: {}", error_msg ); assert!( - error_msg.contains("--experimental-frozen-lockfile"), - "Error message should mention the flag to disable. Got: {}", + error_msg.contains("--frozen-lockfile"), + "Error message should mention the graduated flag. Got: {}", error_msg ); } @@ -321,7 +322,7 @@ fn test_frozen_lockfile_mismatch_fails() { // Verify error message content let error_msg = validation_result.format_error(); assert!( - error_msg.contains("features declared in config but missing from lockfile"), + error_msg.contains("Features declared in config but missing from lockfile"), "Error message should describe mismatch direction. Got: {}", error_msg ); @@ -385,7 +386,7 @@ fn test_lockfile_mismatch_warns_continues() { // The format_error() provides the warning message that would be logged. let warning_msg = validation_result.format_error(); assert!( - warning_msg.contains("features in lockfile but not declared in config"), + warning_msg.contains("Features in lockfile but not declared in config"), "Warning should describe the mismatch. Got: {}", warning_msg ); @@ -480,7 +481,7 @@ fn test_frozen_mode_with_lockfile_features_not_in_config_fails() { let error_msg = validation_result.format_error(); assert!( - error_msg.contains("features in lockfile but not declared in config"), + error_msg.contains("Features in lockfile but not declared in config"), "Error should describe the mismatch direction. Got: {}", error_msg ); @@ -584,10 +585,10 @@ fn test_frozen_missing_lockfile_error_message_content() { let error_msg = result.format_error(); - // Verify message contains required components + // Verify message matches upstream-aligned format assert!( - error_msg.contains("Frozen lockfile mode requires a lockfile"), - "Error should indicate frozen mode requirement. Got: {}", + error_msg.contains("Lockfile does not exist."), + "Error should use upstream missing-lockfile string. Got: {}", error_msg ); assert!( @@ -596,8 +597,8 @@ fn test_frozen_missing_lockfile_error_message_content() { error_msg ); assert!( - error_msg.contains("--experimental-frozen-lockfile"), - "Error should provide actionable guidance. Got: {}", + error_msg.contains("--frozen-lockfile"), + "Error should reference the graduated flag. Got: {}", error_msg ); } @@ -612,12 +613,12 @@ fn test_frozen_mismatch_missing_from_lockfile_error_message_content() { let error_msg = result.format_error(); assert!( - error_msg.contains("Frozen lockfile mismatch"), - "Error should indicate frozen lockfile mismatch. Got: {}", + error_msg.contains("Lockfile does not match."), + "Error should use upstream mismatch string. Got: {}", error_msg ); assert!( - error_msg.contains("features declared in config but missing from lockfile"), + error_msg.contains("Features declared in config but missing from lockfile"), "Error should describe mismatch direction. Got: {}", error_msg ); @@ -638,12 +639,12 @@ fn test_frozen_mismatch_extra_in_lockfile_error_message_content() { let error_msg = result.format_error(); assert!( - error_msg.contains("Frozen lockfile mismatch"), - "Error should indicate frozen lockfile mismatch. Got: {}", + error_msg.contains("Lockfile does not match."), + "Error should use upstream mismatch string. Got: {}", error_msg ); assert!( - error_msg.contains("features in lockfile but not declared in config"), + error_msg.contains("Features in lockfile but not declared in config"), "Error should describe mismatch direction. Got: {}", error_msg ); @@ -665,8 +666,8 @@ fn test_frozen_mismatch_bidirectional_error_message_content() { let error_msg = result.format_error(); assert!( - error_msg.contains("Frozen lockfile mismatch"), - "Error should indicate frozen lockfile mismatch. Got: {}", + error_msg.contains("Lockfile does not match."), + "Error should use upstream mismatch string. Got: {}", error_msg ); assert!(