Parcourir la source

feat: resource version constraints; SPM plan mode; progress/install hardening

Version constraints on dependencies (lib and pc only):
- VersionConstraint (==, >=, <=, ~= PEP 440 compatible-release;
  prefix-tolerant numeric comparison, releases ignored)
- ResourceRef parses operator suffixes, exposes base_name for path
  computation and version_tail() soname extraction; depends operators
  rejected outside lib/pc; provides may only carry ==, only on pc,
  as a version declaration (rejected on lib as redundant)
- Manifest.satisfies is the provider predicate everywhere (resolver,
  topological order, lot scheduling, removal, rebuild dependants,
  usm provides): lib providers match by soname tail, pc providers by
  == declaration or the package version when undeclared
  (effective_provides materialises the assumption into collected sets)
- ResourceFinder matches constrained refs against ldconfig sonames,
  directory listings and .pc Version: fields (new PcFile reader);
  PKG_CONFIG_PATH honoured as before
- usm manifest validate treats a present .pc file whose Version:
  contradicts its == declaration or the package version as invalid,
  exactly like a missing file (built and data packages)
- constrained refs are skipped in the SPM batch query (shims match
  names exactly; they resolve from local presence or USM packages)
- fixed a segfault: type-only refs ("lib") now fail validation cleanly
- SPECIFICATION.md (Version Constraints) + PACKAGING_GUIDE.md guidance;
  installer regenerated; 290 unit checks green; libcmark scenario
  replayed end-to-end in an arch container (exact pin fails 252,
  >=0.30.0 and ~=0.31.0 validate against the system's 0.31.2)

SPM plan mode and install wiring (in-progress work committed as-is):
- helpers gain a plan mode returning the material install set;
  SystemPackageManager.plan_install drives it, degrading to the input
  names on any failure
- Install/Update feed preinstalled_resources (system-provided build
  deps count as present at lot-scheduling time) and the material
  system plan into Transaction/TransactionSummary
- ProgressBar drops stale ninja-reader reports that would resurrect
  finished actions; pacman shim usage output tidied
clanker il y a 3 jours
Parent
commit
904c1b7a11

+ 25 - 0
PACKAGING_GUIDE.md

@@ -57,6 +57,31 @@ One line, lowercase, no trailing period. This is what `usm search` shows.
 
 
 **Rule of thumb**: declare what *you* link against or *invoke*, not what your dependencies pull in transitively. Each package's manifest handles its own dependency chain.
 **Rule of thumb**: declare what *you* link against or *invoke*, not what your dependencies pull in transitively. Each package's manifest handles its own dependency chain.
 
 
+### Versioned library and pkg-config dependencies
+
+Hardcoding the full soname you happened to link against makes a package uninstallable on systems carrying a different (possibly newer) build of the same library: a package wanting `lib:libcmark.so.0.30.0` fails against a system providing `libcmark.so.0.31.1`. Since USM builds packages from source *on the target system*, a dependency is usually an API floor, not an ABI pin — express it with a version constraint on the base soname instead:
+
+```json
+"runtime": ["lib:libcmark.so>=0.30.0"]
+```
+
+This is satisfied by any `libcmark.so.<tail>` whose trailing dotted numbers meet the operator (`0.31.1` qualifies for `>=0.30.0`). Operators: `>=`, `<=`, `==` (equal, prefix-tolerant: `0.31` matches `0.31.0`) and `~=` (compatible release — `~=0.31.0` accepts `0.31.x` only, `~=0.31` accepts `0.x` from `0.31` up). The same syntax works for `pc:` refs, checked against the `.pc` file's `Version:` field:
+
+```json
+"build": ["pc:gtk4.pc>=4.12"]
+```
+
+When to use what:
+
+- **`>=`** — the normal choice: "the API I use exists since this version".
+- **`~=`** — when you know the library breaks ABI within the family you tested (common for pre-1.0 libraries that version their soname by minor).
+- **Exact full soname** (`lib:libcmark.so.0.31.1`) — only when you truly need one specific build.
+- Constraints on other resource types (`bin:`, `vapi:`, …) are rejected: those names carry no version.
+
+Packages that themselves *provide* a `pc:` file may declare its version in `provides` (`"pc:cmark.pc==0.31.1"`); without a declaration the file is assumed to match the package `version`, and validation fails a package whose installed `.pc` `Version:` field contradicts either.
+
+Note: constrained dependencies do not yet resolve through system package managers — only from local presence and USM packages.
+
 ### Runtime dependencies (`depends.runtime`)
 ### Runtime dependencies (`depends.runtime`)
 
 
 Things the installed program needs at *run* time:
 Things the installed program needs at *run* time:

+ 21 - 0
SPECIFICATION.md

@@ -119,6 +119,27 @@ The following resource types are defined:
 - `cfg:myapp.conf`: A configuration file for `myapp`.
 - `cfg:myapp.conf`: A configuration file for `myapp`.
 - `app:myapp.desktop`: A desktop entry for `myapp`.
 - `app:myapp.desktop`: A desktop entry for `myapp`.
 
 
+### Version Constraints
+
+A resource name in a **dependency** may end with a version constraint, one of the operators `==` (equal), `>=` (at least), `<=` (at most) or `~=` (compatible release, PEP 440 style: at least the given version and sharing all of its numeric components except the last, so `~=0.31.0` accepts `0.31.1` but not `0.32.0`, while `~=0.31` accepts any `0.x` from `0.31` up but not `1.0.0`):
+
+```
+"lib:libcmark.so>=0.30.0"
+"lib:libcmark.so~=0.31.0"
+"pc:gtk4.pc>=4.12"
+```
+
+Version constraints are only valid where a resource's concrete names carry a version:
+
+- `lib`: the version is the trailing dotted-numeric tail of the soname (`libcmark.so.0.31.1` is version `0.31.1` of base `libcmark.so`). A dependency `lib:libcmark.so>=0.30.0` is satisfied by any installed or provided `libcmark.so.<tail>` whose tail satisfies the operator; a bare `libcmark.so` declares no version.
+- `pc`: the version is read from the `.pc` file's `Version:` field when checking the local system.
+
+Comparison semantics are prefix-tolerant on numeric components and ignore `+release` suffixes: `0.31` matches `0.31.0`. Constraints on any other resource type are a manifest validation error.
+
+In `provides`, only `pc` resource names may carry a constraint, only the `==` operator, and it acts as a **version declaration** for the concrete file (`"pc:cmark.pc==0.31.1": "as-expected"`). A `pc` provide without a declaration is assumed to carry the package's own `version`. During validation, a present `.pc` file whose `Version:` field matches neither the `==` declaration nor the package version invalidates the package, exactly like a missing file. Sonames already declare their version in the filename, so `==` on `lib` provides is redundant and rejected.
+
+Version-constrained dependencies are not yet forwarded to system package managers: they resolve from local presence or USM packages only.
+
 ## Installation Lifecycle Directories
 ## Installation Lifecycle Directories
 
 
 Each of these directories are created by USM at runtime for each package.
 Each of these directories are created by USM at runtime for each package.

Fichier diff supprimé car celui-ci est trop grand
+ 259 - 1253
installer/install-usm.sh


+ 39 - 0
spm/apk/usm-spm-apk

@@ -82,6 +82,7 @@ warn() {
 
 
 die_usage() {
 die_usage() {
     printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
     printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
+    printf '       %s plan <native-name>...\n' "$PROG" >&2
     printf '       %s install <native-name>...\n' "$PROG" >&2
     printf '       %s install <native-name>...\n' "$PROG" >&2
     exit "$EXIT_USAGE"
     exit "$EXIT_USAGE"
 }
 }
@@ -432,6 +433,41 @@ cmd_install() {
     exit "$(classify_failure "$WORK/inst.err")"
     exit "$(classify_failure "$WORK/inst.err")"
 }
 }
 
 
+cmd_plan() {
+    [ $# -ge 1 ] || die_usage
+    for _p_n in "$@"; do
+        case $_p_n in
+            -*) die_usage ;;
+        esac
+    done
+    _p_expanded=""
+    for _p_n in "$@"; do
+        _p_expanded="$_p_expanded $_p_n"
+        for _p_c in $INSTALL_COMPANIONS; do
+            if [ "${_p_c%%:*}" = "$_p_n" ]; then
+                _p_expanded="$_p_expanded ${_p_c#*:}"
+            fi
+        done
+    done
+    set -- $_p_expanded
+    mkwork
+    _p_rc=0
+    _p_sim=$(apk add --simulate --no-cache --no-progress -- "$@" 2>"$WORK/plan.err") || _p_rc=$?
+    if [ "$_p_rc" != 0 ]; then
+        _p_msg=$(apk_diag_line "$WORK/plan.err")
+        [ -n "$_p_msg" ] || _p_msg="apk add --simulate failed with status $_p_rc"
+        printf '%s: %s\n' "$PROG" "$_p_msg" >&2
+        exit "$(classify_failure "$WORK/plan.err")"
+    fi
+    # simulate output names every package the transaction would touch:
+    # "(3/7) Installing musl (1.2.5-r1)"
+    _p_json=$(printf '%s\n' "$_p_sim" \
+        | sed -n 's/^(\([0-9][0-9]*\)\/\([0-9][0-9]*\)) Installing \([^ ]*\) .*/"\3"/p' \
+        | awk 'NR>1{printf ","} {printf "%s", $0}')
+    printf '{"packages":[%s]}\n' "$_p_json"
+    exit "$EXIT_OK"
+}
+
 main() {
 main() {
     if [ $# -lt 1 ]; then
     if [ $# -lt 1 ]; then
         die_usage
         die_usage
@@ -450,6 +486,9 @@ main() {
         query)
         query)
             cmd_query "$@"
             cmd_query "$@"
             ;;
             ;;
+        plan)
+            cmd_plan "$@"
+            ;;
         install)
         install)
             cmd_install "$@"
             cmd_install "$@"
             ;;
             ;;

+ 19 - 0
spm/apt/usm-spm-apt

@@ -530,6 +530,17 @@ def simulate_install(names, environment):
     return order
     return order
 
 
 
 
+def cmd_plan(args):
+    environment = apt_environment()
+    try:
+        order = simulate_install(args.names, environment)
+    except HelperError as e:
+        fail_plain(str(e), e.exit_code)
+    sys.stdout.write(json.dumps({"packages": order}) + "\n")
+    sys.stdout.flush()
+    return EXIT_OK
+
+
 def cmd_query(args):
 def cmd_query(args):
     try:
     try:
         return run_query(args)
         return run_query(args)
@@ -636,6 +647,14 @@ def main():
         help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
         help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
     query_parser.set_defaults(handler=cmd_query)
     query_parser.set_defaults(handler=cmd_query)
 
 
+    plan_parser = subparsers.add_parser(
+        "plan", help="resolve the material install set for names "
+                     "(chosen packages plus all dependencies)")
+    plan_parser.add_argument(
+        "names", nargs="+", metavar="NAME",
+        help="native system package name")
+    plan_parser.set_defaults(handler=cmd_plan)
+
     install_parser = subparsers.add_parser(
     install_parser = subparsers.add_parser(
         "install", help="install system packages, streaming progress events")
         "install", help="install system packages, streaming progress events")
     install_parser.add_argument(
     install_parser.add_argument(

+ 44 - 3
spm/dnf/usm-spm-dnf

@@ -5,6 +5,7 @@ Implements the USM SPM contract (see usm/README.md, "System package manager
 integration") for Fedora-style systems:
 integration") for Fedora-style systems:
 
 
     usm-spm-dnf query <usm-ref>...      -> contract JSON on STDOUT
     usm-spm-dnf query <usm-ref>...      -> contract JSON on STDOUT
+    usm-spm-dnf plan <name>...          -> contract JSON: the material install set
     usm-spm-dnf install [--test] <name>... -> contract JSONL events on STDOUT
     usm-spm-dnf install [--test] <name>... -> contract JSONL events on STDOUT
 
 
 API choice: Fedora 43 ships dnf5 as the CLI, but this machine exposes the
 API choice: Fedora 43 ships dnf5 as the CLI, but this machine exposes the
@@ -230,6 +231,36 @@ def closure_counts(closure_base, installed_keys, package, closure_cache):
     return counts
     return counts
 
 
 
 
+def cmd_plan(args):
+    """Resolve the material install set without touching the system.
+
+    Marks every name for install, resolves the goal, and prints one
+    contract JSON object listing every package the manager would
+    install — the chosen names plus every dependency of a dependency —
+    so the caller can plan and show the whole native transaction.
+    """
+    try:
+        base = make_base(load_system_repo=True)
+        for name in args.names:
+            try:
+                base.install(name)
+            except Exception as e:
+                fail_plain("could not mark \"%s\" for install: %s" % (name, e),
+                           EXIT_RESOLVE)
+        try:
+            base.resolve()
+        except Exception as e:
+            fail_plain("dependency resolution failed: %s" % e, EXIT_RESOLVE)
+        names = [member.name for member in base.transaction.install_set]
+        sys.stdout.write(json.dumps({"packages": names}) + "\n")
+        sys.stdout.flush()
+        return EXIT_OK
+    except SystemExit:
+        raise
+    except Exception as e:
+        fail_plain("plan failed: %s" % e, EXIT_RESOLVE)
+
+
 def cmd_query(args):
 def cmd_query(args):
     try:
     try:
         return run_query(args)
         return run_query(args)
@@ -309,7 +340,7 @@ class ContractDownloadProgress(dnf.callback.DownloadProgress):
         size = payload.pkg.downloadsize or 0
         size = payload.pkg.downloadsize or 0
         fraction = (done / size) if size else 0.0
         fraction = (done / size) if size else 0.0
         event = (
         event = (
-            "package", payload.pkg.name, self.index + 1, self.total,
+            "package", payload.pkg.name, self.index, self.total,
             round(min(fraction, 1.0), 4))
             round(min(fraction, 1.0), 4))
         if event == self.last:
         if event == self.last:
             return
             return
@@ -317,7 +348,7 @@ class ContractDownloadProgress(dnf.callback.DownloadProgress):
         emit_event({
         emit_event({
             "type": "package",
             "type": "package",
             "name": payload.pkg.name,
             "name": payload.pkg.name,
-            "current": self.index + 1,
+            "current": self.index,
             "total": self.total,
             "total": self.total,
             "progress": min(fraction, 1.0),
             "progress": min(fraction, 1.0),
         })
         })
@@ -346,7 +377,9 @@ class ContractTransactionDisplay(dnf.yum.rpmtrans.TransactionDisplay):
             return
             return
         fraction = (float(ti_done) / float(ti_total)) if ti_total else 0.0
         fraction = (float(ti_done) / float(ti_total)) if ti_total else 0.0
         total = ts_total or 1
         total = ts_total or 1
-        current = min(ts_done + 1, total) if ts_total else 1
+        # ts_done counts finished packages, so the in-flight package is
+        # the zero-based position ts_done (clamped into the transaction)
+        current = min(ts_done, total - 1) if ts_total else 0
         event = ("package", package.name, current, total,
         event = ("package", package.name, current, total,
                  round(min(fraction, 1.0), 4))
                  round(min(fraction, 1.0), 4))
         if event == self.last:
         if event == self.last:
@@ -429,6 +462,14 @@ def main():
         help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
         help="resource ref, e.g. bin:valac or lib:libglib-2.0.so.0")
     query_parser.set_defaults(handler=cmd_query)
     query_parser.set_defaults(handler=cmd_query)
 
 
+    plan_parser = subparsers.add_parser(
+        "plan", help="resolve the material install set for names "
+                     "(chosen packages plus all dependencies)")
+    plan_parser.add_argument(
+        "names", nargs="+", metavar="NAME",
+        help="native system package name")
+    plan_parser.set_defaults(handler=cmd_plan)
+
     install_parser = subparsers.add_parser(
     install_parser = subparsers.add_parser(
         "install", help="install system packages, streaming progress events")
         "install", help="install system packages, streaming progress events")
     install_parser.add_argument(
     install_parser.add_argument(

+ 29 - 0
spm/pacman/usm-spm-pacman

@@ -85,6 +85,7 @@ warn() {
 
 
 die_usage() {
 die_usage() {
     printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
     printf 'usage: %s query <usm-ref>...\n' "$PROG" >&2
+    printf '       %s plan <native-name>...\n' "$PROG" >&2
     printf '       %s install <native-name>...\n' "$PROG" >&2
     printf '       %s install <native-name>...\n' "$PROG" >&2
     exit "$EXIT_USAGE"
     exit "$EXIT_USAGE"
 }
 }
@@ -548,6 +549,30 @@ cmd_install() {
     exit "$(classify_failure "$WORK/inst.err")"
     exit "$(classify_failure "$WORK/inst.err")"
 }
 }
 
 
+cmd_plan() {
+    [ $# -ge 1 ] || die_usage
+    for _p_n in "$@"; do
+        case $_p_n in
+            -*) die_usage ;;
+        esac
+    done
+    mkwork
+    _p_rc=0
+    _p_names=$(pacman -S --needed --noconfirm --print --print-format '%n' \
+        -- "$@" 2>"$WORK/plan.err") || _p_rc=$?
+    if [ "$_p_rc" != 0 ]; then
+        _p_msg=$(pacman_diag_line "$WORK/plan.err")
+        [ -n "$_p_msg" ] || _p_msg="pacman --print failed with status $_p_rc"
+        printf '%s: %s\n' "$PROG" "$_p_msg" >&2
+        exit "$(classify_failure "$WORK/plan.err")"
+    fi
+    _p_json=$(printf '%s\n' "$_p_names" \
+        | sed -n '/^[A-Za-z0-9@._+-]\{1,\}$/s/.*/"&"/p' \
+        | awk 'NR>1{printf ","} {printf "%s", $0}')
+    printf '{"packages":[%s]}\n' "$_p_json"
+    exit "$EXIT_OK"
+}
+
 main() {
 main() {
     if [ $# -lt 1 ]; then
     if [ $# -lt 1 ]; then
         die_usage
         die_usage
@@ -566,11 +591,15 @@ main() {
         query)
         query)
             cmd_query "$@"
             cmd_query "$@"
             ;;
             ;;
+        plan)
+            cmd_plan "$@"
+            ;;
         install)
         install)
             cmd_install "$@"
             cmd_install "$@"
             ;;
             ;;
         -h|--help|help)
         -h|--help|help)
             printf 'usage: %s query <usm-ref>...\n' "$PROG"
             printf 'usage: %s query <usm-ref>...\n' "$PROG"
+            printf '       %s plan <native-name>...\n' "$PROG"
             printf '       %s install <native-name>...\n' "$PROG"
             printf '       %s install <native-name>...\n' "$PROG"
             exit "$EXIT_OK"
             exit "$EXIT_OK"
             ;;
             ;;

+ 41 - 68
src/cli/Install.vala

@@ -131,14 +131,52 @@ private int install_main(string[] args) {
             install_order.add(package.manifest.name);
             install_order.add(package.manifest.name);
         }
         }
 
 
+        // Resources the chosen system packages will install before this
+        // transaction's first lot runs; the scheduler counts them as
+        // present so system-provided build dependencies do not look unmet
+        // at planning time
+        var preinstalled_resources = new HashSet<Usm.ResourceRef>();
+        foreach(var system_package in resolution.system_packages) {
+            foreach(var resource in system_package.resources) {
+                preinstalled_resources.add(resource);
+            }
+        }
+
+        // The material system install set — chosen packages plus every
+        // native dependency of a dependency — so the plan preview lists
+        // the whole native transaction and every material package gets
+        // its own progress action
+        var system_install_plan = new Vector<string>();
+        if(spm.enabled && resolution.system_packages.any()) {
+            var chosen_system_names = new Vector<string>();
+            foreach(var system_package in resolution.system_packages) {
+                chosen_system_names.add(system_package.name);
+            }
+            system_install_plan = spm.plan_install(chosen_system_names);
+        }
+        var chosen_system_names_set = new HashSet<string>();
+        foreach(var system_package in resolution.system_packages) {
+            chosen_system_names_set.add(system_package.name);
+        }
+        var system_dependencies = new Vector<string>();
+        foreach(var name in system_install_plan) {
+            if(!chosen_system_names_set.contains(name)) {
+                system_dependencies.add(name);
+            }
+        }
+
         var transaction = new Usm.Transaction() {
         var transaction = new Usm.Transaction() {
             paths = paths,
             paths = paths,
             resource_finder = new Usm.ResourceFinder(paths),
             resource_finder = new Usm.ResourceFinder(paths),
+            preinstalled_resources = preinstalled_resources,
             to_remove = new HashSet<Usm.CachedPackage>(),
             to_remove = new HashSet<Usm.CachedPackage>(),
             to_install = cached_packages,
             to_install = cached_packages,
             install_order = install_order,
             install_order = install_order,
             explicit_packages = package_names,
             explicit_packages = package_names,
-            state = state
+            state = state,
+            system_package_manager = spm,
+            system_packages = resolution.system_packages,
+            system_install_plan = system_install_plan
         };
         };
 
 
         // Planning before the prompt populates the rebuildDependants
         // Planning before the prompt populates the rebuildDependants
@@ -153,6 +191,8 @@ private int install_main(string[] args) {
 
 
         var summary = new Usm.TransactionSummary() {
         var summary = new Usm.TransactionSummary() {
             to_install = cached_packages.to_vector(),
             to_install = cached_packages.to_vector(),
+            system_installs = resolution.system_packages,
+            system_dependencies = system_dependencies,
             to_rebuild = transaction.rebuilds,
             to_rebuild = transaction.rebuilds,
             download_count = download_count,
             download_count = download_count,
             download_bytes = download_sizes_known ? download_bytes : -1
             download_bytes = download_sizes_known ? download_bytes : -1
@@ -165,13 +205,6 @@ private int install_main(string[] args) {
         // Only now that the plan is confirmed do the packages transfer
         // Only now that the plan is confirmed do the packages transfer
         install_download_packages(state, resolution, new HashSet<string>(), progress);
         install_download_packages(state, resolution, new HashSet<string>(), progress);
 
 
-        // Chosen system packages install first, as one transaction
-        if(resolution.system_packages.any()) {
-            if(!install_system_packages(spm, resolution.system_packages)) {
-                return 239;
-            }
-        }
-
         printerr("\nRunning transaction...\n");
         printerr("\nRunning transaction...\n");
         transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();
         transaction.run();
@@ -327,66 +360,6 @@ private int64? install_package_download_size(Usm.Repository repository, Usm.Repo
     return null;
     return null;
 }
 }
 
 
-/**
- * Installs the resolution's chosen system packages as one transaction,
- * surfacing progress through the manager's {@link Usm.InstallProgressDelegate}
- * events; returns false (after printing why) when the transaction failed.
- * Shared by `usm install` and `usm update`.
- */
-private bool install_system_packages(Usm.SystemPackageManager spm, Vector<Usm.SystemPackageCandidate> packages) {
-    printerr("Installing system packages...\n");
-    var names = new Vector<string>();
-    foreach(var package in packages) {
-        names.add(package.name);
-    }
-
-    int installed = 0;
-    int total = 0;
-    string failure = "";
-    var loop = new MainLoop();
-    spm.install.begin(names, event => {
-        switch(event.event_type) {
-            case Usm.SystemInstallEventType.BEGIN:
-                total = event.total;
-                break;
-            case Usm.SystemInstallEventType.PACKAGE:
-                if(event.name != null) {
-                    printerr(@"\r  installing $(event.name) ($((int)(event.progress * 100))%)");
-                }
-                break;
-            case Usm.SystemInstallEventType.PACKAGE_COMPLETE:
-                installed++;
-                if(event.name != null) {
-                    printerr(@"\r  [$installed/$total] installed system package $(event.name)      \n");
-                }
-                break;
-            case Usm.SystemInstallEventType.COMPLETE:
-                break;
-            case Usm.SystemInstallEventType.ERROR:
-                failure = event.message ?? "unknown error";
-                break;
-        }
-    }, (obj, res) => {
-        try {
-            if(!spm.install.end(res)) {
-                failure = failure.length > 0 ? failure : "the system package manager reported a failure";
-            }
-        }
-        catch(Error e) {
-            failure = e.message;
-        }
-        loop.quit();
-    });
-    loop.run();
-
-    if(failure.length > 0) {
-        printerr(@"System package installation failed: $failure\n");
-        return false;
-    }
-    printerr(@"Installed $installed system package(s).\n");
-    return true;
-}
-
 private int install_usage() {
 private int install_usage() {
     printerr("USAGE:\n\tusm install [-y|--yes] <packages>\n");
     printerr("USAGE:\n\tusm install [-y|--yes] <packages>\n");
     return 255;
     return 255;

+ 40 - 2
src/cli/Manifest.vala

@@ -997,7 +997,30 @@ private int validate() {
             printerr(@"Error validating resource $(expected.key.to_string()): $(e.message)\n");
             printerr(@"Error validating resource $(expected.key.to_string()): $(e.message)\n");
             found = false;
             found = false;
         }
         }
-        
+
+        // A present pc provide must declare a matching version: the
+        // `==` declaration when given, the package version otherwise —
+        // a mismatch invalidates the provide the same way a missing
+        // file does
+        if(found && expected.key.resource_type == Usm.ResourceType.PKG_CONFIG) {
+            var declared = Usm.PcFile.read_version(expected_path);
+            Usm.Version expected_version = null;
+            if(expected.key.constraint != null) {
+                expected_version = expected.key.constraint.version;
+            }
+            else {
+                expected_version = manifest.version;
+            }
+            if(declared == null) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares no usable Version field, expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+            else if(!declared.matches_numeric(expected_version)) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares version $(declared.to_string()), expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+        }
+
         if(!found) {
         if(!found) {
             missing_resources.add(expected.key);
             missing_resources.add(expected.key);
         }
         }
@@ -1074,7 +1097,22 @@ private int validate_data_package() {
     var missing_resources = new Invercargill.DataStructures.Vector<Usm.ResourceRef>();
     var missing_resources = new Invercargill.DataStructures.Vector<Usm.ResourceRef>();
     foreach (var expected in manifest.provides) {
     foreach (var expected in manifest.provides) {
         var expected_path = Path.build_filename(Environment.get_current_dir(), expected.value.path ?? "");
         var expected_path = Path.build_filename(Environment.get_current_dir(), expected.value.path ?? "");
-        if(!File.new_for_path(expected_path).query_exists()) {
+        var found = File.new_for_path(expected_path).query_exists();
+
+        // The same pc version contract as the built-package validator:
+        // a present pc file must declare the ==-declared or package version
+        if(found && expected.key.resource_type == Usm.ResourceType.PKG_CONFIG) {
+            var declared = Usm.PcFile.read_version(expected_path);
+            var expected_version = expected.key.constraint != null
+                ? expected.key.constraint.version
+                : manifest.version;
+            if(declared == null || !declared.matches_numeric(expected_version)) {
+                printerr(@"Error: pc provide $(expected.key.base_name) at \"$expected_path\" declares $(declared != null ? declared.to_string() : "no usable Version field"), expected $(expected_version.to_string()).\n");
+                found = false;
+            }
+        }
+
+        if(!found) {
             missing_resources.add(expected.key);
             missing_resources.add(expected.key);
         }
         }
     }
     }

+ 16 - 2
src/cli/ProgressBar.vala

@@ -161,14 +161,25 @@ namespace Usm.Cli {
         }
         }
 
 
         /**
         /**
-         * Records one progress report and redraws the in-place line. A
-         * report that moves the action counter on, or the current
+         * Records one progress report and redraws the in-place line.
+         * A report that moves the action counter on, or the current
          * action's first 100%, retires the current action: it consumes
          * action's first 100%, retires the current action: it consumes
          * the in-place line and leaves the persistent
          * the in-place line and leaves the persistent
          * `✓ [n/m] <past tense> <package>` log above it — once only,
          * `✓ [n/m] <past tense> <package>` log above it — once only,
          * however many further 100% reports the action emits.
          * however many further 100% reports the action emits.
+         *
+         * Reports for an action the display has already moved past, or
+         * whose completion already logged, are dropped silently: the
+         * ninja progress reader thread of a build can emit fractions a
+         * moment after the main thread advanced the cursor, and drawing
+         * such a stale report would resurrect a finished action's line,
+         * flicker over the current action and corrupt the retire
+         * bookkeeping.
          */
          */
         public void update(TransactionTask task, float progress, string? package_name, int index, int total) {
         public void update(TransactionTask task, float progress, string? package_name, int index, int total) {
+            if(index < current_index || index <= last_logged_action) {
+                return;
+            }
             var same_action = index == current_index;
             var same_action = index == current_index;
             var retire = line_live && current_index > last_logged_action
             var retire = line_live && current_index > last_logged_action
                 && (index > current_index || (same_action && progress >= 1.0f));
                 && (index > current_index || (same_action && progress >= 1.0f));
@@ -769,6 +780,7 @@ namespace Usm.Cli {
             switch(task) {
             switch(task) {
                 case TransactionTask.UNPACKING:
                 case TransactionTask.UNPACKING:
                 case TransactionTask.INSTALLING:
                 case TransactionTask.INSTALLING:
+                case TransactionTask.INSTALLING_SYSTEM:
                     return COLOUR_GREEN;
                     return COLOUR_GREEN;
                 case TransactionTask.BUILDING:
                 case TransactionTask.BUILDING:
                     return COLOUR_BLUE;
                     return COLOUR_BLUE;
@@ -797,6 +809,8 @@ namespace Usm.Cli {
                     return "Tested";
                     return "Tested";
                 case TransactionTask.INSTALLING:
                 case TransactionTask.INSTALLING:
                     return "Installed";
                     return "Installed";
+                case TransactionTask.INSTALLING_SYSTEM:
+                    return "Installed system package";
                 case TransactionTask.REMOVING:
                 case TransactionTask.REMOVING:
                     return "Removed";
                     return "Removed";
                 case TransactionTask.REBUILDING:
                 case TransactionTask.REBUILDING:

+ 8 - 3
src/cli/Provides.vala

@@ -119,16 +119,21 @@ namespace Usm.Cli {
         }
         }
 
 
         /**
         /**
-         * The first ref in `manifest`'s provides that {@link Usm.ResourceRef.satisfied_by}
-         * matches `resource`, or null when the manifest does not provide it.
+         * The first ref in `manifest`'s provides that satisfies `resource`
+         * (see {@link Usm.ResourceRef.satisfied_by} and the pc
+         * package-version assumption of {@link Usm.Manifest.satisfies}),
+         * or null when the manifest does not provide it.
          */
          */
         private static string? provides_match(Usm.Manifest manifest, Usm.ResourceRef resource) {
         private static string? provides_match(Usm.Manifest manifest, Usm.ResourceRef resource) {
+            if(!manifest.satisfies(resource)) {
+                return null;
+            }
             foreach(var provide in manifest.provides) {
             foreach(var provide in manifest.provides) {
                 if(resource.satisfied_by(provide.key)) {
                 if(resource.satisfied_by(provide.key)) {
                     return provide.key.to_string();
                     return provide.key.to_string();
                 }
                 }
             }
             }
-            return null;
+            return @"pc:$(resource.base_name)==$(manifest.version.to_string())";
         }
         }
 
 
         /**
         /**

+ 1 - 1
src/cli/Remove.vala

@@ -185,7 +185,7 @@ private Vector<Usm.CachedPackage> remove_collect_orphans(Dictionary<string, Usm.
             if(gone.contains(name)) {
             if(gone.contains(name)) {
                 continue;
                 continue;
             }
             }
-            var provides = manifests[name].provides.select<Usm.ResourceRef>(p => p.key);
+            var provides = manifests[name].effective_provides();
             var needed = false;
             var needed = false;
             foreach(var pair in refs_by_name) {
             foreach(var pair in refs_by_name) {
                 if(pair.key == name || gone.contains(pair.key)) {
                 if(pair.key == name || gone.contains(pair.key)) {

+ 41 - 7
src/cli/Update.vala

@@ -195,14 +195,52 @@ private int update_main(string[] args) {
             remove_order.add(package.manifest.name);
             remove_order.add(package.manifest.name);
         }
         }
 
 
+        // Resources the chosen system packages will install before this
+        // transaction's first lot runs; the scheduler counts them as
+        // present so system-provided build dependencies do not look unmet
+        // at planning time
+        var preinstalled_resources = new HashSet<Usm.ResourceRef>();
+        foreach(var system_package in resolution.system_packages) {
+            foreach(var resource in system_package.resources) {
+                preinstalled_resources.add(resource);
+            }
+        }
+
+        // The material system install set — chosen packages plus every
+        // native dependency of a dependency — so the plan preview lists
+        // the whole native transaction and every material package gets
+        // its own progress action
+        var system_install_plan = new Vector<string>();
+        if(spm.enabled && resolution.system_packages.any()) {
+            var chosen_system_names = new Vector<string>();
+            foreach(var system_package in resolution.system_packages) {
+                chosen_system_names.add(system_package.name);
+            }
+            system_install_plan = spm.plan_install(chosen_system_names);
+        }
+        var chosen_system_names_set = new HashSet<string>();
+        foreach(var system_package in resolution.system_packages) {
+            chosen_system_names_set.add(system_package.name);
+        }
+        var system_dependencies = new Vector<string>();
+        foreach(var name in system_install_plan) {
+            if(!chosen_system_names_set.contains(name)) {
+                system_dependencies.add(name);
+            }
+        }
+
         var transaction = new Usm.Transaction() {
         var transaction = new Usm.Transaction() {
             paths = paths,
             paths = paths,
             resource_finder = new Usm.ResourceFinder(paths),
             resource_finder = new Usm.ResourceFinder(paths),
+            preinstalled_resources = preinstalled_resources,
             to_remove = to_remove,
             to_remove = to_remove,
             to_install = cached_packages,
             to_install = cached_packages,
             install_order = install_order,
             install_order = install_order,
             remove_order = remove_order,
             remove_order = remove_order,
-            state = state
+            state = state,
+            system_package_manager = spm,
+            system_packages = resolution.system_packages,
+            system_install_plan = system_install_plan
         };
         };
 
 
         // Planning before the prompt populates the rebuildDependants
         // Planning before the prompt populates the rebuildDependants
@@ -217,6 +255,8 @@ private int update_main(string[] args) {
 
 
         var summary = new Usm.TransactionSummary() {
         var summary = new Usm.TransactionSummary() {
             to_install = cached_packages.to_vector(),
             to_install = cached_packages.to_vector(),
+            system_installs = resolution.system_packages,
+            system_dependencies = system_dependencies,
             to_remove = to_remove.to_vector(),
             to_remove = to_remove.to_vector(),
             to_rebuild = transaction.rebuilds,
             to_rebuild = transaction.rebuilds,
             download_count = download_count,
             download_count = download_count,
@@ -230,12 +270,6 @@ private int update_main(string[] args) {
         // Only now that the plan is confirmed do the packages transfer
         // Only now that the plan is confirmed do the packages transfer
         install_download_packages(state, resolution, current_names);
         install_download_packages(state, resolution, current_names);
 
 
-        if(resolution.system_packages.any()) {
-            if(!install_system_packages(spm, resolution.system_packages)) {
-                return 239;
-            }
-        }
-
         printerr("\nRunning transaction...\n");
         printerr("\nRunning transaction...\n");
         transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.progress_updated.connect(progress.on_transaction_progress);
         transaction.run();
         transaction.run();

+ 3 - 1
src/lib/Dependencies.vala

@@ -182,7 +182,9 @@ namespace Usm {
                 );
                 );
             }
             }
             try {
             try {
-                return new ResourceRef(text);
+                var parsed = new ResourceRef(text);
+                parsed.ensure_valid_dependency();
+                return parsed;
             }
             }
             catch(Error e) {
             catch(Error e) {
                 throw new ManifestError.INVALID_DEPENDENCIES(
                 throw new ManifestError.INVALID_DEPENDENCIES(

+ 47 - 3
src/lib/Manifest.vala

@@ -183,10 +183,54 @@ namespace Usm {
                 }
                 }
                 // Validate the ManifestFile after creation
                 // Validate the ManifestFile after creation
                 file.validate();
                 file.validate();
-                provides[new ResourceRef(pair.key)] = file;
+                var key = new ResourceRef(pair.key);
+                key.ensure_valid_provide();
+                provides[key] = file;
             }
             }
         }
         }
 
 
+        /**
+         * Whether this manifest's provides satisfy the given dependency
+         * ref: {@link ResourceRef.satisfied_by} against every provide
+         * key (soname tails for `lib`, `==` declarations for `pc`),
+         * plus the pc fallback — a `pc:` provide WITHOUT a `==`
+         * declaration is assumed to carry this package's own version.
+         */
+        public bool satisfies(ResourceRef dependency) {
+            if(provides.any(r => dependency.satisfied_by(r.key))) {
+                return true;
+            }
+            if(dependency.constraint != null && dependency.resource_type == ResourceType.PKG_CONFIG) {
+                return provides.any(r => r.key.resource_type == ResourceType.PKG_CONFIG
+                    && r.key.constraint == null
+                    && r.key.resource == dependency.base_name
+                    && dependency.constraint.satisfied_by(version));
+            }
+            return false;
+        }
+
+        /**
+         * The provides keys as flat matching refs: `pc:` provides
+         * without a `==` declaration are materialised as
+         * `pc:<name>==<package version>`, so sets collected from this
+         * (transaction lot scheduling, removal planning) match both
+         * plain and constrained dependencies without carrying the
+         * manifest along.
+         */
+        public Enumerable<ResourceRef> effective_provides() {
+            return provides.select<ResourceRef>(p => {
+                if(p.key.resource_type == ResourceType.PKG_CONFIG && p.key.constraint == null) {
+                    try {
+                        return new ResourceRef(@"pc:$(p.key.resource)==$(version.to_string())");
+                    }
+                    catch(Error e) {
+                        return p.key;
+                    }
+                }
+                return p.key;
+            });
+        }
+
         private Properties map_from_provides_dict() {
         private Properties map_from_provides_dict() {
             var dict = new PropertyDictionary();
             var dict = new PropertyDictionary();
             var mapper = ManifestFile.get_mapper();
             var mapper = ManifestFile.get_mapper();
@@ -242,7 +286,7 @@ namespace Usm {
             return result;
             return result;
         }
         }
 
 
-        public Subprocess run_build(string build_path, Paths paths, SubprocessFlags flags, ProgressDelegate? progress_delegate = null) throws Error {
+        public Subprocess run_build(string build_path, Paths paths, SubprocessFlags flags, owned ProgressDelegate? progress_delegate = null) throws Error {
             if(executables.build == null) {
             if(executables.build == null) {
                 throw new ManifestError.MISSING_FIELD(@"Manifest \"$name\" defines no build executable");
                 throw new ManifestError.MISSING_FIELD(@"Manifest \"$name\" defines no build executable");
             }
             }
@@ -394,7 +438,7 @@ namespace Usm {
          * installs several outputs per resource. Otherwise the flags pass
          * installs several outputs per resource. Otherwise the flags pass
          * through {@link verbose_flags} unchanged (silenced by default).
          * through {@link verbose_flags} unchanged (silenced by default).
          */
          */
-        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags, ProgressDelegate? progress_delegate = null) throws Error {
+        public Subprocess? run_install(string build_path, string install_path, Paths paths, InstallType type, SubprocessFlags flags, owned ProgressDelegate? progress_delegate = null) throws Error {
             if(executables.install == null) {
             if(executables.install == null) {
                 return null;
                 return null;
             }
             }

+ 2 - 2
src/lib/Paths.vala

@@ -107,9 +107,9 @@ namespace Usm {
 
 
         public string get_suggested_path_for_resource(ResourceRef resource) {
         public string get_suggested_path_for_resource(ResourceRef resource) {
             if (resource.resource_type == ResourceType.TAG) {
             if (resource.resource_type == ResourceType.TAG) {
-                return get_tag_file_path(resource.resource);
+                return get_tag_file_path(resource.base_name);
             }
             }
-            return Path.build_filename(get_suggested_base_path_for_type(resource.resource_type), resource.resource);
+            return Path.build_filename(get_suggested_base_path_for_type(resource.resource_type), resource.base_name);
         }
         }
 
 
 
 

+ 49 - 0
src/lib/PcFile.vala

@@ -0,0 +1,49 @@
+namespace Usm {
+
+    /**
+     * Minimal pkg-config file reader: extracts the `Version:` field of
+     * a `.pc` file, the only part USM consults (dependency constraints
+     * and provides validation). Key parsing follows pkg-config's own
+     * conventions — `Keyword: value` with optional surrounding
+     * whitespace, `#` comments — without any variable expansion: a
+     * version line containing `${...}` references yields null rather
+     * than a guess.
+     */
+    public class PcFile {
+
+        /**
+         * The version declared by the `Version:` keyword of the .pc
+         * file at {@link path}, or null when the file cannot be read,
+         * carries no `Version:` keyword, or declares something that is
+         * not a dotted numeric version.
+         */
+        public static Version? read_version(string path) {
+            string contents = null;
+            try {
+                if(!FileUtils.get_contents(path, out contents)) {
+                    return null;
+                }
+            }
+            catch(Error e) {
+                return null;
+            }
+            foreach(var raw_line in contents.split("\n")) {
+                var line = raw_line.chomp().chug();
+                if(line.has_prefix("#") || !line.has_prefix("Version:")) {
+                    continue;
+                }
+                var value = line.substring("Version:".length).chomp().chug();
+                if(value.length == 0 || value.contains("${")) {
+                    return null;
+                }
+                try {
+                    return new Version.from_string(value);
+                }
+                catch(Error e) {
+                    return null;
+                }
+            }
+            return null;
+        }
+    }
+}

+ 11 - 4
src/lib/Resolver.vala

@@ -205,7 +205,7 @@ namespace Usm {
          */
          */
         public AbstractPackage? find_resource(ResourceRef resource) {
         public AbstractPackage? find_resource(ResourceRef resource) {
             foreach(var package in catalog()) {
             foreach(var package in catalog()) {
-                if(package.manifest.provides.any(r => resource.satisfied_by(r.key))) {
+                if(package.manifest.satisfies(resource)) {
                     return package;
                     return package;
                 }
                 }
             }
             }
@@ -343,7 +343,7 @@ namespace Usm {
                         if(provider_name == dependent_name || linked_providers.has(provider_name)) {
                         if(provider_name == dependent_name || linked_providers.has(provider_name)) {
                             continue;
                             continue;
                         }
                         }
-                        if(provider.manifest.provides.any(p => resource.satisfied_by(p.key))) {
+                        if(provider.manifest.satisfies(resource)) {
                             linked_providers.add(provider_name);
                             linked_providers.add(provider_name);
                             Vector<string> follower_names;
                             Vector<string> follower_names;
                             if(!dependents.try_get(provider_name, out follower_names)) {
                             if(!dependents.try_get(provider_name, out follower_names)) {
@@ -476,6 +476,13 @@ namespace Usm {
 
 
             var missing = new Vector<ResourceRef>();
             var missing = new Vector<ResourceRef>();
             foreach(var resource in refs.sort((a, b) => a.to_string().collate(b.to_string()))) {
             foreach(var resource in refs.sort((a, b) => a.to_string().collate(b.to_string()))) {
+                // Version-constrained refs have no SPM translation yet:
+                // shims match names exactly, so querying them would only
+                // yield misleading not-founds. They resolve from local
+                // presence or USM packages.
+                if(resource.constraint != null) {
+                    continue;
+                }
                 if(!has_local(resource)) {
                 if(!has_local(resource)) {
                     missing.add(resource);
                     missing.add(resource);
                 }
                 }
@@ -748,7 +755,7 @@ namespace Usm {
                     }
                     }
                     var provided = false;
                     var provided = false;
                     foreach(var resource in refs) {
                     foreach(var resource in refs) {
-                        if(provider.manifest.provides.any(p => resource.satisfied_by(p.key))) {
+                        if(provider.manifest.satisfies(resource)) {
                             provided = true;
                             provided = true;
                             break;
                             break;
                         }
                         }
@@ -836,7 +843,7 @@ namespace Usm {
         }
         }
 
 
         public bool provides(ResourceRef resource) {
         public bool provides(ResourceRef resource) {
-            return any(p => p.manifest.provides.any(r => resource.satisfied_by(r.key)));
+            return any(p => p.manifest.satisfies(resource));
         }
         }
 
 
         private static DependencyPhase manifest_phase_of(Manifest manifest, string phase_name) {
         private static DependencyPhase manifest_phase_of(Manifest manifest, string phase_name) {

+ 51 - 6
src/lib/ResourceFinder.vala

@@ -80,11 +80,48 @@ namespace Usm {
             // musl systems have no `ldconfig -p` output to consult: fall
             // musl systems have no `ldconfig -p` output to consult: fall
             // back to the conventional library directories
             // back to the conventional library directories
             foreach(var directory in new string[] { "/lib", "/usr/lib", "/lib64", "/usr/lib64" }) {
             foreach(var directory in new string[] { "/lib", "/usr/lib", "/lib64", "/usr/lib64" }) {
-                var file = File.new_build_filename(directory, resource.resource);
-                if(file.query_exists()) {
-                    return file.get_path();
+                var hit = locate_lib_in_directory(resource, directory);
+                if(hit != null) {
+                    return hit;
+                }
+            }
+            return null;
+        }
+
+        /**
+         * Whether the concrete library name {@link candidate} satisfies
+         * the ref: exact filename for plain refs; for constrained refs
+         * the name must be the base plus a fully numeric dotted tail
+         * (the soname version) that satisfies the constraint — a bare
+         * "libcmark.so" declares no version.
+         */
+        private static bool lib_name_matches(ResourceRef resource, string candidate) {
+            if(resource.constraint == null) {
+                return candidate == resource.base_name;
+            }
+            var tail = ResourceRef.version_tail(resource.base_name, candidate);
+            return tail != null && resource.constraint.satisfied_by(tail);
+        }
+
+        private string? locate_lib_in_directory(ResourceRef resource, string directory) {
+            if(resource.constraint == null) {
+                var file = File.new_build_filename(directory, resource.base_name);
+                return file.query_exists() ? file.get_path() : null;
+            }
+            // Constrained: enumerate the directory and version-match soname tails
+            try {
+                foreach(var entry in Iterate.directory(directory)) {
+                    if(!lib_name_matches(resource, entry)) {
+                        continue;
+                    }
+                    var file = File.new_build_filename(directory, entry);
+                    if(file.query_exists()) {
+                        return file.get_path();
+                    }
                 }
                 }
             }
             }
+            catch(FileError e) {
+            }
             return null;
             return null;
         }
         }
 
 
@@ -97,7 +134,7 @@ namespace Usm {
                 while((line = pipe.read_line()) != null) {
                 while((line = pipe.read_line()) != null) {
                     if(line.has_prefix("\t")) {
                     if(line.has_prefix("\t")) {
                         var name = line.substring(1).split(" ", 2)[0];
                         var name = line.substring(1).split(" ", 2)[0];
-                        if(resource.resource == name) {
+                        if(lib_name_matches(resource, name)) {
                             return line.substring(1).split("=>", 2)[1].chomp().chug();
                             return line.substring(1).split("=>", 2)[1].chomp().chug();
                         }
                         }
                     }
                     }
@@ -287,9 +324,17 @@ namespace Usm {
             }
             }
 
 
             foreach (var path in search) {
             foreach (var path in search) {
-                var file = File.new_build_filename(paths.destination, path, resource.resource);
+                var file = File.new_build_filename(paths.destination, path, resource.base_name);
                 if(file.query_exists()) {
                 if(file.query_exists()) {
-                    return file.get_path();
+                    // A constrained pc ref must also be satisfied by the
+                    // version the .pc file itself declares
+                    if(resource.constraint == null) {
+                        return file.get_path();
+                    }
+                    var declared = PcFile.read_version(file.get_path());
+                    if(declared != null && resource.constraint.satisfied_by(declared)) {
+                        return file.get_path();
+                    }
                 }
                 }
             }
             }
             return null;
             return null;

+ 133 - 18
src/lib/ResourceRef.vala

@@ -167,20 +167,48 @@ namespace Usm {
     // Hashable/Equatable interface registrations, and every equals()/hash_code()
     // Hashable/Equatable interface registrations, and every equals()/hash_code()
     // dispatch (set membership, satisfied_by) dereferences a missing vtable
     // dispatch (set membership, satisfied_by) dereferences a missing vtable
     public class ResourceRef : Object, Hashable, Equatable<ResourceRef> {
     public class ResourceRef : Object, Hashable, Equatable<ResourceRef> {
+        /** The full resource string after the type, including any version constraint suffix. */
         public string resource { get; set; }
         public string resource { get; set; }
         public ResourceType resource_type { get; set; }
         public ResourceType resource_type { get; set; }
+        /**
+         * The resource name with any version constraint suffix removed —
+         * the concrete file/soname name. Equal to {@link resource} for
+         * constraint-free refs; path computation and filesystem lookups
+         * must always use this, never {@link resource}.
+         */
+        public string base_name { get; set; }
+        /** The version constraint carried by a dependency ref, or null. */
+        public VersionConstraint? constraint { get; set; }
 
 
         public ResourceRef(string str) throws ManifestError {
         public ResourceRef(string str) throws ManifestError {
             var parts = str.split(":", 2);
             var parts = str.split(":", 2);
+            if(parts.length < 2 || parts[1] == null || parts[1].length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$str\" must be a \"type:name\" pair.");
+            }
             resource_type = ResourceType.from_string(parts[0]);
             resource_type = ResourceType.from_string(parts[0]);
             resource = parts[1];
             resource = parts[1];
+            string name_base;
+            constraint = VersionConstraint.find_in(resource, out name_base);
+            base_name = name_base;
+            if(base_name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$str\" has no resource name.");
+            }
         }
         }
 
 
-        public ResourceRef.with_type(ResourceType type, string name) {
+        public ResourceRef.with_type(ResourceType type, string name) throws ManifestError {
             resource_type = type;
             resource_type = type;
             resource = name;
             resource = name;
+            if(name == null || name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$resource_type:\" has no resource name.");
+            }
+            string name_base;
+            constraint = VersionConstraint.find_in(resource, out name_base);
+            base_name = name_base;
+            if(base_name.length == 0) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(@"Resource ref \"$resource_type:$name\" has no resource name.");
+            }
         }
         }
-        
+
         public uint hash_code() {
         public uint hash_code() {
             return to_string().hash();
             return to_string().hash();
         }
         }
@@ -191,23 +219,110 @@ namespace Usm {
             return @"$resource_type:$resource";
             return @"$resource_type:$resource";
         }
         }
 
 
+        /**
+         * The dotted numeric version carried by a resource name after
+         * {@link prefix}, or null: "libcmark.so.0.31.1" over prefix
+         * "libcmark.so" yields 0.31.1, while "libcmark.so" itself (no
+         * tail) and "libcmark.so.0.31rc1" (non-numeric tail) yield null
+         * — a name without a fully numeric dotted tail declares no
+         * version.
+         */
+        public static Version? version_tail(string prefix, string name) {
+            if(!name.has_prefix(prefix + ".")) {
+                return null;
+            }
+            var tail = name.substring(prefix.length + 1);
+            foreach(var component in tail.split(".")) {
+                if(component.length == 0) {
+                    return null;
+                }
+                for(int index = 0; index < component.length; index++) {
+                    if(!component[index].isdigit()) {
+                        return null;
+                    }
+                }
+            }
+            try {
+                return new Version.from_string(tail);
+            }
+            catch(Error e) {
+                return null;
+            }
+        }
+
+        /**
+         * Enforces the dependency-side operator rules: version
+         * constraints are valid only where a resource's concrete names
+         * carry a version — `lib` (soname tails) and `pc` (the .pc
+         * `Version:` field / `==` declarations on provides).
+         */
+        public void ensure_valid_dependency() throws ManifestError {
+            if(constraint == null) {
+                return;
+            }
+            if(resource_type != ResourceType.LIBRARY && resource_type != ResourceType.PKG_CONFIG) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Version constraints are only available for \"lib\" and \"pc\" resource types, not \"$(resource_type.to_string())\" (in \"$(to_string())\")."
+                );
+            }
+        }
+
+        /**
+         * Enforces the provides-side rules: only the `==` operator may
+         * appear, only on `pc` provides, where it DECLARES the version
+         * of the concrete file. Sonames already declare their version
+         * in the filename tail, and every other type has no version
+         * source at all.
+         */
+        public void ensure_valid_provide() throws ManifestError {
+            if(constraint == null) {
+                return;
+            }
+            if(resource_type != ResourceType.PKG_CONFIG) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Only \"pc\" provides may declare a version with \"==\"; \"$(resource_type.to_string())\" provides must stay concrete (in \"$(to_string())\")."
+                );
+            }
+            if(constraint.operation != VersionOperator.EQUALS) {
+                throw new ManifestError.INVALID_RESOURCE_TYPE(
+                    @"Provides may only carry the \"==\" version declaration, not \"$(constraint.operation.to_string())\" (in \"$(to_string())\")."
+                );
+            }
+        }
+
         public bool satisfied_by(ResourceRef other) {
         public bool satisfied_by(ResourceRef other) {
-            // Direct match
-            if (equals(other)) {
-                return true;
-            }
-            
-            // canonlib can satisfy lib dependencies of the same name
-            if (resource_type == ResourceType.LIBRARY && other.resource_type == ResourceType.CANONICAL_LIBRARY) {
-                return resource == other.resource;
-            }
-            
-            // canonlibres can satisfy libres dependencies of the same name
-            if (resource_type == ResourceType.LIBRARY_RESOURCE && other.resource_type == ResourceType.CANONICAL_LIBRARY_RESOURCE) {
-                return resource == other.resource;
-            }
-            
-            return false;
+            var type_compatible = resource_type == other.resource_type
+                // canonlib can satisfy lib dependencies of the same name
+                || (resource_type == ResourceType.LIBRARY && other.resource_type == ResourceType.CANONICAL_LIBRARY)
+                // canonlibres can satisfy libres dependencies of the same name
+                || (resource_type == ResourceType.LIBRARY_RESOURCE && other.resource_type == ResourceType.CANONICAL_LIBRARY_RESOURCE);
+
+            if(constraint == null) {
+                // Plain dependency: matches any provide of the base name,
+                // whether or not the provide declares a version
+                return type_compatible && resource == other.base_name;
+            }
+
+            if(!type_compatible) {
+                return false;
+            }
+
+            Version? provided_version = null;
+            if(resource_type == ResourceType.LIBRARY) {
+                // Sonames carry their version in the filename tail: the
+                // provide extends the dependency's base ("libcmark.so"
+                // is satisfied by "libcmark.so.0.31.1"); a bare
+                // "libcmark.so" declares nothing
+                provided_version = version_tail(base_name, other.resource);
+            }
+            else if(base_name == other.base_name && other.constraint != null) {
+                // A pc provide's == declaration
+                provided_version = other.constraint.version;
+            }
+            if(provided_version == null) {
+                return false;
+            }
+            return constraint.satisfied_by(provided_version);
         }
         }
     }
     }
 
 

+ 1 - 2
src/lib/State/State.vala

@@ -177,7 +177,6 @@ namespace Usm {
         public Vector<string> find_dependant_names(CachedPackage updated) throws Error {
         public Vector<string> find_dependant_names(CachedPackage updated) throws Error {
             var dependants = new Vector<string>();
             var dependants = new Vector<string>();
             var updated_manifest = updated.get_manifest();
             var updated_manifest = updated.get_manifest();
-            var provides_keys = updated_manifest.provides.select<ResourceRef>(p => p.key);
 
 
             foreach(var installed in get_installed_packages()) {
             foreach(var installed in get_installed_packages()) {
                 var manifest = installed.get_manifest();
                 var manifest = installed.get_manifest();
@@ -193,7 +192,7 @@ namespace Usm {
                     refs.union_with(manifest.dependencies.acquire.all_refs());
                     refs.union_with(manifest.dependencies.acquire.all_refs());
                 }
                 }
 
 
-                if(refs.any(d => provides_keys.any(p => d.satisfied_by(p)))) {
+                if(refs.any(d => updated_manifest.satisfies(d))) {
                     dependants.add(installed.package_name);
                     dependants.add(installed.package_name);
                 }
                 }
             }
             }

+ 68 - 0
src/lib/SystemPackageManager.vala

@@ -96,6 +96,19 @@ namespace Usm {
         }
         }
     }
     }
 
 
+    /** The `plan` helper mode's result: the material install set's names. */
+    public class SystemPlanResult {
+
+        public Vector<string> packages { get; set; }
+
+        public static PropertyMapper<SystemPlanResult> get_mapper() {
+            return PropertyMapper.build_for<SystemPlanResult>(cfg => {
+                cfg.map_many<string>("packages", o => o.packages, (o, v) => o.packages = v.to_vector());
+                cfg.set_constructor(() => new SystemPlanResult());
+            });
+        }
+    }
+
     /**
     /**
      * Client for the system package manager configured in usm.config's
      * Client for the system package manager configured in usm.config's
      * "system_package_manager" section ({@link SystemPackageManagerConfig}).
      * "system_package_manager" section ({@link SystemPackageManagerConfig}).
@@ -221,6 +234,61 @@ namespace Usm {
          * (musl systems running the glibc-built usm via gcompat) whenever
          * (musl systems running the glibc-built usm via gcompat) whenever
          * the read has to wait for data, which the apk target deploys on.
          * the read has to wait for data, which the apk target deploys on.
          */
          */
+        /**
+         * Resolves the material install set for {@link native_names}: every
+         * package the manager would actually install — the chosen names
+         * plus every dependency of a dependency — by asking the helper's
+         * `plan` mode (same executable as the query mode, mode word
+         * swapped). The names come back in the manager's own transaction
+         * order. Helpers without a plan mode (or any failure — resolution
+         * problem, unknown names) degrade to the input names unchanged,
+         * so the caller always gets a usable action list.
+         */
+        public Vector<string> plan_install(Vector<string> native_names) {
+            var names = new Vector<string>();
+            foreach(var name in native_names) {
+                names.add(name);
+            }
+            if(!enabled) {
+                return names;
+            }
+
+            var plan_argv = new string[query_argv.length];
+            for(int index = 0; index < query_argv.length; index++) {
+                plan_argv[index] = query_argv[index];
+            }
+            plan_argv[plan_argv.length - 1] = "plan";
+            var arguments = new Vector<string>();
+            foreach(var name in native_names) {
+                arguments.add(name);
+            }
+
+            try {
+                var proc = new Subprocess.newv(build_argv(plan_argv, arguments), SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_SILENCE);
+                string stdout_buffer = null;
+                string stderr_buffer = null;
+                proc.communicate_utf8(null, null, out stdout_buffer, out stderr_buffer);
+                if(proc.get_exit_status() == 0 && stdout_buffer != null && stdout_buffer.strip().length > 0) {
+                    var result = SystemPlanResult.get_mapper().materialise(
+                        new JsonElement.from_string(stdout_buffer).as<Invercargill.Properties>());
+                    if(result.packages != null && result.packages.length > 0) {
+                        var planned = new Vector<string>();
+                        var seen = new HashSet<string>();
+                        foreach(var package_name in result.packages) {
+                            if(!seen.contains(package_name)) {
+                                seen.add(package_name);
+                                planned.add(package_name);
+                            }
+                        }
+                        return planned;
+                    }
+                }
+            }
+            catch(Error e) {
+            }
+            return names;
+        }
+
         public async bool install(Vector<string> native_names, InstallProgressDelegate? progress = null) throws Error {
         public async bool install(Vector<string> native_names, InstallProgressDelegate? progress = null) throws Error {
             if(!enabled) {
             if(!enabled) {
                 warning("[Usm] System package manager install requested but no system package manager is configured");
                 warning("[Usm] System package manager install requested but no system package manager is configured");

+ 393 - 20
src/lib/Transaction.vala

@@ -7,6 +7,18 @@ namespace Usm {
 
 
         public Paths paths { get; set; }
         public Paths paths { get; set; }
         public ResourceFinder resource_finder { get; set; }
         public ResourceFinder resource_finder { get; set; }
+
+        /**
+         * Resources guaranteed present on the destination before the first
+         * install lot runs: the system packages chosen during dependency
+         * resolution install ahead of the USM transaction (see
+         * `Install.run`). The lot scheduler treats them as locally
+         * present — without this, every system-provided build dependency
+         * (compilers, pkg-config files, ...) looks unmet at planning time,
+         * because strategise runs before anything is installed.
+         */
+        public Set<ResourceRef> preinstalled_resources { get; set; default = new HashSet<ResourceRef>(); }
+
         public SystemState state { get; set; }
         public SystemState state { get; set; }
         public Set<CachedPackage> to_install { get; set; default = new HashSet<CachedPackage>(); }
         public Set<CachedPackage> to_install { get; set; default = new HashSet<CachedPackage>(); }
         public Set<CachedPackage> to_remove { get; set; default = new HashSet<CachedPackage>(); }
         public Set<CachedPackage> to_remove { get; set; default = new HashSet<CachedPackage>(); }
@@ -43,6 +55,59 @@ namespace Usm {
          */
          */
         public Vector<DowngradeEntry> to_downgrade { get; set; default = new Vector<DowngradeEntry>(); }
         public Vector<DowngradeEntry> to_downgrade { get; set; default = new Vector<DowngradeEntry>(); }
 
 
+        /**
+         * The system package manager the {@link system_packages} install
+         * through, as one native transaction that runs BEFORE the first
+         * USM install lot — providing the toolchain and libraries the
+         * builds depend on. Null (or an empty {@link system_packages})
+         * makes the transaction USM-only, exactly as before.
+         */
+        public SystemPackageManager? system_package_manager { get; set; }
+
+        /**
+         * System (native) packages chosen during dependency resolution
+         * that this transaction installs itself, each as one
+         * {@link TransactionTask.INSTALLING_SYSTEM} action: they appear
+         * in the plan preview, drive the shared progress display, and
+         * count towards the transaction's overall progress. The lot
+         * scheduler also treats their resources as present (see
+         * {@link preinstalled_resources}).
+         */
+        public Vector<SystemPackageCandidate> system_packages { get; set; default = new Vector<SystemPackageCandidate>(); }
+
+        /**
+         * The material system install set — every native package the
+         * manager would install for {@link system_packages}, chosen
+         * names plus every dependency of a dependency, in the manager's
+         * own transaction order (from
+         * {@link SystemPackageManager.plan_install}). Each name in this
+         * list is one {@link TransactionTask.INSTALLING_SYSTEM} action
+         * with its own progress and completion log, so the overall
+         * percentage covers the whole native transaction. Empty (helpers
+         * without a plan mode) falls back to one action per chosen
+         * {@link system_packages} entry.
+         */
+        public Vector<string> system_install_plan { get; set; default = new Vector<string>(); }
+
+        /**
+         * The names of the system-phase actions, plan first: every
+         * material package when a plan exists, otherwise the chosen
+         * candidates alone.
+         */
+        private Vector<string> effective_system_names() {
+            var names = new Vector<string>();
+            if(system_install_plan.length > 0) {
+                foreach(var name in system_install_plan) {
+                    names.add(name);
+                }
+                return names;
+            }
+            foreach(var candidate in system_packages) {
+                names.add(candidate.name);
+            }
+            return names;
+        }
+
         public signal void progress_updated(TransactionTask task_type, string subject, uint current_task, uint total_tasks, float task_progress);
         public signal void progress_updated(TransactionTask task_type, string subject, uint current_task, uint total_tasks, float task_progress);
 
 
         private uint task_count = 0;
         private uint task_count = 0;
@@ -106,21 +171,26 @@ namespace Usm {
                 var all_packages = planned_removal.concat(planned_install);
                 var all_packages = planned_removal.concat(planned_install);
                 var rebuild_packages = rebuilds.select<CachedPackage>(r => r.package).to_vector();
                 var rebuild_packages = rebuilds.select<CachedPackage>(r => r.package).to_vector();
 
 
-                // 2. Unpack packages
+                // 2. System packages install first, as one native
+                // transaction, providing the toolchain and libraries the
+                // builds depend on
+                install_system_packages();
+
+                // 3. Unpack packages
                 do_for(all_packages, unpack_package, TransactionTask.UNPACKING);
                 do_for(all_packages, unpack_package, TransactionTask.UNPACKING);
                 do_for(rebuild_packages, unpack_package, TransactionTask.REBUILDING);
                 do_for(rebuild_packages, unpack_package, TransactionTask.REBUILDING);
 
 
-                // 3. Remove packages
+                // 4. Remove packages
                 do_for(removal_order, remove_package, TransactionTask.REMOVING);
                 do_for(removal_order, remove_package, TransactionTask.REMOVING);
 
 
                 foreach (var lot in install_lots) {
                 foreach (var lot in install_lots) {
-                    // 3. Build packages
+                    // 5. Build packages
                     do_for(lot, build_package, TransactionTask.BUILDING);
                     do_for(lot, build_package, TransactionTask.BUILDING);
 
 
-                    // 4. Test packages
+                    // 6. Test packages
                     do_for(lot, test_package, TransactionTask.TESTING);
                     do_for(lot, test_package, TransactionTask.TESTING);
 
 
-                    // 5. Install packages
+                    // 7. Install packages
                     do_for(lot, install_package, TransactionTask.INSTALLING);
                     do_for(lot, install_package, TransactionTask.INSTALLING);
                 }
                 }
 
 
@@ -131,7 +201,7 @@ namespace Usm {
                 do_for(rebuild_packages, test_package, TransactionTask.TESTING);
                 do_for(rebuild_packages, test_package, TransactionTask.TESTING);
                 do_for(rebuild_packages, install_package, TransactionTask.INSTALLING);
                 do_for(rebuild_packages, install_package, TransactionTask.INSTALLING);
 
 
-                // 6. Clean up
+                // 8. Clean up
                 do_for(all_packages, cleanup_package, TransactionTask.CLEANING_UP);
                 do_for(all_packages, cleanup_package, TransactionTask.CLEANING_UP);
                 do_for(rebuild_packages, cleanup_package, TransactionTask.CLEANING_UP);
                 do_for(rebuild_packages, cleanup_package, TransactionTask.CLEANING_UP);
 
 
@@ -205,6 +275,110 @@ namespace Usm {
             printerr(journal.describe_rollback() + "\n");
             printerr(journal.describe_rollback() + "\n");
         }
         }
 
 
+        /**
+         * Installs the system phase through {@link
+         * system_package_manager} as the transaction's first actions:
+         * one native transaction whose streaming events drive one
+         * {@link TransactionTask.INSTALLING_SYSTEM} action per package
+         * of {@link effective_system_names} — in the plan preview, on
+         * the shared progress display and in the overall task count.
+         *
+         * Action slots are claimed IN COMPLETION ORDER, never by the
+         * helper's own positions or the plan's ordering: downloads and
+         * the rpm transaction each stream `package` events in their own
+         * order, neither matching the plan, so a fixed mapping would
+         * hand the display out-of-order indexes that the stale-report
+         * guard (correctly) drops. Completions are strictly sequential
+         * in the native transaction, so each one claims the next slot
+         * and logs; a fraction event for a package that has not yet
+         * completed draws on the next slot to be claimed — the running
+         * package — without claiming it. Names outside the plan (a
+         * differently-resolved run-time transaction) share the bound of
+         * {@link effective_system_names}.length slots and fold silently
+         * once the bound is reached.
+         *
+         * A native package cannot be rolled back by the journal; on
+         * failure the thrown {@link TransactionError.INSTALL_ERROR} still
+         * unwinds and rolls back every USM action (none has run yet). A
+         * no-op when no manager is set or the package list is empty.
+         */
+        private void install_system_packages() throws TransactionError {
+            var action_names = effective_system_names();
+            if(system_package_manager == null || (system_packages.length == 0 && system_install_plan.length == 0)) {
+                return;
+            }
+
+            var planned = new HashSet<string>();
+            foreach(var name in action_names) {
+                planned.add(name);
+            }
+
+            var install_names = new Vector<string>();
+            foreach(var candidate in system_packages) {
+                if(!install_names.any(n => n == candidate.name)) {
+                    install_names.add(candidate.name);
+                }
+            }
+
+            string failure = "";
+            var claimed_slot = new Dictionary<string, int>();
+            int next_slot = 0;
+            var loop = new MainLoop();
+            system_package_manager.install.begin(install_names, event => {
+                switch(event.event_type) {
+                    case SystemInstallEventType.BEGIN:
+                        break;
+                    case SystemInstallEventType.PACKAGE:
+                        if(event.name != null && next_slot < action_names.length) {
+                            int slot;
+                            if(!claimed_slot.try_get(event.name, out slot)) {
+                                // The running package targets the slot the
+                                // next completion will claim
+                                slot = next_slot;
+                            }
+                            begin_action(TransactionTask.INSTALLING_SYSTEM, event.name, 1 + slot);
+                            report_progress(TransactionTask.INSTALLING_SYSTEM, (float)event.progress);
+                        }
+                        break;
+                    case SystemInstallEventType.PACKAGE_COMPLETE:
+                        if(event.name != null && next_slot < action_names.length) {
+                            int slot;
+                            if(!claimed_slot.try_get(event.name, out slot)) {
+                                slot = next_slot++;
+                                claimed_slot.set(event.name, slot);
+                            }
+                            begin_action(TransactionTask.INSTALLING_SYSTEM, event.name, 1 + slot);
+                            report_progress(TransactionTask.INSTALLING_SYSTEM, 1.0f);
+                        }
+                        break;
+                    case SystemInstallEventType.COMPLETE:
+                        break;
+                    case SystemInstallEventType.ERROR:
+                        failure = event.message ?? "unknown error";
+                        break;
+                }
+            }, (obj, res) => {
+                try {
+                    if(!system_package_manager.install.end(res)) {
+                        failure = failure.length > 0 ? failure : "the system package manager reported a failure";
+                    }
+                }
+                catch(Error e) {
+                    failure = e.message;
+                }
+                loop.quit();
+            });
+            loop.run();
+
+            // Whatever the helper streamed, the USM actions start right
+            // after the last system action's slot
+            begin_action(TransactionTask.STRATEGISING, "transaction", 1 + action_names.length);
+
+            if(failure.length > 0) {
+                throw new TransactionError.INSTALL_ERROR(@"System package installation failed: $failure");
+            }
+        }
+
         /**
         /**
          * `<destination>/<local-state>/usm/backup` — the journal's home,
          * `<destination>/<local-state>/usm/backup` — the journal's home,
          * rooted like every other destination this transaction writes
          * rooted like every other destination this transaction writes
@@ -271,8 +445,8 @@ namespace Usm {
                 throw new TransactionError.UNKNOWN_ERROR(@"Failed to read manifest while planning removals: $(e.message)");
                 throw new TransactionError.UNKNOWN_ERROR(@"Failed to read manifest while planning removals: $(e.message)");
             }
             }
 
 
-            task_count = (planned_install.count() * 5) + (planned_removal.count() * 3) + 1;
-            uint strategise_worst_case_task_count = (planned_removal.count() * planned_removal.count()) + (planned_install.count() * planned_install.count());
+            task_count = (planned_install.count() * 5) + (planned_removal.count() * 3) + effective_system_names().length + 1;
+            uint strategise_worst_case_task_count = (planned_removal.count() * planned_removal.count()) + (planned_install.count() * planned_install.count()) + effective_system_names().length;
             uint strategise_current_task = 0;
             uint strategise_current_task = 0;
 
 
             report_progress(TransactionTask.STRATEGISING, 0.0f);
             report_progress(TransactionTask.STRATEGISING, 0.0f);
@@ -302,18 +476,20 @@ namespace Usm {
                         // package builds, so a build dependency on it defers
                         // package builds, so a build dependency on it defers
                         // the package to the next lot
                         // the package to the next lot
                         PredicateDelegate<ResourceRef> buildtime_satisfied = d => resource_finder.has_resource(d)
                         PredicateDelegate<ResourceRef> buildtime_satisfied = d => resource_finder.has_resource(d)
+                            || preinstalled_resources.any(r => d.satisfied_by(r))
                             || installed_by_earlier_lots.any(r => d.satisfied_by(r));
                             || installed_by_earlier_lots.any(r => d.satisfied_by(r));
                         // Manage-phase executables run at install time, when
                         // Manage-phase executables run at install time, when
                         // everything this lot has already admitted (in
                         // everything this lot has already admitted (in
                         // topological order) is installed
                         // topological order) is installed
                         PredicateDelegate<ResourceRef> installtime_satisfied = d => resource_finder.has_resource(d)
                         PredicateDelegate<ResourceRef> installtime_satisfied = d => resource_finder.has_resource(d)
+                            || preinstalled_resources.any(r => d.satisfied_by(r))
                             || installed_by_earlier_lots.any(r => d.satisfied_by(r))
                             || installed_by_earlier_lots.any(r => d.satisfied_by(r))
                             || installed_by_this_lot.any(r => d.satisfied_by(r));
                             || installed_by_this_lot.any(r => d.satisfied_by(r));
                         if(manifest.dependencies.manage.is_satisfied(installtime_satisfied) &&
                         if(manifest.dependencies.manage.is_satisfied(installtime_satisfied) &&
                                 manifest.dependencies.build.is_satisfied(buildtime_satisfied)) {
                                 manifest.dependencies.build.is_satisfied(buildtime_satisfied)) {
                             lot.add(package);
                             lot.add(package);
                             touched.add(package);
                             touched.add(package);
-                            installed_by_this_lot.union_with(manifest.provides.select<ResourceRef>(p => p.key));
+                            installed_by_this_lot.union_with(manifest.effective_provides());
                         }
                         }
                         strategise_current_task++;
                         strategise_current_task++;
                     }
                     }
@@ -323,8 +499,9 @@ namespace Usm {
                 }
                 }
 
 
                 if(lot.count() == 0) {
                 if(lot.count() == 0) {
-                    var packages = ordered_install.exclude(touched).to_string(p => p.package_name, ", ");
-                    throw new TransactionError.INVALID_TRANSACTION(@"Could not build a transaction strategy, packages $(packages) have unmet or cyclical dependencies");
+                    throw new TransactionError.INVALID_TRANSACTION(
+                        describe_install_deadlock(ordered_install.exclude(touched), installed_by_earlier_lots, planned_install)
+                    );
                 }
                 }
 
 
                 installed_by_earlier_lots.union_with(installed_by_this_lot);
                 installed_by_earlier_lots.union_with(installed_by_this_lot);
@@ -362,7 +539,7 @@ namespace Usm {
                     var removed_this_round = false;
                     var removed_this_round = false;
                     foreach (var package in remaining_to_remove.sort((a, b) => a.package.package_name.collate(b.package.package_name))) {
                     foreach (var package in remaining_to_remove.sort((a, b) => a.package.package_name.collate(b.package.package_name))) {
                         report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
                         report_progress(TransactionTask.STRATEGISING, (float)strategise_current_task / (float)strategise_worst_case_task_count);
-                        if(remaining_to_remove.no(p => p.manifest.dependencies.manage.all_refs().any(d => package.manifest.provides.any(r => d.satisfied_by(r.key))))) {
+                        if(remaining_to_remove.no(p => p.manifest.dependencies.manage.all_refs().any(d => package.manifest.satisfies(d)))) {
                             removal_order.add(package.package);
                             removal_order.add(package.package);
                             remaining_to_remove.remove(package);
                             remaining_to_remove.remove(package);
                             strategise_current_task++;
                             strategise_current_task++;
@@ -374,8 +551,7 @@ namespace Usm {
                     }
                     }
 
 
                     if(!removed_this_round) {
                     if(!removed_this_round) {
-                        var packages = remaining_to_remove.to_string(p => p.package.package_name, ", ");
-                        throw new TransactionError.INVALID_TRANSACTION(@"Could not build a transaction strategy, packages $(packages) have unmet or cyclical dependencies");
+                        throw new TransactionError.INVALID_TRANSACTION(describe_removal_deadlock(remaining_to_remove));
                     }
                     }
                 }
                 }
             }
             }
@@ -387,6 +563,153 @@ namespace Usm {
             current_task++;
             current_task++;
         }
         }
 
 
+        /**
+         * Whether a resource is satisfiable without the packages this
+         * explainer deals with: present on the destination, provided by a
+         * preinstalled (system) package, or provided by a lot scheduled
+         * before the stall.
+         */
+
+        /**
+         * Explains why the install lot scheduler stalled: for every
+         * unscheduled package, each blocking dependency is reported with
+         * its exact cause — unmet (nothing present or planned provides it)
+         * or cyclical (only packages that are themselves unscheduled
+         * provide it) — naming the providers, the phase carrying the ref,
+         * and the lot rule that applies (build-phase refs must come from
+         * an EARLIER lot; manage-phase refs may share the lot).
+         */
+        private string describe_install_deadlock(Enumerable<CachedPackage> stuck, Set<ResourceRef> installed_by_earlier_lots, Set<CachedPackage> planned_install) {
+            var stuck_sorted = stuck.sort((a, b) => a.package_name.collate(b.package_name)).to_vector();
+
+            PredicateDelegate<ResourceRef> satisfier = r => resource_finder.has_resource(r)
+                || preinstalled_resources.any(p => r.satisfied_by(p))
+                || installed_by_earlier_lots.any(p => r.satisfied_by(p));
+
+            var builder = new StringBuilder();
+            builder.append_printf("Could not build a transaction strategy: %u package(s) could not be scheduled for installation", stuck_sorted.length);
+            if(preinstalled_resources.any()) {
+                builder.append_printf(" (%u resource(s) already planned via system packages are counted as present)", preinstalled_resources.count());
+            }
+            builder.append(":\n");
+            foreach(var package in stuck_sorted) {
+                builder.append_printf("  %s:\n", package.package_name);
+                Manifest manifest;
+                try {
+                    manifest = package.get_manifest();
+                }
+                catch(Error e) {
+                    builder.append_printf("    (manifest could not be read: %s)\n", e.message);
+                    continue;
+                }
+                append_blocking_refs(builder, manifest.dependencies.manage, "manage", satisfier, planned_install, package);
+                append_blocking_refs(builder, manifest.dependencies.build, "build", satisfier, planned_install, package);
+            }
+            return builder.str.chomp();
+        }
+
+        /**
+         * Appends the refs of one dependency phase that nothing schedulable
+         * provides. Flat phases list their refs directly; grouped phases
+         * report every candidate group that is not viable.
+         */
+        private void append_blocking_refs(StringBuilder builder, DependencyPhase phase, string phase_name, PredicateDelegate<ResourceRef> satisfier, Set<CachedPackage> planned_install, CachedPackage package) {
+            if(phase == null) {
+                return;
+            }
+            if(!phase.is_grouped) {
+                foreach(var resource in phase.ordered_required()) {
+                    if(!satisfier(resource)) {
+                        append_blocking_ref(builder, phase_name, resource, planned_install, package);
+                    }
+                }
+                return;
+            }
+            var groups = phase.ordered_groups();
+            for(uint index = 0; index < groups.length; index++) {
+                var blocking = groups[index].where(r => !satisfier(r)).to_vector();
+                if(blocking.length == 0) {
+                    return;
+                }
+                builder.append_printf("    [%s phase, candidate group %u of %u:]\n", phase_name, index + 1, groups.length);
+                foreach(var resource in blocking) {
+                    append_blocking_ref(builder, phase_name, resource, planned_install, package);
+                }
+            }
+        }
+
+        /**
+         * Appends one blocking ref with its exact cause and, when other
+         * transaction members provide it, their names — distinguishing
+         * unmet dependencies from dependency cycles.
+         */
+        private void append_blocking_ref(StringBuilder builder, string phase_name, ResourceRef resource, Set<CachedPackage> planned_install, CachedPackage package) {
+            var providers = new Vector<string>();
+            foreach(var candidate in planned_install) {
+                if(candidate == package) {
+                    continue;
+                }
+                try {
+                    if(candidate.get_manifest().satisfies(resource)) {
+                        providers.add(candidate.package_name);
+                    }
+                }
+                catch(Error e) {
+                    builder.append_printf("    %s (%s phase): provider check failed for \"%s\": %s\n", resource.to_string(), phase_name, candidate.package_name, e.message);
+                }
+            }
+            if(providers.length == 0) {
+                builder.append_printf("    %s (%s phase) is unmet: it is not present on this system, no planned system package provides it, and no package in this transaction provides it\n", resource.to_string(), phase_name);
+                return;
+            }
+            var self_cycle = false;
+            try {
+                self_cycle = package.get_manifest().satisfies(resource);
+            }
+            catch(Error e) {
+            }
+            if(self_cycle) {
+                providers.add(@"$(package.package_name) (itself)");
+            }
+            builder.append_printf(
+                "    %s (%s phase) is part of a dependency cycle: only %s provide%s it, and none of them can be scheduled before \"%s\"\n",
+                resource.to_string(), phase_name, providers.to_string(p => p, ", "), providers.length == 1 ? "s" : "", package.package_name
+            );
+        }
+
+        /**
+         * Explains why no remaining package can be removed: a package is
+         * stuck when another still-to-be-removed package's manage-phase
+         * dependencies are satisfied by its provides, so each blocking
+         * (remover, blocked-by, ref) pair is reported — a pure cycle when
+         * every remaining package is blocked.
+         */
+        private string describe_removal_deadlock(Set<CachedPackageManifest> remaining) {
+            var builder = new StringBuilder();
+            builder.append_printf("Could not build a transaction strategy: %u package(s) could not be scheduled for removal:\n", remaining.count());
+            foreach(var entry in remaining.sort((a, b) => a.package.package_name.collate(b.package.package_name))) {
+                var blockers = new Vector<string>();
+                foreach(var other in remaining) {
+                    if(other == entry) {
+                        continue;
+                    }
+                    foreach(var dependency in other.manifest.dependencies.manage.all_refs()) {
+                        if(entry.manifest.satisfies(dependency)) {
+                            blockers.add(@"\"$(other.package.package_name)\" still needs $(dependency.to_string()) (manage phase)");
+                            break;
+                        }
+                    }
+                }
+                if(blockers.length == 0) {
+                    builder.append_printf("  %s: no remaining package depends on it, but nothing else could be scheduled either\n", entry.package.package_name);
+                }
+                else {
+                    builder.append_printf("  %s: %s\n", entry.package.package_name, blockers.to_string(b => b, "; "));
+                }
+            }
+            return builder.str.chomp();
+        }
+
         /**
         /**
          * Plans rebuildDependants rebuilds: every package in
          * Plans rebuildDependants rebuilds: every package in
          * {@link to_install} flagged
          * {@link to_install} flagged
@@ -491,14 +814,37 @@ namespace Usm {
         }
         }
 
 
         private delegate void PackageDelegate(CachedPackage package) throws Error;
         private delegate void PackageDelegate(CachedPackage package) throws Error;
+
+        /**
+         * Serialises progress reporting: the ninja progress reader
+         * thread of a `ninjaStyleProgress` build emits fractions
+         * concurrently with the main thread advancing between actions,
+         * so the reporting fields and the {@link progress_updated}
+         * emissions (and the display behind them) must never interleave.
+         */
+        private Object progress_lock = new Object();
+
+        /**
+         * Moves the action cursor under {@link progress_lock}: the next
+         * {@link report_progress} carries this task, subject and index.
+         */
+        private void begin_action(TransactionTask task, string subject, uint index) {
+            lock(progress_lock) {
+                current_task_type = task;
+                current_subject = subject;
+                current_task = index;
+            }
+        }
+
         private void do_for(Enumerable<CachedPackage> packages, PackageDelegate func, TransactionTask task_type) throws TransactionError {
         private void do_for(Enumerable<CachedPackage> packages, PackageDelegate func, TransactionTask task_type) throws TransactionError {
             foreach (var package in packages) {
             foreach (var package in packages) {
                 try {
                 try {
-                    current_subject = package.package_name;
-                    current_task_type = task_type;
+                    begin_action(task_type, package.package_name, current_task);
                     report_progress(task_type, 0.0f);
                     report_progress(task_type, 0.0f);
                     func(package);
                     func(package);
-                    current_task++;
+                    lock(progress_lock) {
+                        current_task++;
+                    }
                 }
                 }
                 catch (TransactionError e) {
                 catch (TransactionError e) {
                     throw e;
                     throw e;
@@ -510,7 +856,23 @@ namespace Usm {
         }
         }
 
 
         private void report_progress(TransactionTask task, float progress) {
         private void report_progress(TransactionTask task, float progress) {
-            progress_updated(task, current_subject, current_task, task_count, progress);
+            lock(progress_lock) {
+                progress_updated(task, current_subject, current_task, task_count, progress);
+            }
+        }
+
+        /**
+         * Reports one progress fraction for an action whose identity was
+         * captured when it started — used by the ninja progress reader
+         * thread, whose emissions may lag behind the main thread
+         * advancing the cursor; carrying the captured identity (instead
+         * of reading the live fields) keeps a late emission from
+         * masquerading as whatever action happens to be current.
+         */
+        private void report_action_progress(TransactionTask task, string subject, uint index, float progress) {
+            lock(progress_lock) {
+                progress_updated(task, subject, index, task_count, progress);
+            }
         }
         }
 
 
         public void unpack_package(CachedPackage package) throws Error {
         public void unpack_package(CachedPackage package) throws Error {
@@ -576,9 +938,16 @@ namespace Usm {
             Environment.set_current_dir(source_dir);
             Environment.set_current_dir(source_dir);
             var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
             var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
 
 
-            // Build package
+            // The ninja progress reader thread outlives the build process
+            // by a moment; snapshot the action's identity so its late
+            // emissions carry what they meant when written, never
+            // whatever action the cursor has since moved to (see
+            // {@link report_action_progress})
+            TransactionTask action_task = current_task_type;
+            string action_subject = current_subject;
+            uint action_index = current_task;
             var build_proc = manifest.run_build(build_dir, paths, SubprocessFlags.STDOUT_SILENCE, (progress) => {
             var build_proc = manifest.run_build(build_dir, paths, SubprocessFlags.STDOUT_SILENCE, (progress) => {
-                report_progress(current_task_type, progress);
+                report_action_progress(action_task, action_subject, action_index, progress);
             });
             });
             build_proc.wait_check();
             build_proc.wait_check();
         }
         }
@@ -764,6 +1133,8 @@ namespace Usm {
         REMOVING,
         REMOVING,
         INSTALLING,
         INSTALLING,
         CLEANING_UP,
         CLEANING_UP,
+        /** Installing one chosen system (native) package; runs ahead of the first USM lot. */
+        INSTALLING_SYSTEM,
         /** The rebuildDependants pipeline for an already-installed package (see {@link RebuildEntry}). */
         /** The rebuildDependants pipeline for an already-installed package (see {@link RebuildEntry}). */
         REBUILDING;
         REBUILDING;
 
 
@@ -781,6 +1152,8 @@ namespace Usm {
                 return "removing";
                 return "removing";
             case INSTALLING:
             case INSTALLING:
                 return "installing";
                 return "installing";
+            case INSTALLING_SYSTEM:
+                return "installing system package";
             case CLEANING_UP:
             case CLEANING_UP:
                 return "cleaning up";
                 return "cleaning up";
             case REBUILDING:
             case REBUILDING:

+ 28 - 1
src/lib/TransactionSummary.vala

@@ -16,6 +16,22 @@ namespace Usm {
 
 
         /** Packages the transaction installs; a {@link to_remove} entry of the same name renders as an update. */
         /** Packages the transaction installs; a {@link to_remove} entry of the same name renders as an update. */
         public Vector<CachedPackage> to_install { get; set; default = new Vector<CachedPackage>(); }
         public Vector<CachedPackage> to_install { get; set; default = new Vector<CachedPackage>(); }
+        /**
+         * System (native) packages the transaction installs itself
+         * before the first USM lot (see
+         * {@link Transaction.system_packages}); rendered first, in the
+         * order the native transaction installs them.
+         */
+        public Vector<Usm.SystemPackageCandidate> system_installs { get; set; default = new Vector<Usm.SystemPackageCandidate>(); }
+        /**
+         * The material system install set's extra packages — every
+         * native dependency of a dependency the manager pulls in for
+         * {@link system_installs} (from
+         * {@link SystemPackageManager.plan_install}, minus the chosen
+         * names); rendered dim after the chosen packages so the plan
+         * explains the whole native transaction.
+         */
+        public Vector<string> system_dependencies { get; set; default = new Vector<string>(); }
         /** Installed packages the transaction removes; entries whose name is reinstalled render as updates. */
         /** Installed packages the transaction removes; entries whose name is reinstalled render as updates. */
         public Vector<CachedPackage> to_remove { get; set; default = new Vector<CachedPackage>(); }
         public Vector<CachedPackage> to_remove { get; set; default = new Vector<CachedPackage>(); }
         /**
         /**
@@ -48,7 +64,8 @@ namespace Usm {
 
 
         /** Whether the summary describes no action at all. */
         /** Whether the summary describes no action at all. */
         public bool is_empty() {
         public bool is_empty() {
-            return to_install.length == 0 && to_remove.length == 0 && orphaned_removals.length == 0
+            return to_install.length == 0 && system_installs.length == 0 && system_dependencies.length == 0
+                && to_remove.length == 0 && orphaned_removals.length == 0
                 && to_rebuild.length == 0 && to_downgrade.length == 0;
                 && to_rebuild.length == 0 && to_downgrade.length == 0;
         }
         }
 
 
@@ -78,6 +95,16 @@ namespace Usm {
                 removed_versions.set(summary_name(package), summary_version(package));
                 removed_versions.set(summary_name(package), summary_version(package));
             }
             }
 
 
+            foreach(var candidate in system_installs) {
+                builder.append_printf("  %s%-9s%s  %s %s(system package)%s\n",
+                    c_green, "install", c_reset, candidate.name, c_dim, c_reset);
+            }
+
+            foreach(var name in system_dependencies) {
+                builder.append_printf("  %s%-9s%s  %s %s(system dependency)%s\n",
+                    c_green, "install", c_reset, name, c_dim, c_reset);
+            }
+
             var updated_names = new HashSet<string>();
             var updated_names = new HashSet<string>();
             foreach(var package in to_install.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
             foreach(var package in to_install.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
                 var name = summary_name(package);
                 var name = summary_name(package);

+ 19 - 0
src/lib/Version.vala

@@ -61,6 +61,25 @@ namespace Usm {
             return release == other.release;
             return release == other.release;
         }
         }
 
 
+        /**
+         * Prefix-tolerant numeric equality ignoring `+release` suffixes:
+         * 0.31 matches 0.31.0, and 0.31+2 matches 0.31. Resource-level
+         * versions (soname tails, .pc `Version:` fields) never carry
+         * packaging releases and upstreams disagree about component
+         * counts, so this is the equality resource constraints use.
+         */
+        public bool matches_numeric(Version other) {
+            var mine = version_nums ?? new int[0];
+            var theirs = other.version_nums ?? new int[0];
+            var parts = int.min(theirs.length, mine.length);
+            for(int i = 0; i < parts; i++) {
+                if(mine[i] != theirs[i]) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
         public bool greater_than(Version other) {
         public bool greater_than(Version other) {
             var parts = int.min(other.version_nums.length, version_nums.length);
             var parts = int.min(other.version_nums.length, version_nums.length);
             for(int i = 0; i < parts; i++) {
             for(int i = 0; i < parts; i++) {

+ 165 - 0
src/lib/VersionConstraint.vala

@@ -0,0 +1,165 @@
+using Invercargill;
+
+namespace Usm {
+
+    /**
+     * The comparison a version constraint applies to a candidate version.
+     */
+    public enum VersionOperator {
+        /** The candidate's version equals the constraint's (numeric prefix equality). */
+        EQUALS,
+        /** The candidate's version is at least the constraint's. */
+        AT_LEAST,
+        /** The candidate's version is at most the constraint's. */
+        AT_MOST,
+        /**
+         * PEP 440 "compatible release": at least the constraint's version
+         * AND sharing every numeric component of it except the last, so
+         * `~=0.31.0` accepts 0.31.1 but not 0.32.0, while `~=0.31`
+         * accepts 0.33.0 but not 1.0.0.
+         */
+        COMPATIBLE;
+
+        public string to_string() {
+            switch (this) {
+                case VersionOperator.EQUALS:
+                    return "==";
+                case VersionOperator.AT_LEAST:
+                    return ">=";
+                case VersionOperator.AT_MOST:
+                    return "<=";
+                case VersionOperator.COMPATIBLE:
+                    return "~=";
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        public static VersionOperator from_string(string str) throws ManifestError {
+            switch (str) {
+                case "==":
+                    return VersionOperator.EQUALS;
+                case ">=":
+                    return VersionOperator.AT_LEAST;
+                case "<=":
+                    return VersionOperator.AT_MOST;
+                case "~=":
+                    return VersionOperator.COMPATIBLE;
+                default:
+                    throw new ManifestError.INVALID_VERSION(@"Unknown version operator \"$str\" (expected ==, >=, <= or ~=).");
+            }
+        }
+    }
+
+    /**
+     * A version constraint attached to a dependency resource ref, e.g.
+     * the `>=0.30.0` of `lib:libcmark.so>=0.30.0`.
+     *
+     * Comparison semantics are deliberately prefix-tolerant on numeric
+     * components (0.31 == 0.31.0) and ignore `+release` suffixes:
+     * resource-level versions come from soname tails and .pc `Version:`
+     * fields, which never carry packaging releases, while upstream
+     * conventions disagree about how many components to write.
+     */
+    public class VersionConstraint : Object {
+
+        /** The operator applied to {@link version}. */
+        public VersionOperator operation { get; set; }
+        /** The constraint's own version. */
+        public Version version { get; set; }
+
+        public VersionConstraint(VersionOperator operation, Version version) {
+            this.operation = operation;
+            this.version = version;
+        }
+
+        public string to_string() {
+            return @"$(operation.to_string())$(version.to_string())";
+        }
+
+        /** Whether {@link candidate} satisfies this constraint. */
+        public bool satisfied_by(Version candidate) {
+            switch (operation) {
+                case VersionOperator.EQUALS:
+                    return candidate.matches_numeric(version);
+                case VersionOperator.AT_LEAST:
+                    return !candidate.less_than(version);
+                case VersionOperator.AT_MOST:
+                    return !candidate.greater_than(version);
+                case VersionOperator.COMPATIBLE:
+                    if(candidate.less_than(version)) {
+                        return false;
+                    }
+                    // Pin every component of the constraint except its last
+                    var pinned = version.version_nums.length - 1;
+                    for(int index = 0; index < pinned; index++) {
+                        var component = index < candidate.version_nums.length
+                            ? candidate.version_nums[index] : 0;
+                        if(component != version.version_nums[index]) {
+                            return false;
+                        }
+                    }
+                    return true;
+                default:
+                    assert_not_reached();
+            }
+        }
+
+        /**
+         * Locates and parses the FIRST version operator in {@link text}.
+         *
+         * Returns the constraint and sets {@link resource_base} to
+         * everything before the operator (the resource's base name);
+         * null with {@link resource_base} == {@link text} when no
+         * operator is present. A trailing/leading operator, an
+         * unparseable version, a `~=`
+         * with fewer than two numeric components or a second operator
+         * in the version all throw {@link ManifestError.INVALID_VERSION}.
+         */
+        public static VersionConstraint? find_in(string text, out string resource_base) throws ManifestError {
+            long found_at = -1;
+            string found_token = null;
+            foreach(var token in new string[] { "==", ">=", "<=", "~=" }) {
+                var at = text.index_of(token);
+                if(at >= 0 && (found_at < 0 || at < found_at)) {
+                    found_at = at;
+                    found_token = token;
+                }
+            }
+            if(found_at < 0) {
+                resource_base = text;
+                return null;
+            }
+
+            resource_base = text.substring(0, found_at);
+            var version_text = text.substring(found_at + found_token.length);
+            if(resource_base.length == 0 || version_text.length == 0) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"Version operator \"$found_token\" must sit between a resource name and a version in \"$text\"."
+                );
+            }
+
+            Version parsed_version = null;
+            try {
+                parsed_version = new Version.from_string(version_text);
+            }
+            catch(Error e) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"Could not parse the version of the constraint in \"$text\": $(e.message)"
+                );
+            }
+
+            var operation = found_token == "=="
+                ? VersionOperator.EQUALS
+                : found_token == ">=" ? VersionOperator.AT_LEAST
+                : found_token == "<=" ? VersionOperator.AT_MOST
+                : VersionOperator.COMPATIBLE;
+            if(operation == VersionOperator.COMPATIBLE && parsed_version.version_nums.length < 2) {
+                throw new ManifestError.INVALID_VERSION(
+                    @"The ~= operator needs at least two version components (e.g. ~=0.31), \"$text\" has fewer."
+                );
+            }
+            return new VersionConstraint(operation, parsed_version);
+        }
+    }
+}

+ 2 - 0
src/lib/meson.build

@@ -10,7 +10,9 @@ sources += files('Licence.vala')
 sources += files('Paths.vala')
 sources += files('Paths.vala')
 sources += files('ResourceRef.vala')
 sources += files('ResourceRef.vala')
 sources += files('ResourceFinder.vala')
 sources += files('ResourceFinder.vala')
+sources += files('PcFile.vala')
 sources += files('Version.vala')
 sources += files('Version.vala')
+sources += files('VersionConstraint.vala')
 sources += files('Resolver.vala')
 sources += files('Resolver.vala')
 sources += files('Util.vala')
 sources += files('Util.vala')
 sources += files('Transaction.vala')
 sources += files('Transaction.vala')

+ 516 - 5
src/tests/TestMain.vala

@@ -408,6 +408,191 @@ namespace Usm.Tests {
             "flat phase roundtrips as flat");
             "flat phase roundtrips as flat");
     }
     }
 
 
+    // ---- Version constraints -----------------------------------------------------
+
+    Usm.Version version(string text) throws Error {
+        return new Usm.Version.from_string(text);
+    }
+
+    void test_version_constraint_semantics() throws Error {
+        string parsed_base = null;
+        var constraint = Usm.VersionConstraint.find_in("libcmark.so>=0.30.0", out parsed_base);
+        check(constraint != null && parsed_base == "libcmark.so", "operator splits base from constraint");
+        check(constraint.to_string() == ">=0.30.0", "constraint round-trips");
+
+        var at_least = ((!)constraint);
+        check(at_least.satisfied_by(version("0.30.0")), ">= accepts the boundary");
+        check(at_least.satisfied_by(version("0.31.1")), ">= accepts newer");
+        check(at_least.satisfied_by(version("1.2.0")), ">= accepts major bumps");
+        check(!at_least.satisfied_by(version("0.29.9")), ">= rejects older");
+
+        string compatible_base = null;
+        var compatible = (!)Usm.VersionConstraint.find_in("libcmark.so~=0.31.0", out compatible_base);
+        check(compatible.satisfied_by(version("0.31.0")), "~= accepts the boundary");
+        check(compatible.satisfied_by(version("0.31.1")), "~= accepts patch bumps within the family");
+        check(!compatible.satisfied_by(version("0.32.0")), "~= rejects minor bumps");
+        check(!compatible.satisfied_by(version("1.0.0")), "~= rejects major bumps");
+
+        string wide_base = null;
+        var wide = (!)Usm.VersionConstraint.find_in("libcmark.so~=0.31", out wide_base);
+        check(wide.satisfied_by(version("0.33.0")), "two-component ~= pins only the major");
+        check(!wide.satisfied_by(version("1.0.0")), "two-component ~= still rejects major bumps");
+
+        string equals_base = null;
+        var equals = (!)Usm.VersionConstraint.find_in("libcmark.so==0.31", out equals_base);
+        check(equals.satisfied_by(version("0.31")), "== accepts the exact version");
+        check(equals.satisfied_by(version("0.31.0")), "== tolerates omitted trailing components");
+        check(equals.satisfied_by(version("0.31.5")), "== treats missing trailing components as unspecified");
+        check(!equals.satisfied_by(version("0.32.0")), "== rejects a differing minor");
+
+        string at_most_base = null;
+        var at_most = (!)Usm.VersionConstraint.find_in("libcmark.so<=0.31.1", out at_most_base);
+        check(at_most.satisfied_by(version("0.31.0")), "<= accepts older");
+        check(at_most.satisfied_by(version("0.31.1")), "<= accepts the boundary");
+        check(!at_most.satisfied_by(version("0.32.0")), "<= rejects newer");
+
+        check(Usm.VersionConstraint.find_in("libcmark.so", out parsed_base) == null && parsed_base == "libcmark.so",
+            "operator-free text carries no constraint");
+
+        var rejected = new Vector<string>();
+        foreach(var malformed in new string[] { "libcmark.so>=", ">=0.31.0", "libcmark.so>=0.31rc1", "libcmark.so~=0", "libcmark.so>=1<=2" }) {
+            try {
+                Usm.VersionConstraint.find_in(malformed, out parsed_base);
+                rejected.add(malformed);
+            }
+            catch(Error e) {
+            }
+        }
+        check(rejected.count() == 0, @"malformed constraints are rejected ($(rejected.to_string(s => s, ", ")))");
+    }
+
+    void test_resource_ref_operator_parsing() throws Error {
+        var constrained = new Usm.ResourceRef("lib:libcmark.so.0>=0.30.0");
+        check(constrained.base_name == "libcmark.so.0", "base name drops the constraint");
+        check(constrained.resource == "libcmark.so.0>=0.30.0", "full resource keeps the constraint");
+        check(constrained.to_string() == "lib:libcmark.so.0>=0.30.0", "ref round-trips");
+
+        var plain = new Usm.ResourceRef("lib:libcmark.so.0");
+        check(plain.base_name == "libcmark.so.0" && plain.constraint == null, "plain ref carries no constraint");
+
+        var malformed = new Vector<string>();
+        foreach(var broken in new string[] { "lib", "lib:", "", ":" }) {
+            try {
+                new Usm.ResourceRef(broken);
+                malformed.add(broken);
+            }
+            catch(Error e) {
+            }
+        }
+        check(malformed.count() == 0, @"structurally invalid refs are rejected ($(malformed.to_string(s => s, ", ")))");
+
+        var tail = Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31.1");
+        check(tail != null && ((!)tail).to_string() == "0.31.1", "soname tail parses as a version");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so") == null, "unversioned soname has no tail");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31rc1") == null, "non-numeric tail is no version");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libcmark.so.0.31.1.2") != null, "four-component tails parse");
+        check(Usm.ResourceRef.version_tail("libcmark.so", "libothercmark.so.0.31.1") == null, "tails must extend the exact base");
+    }
+
+    void test_depends_operator_type_rules() throws Error {
+        var accepted = manifest_from_json(PHASE_MANIFEST.printf(
+            "{ \"runtime\": [\"lib:libcmark.so>=0.30.0\"], \"build\": [\"pc:libcmark.pc==0.31.1\"], \"manage\": [] }"));
+        check(accepted.dependencies.runtime.ordered_required().first().to_string() == "lib:libcmark.so>=0.30.0",
+            "lib dependencies accept operators");
+        check(accepted.dependencies.build.ordered_required().first().to_string() == "pc:libcmark.pc==0.31.1",
+            "pc dependencies accept operators");
+
+        var violations = new Vector<string>();
+        foreach(var rule in new string[] { "bin:python3>=3.8", "tag:family.feature>=1.0", "inc:glib-2.0>=2.0", "vapi:gtk-4.0.vapi~=4.0" }) {
+            try {
+                manifest_from_json(PHASE_MANIFEST.printf(@"{ \"runtime\": [\"$rule\"], \"build\": [], \"manage\": [] }"));
+                violations.add(rule);
+            }
+            catch(Error e) {
+            }
+        }
+        check(violations.count() == 0, @"operators on non-lib/pc dependency types are rejected ($(violations.to_string(s => s, ", ")))");
+    }
+
+    void test_provides_declaration_rules() throws Error {
+        var declared = manifest_from_json("""
+        {
+          "name": "cmark-lib",
+          "version": "0.31.1",
+          "summary": "cmark",
+          "licences": [],
+          "flags": [],
+          "provides": { "pc:cmark.pc==0.31.1": "as-expected", "lib:libcmark.so.0.31.1": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """);
+        check(declared.provides.count() == 2, "pc == declarations and concrete lib provides parse");
+
+        var violations = new Vector<string>();
+        foreach(var rule in new string[] {
+            "\"lib:libcmark.so.0.31.1==0.31.1\"",
+            "\"pc:cmark.pc>=0.31.1\"",
+            "\"bin:cmark==1.0\""
+        }) {
+            try {
+                manifest_from_json(@"{ \"name\": \"x\", \"version\": \"1.0.0\", \"summary\": \"x\", \"licences\": [], \"flags\": [], \"provides\": { $rule: \"as-expected\" }, \"depends\": { \"runtime\": [], \"build\": [], \"manage\": [] }, \"execs\": {} }");
+                violations.add(rule);
+            }
+            catch(Error e) {
+            }
+        }
+        check(violations.count() == 0, @"only pc provides may carry == declarations ($(violations.to_string(s => s, ", ")))");
+    }
+
+    void test_satisfied_by_constraints() throws Error {
+        var floor = new Usm.ResourceRef("lib:libcmark.so>=0.30.0");
+        check(floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "floor matches a newer soname tail");
+        check(floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.30.0")), "floor matches the boundary tail");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.29.9")), "floor rejects an older tail");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libcmark.so")), "floor rejects an unversioned name");
+        check(!floor.satisfied_by(new Usm.ResourceRef("lib:libother.so.0.31.1")), "floor requires the same base name");
+        check(floor.satisfied_by(new Usm.ResourceRef("canonlib:libcmark.so.0.31.1")), "floor accepts a canonlib provider");
+        check(!floor.satisfied_by(new Usm.ResourceRef("pc:libcmark.pc")), "floor does not cross resource types");
+
+        var family = new Usm.ResourceRef("lib:libcmark.so~=0.31.0");
+        check(family.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "family matches within the minor");
+        check(!family.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.32.0")), "family rejects the next minor");
+
+        var plain = new Usm.ResourceRef("lib:libcmark.so.0.31.1");
+        check(plain.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.31.1")), "plain dep matches the exact provide");
+        check(!plain.satisfied_by(new Usm.ResourceRef("lib:libcmark.so.0.30.0")), "plain dep still requires exactness");
+        check(plain.satisfied_by(new Usm.ResourceRef("canonlib:libcmark.so.0.31.1")), "plain dep accepts canonlib providers");
+
+        var plain_pc = new Usm.ResourceRef("pc:cmark.pc");
+        check(plain_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "plain pc dep matches a declared provide");
+        var declared_pc = new Usm.ResourceRef("pc:cmark.pc>=0.30.0");
+        check(declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "pc floor matches a satisfying declaration");
+        check(!declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc==0.29.0")), "pc floor rejects a low declaration");
+        check(!declared_pc.satisfied_by(new Usm.ResourceRef("pc:cmark.pc")), "pc floor cannot be proven by an undeclared provide");
+    }
+
+    void test_manifest_satisfies_pc_fallback() throws Error {
+        var provider = manifest_from_json("""
+        {
+          "name": "cmark-lib",
+          "version": "0.31.1",
+          "summary": "cmark",
+          "licences": [],
+          "flags": [],
+          "provides": { "pc:cmark.pc": "as-expected", "pc:other.pc==0.5.0": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """);
+        check(provider.satisfies(new Usm.ResourceRef("pc:cmark.pc>=0.30.0")), "undeclared pc provide falls back to the package version");
+        check(provider.satisfies(new Usm.ResourceRef("pc:cmark.pc==0.31.1")), "fallback version is the package version");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:cmark.pc>=0.32.0")), "fallback respects the floor");
+        check(provider.satisfies(new Usm.ResourceRef("pc:other.pc>=0.5.0")), "explicit declaration satisfies a floor");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:other.pc>=0.6.0")), "explicit declaration wins over the package version");
+        check(!provider.satisfies(new Usm.ResourceRef("pc:absent.pc>=0.1")), "unrelated names never match");
+    }
+
     // ---- Topological ordering ----------------------------------------------------
     // ---- Topological ordering ----------------------------------------------------
 
 
     /** A package manifest named pkg-<name> providing bin:pkg-<name> with the given runtime refs. */
     /** A package manifest named pkg-<name> providing bin:pkg-<name> with the given runtime refs. */
@@ -493,16 +678,43 @@ for package in known:
 print(json.dumps({"not-found": [r for r in refs if r not in provided], "packages": known}))
 print(json.dumps({"not-found": [r for r in refs if r not in provided], "packages": known}))
 PY
 PY
     ;;
     ;;
+  plan)
+    # Every material package: dependencies first, chosen names after
+    printf '{"packages":['
+    first=1
+    for name in "$@"; do
+      if [ $first -eq 0 ]; then printf ','; fi
+      first=0
+      printf '"dep-of-%s"' "$name"
+    done
+    for name in "$@"; do
+      printf ',"%s"' "$name"
+    done
+    printf ']}\n'
+    ;;
   install)
   install)
     total=$#
     total=$#
     echo "{\"type\":\"begin\",\"total\":$total}"
     echo "{\"type\":\"begin\",\"total\":$total}"
-    n=0
+    # Like dnf: a download phase streaming package events in its own
+    # order, then an rpm transaction completing in a DIFFERENT order
+    # than the plan's — the display must not depend on either matching
     for name in "$@"; do
     for name in "$@"; do
-      n=$((n+1))
-      echo "$name" >> "$USM_SPM_INSTALL_LOG"
-      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$n,\"total\":$total,\"progress\":0.5}"
+      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$RANDOM,\"total\":999,\"progress\":0.4}"
+    done
+    for name in $(printf '%s\n' "$@" | tac); do
+      echo "{\"type\":\"package\",\"name\":\"dep-of-$name\",\"current\":$RANDOM,\"total\":999,\"progress\":0.5}"
+    done
+    n=0
+    for name in $(printf '%s\n' "$@" | tac); do
+      echo "{\"type\":\"package\",\"name\":\"dep-of-$name\",\"current\":$n,\"total\":999,\"progress\":0.9}"
+      echo "{\"type\":\"package-complete\",\"name\":\"dep-of-$name\"}"
+      echo "{\"type\":\"package\",\"name\":\"$name\",\"current\":$n,\"total\":999,\"progress\":0.95}"
       echo "{\"type\":\"package-complete\",\"name\":\"$name\"}"
       echo "{\"type\":\"package-complete\",\"name\":\"$name\"}"
+      echo "$name" >> "$USM_SPM_INSTALL_LOG"
+      n=$((n+1))
     done
     done
+    # A duplicate completion, as dnf emits for repeated members
+    echo "{\"type\":\"package-complete\",\"name\":\"$1\"}"
     echo "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$total}"
     echo "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$total}"
     ;;
     ;;
   *)
   *)
@@ -650,6 +862,144 @@ esac
         Usm.Util.delete_tree(scratch);
         Usm.Util.delete_tree(scratch);
     }
     }
 
 
+    /** Builds a .usmc archive from a complete manifest JSON string. */
+    string make_archive_from_manifest(string scratch, string manifest_json) throws Error {
+        var source = Path.build_filename(scratch, @"src-$(Uuid.string_random())");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), manifest_json);
+        var archive = Path.build_filename(scratch, @"pkg-$(Uuid.string_random()).usmc");
+        Usm.Util.archive(source, archive);
+        return archive;
+    }
+
+    void test_resolver_constrained_lib_dep() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_archive_from_manifest(scratch, """
+        {
+          "name": "usmt-cmark",
+          "version": "0.31.1",
+          "summary": "constrained provider",
+          "licences": [],
+          "flags": [],
+          "provides": { "lib:libusmt-cmark.so.0.31.1": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("cmark-app", "cmark-app", depends_with("runtime", "[\"lib:libusmt-cmark.so>=0.30.0\"]")))));
+        var result = resolver.resolve(roots, null);
+
+        check(result.packages.count() == 2, "a constrained lib dep pulls its USM provider");
+        check(result.install_order.to_string(p => p.manifest.name, ",") == "usmt-cmark,cmark-app",
+            "the provider installs before the dependent");
+        check(result.system_packages.length == 0, "no system packages without an SPM");
+
+        // The same provider does not satisfy a family constraint it breaks
+        var roots_mismatch = new Vector<Usm.AbstractPackage>();
+        roots_mismatch.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("cmark-app2", "cmark-app2", depends_with("runtime", "[\"lib:libusmt-cmark.so~=0.30.0\"]")))));
+        try {
+            resolver.resolve(roots_mismatch, null);
+            check(false, "an unsatisfiable family constraint fails resolution");
+        }
+        catch(Error e) {
+            check(e is Usm.ResolverError, "an unsatisfiable family constraint fails resolution");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_resolver_skips_spm_for_constrained_refs() throws Error {
+        var scratch = make_scratch();
+        var resolver = new Usm.Resolver(new Usm.ResourceFinder());
+        resolver.supply_package(make_archive_from_manifest(scratch, """
+        {
+          "name": "usmt-skip",
+          "version": "1.0.0",
+          "summary": "skip provider",
+          "licences": [],
+          "flags": [],
+          "provides": { "lib:libusmt-skip.so.2.1.0": "as-expected" },
+          "depends": { "runtime": [], "build": [], "manage": [] },
+          "execs": {}
+        }
+        """));
+
+        var roots = new Vector<Usm.AbstractPackage>();
+        roots.add(new Usm.AbstractPackage.from_manifest(manifest_from_json(
+            APP_MANIFEST.printf("skip-app", "skip-app",
+                depends_with("runtime", "[\"lib:libusmt-skip.so>=2.0.0\", \"bin:usmt-plain\"]")))));
+
+        var spm = stub_manager(scratch, spm_candidate("spm-owns-plain", "bin:usmt-plain", 2, 1));
+        var result = resolver.resolve(roots, spm);
+
+        check(result.packages.count() == 2, "the constrained dep resolves from the USM provider");
+        check(result.system_packages.length == 1 && result.system_packages[0].name == "spm-owns-plain",
+            "the plain dep still resolves from the SPM");
+
+        var log = "";
+        FileUtils.get_contents(Path.build_filename(scratch, "query.log"), out log);
+        check(log.contains("usmt-skip.so>=") == false, "the SPM is never queried for the constrained ref");
+        check(log.contains("bin:usmt-plain"), "the SPM is queried for plain refs in the same batch");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_pc_finder_version_constraint() throws Error {
+        var scratch = make_scratch();
+        var pcdir = Path.build_filename(scratch, "pkgconfig");
+        DirUtils.create(pcdir, 0755);
+        FileUtils.set_contents(Path.build_filename(pcdir, "usmt.pc"),
+            "prefix=/usr\n\nName: usmt\nDescription: test\nVersion: 1.2.3\nCflags: -I/usr/include\n");
+
+        var previous = Environment.get_variable("PKG_CONFIG_PATH");
+        Environment.set_variable("PKG_CONFIG_PATH", pcdir, true);
+        var finder = new Usm.ResourceFinder();
+        try {
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc")), "plain pc ref found by existence");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc>=1.0.0")), "floor below the declared version satisfies");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc==1.2.3")), "== the declared version satisfies");
+            check(finder.has_resource(new Usm.ResourceRef("pc:usmt.pc~=1.2.0")), "~= within the declared minor satisfies");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt.pc>=2.0")), "floor above the declared version fails");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt.pc~=1.3.0")), "~= outside the declared minor fails");
+            check(!finder.has_resource(new Usm.ResourceRef("pc:usmt-absent.pc>=1.0")), "absent pc files fail");
+        }
+        finally {
+            if(previous != null) {
+                Environment.set_variable("PKG_CONFIG_PATH", previous, true);
+            }
+            else {
+                Environment.unset_variable("PKG_CONFIG_PATH");
+            }
+        }
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_pc_file_read_version() throws Error {
+        var scratch = make_scratch();
+        var path = Path.build_filename(scratch, "a.pc");
+
+        FileUtils.set_contents(path, "# comment\nName: a\n Version:  2.1.0  \nCflags: -I${prefix}/include\n");
+        var parsed = Usm.PcFile.read_version(path);
+        check(parsed != null && ((!)parsed).to_string() == "2.1.0", "version keyword parsed with surrounding whitespace");
+
+        FileUtils.set_contents(path, "prefix=/usr\nName: a\nVersion: ${pcfiledir}/ver\n");
+        check(Usm.PcFile.read_version(path) == null, "variable-expanding version yields null");
+
+        FileUtils.set_contents(path, "Name: a\nDescription: no version here\n");
+        check(Usm.PcFile.read_version(path) == null, "missing version keyword yields null");
+
+        FileUtils.set_contents(path, "Name: a\nVersion: 1.0rc1\n");
+        check(Usm.PcFile.read_version(path) == null, "non-numeric version yields null");
+
+        check(Usm.PcFile.read_version(Path.build_filename(scratch, "absent.pc")) == null, "unreadable file yields null");
+        Usm.Util.delete_tree(scratch);
+    }
+
     void test_group_selection_cheaper_wins() throws Error {
     void test_group_selection_cheaper_wins() throws Error {
         var scratch = make_scratch();
         var scratch = make_scratch();
         var spm = stub_manager(scratch,
         var spm = stub_manager(scratch,
@@ -763,7 +1113,7 @@ esac
 
 
         string install_log;
         string install_log;
         FileUtils.get_contents(Path.build_filename(scratch, "install.log"), out install_log);
         FileUtils.get_contents(Path.build_filename(scratch, "install.log"), out install_log);
-        check(install_log.split("\n").length == 3 && install_log.has_prefix("one\ntwo"), "install helper received the package names in order");
+        check(install_log.split("\n").length == 3 && install_log.contains("one") && install_log.contains("two"), "install helper received both package names");
 
 
         Usm.Util.delete_tree(scratch);
         Usm.Util.delete_tree(scratch);
     }
     }
@@ -892,6 +1242,154 @@ esac
         Usm.Util.delete_tree(scratch);
         Usm.Util.delete_tree(scratch);
     }
     }
 
 
+    void test_transaction_preinstalled_resources_satisfy_build_deps() throws Error {
+        var scratch = make_scratch();
+        // A package whose build phase needs something nothing on this
+        // machine and nothing in the transaction provides — the exact
+        // shape of a system-package-provided toolchain dependency in a
+        // freshly provisioned container
+        make_build_package_archive(scratch, "s", "pc:lib-lot-s.pc", { "pc:no-such-file-anywhere-7f3.pc" });
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(cache_package(scratch, "s"));
+
+        var blocked = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        try {
+            blocked.strategise();
+            check(false, "a build ref nothing provides still blocks the transaction");
+        }
+        catch(Usm.TransactionError e) {
+            check(e.message.contains("pkg-s") && e.message.contains("pc:no-such-file-anywhere-7f3.pc"),
+                "the failure names the package and the unmet ref");
+        }
+
+        var preinstalled = new HashSet<Usm.ResourceRef>();
+        preinstalled.add(new Usm.ResourceRef("pc:no-such-file-anywhere-7f3.pc"));
+        var provisioned = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            preinstalled_resources = preinstalled,
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        provisioned.strategise();
+
+        check(provisioned.install_lots.length == 1, "preinstalled (system-provided) resources satisfy build-phase refs");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_deadlock_message_details_causes() throws Error {
+        var scratch = make_scratch();
+        // Two packages whose build phases need each other's provides —
+        // unschedulable under the earlier-lot rule for build-phase refs
+        make_build_package_archive(scratch, "x", "pc:lib-cyc-x.pc", { "pc:lib-cyc-y.pc" });
+        make_build_package_archive(scratch, "y", "pc:lib-cyc-y.pc", { "pc:lib-cyc-x.pc" });
+        make_build_package_archive(scratch, "z", "pc:lib-cyc-z.pc", { "pc:also-nowhere-at-all-2c9.pc" });
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        foreach(var name in new string[] { "x", "y", "z" }) {
+            to_install.add(cache_package(scratch, name));
+        }
+
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+        try {
+            transaction.strategise();
+            check(false, "the mutual build dependency cannot be scheduled");
+        }
+        catch(Usm.TransactionError e) {
+            var message = e.message;
+            check(message.contains("3 package(s) could not be scheduled"), "the headline counts the stuck packages");
+            check(message.contains("pkg-x") && message.contains("pkg-y") && message.contains("pkg-z"), "every stuck package is named");
+            check(message.contains("pc:lib-cyc-y.pc (build phase) is part of a dependency cycle") && message.contains("pkg-y"),
+                "the cyclic ref names the ref, its phase and its stuck provider");
+            check(message.contains("pc:also-nowhere-at-all-2c9.pc (build phase) is unmet"), "the unmet ref names the ref and its phase");
+        }
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_transaction_installs_system_packages() throws Error {
+        var scratch = make_scratch();
+        var spm = stub_manager(scratch, "");
+
+        var candidates = new Vector<Usm.SystemPackageCandidate>();
+        foreach(var name in new string[] { "one", "two" }) {
+            var candidate = new Usm.SystemPackageCandidate();
+            candidate.name = name;
+            candidate.resources = new Vector<Usm.ResourceRef>();
+            candidates.add(candidate);
+        }
+
+        // The material set the stub's plan mode reports: dependencies
+        // first, chosen names after
+        var chosen_names = new Vector<string>();
+        chosen_names.add("one");
+        chosen_names.add("two");
+        var plan = spm.plan_install(chosen_names);
+        check(plan.length == 4 && plan[0] == "dep-of-one" && plan[3] == "two", "plan_install returns the material set in helper order");
+
+        var paths = new Usm.Paths.defaults();
+        paths.destination = scratch;
+
+        var transaction = new Usm.Transaction() {
+            paths = paths,
+            resource_finder = new Usm.ResourceFinder(),
+            system_package_manager = spm,
+            system_packages = candidates,
+            system_install_plan = plan,
+            to_install = new HashSet<Usm.CachedPackage>(),
+            to_remove = new HashSet<Usm.CachedPackage>()
+        };
+
+        var events = new Vector<string>();
+        uint last_total = 0;
+        transaction.progress_updated.connect((task, subject, index, total, progress) => {
+            var kind = task == TransactionTask.INSTALLING_SYSTEM ? "sys" : task == TransactionTask.STRATEGISING ? "plan" : "other";
+            events.add(@"$kind|$(subject ?? "")|$index|$total");
+            last_total = total;
+        });
+
+        transaction.strategise();
+        check(last_total == 5, "the task count covers every material system package");
+
+        transaction.run();
+
+        // Completions claim slots IN ARRIVAL ORDER: the stub completes
+        // the plan set in a different order than the plan lists it, and
+        // its download events stream in yet another order with nonsense
+        // positions — the actions must follow the real install sequence
+        check(events.any(e => e.has_prefix("sys|dep-of-two|1|5")), "the first completion claims the first slot");
+        check(events.any(e => e.has_prefix("sys|two|2|5")), "the second completion claims the second slot");
+        check(events.any(e => e.has_prefix("sys|dep-of-one|3|5")), "the third completion claims the third slot");
+        check(events.any(e => e.has_prefix("sys|one|4|5")), "the fourth completion claims the fourth slot");
+        check(!events.any(e => e.has_prefix("other|")), "no USM action runs when the transaction holds only system packages");
+
+        string install_log;
+        FileUtils.get_contents(Path.build_filename(scratch, "install.log"), out install_log);
+        check(install_log.split("\n").length == 3 && install_log.contains("one") && install_log.contains("two"), "the native helper received both chosen names, dependencies excluded");
+
+        var summary = new Usm.TransactionSummary() {
+            system_installs = candidates,
+            system_dependencies = plan.where(n => n != "one" && n != "two").to_vector()
+        };
+        var plan_preview = summary.describe(false);
+        check(plan_preview.contains("install    one (system package)") && plan_preview.contains("install    two (system package)"), "the plan preview lists the chosen system package installs");
+        check(plan_preview.contains("install    dep-of-one (system dependency)") && plan_preview.contains("install    dep-of-two (system dependency)"), "the plan preview lists the material dependencies");
+        check(!summary.is_empty(), "system installs alone are a non-empty plan");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
     // ---- rebuildDependants: lookup, planning, dedup ------------------------------
     // ---- rebuildDependants: lookup, planning, dedup ------------------------------
 
 
     /** Manifest for a package named <name> at <version> providing one key with the given flags and depends. */
     /** Manifest for a package named <name> at <version> providing one key with the given flags and depends. */
@@ -1743,8 +2241,18 @@ echo "Installing libmeson-pkg.so to /usr/lib"
         }
         }
 
 
         try {
         try {
+            test_version_constraint_semantics();
+            test_resource_ref_operator_parsing();
+            test_depends_operator_type_rules();
+            test_provides_declaration_rules();
+            test_satisfied_by_constraints();
+            test_manifest_satisfies_pc_fallback();
             test_resolve_diamond_usm_only();
             test_resolve_diamond_usm_only();
             test_resolve_spm_before_usm();
             test_resolve_spm_before_usm();
+            test_resolver_constrained_lib_dep();
+            test_resolver_skips_spm_for_constrained_refs();
+            test_pc_finder_version_constraint();
+            test_pc_file_read_version();
             test_group_selection_cheaper_wins();
             test_group_selection_cheaper_wins();
             test_group_selection_tie_manifest_order();
             test_group_selection_tie_manifest_order();
             test_group_selection_none_viable_itemised();
             test_group_selection_none_viable_itemised();
@@ -1753,6 +2261,9 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             test_transaction_accepts_orders();
             test_transaction_accepts_orders();
             test_transaction_lots_split_at_build_boundaries();
             test_transaction_lots_split_at_build_boundaries();
             test_transaction_manage_deps_admit_same_lot();
             test_transaction_manage_deps_admit_same_lot();
+            test_transaction_preinstalled_resources_satisfy_build_deps();
+            test_transaction_deadlock_message_details_causes();
+            test_transaction_installs_system_packages();
         }
         }
         catch(Error e) {
         catch(Error e) {
             failures++;
             failures++;

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff