fix: correct false breakage detection for flavored packages and confi… (#3149)

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) <noreply@anthropic.com>
This commit is contained in:
Matt Hill
2026-03-29 13:07:52 -06:00
committed by GitHub
parent b0b4b41c42
commit e3b7277ccd
11 changed files with 69 additions and 13 deletions

View File

@@ -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(),

View File

@@ -110,6 +110,8 @@ pub struct PackageMetadata {
pub hardware_acceleration: bool,
#[serde(default)]
pub plugins: BTreeSet<PluginId>,
#[serde(default)]
pub satisfies: BTreeSet<VersionString>,
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]

View File

@@ -197,7 +197,6 @@ impl TryFrom<ManifestV1> 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<ManifestV1> for Manifest {
PackageProcedure::Script(_) => false,
},
plugins: BTreeSet::new(),
satisfies: BTreeSet::new(),
},
images: BTreeMap::new(),
volumes: value

View File

@@ -32,7 +32,6 @@ pub(crate) fn current_version() -> Version {
pub struct Manifest {
pub id: PackageId,
pub version: VersionString,
pub satisfies: BTreeSet<VersionString>,
#[ts(type = "string")]
pub can_migrate_to: VersionRange,
#[ts(type = "string")]

View File

@@ -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()

View File

@@ -15,7 +15,6 @@ import type { VolumeId } from './VolumeId'
export type Manifest = {
id: PackageId
version: Version
satisfies: Array<Version>
canMigrateTo: string
canMigrateFrom: string
images: { [key: ImageId]: ImageConfig }
@@ -37,4 +36,5 @@ export type Manifest = {
sdkVersion: string | null
hardwareAcceleration: boolean
plugins: Array<PluginId>
satisfies: Array<Version>
}

View File

@@ -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<PluginId>
satisfies: Array<Version>
}

View File

@@ -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)

View File

@@ -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<string, unknown>
const r = right as Record<string, unknown>
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
}

View File

@@ -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,
},

View File

@@ -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))
)
}