From e3b7277ccd7f3819f24c8c1992d2d1749af84d07 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Sun, 29 Mar 2026 13:07:52 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20correct=20false=20breakage=20detection?= =?UTF-8?q?=20for=20flavored=20packages=20and=20confi=E2=80=A6=20(#3149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: correct false breakage detection for flavored packages and config changes Two bugs caused the UI to incorrectly warn about dependency breakages: 1. dryUpdate (version path): Flavored package versions (e.g. #knots:27.0.0:0) failed exver.satisfies() against flavorless ranges (e.g. >=26.0.0) due to flavor mismatch. Now checks the manifest's `satisfies` declarations, matching the pattern already used in DepErrorService. Added `satisfies` field to PackageVersionInfo so it's available from registry data. 2. checkConflicts (config path): fast-json-patch's compare() treated missing keys as conflicts (add ops) and used positional array comparison, diverging from the backend's conflicts() semantics. Replaced with a conflicts() function that mirrors core/src/service/action.rs — missing keys are not conflicts, and arrays use set-based comparison. Co-authored-by: Claude Opus 4.6 (1M context) --- core/src/registry/package/get.rs | 1 + core/src/registry/package/index.rs | 2 ++ core/src/s9pk/v2/compat.rs | 2 +- core/src/s9pk/v2/manifest.rs | 1 - core/src/service/effects/dependency.rs | 2 +- sdk/base/lib/osBindings/Manifest.ts | 2 +- sdk/base/lib/osBindings/PackageVersionInfo.ts | 2 ++ .../components/controls.component.ts | 10 +++++-- .../services/modals/action-input.component.ts | 28 ++++++++++++++++--- .../ui/src/app/services/api/api.fixures.ts | 10 +++++++ web/projects/ui/src/app/utils/dry-update.ts | 22 +++++++++++++-- 11 files changed, 69 insertions(+), 13 deletions(-) diff --git a/core/src/registry/package/get.rs b/core/src/registry/package/get.rs index b95095032..aa1a4148f 100644 --- a/core/src/registry/package/get.rs +++ b/core/src/registry/package/get.rs @@ -615,6 +615,7 @@ fn check_matching_info_short() { sdk_version: None, hardware_acceleration: false, plugins: BTreeSet::new(), + satisfies: BTreeSet::new(), }, icon: DataUrl::from_vec("image/png", vec![]), dependency_metadata: BTreeMap::new(), diff --git a/core/src/registry/package/index.rs b/core/src/registry/package/index.rs index e7848b13c..89147216f 100644 --- a/core/src/registry/package/index.rs +++ b/core/src/registry/package/index.rs @@ -110,6 +110,8 @@ pub struct PackageMetadata { pub hardware_acceleration: bool, #[serde(default)] pub plugins: BTreeSet, + #[serde(default)] + pub satisfies: BTreeSet, } #[derive(Debug, Deserialize, Serialize, HasModel, TS)] diff --git a/core/src/s9pk/v2/compat.rs b/core/src/s9pk/v2/compat.rs index 9b0add0bf..5df2bc501 100644 --- a/core/src/s9pk/v2/compat.rs +++ b/core/src/s9pk/v2/compat.rs @@ -197,7 +197,6 @@ impl TryFrom for Manifest { Ok(Self { id: value.id, version: version.into(), - satisfies: BTreeSet::new(), can_migrate_from: VersionRange::any(), can_migrate_to: VersionRange::none(), metadata: PackageMetadata { @@ -219,6 +218,7 @@ impl TryFrom for Manifest { PackageProcedure::Script(_) => false, }, plugins: BTreeSet::new(), + satisfies: BTreeSet::new(), }, images: BTreeMap::new(), volumes: value diff --git a/core/src/s9pk/v2/manifest.rs b/core/src/s9pk/v2/manifest.rs index bc31bec8f..eee778cdd 100644 --- a/core/src/s9pk/v2/manifest.rs +++ b/core/src/s9pk/v2/manifest.rs @@ -32,7 +32,6 @@ pub(crate) fn current_version() -> Version { pub struct Manifest { pub id: PackageId, pub version: VersionString, - pub satisfies: BTreeSet, #[ts(type = "string")] pub can_migrate_to: VersionRange, #[ts(type = "string")] diff --git a/core/src/service/effects/dependency.rs b/core/src/service/effects/dependency.rs index 4c054baa7..eee2bfc4c 100644 --- a/core/src/service/effects/dependency.rs +++ b/core/src/service/effects/dependency.rs @@ -358,7 +358,7 @@ pub async fn check_dependencies( }; let manifest = package.as_state_info().as_manifest(ManifestPreference::New); let installed_version = manifest.as_version().de()?.into_version(); - let satisfies = manifest.as_satisfies().de()?; + let satisfies = manifest.as_metadata().as_satisfies().de()?; let installed_version = Some(installed_version.clone().into()); let is_running = package .as_status_info() diff --git a/sdk/base/lib/osBindings/Manifest.ts b/sdk/base/lib/osBindings/Manifest.ts index c962425eb..ff3819f3a 100644 --- a/sdk/base/lib/osBindings/Manifest.ts +++ b/sdk/base/lib/osBindings/Manifest.ts @@ -15,7 +15,6 @@ import type { VolumeId } from './VolumeId' export type Manifest = { id: PackageId version: Version - satisfies: Array canMigrateTo: string canMigrateFrom: string images: { [key: ImageId]: ImageConfig } @@ -37,4 +36,5 @@ export type Manifest = { sdkVersion: string | null hardwareAcceleration: boolean plugins: Array + satisfies: Array } diff --git a/sdk/base/lib/osBindings/PackageVersionInfo.ts b/sdk/base/lib/osBindings/PackageVersionInfo.ts index 00a3f3052..f7fcc9045 100644 --- a/sdk/base/lib/osBindings/PackageVersionInfo.ts +++ b/sdk/base/lib/osBindings/PackageVersionInfo.ts @@ -10,6 +10,7 @@ import type { MerkleArchiveCommitment } from './MerkleArchiveCommitment' import type { PackageId } from './PackageId' import type { PluginId } from './PluginId' import type { RegistryAsset } from './RegistryAsset' +import type { Version } from './Version' export type PackageVersionInfo = { icon: DataUrl @@ -31,4 +32,5 @@ export type PackageVersionInfo = { sdkVersion: string | null hardwareAcceleration: boolean plugins: Array + satisfies: Array } diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts index d117f6f4e..0805d85ee 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts @@ -31,7 +31,7 @@ import { hasCurrentDeps } from 'src/app/utils/has-deps' import { MarketplaceAlertsService } from '../services/alerts.service' -type KEYS = 'id' | 'version' | 'alerts' | 'flavor' +type KEYS = 'id' | 'version' | 'alerts' | 'flavor' | 'satisfies' @Component({ selector: 'marketplace-controls', @@ -185,9 +185,13 @@ export class MarketplaceControlsComponent { } private async dryInstall(url: string | null) { - const { id, version } = this.pkg() + const { id, version, satisfies } = this.pkg() const packages = await getAllPackages(this.patch) - const breakages = dryUpdate({ id, version }, packages, this.exver) + const breakages = dryUpdate( + { id, version, satisfies: satisfies || [] }, + packages, + this.exver, + ) if (!breakages.length || (await this.alerts.alertBreakages(breakages))) { this.installOrUpload(url) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts index 0fc8200af..1d2729a07 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts @@ -14,7 +14,6 @@ import { TuiNotification, } from '@taiga-ui/core' import { injectContext } from '@taiga-ui/polymorpheus' -import * as json from 'fast-json-patch' import { compare } from 'fast-json-patch' import { PatchDB } from 'patch-db-client' import { catchError, EMPTY, endWith, firstValueFrom, from, map } from 'rxjs' @@ -191,9 +190,7 @@ export class ActionInputModal { task.actionId === this.actionId && task.when?.condition === 'input-not-matches' && task.input && - json - .compare(input, task.input.value) - .some(op => op.op === 'add' || op.op === 'replace'), + conflicts(task.input.value, input), ), ) .map(id => id) @@ -214,3 +211,26 @@ export class ActionInputModal { ) } } + +// Mirrors the Rust backend's `conflicts()` function in core/src/service/action.rs. +// A key in the partial that is missing from the full input is NOT a conflict. +function conflicts(left: unknown, right: unknown): boolean { + if ( + typeof left === 'object' && + left !== null && + !Array.isArray(left) && + typeof right === 'object' && + right !== null && + !Array.isArray(right) + ) { + const l = left as Record + const r = right as Record + return Object.keys(l).some(k => (k in r ? conflicts(l[k], r[k]) : false)) + } + + if (Array.isArray(left) && Array.isArray(right)) { + return left.some(v => right.every(vr => conflicts(v, vr))) + } + + return left !== right +} diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index 436dcced9..a0569c54e 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -459,6 +459,7 @@ export namespace Mock { gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: {}, donationUrl: null, alerts: { @@ -501,6 +502,7 @@ export namespace Mock { gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: {}, donationUrl: null, alerts: { @@ -553,6 +555,7 @@ export namespace Mock { gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: {}, donationUrl: null, alerts: { @@ -595,6 +598,7 @@ export namespace Mock { gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: {}, donationUrl: null, alerts: { @@ -649,6 +653,7 @@ export namespace Mock { gitHash: 'fakehash', icon: LND_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: { bitcoind: BitcoinDep, 'btc-rpc-proxy': ProxyDep, @@ -704,6 +709,7 @@ export namespace Mock { gitHash: 'fakehash', icon: LND_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: { bitcoind: BitcoinDep, 'btc-rpc-proxy': ProxyDep, @@ -763,6 +769,7 @@ export namespace Mock { gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: {}, donationUrl: null, alerts: { @@ -805,6 +812,7 @@ export namespace Mock { gitHash: 'fakehash', icon: BTC_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: {}, donationUrl: null, alerts: { @@ -857,6 +865,7 @@ export namespace Mock { gitHash: 'fakehash', icon: LND_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: { bitcoind: BitcoinDep, 'btc-rpc-proxy': ProxyDep, @@ -912,6 +921,7 @@ export namespace Mock { gitHash: 'fakehash', icon: PROXY_ICON, sourceVersion: null, + satisfies: [], dependencyMetadata: { bitcoind: BitcoinDep, }, diff --git a/web/projects/ui/src/app/utils/dry-update.ts b/web/projects/ui/src/app/utils/dry-update.ts index 2a71c20d4..f0021d713 100644 --- a/web/projects/ui/src/app/utils/dry-update.ts +++ b/web/projects/ui/src/app/utils/dry-update.ts @@ -3,7 +3,11 @@ import { DataModel } from '../services/patch-db/data-model' import { getManifest } from './get-package-data' export function dryUpdate( - { id, version }: { id: string; version: string }, + { + id, + version, + satisfies, + }: { id: string; version: string; satisfies: string[] }, pkgs: DataModel['packageData'], exver: Exver, ): string[] { @@ -13,10 +17,24 @@ export function dryUpdate( Object.keys(pkg.currentDependencies || {}).some( pkgId => pkgId === id, ) && - !exver.satisfies( + !versionSatisfies( version, + satisfies, pkg.currentDependencies[id]?.versionRange || '', + exver, ), ) .map(pkg => getManifest(pkg).title) } + +function versionSatisfies( + version: string, + satisfies: string[], + range: string, + exver: Exver, +): boolean { + return ( + exver.satisfies(version, range) || + satisfies.some(v => exver.satisfies(v, range)) + ) +}