do not edit — generated by btf.
git.druid.rocksindexdruid520mpsrc/mplib/resolve.pm

src/mplib/resolve.pm


use v5.16;
package mplib::resolve;
use strict;
use warnings FATAL => 'all';
use Exporter 'import';
use mplib::config qw($CFG load load_overlay read_conf set_pkgconf);
use mplib::util qw(note fail ok try_soft bare_name slot_key split_slot_key backend pconf_lookup pconf_section order_manifest delta_or_literal spin_start spin_tick spin_stop);
use mplib::version qw(vercmp ver_satisfies parse_dep_token);
use mplib::portformat qw(resolve_port write_phase_scripts parse_pkg_use enabled_use apply_use_patches resolve_slot USE_NONE);
use mplib::db qw(read_lines read_db write_db write_manifest read_snapshot diff_snapshot find_file_conflict
    write_linkdeps_for linkdeps_users read_subslot_deps write_subslot_deps_for db_lock db_unlock
    canon_lock canon_unlock);
use mplib::hooks qw(port_env_args run_hook);
 
our @EXPORT_OK = qw(
	all_ports find_portdir try_pkgconf pkgconf
	expand_meta expand_set
	effective_deps tag_providers tag_satisfied portpin
	check_static_conflicts soft_conflict_targets reachable_needed_set effective_conflicts held_reason version_held_mismatch masked_reason keyword_reason pconf_lookup
	use_requirement_mismatches apply_use_requirement
	resolve_dep_name resolve_dep pick_provider interactive_pick resolved_db_key
	resolved_slotted_deps dep_slot_prefixes with_sysroot_scope apply_patch_manifest
	record_subslot_dep subslot_changed_targets
	install_pkg remove_pkg reinstall_pkg clean_pkg sync_ports init
	plan_resolution
);
 
# every mutating command needs the working dir/prefix skeleton/ports tree
# to exist first; read-only queries (list/search/info/...) call this too
# so they work identically on a freshly-bootstrapped system.
sub init {
	unless(-d $CFG->{WD})
	{
		system('mkdir', '-p', $CFG->{WD}) == 0 or fail("failed making $CFG->{WD}");
	}
	for my $sub (qw(bin sbin lib libexec include share))
	{
		my $d = "$CFG->{INSTPREFIX}/$sub";
		unless(-d $d)
		{
			system('mkdir', '-p', $d) == 0 or fail("failed making $d");
		}
	}
	# same reasoning as sync_ports' own spinner just below in this file:
	# a tick per repo about to be cloned, not a live animation during
	# any one clone -- git's own clone output takes over the moment it
	# actually starts, same as a pull's.
	spin_start("syncing ports tree...");
	for my $r (@{$CFG->{REPOS}})
	{
		next if -d $r->{dir};
		spin_tick();
		note("repo '$r->{name}' uninitialized, cloning");
		my @cmd = ('git', 'clone');
		push @cmd, '-b', $r->{branch} if defined $r->{branch} && $r->{branch} ne '';
		push @cmd, $r->{url}, $r->{dir};
		system(@cmd) == 0
			or fail("failed cloning $r->{url} to $r->{dir}");
	}
	spin_stop();
	unless(-f $CFG->{DBFILE})
	{
		my $fh;
		open($fh, '>', $CFG->{DBFILE}) or fail("failed making $CFG->{DBFILE}");
		close($fh);
	}
	return;
}
 
my $TAGMAP;
my $ALLPORTS;
 
sub all_ports {
	my @out;
	my %seen;
 
	return @$ALLPORTS if $ALLPORTS;
	for my $root ($CFG->{CUSTOM_PORTS}, map { $_->{ports} } @{$CFG->{REPOS}})
	{
		my $dh;
 
		next unless -d $root && opendir($dh, $root);
		my @cats = sort grep { !/^\./ && -d "$root/$_" } readdir($dh);
		closedir($dh);
		for my $cat (@cats)
		{
			my $cdh;
			next unless opendir($cdh, "$root/$cat");
			my @names = sort grep { !/^\./ && -d "$root/$cat/$_" } readdir($cdh);
			closedir($cdh);
			for my $name (@names)
			{
				next if $seen{"$cat/$name"};
				$seen{"$cat/$name"} = 1;
				push @out, { cat => $cat, name => $name, dir => "$root/$cat/$name" };
			}
		}
	}
	$ALLPORTS = \@out;
	return @out;
}
 
# same category/name double-readdir scan all_ports() itself does, but
# rooted at exactly ONE directory instead of CUSTOM_PORTS plus every
# configured repo -- and deliberately NOT cached the way all_ports()'s
# own $ALLPORTS is: a repo-qualified lookup (find_portdir's own third
# arg, below) is rare/deliberate, not a hot path worth the cache
# invalidation complexity a second, root-keyed cache would add.
sub _ports_in_root {
	my ($root) = @_;
	my @out;
 
	return @out unless -d $root && opendir(my $dh, $root);
	my @cats = sort grep { !/^\./ && -d "$root/$_" } readdir($dh);
	closedir($dh);
	for my $cat (@cats)
	{
		next unless opendir(my $cdh, "$root/$cat");
		my @names = sort grep { !/^\./ && -d "$root/$cat/$_" } readdir($cdh);
		closedir($cdh);
		push @out, { cat => $cat, name => $_, dir => "$root/$cat/$_" } for @names;
	}
	return @out;
}
 
# the shared "resolve $name (+ optional $wantslot) against this list of
# port entries" logic find_portdir itself needs twice: once against the
# default, cross-repo, deduped all_ports() list, and once (for a
# repo-qualified lookup) against a single repo's own _ports_in_root(),
# fresh and undeduped. identical resolution order either way: an exact
# directory-name match first (skipped entirely when a slot was
# requested, same reasoning as before this was split out -- a directory
# literally named $name has no slot of its own to check against), else
# fall back to scanning declared pkg_name/pkg_slot across every entry
# in the list.
sub _resolve_in_list {
	my ($entries, $name, $wantslot) = @_;
 
	unless(defined $wantslot && $wantslot ne '')
	{
		for my $p (@$entries)
		{
			return $p->{dir} if $p->{name} eq $name;
		}
	}
	my @matches;
	for my $p (@$entries)
	{
		my $pc = try_pkgconf($p->{dir});
		next unless $pc && $pc->{pkg_name} eq $name;
		# a pkg_slot_use port's effective slot depends on which USE flags
		# are enabled for its bare name, not just its static pkg_slot (see
		# mplib::portformat::resolve_slot) -- composing it here lets a
		# dep reference like "curl:nossl" find curl's one port directory
		# even though "nossl" never appears literally in its pkg.conf.
		push @matches, { dir => $p->{dir}, slot => (resolve_slot($pc, $pc->{pkg_name}) || '0'), pref => ($pc->{pkg_pref} || 50) };
	}
	return undef unless @matches;
	if(defined $wantslot && $wantslot ne '')
	{
		for my $m (@matches) { return $m->{dir} if $m->{slot} eq $wantslot; }
		return undef;
	}
	my @sorted = sort { $a->{pref} <=> $b->{pref} } @matches;
	return $sorted[0]{dir};
}
 
# $wantrepo (optional, third arg -- parse_dep_token's own "::repo"
# suffix, see mplib::version): restricts the search to exactly the
# named configured repo instead of the normal cross-repo priority
# search, for a dependency atom (or CLI token) that deliberately wants
# one specific repo's copy rather than whichever wins the usual
# priority order. CUSTOM_PORTS_DIR still applies unconditionally either
# way -- it's documented as "highest priority of all, for every port
# lookup" (mp.conf.example), an entirely separate axis (the user's own
# local override) from which of several CONFIGURED repos a name should
# resolve against, so a repo qualifier narrowing the latter has no
# business overriding the former.
#
# the "cat/name" branch just below DOES honor $wantrepo when called
# directly (and is exercised that way, deliberately, by this function's
# own test coverage) -- but parse_dep_token itself has no "/" in ANY of
# its character classes (a cat/name-shaped token is treated as one
# opaque, entirely unparsed literal name -- see its own deptok_
# unparsable_slash test), so "cat/name::repo" typed on the command line
# or in a pkg_deps= entry never actually reaches here with $wantrepo
# populated at all. only the bare-name form (the overwhelmingly common
# case for a real dependency atom) is reachable with a repo qualifier
# today; closing the cat/name gap too would mean teaching
# parse_dep_token to parse "/" specifically for this one shape, without
# disturbing find_portdir's own existing, separate "cat/name" splitting
# -- left as a followup, not attempted here.
sub find_portdir {
	my ($name, $wantslot, $wantrepo) = @_;
 
	if($name =~ m{^([^/]+)/([^/]+)$})
	{
		my $custom = "$CFG->{CUSTOM_PORTS}/$1/$2";
		return $custom if -d $custom;
		if(defined $wantrepo && $wantrepo ne '')
		{
			my ($r) = grep { $_->{name} eq $wantrepo } @{$CFG->{REPOS}};
			return undef unless $r;
			my $cand = "$r->{ports}/$1/$2";
			return -d $cand ? $cand : undef;
		}
		for my $r (@{$CFG->{REPOS}})
		{
			my $cand = "$r->{ports}/$1/$2";
			return $cand if -d $cand;
		}
		return undef;
	}
	if(defined $wantrepo && $wantrepo ne '')
	{
		my ($r) = grep { $_->{name} eq $wantrepo } @{$CFG->{REPOS}};
		return undef unless $r;
		return _resolve_in_list([ _ports_in_root($r->{ports}) ], $name, $wantslot);
	}
	# no directory is literally named $name (or a specific slot was asked
	# for): a slotted port's directory need not match its pkg_name (e.g.
	# compilers/gcc-12, compilers/gcc-13 both declaring pkg_name="gcc"),
	# so fall back to scanning declared pkg_name/pkg_slot across the tree.
	return _resolve_in_list([ all_ports() ], $name, $wantslot);
}
 
sub try_pkgconf {
	my ($portdir) = @_;
	return resolve_port($portdir);
}
 
sub pkgconf {
	my ($portdir) = @_;
	my $out = try_pkgconf($portdir);
 
	fail("failed reading pkg.conf for $portdir") unless $out;
	return $out;
}
 
sub expand_meta {
	my ($name, $db, $seen) = @_;
	my $bare = bare_name($name);
 
	if($bare eq 'world')
	{
		return sort keys %$db;
	}
	if($bare eq 'all')
	{
		return map { $_->{name} } all_ports();
	}
	if($bare =~ /^@(.+)$/)
	{
		return expand_set($1, $db, $seen || {});
	}
	return ($name);
}
 
# a user-makable package set: plain text, one name/@nested-set/world/all
# per line, at $WD/sets/<setname>.set -- no dedicated command needed to
# make one, just write the file, matching how per-package config sections
# already work.
sub expand_set {
	my ($setname, $db, $seen) = @_;
 
	if($seen->{$setname}++)
	{
		fail("set \@$setname is self-referential");
	}
	my @lines = read_lines("$CFG->{WD}/sets/$setname.set");
	fail("set \@$setname not found ($CFG->{WD}/sets/$setname.set)") unless @lines;
	my @out;
	for my $line (@lines)
	{
		next unless $line =~ /\S/;
		next if $line =~ /^\s*#/;
		push @out, expand_meta($line, $db, $seen);
	}
	# clear this set's mark once its own expansion is done, not just on a
	# true cycle: $seen only needs to track sets currently on the path
	# from the top-level call down to here, so a second, non-cyclic
	# reference to the same set from a sibling branch (a diamond, e.g.
	# two sets both including @common) is legitimate and shouldn't be
	# mistaken for self-reference just because $common was already fully
	# expanded and returned from earlier.
	delete $seen->{$setname};
	return @out;
}
 
# a pkg_conflicts entry is written against a port's bare name (slots
# don't exist yet at port-authoring time -- the same reason pconf_lookup
# falls back from a slotted canon to its bare name), so it must match a
# db key regardless of which slot suffix that key carries; an entry that
# DOES carry an explicit ":slot" of its own (a deliberate pin to one
# specific slot, not the common case) is the one exception, matched
# exactly rather than falling back. without this, a conflict declared
# the natural bare way is silently never detected against any slotted
# install of the target.
sub _conflict_matches {
	my ($token, $key) = @_;
	return 1 if $token eq $key;
	return 0 if $token =~ /:/;
	my ($base) = split_slot_key($key);
	return $token eq $base;
}
 
# CONFLICTS= (mp.conf, per-package only -- bare_name('.conflicts) fallback
# like every other per-package key): an OPTIONAL user override on top of
# a port's own declared pkg_conflicts, same +/- delta convention as
# USE=/"mp use" -- a bare list REPLACES the declared conflicts outright,
# a +token/-token delta is applied atop them instead ("+!!other-libc"
# adds a hard blocker the port itself never declared, "-!somepkg" lifts
# a soft one it did -- the token after +/- is matched verbatim against a
# declared entry, '!'/'!!' markers included, so removing a soft entry
# needs the same leading '!' the declaration itself used). no global
# variant: a conflict is inherently a relationship between two SPECIFIC
# packages, not a system-wide toggle like a USE flag -- a global
# CONFLICTS= would silently blocker-ize every OTHER port's coexistence
# with whatever it names, a far larger blast radius than intended. no
# dedicated command persists this today -- hand-edit mp.conf.
sub effective_conflicts {
	my ($canon, $pc) = @_;
	my $declared = $pc->{pkg_conflicts} || '';
	my $raw = $canon ? pconf_lookup($canon, 'CONFLICTS') : undef;
	return $declared unless defined $raw;
	return '' if $raw eq USE_NONE;
	return delta_or_literal($raw, $declared, "CONFLICTS for '$canon'");
}
 
# a pkg_conflicts entry may be "pkg" or "!pkg" (soft blocker: warns but
# does not block -- %tag resolution already tries a conflict-free
# provider automatically, see pick_provider) or "!!pkg" (hard blocker:
# always blocks, e.g. two libc slots that genuinely cannot coexist).
sub check_static_conflicts {
	my ($name, $pc, $db) = @_;
 
	for my $c (split ' ', effective_conflicts($name, $pc))
	{
		my $hard = 1;
		if($c =~ /^!!(.+)$/)   { $hard = 1; $c = $1; }
		elsif($c =~ /^!(.+)$/) { $hard = 0; $c = $1; }
		my ($match) = grep { _conflict_matches($c, $_) } keys %$db;
		next unless defined $match;
		if($hard) { return $match; }
		note("$name softly conflicts with installed $match");
	}
	for my $installed (sort keys %$db)
	{
		# the slot must be threaded through to find_portdir too, not just
		# the bare name -- a statically multi-directory slotted port
		# (e.g. compilers/gcc-12 and compilers/gcc-13, both declaring
		# pkg_name="gcc") would otherwise resolve to whichever directory
		# has the lowest pkg_pref (find_portdir's own tie-break for "no
		# slot requested"), not the specific slot actually installed --
		# silently checking the WRONG variant's pkg_conflicts against
		# $name, either missing a real conflict the installed slot
		# declares or attributing one from a different, not-installed
		# slot.
		my ($ibase, $islot) = split_slot_key($installed);
		my $idir = find_portdir($ibase, $islot);
		next unless $idir;
		my $ipc = try_pkgconf($idir);
		next unless $ipc;
		for my $c (split ' ', effective_conflicts($installed, $ipc))
		{
			my $hard = 1;
			if($c =~ /^!!(.+)$/)   { $hard = 1; $c = $1; }
			elsif($c =~ /^!(.+)$/) { $hard = 0; $c = $1; }
			next unless _conflict_matches($c, $name);
			if($hard) { return $installed; }
			note("installed $installed softly conflicts with $name");
		}
	}
	return undef;
}
 
# every currently-installed package a soft ("!", not "!!") conflict
# actually matches, in both directions -- the exact same pairs
# check_static_conflicts itself discovers above but only ever note()s,
# never returns (a hard conflict there aborts/backtracks instead, so
# nothing else needed the list until now). kept as its own separate
# function rather than reshaping check_static_conflicts' own return
# value -- that one has a second caller (pick_provider, backtracking
# among %tag candidates) relying on its existing plain truthy/falsy
# scalar return, not worth the regression risk of changing its shape
# for this one new use. install_pkg_body is the caller: a soft conflict
# with something genuinely unneeded (mplib::resolve::reachable_needed_
# set says nothing reaches it) gets auto-removed as part of the same
# install, same spirit as a real transactional weak-blocker resolution;
# one still needed escalates to a hard failure instead of silently
# letting both coexist -- see install_pkg_body's own comment for why
# that split, not "always remove" or "always just warn", is the
# actual improvement here.
sub soft_conflict_targets {
	my ($name, $pc, $db) = @_;
	my @out;
 
	for my $c (split ' ', effective_conflicts($name, $pc))
	{
		next if $c =~ /^!!/;
		next unless $c =~ s/^!//;
		my ($match) = grep { _conflict_matches($c, $_) } keys %$db;
		push @out, $match if defined $match;
	}
	for my $installed (sort keys %$db)
	{
		# see check_static_conflicts' own identical comment just above --
		# the slot must be threaded through find_portdir here too, for
		# the identical reason.
		my ($ibase, $islot) = split_slot_key($installed);
		my $idir = find_portdir($ibase, $islot);
		next unless $idir;
		my $ipc = try_pkgconf($idir);
		next unless $ipc;
		for my $c (split ' ', effective_conflicts($installed, $ipc))
		{
			next if $c =~ /^!!/;
			next unless $c =~ s/^!//;
			next unless _conflict_matches($c, $name);
			push @out, $installed;
		}
	}
	my %seen;
	return grep { !$seen{$_}++ } @out;
}
 
# the transitive RUNTIME-dependency closure over every explicitly
# requested package -- "still needed" in the exact sense mp prune
# itself uses to decide what's safe to remove. shared between prune.pl
# (which used to duplicate this exact loop inline) and
# soft_conflict_targets' own caller, install_pkg_body -- one algorithm,
# not two copies quietly drifting apart over time.
#
# $tick (optional, second arg): called once per queue item visited --
# prune.pl's own whole-installed-db walk can be slow enough to want its
# existing "resolving dependencies..." spinner (mplib::util::spin_tick)
# ticking here same as before this was split out; install_pkg_body's
# own use (checking a soft conflict, typically a handful of packages)
# has no such need and simply omits it.
sub reachable_needed_set {
	my ($db, $tick) = @_;
	my %need;
	my @queue;
 
	for my $name (keys %$db)
	{
		if($db->{$name}{requested})
		{
			$need{$name} = 1;
			push @queue, $name;
		}
	}
	while(my $name = shift @queue)
	{
		$tick->() if $tick;
		my ($base, $slot) = split_slot_key($name);
		my $dir = find_portdir($base, $slot);
		next unless $dir;
		my $pc = try_pkgconf($dir);
		next unless $pc;
		for my $d (effective_deps($pc, $name, 'runtime'))
		{
			my $target = resolve_dep_name($d, $name, $db);
			next unless defined $target;
			my $t = resolved_db_key($target, $db);
			next unless defined $t;
			next if $need{$t};
			$need{$t} = 1;
			push @queue, $t;
		}
	}
	return \%need;
}
 
# every flag in $use ({flag=>1|0,...}, mplib::version::parse_dep_token's
# own atom-bracket parse -- 1 means required enabled, 0 required
# disabled) that $enabled ({flag=>1,...}, mplib::portformat::
# enabled_use's own return -- present+truthy means enabled, ABSENT
# means disabled, there is no explicit "0" entry) does NOT currently
# satisfy. empty if every requirement already holds.
sub use_requirement_mismatches {
	my ($enabled, $use) = @_;
	return grep { ($use->{$_} ? 1 : 0) != ($enabled->{$_} ? 1 : 0) } keys %$use;
}
 
# tracks, for the lifetime of this ONE mp process, every USE-conditional
# atom requirement actually applied so far ("$canon\0$flag" => 0|1) --
# lets a SECOND dependency atom (from a different consumer, or the same
# one twice) requiring the OPPOSITE state on the same not-yet-installed
# package's same flag fail with a clear, immediate error naming exactly
# what disagrees, instead of the second requirement silently winning (or
# losing) with no indication anything was ever contested. resets
# implicitly every run (mp is a fresh process per invocation) -- nothing
# here needs to survive beyond one command.
my %USE_REQUIRED_THIS_RUN;
 
# applies a USE-conditional atom's requirement ($use, mplib::version::
# parse_dep_token's own bracket parse, e.g. from "somelib[ssl,-static]")
# to $canon (the BARE name -- USE= is always persisted/read under the
# bare name regardless of slot, the same convention every other
# per-package USE mechanism already follows; see use.pl's own identical
# reasoning) before it gets installed or rebuilt: a flag already in the
# required state is left alone; a genuine change gets persisted via
# set_pkgconf so the upcoming build actually picks it up, preserving
# every OTHER currently-enabled flag exactly as it was (never a blanket
# "just these flags, drop everything else").
#
# a SECOND, contradictory requirement on the same canon+flag within
# this same run is a hard failure, immediately, naming the flag and
# both values -- silently picking one (first-wins, or worse, last-wins)
# would build $canon in a way that quietly breaks whichever consumer
# didn't get its way, with no indication anything was ever contested.
# deliberately NOT PMS's own fuller autounmask-style resolution
# (computing and prompting a config change across the whole graph) --
# a clear, immediate error naming exactly what disagrees is simpler,
# fully deterministic, and arguably better UX for a conflict a human
# still has to resolve either way, for a project this size.
#
# %USE_REQUIRED_THIS_RUN's OWN practical reach is narrower than "any
# two consumers disagreeing," worth being honest about: mp resolves
# pkg_deps= sequentially, one token at a time, and install_pkg_body
# only returns (writing $canon into $db) once a real build has fully
# finished -- so two consumers naming the SAME not-yet-installed target
# almost always have the first one complete, and $db populated, before
# the second is ever looked at, which resolve_dep's own separate
# "already installed, wrong USE state" check (see its own comment)
# catches instead, with a different message. this tracking hash
# specifically guards the narrower remaining window: a requirement
# recorded here by an attempt that itself never reached $db at all (a
# soft dependency that failed and was skipped after already calling
# this, most plausibly) followed by a later, different, non-soft
# consumer wanting the opposite state on that same still-not-installed
# target. verified directly (unit-level, not via a full mp install) for
# exactly this reason -- see test/suites/resolution.sh's own comment on
# its "sequential conflicting requirements" test for the reachable,
# end-to-end case instead.
sub apply_use_requirement {
	my ($canon, $use, $pc) = @_;
 
	for my $flag (keys %$use)
	{
		my $key = "$canon\x00$flag";
		if(exists $USE_REQUIRED_THIS_RUN{$key} && $USE_REQUIRED_THIS_RUN{$key} != $use->{$flag})
		{
			fail("conflicting USE requirements on '$canon': flag '$flag' required "
			    . ($use->{$flag} ? 'enabled' : 'disabled') . " by one dependency atom, "
			    . ($USE_REQUIRED_THIS_RUN{$key} ? 'enabled' : 'disabled') . " by another this same run");
		}
		$USE_REQUIRED_THIS_RUN{$key} = $use->{$flag};
	}
	my $declared = parse_pkg_use($pc->{pkg_use});
	my $enabled = enabled_use($canon, $declared);
	my @mismatch = use_requirement_mismatches($enabled, $use);
	return unless @mismatch;
	my %want = %$enabled;
	$want{$_} = $use->{$_} for keys %$use;
	my $result = join(' ', sort grep { $want{$_} } keys %want);
	note("setting USE for '$canon' to satisfy a dependency atom's own requirement: "
	    . join(',', map { ($use->{$_} ? '' : '-') . $_ } @mismatch));
	set_pkgconf($canon, 'USE', $result eq '' ? USE_NONE : $result);
	return;
}
 
sub tag_providers {
	my %providers;
 
	return $TAGMAP if $TAGMAP;
	for my $p (all_ports())
	{
		my $pc = try_pkgconf($p->{dir});
		next unless $pc;
		for my $tag (split ' ', $pc->{pkg_tags} || '')
		{
			push @{$providers{$tag}}, { name => $p->{name}, pref => ($pc->{pkg_pref} || 50) };
		}
	}
	$TAGMAP = \%providers;
	return $TAGMAP;
}
 
# which installed db key (if any) currently declares $tag -- $name is a
# raw db key ("gcc:12", "curl:nossl", or a bare "openssl"), and
# find_portdir needs the slot split off and passed separately (it matches
# by declared pkg_name/pkg_slot, not by the compound canon string), or it
# never finds a slotted installed package's directory at all, silently
# reporting the tag as unsatisfied even though it genuinely is.
sub installed_tag_provider {
	my ($tag, $db) = @_;
 
	for my $name (sort keys %$db)
	{
		my ($base, $slot) = split_slot_key($name);
		my $dir = find_portdir($base, $slot);
		next unless $dir;
		my $pc = try_pkgconf($dir);
		next unless $pc;
		return $name if grep { $_ eq $tag } split ' ', $pc->{pkg_tags} || '';
	}
	return undef;
}
 
sub tag_satisfied {
	my ($tag, $db) = @_;
	return defined(installed_tag_provider($tag, $db)) ? 1 : 0;
}
 
sub portpin {
	my ($cur, $tag) = @_;
 
	if($cur)
	{
		for my $key ("TARGET_$tag", "TAG_$tag")
		{
			# pconf_lookup (not a bare $CFG->{pconf}{$cur} exact-canon
			# check): a pkg_slot_use-composed canon (e.g.
			# "pinvariant:variant") doesn't exist yet at the moment a
			# per-package TARGET_/TAG_ pin is written, so a pin declared
			# under the bare name was silently invisible to a slotted
			# package's own %tag resolution, falling through to
			# pkg_pref auto-pick instead of honoring the pin.
			my $val = pconf_lookup($cur, $key);
			return $val if defined $val && $val ne '';
		}
	}
	for my $key ("TARGET_$tag", "TAG_$tag")
	{
		if(defined $CFG->{conf}{$key} && $CFG->{conf}{$key} ne '')
		{
			return $CFG->{conf}{$key};
		}
	}
	return undef;
}
 
# effective dependency list for a package after config modification.
sub effective_deps {
	my ($pc, $canon, $mode) = @_;
	$mode ||= 'all';
	# pkg_deps is legacy shorthand for "needed both to build and at
	# runtime"; pkg_bdepend/pkg_rdepend split the two. 'all' (install
	# time, needs everything) includes both; 'runtime' (mp.prune's "is
	# this still needed" closure) drops bdepend-only deps.
	my @deps = split ' ', ($pc->{pkg_deps} || '');
	push @deps, split ' ', ($pc->{pkg_rdepend} || '');
	push @deps, split ' ', ($pc->{pkg_bdepend} || '') if $mode eq 'all';
	# enabled USE flags add their declared deps (optional features); a
	# flag marked "b:" (build-only) is skipped in runtime mode. collected
	# before the DEPS/DEPS+/DEPS- override block below (not after), so a
	# use-flag-contributed dep is just as overridable as a plain pkg_deps
	# one -- appending it afterward let it silently bypass DEPS- entirely
	# (DEPS-=<tag> could never remove it) and made DEPS= (meant as a full
	# override of the dependency list) not actually override it either.
	my $use = parse_pkg_use($pc->{pkg_use});
	my $en = $canon ? enabled_use($canon, $use) : {};
	for my $f (sort keys %$en)
	{
		next unless $use->{$f};
		next if $mode ne 'all' && $use->{$f}{build};
		push @deps, @{$use->{$f}{deps}};
	}
 
	my %removed;
	my @added;
	# pconf_section (not a bare $CFG->{pconf}{$canon} exact-canon check):
	# same fallback pconf_lookup/portpin already need, since a
	# pkg_slot_use-composed canon doesn't exist yet at the moment a
	# DEPS/DEPS+/DEPS- override is written under a port's bare name.
	my $s = pconf_section($canon);
 
	if(%$s)
	{
		if(defined $s->{DEPS})
		{
			@deps = split ' ', $s->{DEPS};
		}
		if(defined $s->{'DEPS+'})
		{
			push @added, split ' ', $s->{'DEPS+'};
		}
		if(defined $s->{'DEPS-'})
		{
			$removed{$_} = 1 for split ' ', $s->{'DEPS-'};
		}
	}
	@deps = grep { !$removed{$_} } @deps;
	push @deps, @added;
	# soft deps participate in resolution/ordering but never block: if
	# they'd create a cycle or aren't satisfiable, the caller (resolve_dep)
	# just leaves them unresolved rather than dying. generalizes the old
	# hardcoded %libc/%shell/%coreutils bootstrap-triangle exemption below.
	push @deps, map { "?$_" } split ' ', ($pc->{pkg_deps_soft} || '');
	# global deps are appended everywhere except two cases. the no-op
	# logical metas (null/world/all) must stay trivial (they expand to
	# whole sets and can't have deps resolved). and a port providing any
	# of the global %tags is a bootstrap provider: it is exempt from the
	# whole GLOBAL_DEPS line, not just its own tag, so libc/shell/coreutils
	# providers never depend on each other (which would cycle: a shell
	# needs %coreutils to build, coreutils needs %shell). real meta
	# packages (e.g. meta/mp) still get GLOBAL_DEPS like any other port.
	my %noop = map { $_ => 1 } qw(null world all);
	if($CFG->{GLOBAL_DEPS} && !(defined $canon && $noop{bare_name($canon)}))
	{
		my %self = map { $_ => 1 } split ' ', ($pc->{pkg_tags} || '');
		my $bootstrap = 0;
		for my $gd (split ' ', $CFG->{GLOBAL_DEPS})
		{
			my ($tagname) = $gd =~ /^%(.+)$/;
			$bootstrap = 1 if defined $tagname && $self{$tagname};
		}
		unless($bootstrap)
		{
			my %have = map { $_ => 1 } @deps;
			for my $gd (split ' ', $CFG->{GLOBAL_DEPS})
			{
				next if $have{$gd};
				push @deps, $gd;
				$have{$gd} = 1;
			}
		}
	}
	return @deps;
}
 
# hold / version constraint.
sub held_reason {
	my ($name) = @_;
	my $hold = pconf_lookup($name, 'HOLD');
	return (defined $hold && $hold =~ /^(y|yes|1|true)$/i) ? "held by config (HOLD=yes)" : undef;
}
 
sub version_held_mismatch {
	my ($name, $ver) = @_;
	my $hv = pconf_lookup($name, 'HOLD_VERSION');
	return (defined $hv && $hv ne $ver) ? 1 : 0;
}
 
# general masking: MASK=yes in a "<name>:" config section hides a
# candidate from auto-pick and blocks an explicit install, without
# deleting the port -- "mp.install --unmask name" overrides it.
sub masked_reason {
	my ($name) = @_;
	return undef if $CFG->{UNMASK};
	my $mask = pconf_lookup($name, 'MASK');
	return undef unless defined $mask && $mask ne '';
	return undef if $mask =~ /^(n|no|0|false)$/i;
	return "masked by config (MASK=$mask)";
}
 
# keywords gate: pkg_keywords="amd64 arm64 ~riscv" (bare = stable, ~arch =
# testing, absent from the list = unsupported there). ACCEPT_KEYWORDS
# unset (the default) turns the whole check off, so ports that declare no
# pkg_keywords (nearly all of them, today) are never affected.
sub keyword_reason {
	my ($pc) = @_;
	return undef if $CFG->{UNMASK};
	my $accept = $CFG->{conf}{ACCEPT_KEYWORDS};
	return undef unless defined $accept && $accept ne '';
	return undef unless $pc->{pkg_keywords} && $pc->{pkg_keywords} ne '';
	my %kw = map { $_ => 1 } split ' ', $pc->{pkg_keywords};
	for my $a (split ' ', $accept)
	{
		if($a =~ /^~(.+)$/) { return undef if $kw{$a} || $kw{$1}; }
		else                { return undef if $kw{$a}; }
	}
	return "keywords ($pc->{pkg_keywords}) do not satisfy ACCEPT_KEYWORDS ($accept)";
}
 
# try each %tag provider in preference order: prefer one already
# installed outright, else the first whose static conflicts probe clean
# (via try_soft, so a conflicting candidate is skipped rather than
# aborting the whole resolution). this is a real but deliberately simple
# backtracking search over just this one choice point -- not a full SAT
# solver reconsidering earlier choices too.
sub pick_provider {
	my ($sorted, $db) = @_;
	my @cand;
 
	for my $p (@$sorted)
	{
		my $portdir = find_portdir($p->{name});
		next unless $portdir;
		my $pc = try_pkgconf($portdir);
		next unless $pc;
		# resolve_slot (not the raw static pkg_slot) so this canon matches
		# what install_pkg actually computes/installs under for a
		# pkg_slot_use-composed provider -- using the raw pkg_slot here
		# left both the already-installed check below and
		# check_static_conflicts looking under the wrong db key.
		push @cand, { name => $p->{name}, pc => $pc, canon => slot_key($pc->{pkg_name}, resolve_slot($pc, $pc->{pkg_name})) };
	}
	for my $c (@cand)
	{
		return $c->{name} if $db->{$c->{canon}};
	}
	@cand = grep { !masked_reason($_->{canon}) && !keyword_reason($_->{pc}) } @cand;
	for my $c (@cand)
	{
		my $conflict;
		try_soft(sub { $conflict = check_static_conflicts($c->{canon}, $c->{pc}, $db); });
		return $c->{name} unless $conflict;
	}
	return undef;
}
 
# interactive fallback once automatic backtracking exhausts every
# provider without finding a conflict-free one; undef (abort) if there's
# no terminal to prompt on.
sub interactive_pick {
	my ($sorted, $tag, $cur) = @_;
 
	return undef unless -t STDIN;
	print STDERR "no conflict-free provider of %$tag found automatically"
	    . (defined $cur ? " (needed by $cur)" : '') . ":\n";
	for my $i (0 .. $#$sorted)
	{
		print STDERR "  " . ($i + 1) . ") $sorted->[$i]{name} (pref $sorted->[$i]{pref})\n";
	}
	print STDERR "  0) abort\n";
	print STDERR "choice: ";
	my $ans = <STDIN>;
	return undef unless defined $ans;
	chomp $ans;
	return undef if $ans eq '' || $ans eq '0';
	return undef unless $ans =~ /^[0-9]+$/ && $ans >= 1 && $ans <= @$sorted;
	return $sorted->[$ans - 1]{name};
}
 
# resolve a dep token to a concrete port name without installing.
sub resolve_dep_name {
	my ($dep, $cur, $db) = @_;
	# both the "?" soft marker and the "@" subslot-tracking marker (see
	# record_subslot_dep's own comment) are stripped here -- this
	# function's whole job is turning a raw pkg_deps= token into the
	# real dependency NAME it points at, for every caller that needs to
	# recognize the edge at all (update.pl's own rebuild ordering, why.pl/
	# tree.pl/prune.pl, resolved_slotted_deps' buildlink wiring) -- a
	# leading "@" left unstripped would make parse_dep_token (called
	# below, indirectly, for the "%tag" check, and directly by every
	# caller afterward) treat the whole token as one opaque unparsed
	# name instead of recognizing the real dependency at all, silently
	# breaking every one of those uses for a subslot-tracked dependency
	# specifically.
	$dep =~ s/^[?@]+//;
 
	if($dep =~ /^%(.+)$/)
	{
		my $tag = $1;
		my $pin = portpin($cur, $tag);
		return $pin if $pin;
		# already satisfied: return WHAT satisfies it (the actual
		# installed db key), not undef -- every caller (why.pl/tree.pl/
		# update.pl/prune.pl, plus resolved_slotted_deps' buildlink/
		# revdep wiring) needs to know the concrete provider to record a
		# real dependency edge; treating "already satisfied" the same as
		# "unresolvable" silently dropped that edge everywhere, which for
		# prune.pl specifically meant a still-needed slotted %tag
		# provider could look prunable.
		my $installed = installed_tag_provider($tag, $db);
		return $installed if defined $installed;
		# not yet satisfied by anything installed: predict what
		# resolve_dep would actually pick if asked to install this
		# dependency now, which sorts by pkg_pref first (mirroring
		# resolve_dep exactly, not just tag_providers()' raw directory-
		# scan order, which pick_provider itself never uses either).
		my $providers = tag_providers()->{$tag};
		return undef unless $providers && @$providers;
		my @sorted = sort { $a->{pref} <=> $b->{pref} } @$providers;
		return $sorted[0]{name};
	}
	return $dep;
}
 
# find the $db key an already-resolved dep reference actually installed
# under -- a slot-qualified reference (e.g. "curl:nossl") maps directly;
# a bare one falls back to whichever slot of that bare name is actually
# installed (ambiguous if several coexist, same as any other bare dep
# reference -- an explicit name:slot is how a port disambiguates, exactly
# as for a static gcc-style slot).
sub resolved_db_key {
	my ($ref, $db) = @_;
	# a TAG_/TARGET_ pin (see portpin) is conventionally written
	# "category/name" (e.g. "meta/null"), a form parse_dep_token's name
	# grammar doesn't accept at all ('/' isn't in its character class),
	# so strip any such prefix first -- bare_name's plain "up to the last
	# /" strip, same as find_portdir's own "category/name" handling,
	# rather than silently failing to resolve every pinned dependency.
	my $tok = parse_dep_token(bare_name($ref));
	my $key = slot_key($tok->{name}, $tok->{slot});
	return $key if $db->{$key};
	for my $k (sort keys %$db)
	{
		my ($base) = split_slot_key($k);
		return $k if $base eq $tok->{name};
	}
	return undef;
}
 
# every one of $pc's resolved dependencies that is itself slotted (any
# axis -- gcc version, libc, microarch, optimization, or a
# pkg_slot_use-composed USE-flag variant), as canon db keys, deduped. the
# shared source of truth behind both dep_slot_prefixes (buildlink env
# wiring) and the linkdeps revdep-safety tracking (mplib::db) -- a
# dependency living at the default (unslotted) prefix needs no entry in
# either, since it's already covered by the plain MP_PREFIX-based flags
# and can't be removed out from under a specific slot reference anyway.
sub resolved_slotted_deps {
	my ($pc, $canon, $db) = @_;
	my %seen;
	my @keys;
	for my $dep (effective_deps($pc, $canon))
	{
		# both markers stripped (not just "?"): a "@"-prefixed token is
		# just as real a buildlink edge as any other -- see
		# resolve_dep_name's own comment for why both need stripping
		# wherever a raw pkg_deps= token is interpreted as a reference.
		(my $bare = $dep) =~ s/^[?@]+//;
		my $ref = ($bare =~ /^%(.+)$/) ? resolve_dep_name($bare, $canon, $db) : $bare;
		next unless defined $ref;
		my $key = resolved_db_key($ref, $db);
		next unless defined $key;
		my (undef, $slot) = split_slot_key($key);
		next unless $slot && $slot ne '0';
		push @keys, $key unless $seen{$key}++;
	}
	return @keys;
}
 
# buildlink-style wiring: the private install prefix for each of
# resolved_slotted_deps() above. mpx.c's cmd_runsh turns these into
# -I/-L/rpath/PKG_CONFIG_PATH so a build actually links against the exact
# dependency variant it declared, not whatever else happens to be on the
# default search path.
sub dep_slot_prefixes {
	my ($pc, $canon, $db) = @_;
	return map {
		my ($base, $slot) = split_slot_key($_);
		"$CFG->{INSTPREFIX}/$base-$slot";
	} resolved_slotted_deps($pc, $canon, $db);
}
 
# "@dep" in pkg_deps= (mplib::resolve::resolve_dep's own leading-sigil
# strip, same convention as the existing "?" soft marker): records,
# after $consumer successfully resolves against $target_ref, exactly
# what pkg_subslot value the ACTUAL resolved target currently declares
# -- Portage's own ":=" slot operator, in spirit, without needing a
# second, separate slot-like grammar element in the atom itself (mp's
# existing slot syntax already means something different -- an exact
# SLOT pin, not "track and rebuild on ABI change" -- and the two aren't
# mutually exclusive, so layering this onto a sigil instead of the slot
# position keeps them independent). a target with no pkg_subslot
# declared at all is simply never tracked -- "opt-in per port", not a
# blanket requirement every port must declare one to be depended on
# this way.
#
# $target_ref is resolved via resolved_db_key (not assumed to already
# be the exact db key) since the caller may only have the RAW
# reference (a bare name, a %tag's own pin, or the concrete port a %tag
# backtracked to) -- by the time this runs, $db already reflects
# whatever install_pkg just did, so resolved_db_key's own "fall back to
# whichever slot is actually installed" resolution finds the real
# target correctly either way.
sub record_subslot_dep {
	my ($consumer, $target_ref, $db) = @_;
	my $target = resolved_db_key($target_ref, $db);
	return unless defined $target;
	my ($base, $slot) = split_slot_key($target);
	my $dir = find_portdir($base, $slot);
	return unless $dir;
	my $pc = try_pkgconf($dir);
	return unless $pc;
	my $subslot = $pc->{pkg_subslot};
	return unless defined $subslot && $subslot ne '';
	my $edges = read_subslot_deps();
	my %deps = %{$edges->{$consumer} || {}};
	$deps{$target} = $subslot;
	write_subslot_deps_for($consumer, \%deps);
	return;
}
 
# mp update's own rebuild-cascade check: every currently-installed
# package (regardless of whether it's in the caller's own requested
# update set already) whose subslots.db record for at least one tracked
# dependency no longer matches that dependency's CURRENT pkg_subslot --
# i.e. every consumer that built against an ABI which has since moved
# out from under it, and needs rebuilding to catch up. a dependency
# that no longer resolves at all (removed from the tree, or the
# consumer itself no longer installed) is silently skipped, not an
# error -- this is an ADDITIVE discovery pass (more packages to rebuild
# that the caller didn't explicitly name), never itself a source of
# failures.
sub subslot_changed_targets {
	my ($db) = @_;
	my @out;
	my $edges = read_subslot_deps();
 
	for my $consumer (sort keys %$edges)
	{
		next unless $db->{$consumer};
		for my $dep (sort keys %{$edges->{$consumer}})
		{
			next unless $db->{$dep};
			my ($base, $slot) = split_slot_key($dep);
			my $dir = find_portdir($base, $slot);
			next unless $dir;
			my $pc = try_pkgconf($dir);
			next unless $pc;
			my $current = $pc->{pkg_subslot};
			next unless defined $current && $current ne '';
			next if $current eq $edges->{$consumer}{$dep};
			push @out, $consumer;
			last;
		}
	}
	return @out;
}
 
sub resolve_dep {
	my ($dep, $db, $seen, $cur, $req) = @_;
	# "?" (soft) and "@" (subslot-tracked, see record_subslot_dep's own
	# comment) are independent, orthogonal markers -- either order,
	# either alone, or both together, all valid. only ever applied on a
	# genuinely successful, non-soft resolution below (a soft dep that
	# got skipped has nothing real to record against; deliberately not
	# handled for the soft case at all, an esoteric combination this
	# doesn't need to support for the primary use case -- a real ABI
	# dependency being declared soft in the first place would be
	# unusual).
	my ($soft, $tracked) = (0, 0);
	while(1)
	{
		if($dep =~ s/^\?//) { $soft = 1; next; }
		if($dep =~ s/^@//)  { $tracked = 1; next; }
		last;
	}
 
	if($dep =~ /^%(.+)$/)
	{
		my $tag = $1;
		my $pin = portpin($cur, $tag);
		if(defined $pin && $pin ne '')
		{
			note("%$tag pinned to $pin (for $cur)");
			# no short-circuit here beyond what install_pkg already does
			# itself: a bare $db->{bare_name($pin)} lookup never matches
			# a slotted canon, and worse, would wrongly treat a
			# version-pinned "name=X" the same as an unconstrained pin,
			# silently accepting an already-installed version that
			# doesn't actually satisfy "=X". install_pkg's own idempotent
			# skip (quiet if already installed and satisfying) and hard
			# version-mismatch fail() already cover exactly this.
			if($soft)
			{
				note("soft dep %$tag ($pin) failed, skipping") unless try_soft(sub { install_pkg($pin, $db, $seen, 0); });
				return;
			}
			install_pkg($pin, $db, $seen, 0);
			record_subslot_dep($cur, $pin, $db) if $tracked;
			return;
		}
		return if tag_satisfied($tag, $db);
		my $providers = tag_providers()->{$tag};
		unless($providers && @$providers)
		{
			if($soft) { note("soft dep %$tag has no provider, skipping"); return; }
			fail("no port provides tag %$tag");
		}
		my @sorted = sort { $a->{pref} <=> $b->{pref} } @$providers;
		my $picked = pick_provider(\@sorted, $db);
		unless(defined $picked)
		{
			if($soft) { note("soft dep %$tag has no conflict-free provider, skipping"); return; }
			$picked = interactive_pick(\@sorted, $tag, $cur);
			fail("no provider of %$tag could be installed without conflicting") unless defined $picked;
		}
		note("%$tag resolved to $picked");
		if($soft)
		{
			note("soft dep %$tag ($picked) failed, skipping") unless try_soft(sub { install_pkg($picked, $db, $seen, $req); });
			return;
		}
		install_pkg($picked, $db, $seen, $req);
		record_subslot_dep($cur, $picked, $db) if $tracked;
		return;
	}
	my $tok = parse_dep_token($dep);
	my $key = slot_key($tok->{name}, $tok->{slot});
	if($db->{$key})
	{
		if(defined $tok->{op} && !ver_satisfies($db->{$key}{ver}, $tok->{op}, $tok->{val}))
		{
			if($soft) { note("soft dep $dep unsatisfied by installed $key ($db->{$key}{ver}), skipping"); return; }
			fail("$key is installed at $db->{$key}{ver}, which does not satisfy "
			    . "$dep required by $cur (remove/reinstall $key at a satisfying version first)");
		}
		# a USE-conditional atom ("somelib[ssl]") against an ALREADY-
		# installed $key: unlike the not-yet-installed case
		# (install_pkg's own apply_use_requirement, which can still
		# persist an override before the very first build), $key is
		# already built -- there is no way to retroactively change what
		# flags its existing binary was actually compiled with, so a
		# mismatch here can only ever be a hard failure (or a soft-dep
		# skip), never something to silently patch up.
		if(defined $tok->{use})
		{
			my ($base, $slot) = split_slot_key($key);
			my $dir = find_portdir($base, $slot);
			my $pc = $dir ? try_pkgconf($dir) : undef;
			if($pc)
			{
				my $enabled = enabled_use($key, parse_pkg_use($pc->{pkg_use}));
				my @mismatch = use_requirement_mismatches($enabled, $tok->{use});
				if(@mismatch)
				{
					my $desc = join(',', map { ($tok->{use}{$_} ? '' : '-') . $_ } @mismatch);
					if($soft) { note("soft dep $dep unsatisfied by installed ${key}'s own USE state ($desc), skipping"); return; }
					fail("$key is installed without satisfying USE state ($desc) required by "
					    . "$dep required by $cur (remove/reinstall $key with the right USE flags first)");
				}
			}
		}
		record_subslot_dep($cur, $key, $db) if $tracked;
		return;
	}
	if($soft)
	{
		note("soft dep $dep failed, skipping") unless try_soft(sub { install_pkg($dep, $db, $seen, $req); });
		return;
	}
	install_pkg($dep, $db, $seen, $req);
	record_subslot_dep($cur, $key, $db) if $tracked;
	return;
}
 
# mpx.conf's SANDBOX (global or per-package) governs sandboxing by
# default, but mpx itself can't tell a legacy port's combined
# fetch+build+install "install.sh" from a new-format one where fetch
# already ran as its own separate phase -- only the perl side knows that,
# from $legacy. so this forces sandboxing off for the two cases where it
# would break a legitimate network need: the fetch phase itself, and any
# phase at all of a legacy port (whose one script may embed its own
# fetch). anywhere else, no override is passed and mpx.conf decides.
sub sandbox_arg {
	my ($legacy, $ph) = @_;
	return ($legacy || $ph eq 'fetch') ? ('MP_SANDBOX=no') : ();
}
 
# an optional patches/patches.conf gives individual patches -- named by
# their path relative to patches/, e.g. "003-fix.patch" or the
# use-<flag>/nouse-<flag> subdirectory form "use-ssl/002-extra.patch" --
# the same declarative section grammar as mp.conf itself:
#   003-fix.patch:
#       IF_DEP=%zlib !experimental-thing
#       IF_VER=>=2.0
#       IF_USE=ssl
#       AFTER=001-base.patch
# IF_DEP: every listed token must be among $pc's effective_deps (a
# leading '!' requires its ABSENCE instead); IF_VER: pkg_ver must satisfy
# every listed constraint (same op grammar as any dep token); IF_USE: same
# flag/!flag grammar as HOOKS_DIR/<phase>/hooks.conf's IF_USE, letting a
# flat patches/*.patch be USE-gated too, not just one already inside a
# use-<flag>/nouse-<flag> subdirectory; AFTER: ordering prerequisite(s)
# among the patches actually being applied, topologically sorted --
# filename order (today's only ordering) is still the tiebreak/default
# for anything with no AFTER. lives in
# mplib::resolve (not portformat, where the rest of patch synthesis
# happens) because IF_DEP needs effective_deps, and portformat is
# imported BY resolve -- pulling effective_deps in the other direction
# would be circular. a complete no-op (existing phase text from
# resolve_port/apply_use_patches untouched) when there's no
# patches.conf, or nothing was synthesized to filter/reorder (an
# explicit hand-written patch: phase in pkg.conf itself, same as
# apply_use_patches already leaves alone).
sub apply_patch_manifest {
	my ($pc, $canon) = @_;
	return if $pc->{_legacy};
	my $portdir = $pc->{_portdir};
	return unless defined $portdir;
	my $list = $pc->{_patchlist};
	return unless $list && @$list;
	my $mpath = "$portdir/patches/patches.conf";
	return unless -f $mpath;
 
	# normalized (leading '?' soft-marker and '%' tag sigil both stripped)
	# so "IF_DEP=zlib" matches a dep declared as "%zlib" -- an author
	# writing the tag name alone (the intuitive reading of "depends on
	# zlib") shouldn't have to also know/write the exact '%'-prefixed
	# token shape effective_deps happens to store tag dependencies as.
	my @deps = effective_deps($pc, $canon);
	my %havedep;
	for my $d (@deps)
	{
		(my $norm = $d) =~ s/^[?%]+//;
		$havedep{$d} = 1;
		$havedep{$norm} = 1;
	}
	my $enabled = enabled_use($canon, parse_pkg_use($pc->{pkg_use}));
	my $want = sub {
		my ($sec) = @_;
		return 1 unless $sec;
		for my $tok (split ' ', $sec->{IF_DEP} || '')
		{
			my $wantit = ($tok =~ s/^!//) ? 0 : 1;
			(my $ntok = $tok) =~ s/^%//;
			return 0 if (($havedep{$tok} || $havedep{$ntok} ? 1 : 0) != $wantit);
		}
		for my $constraint (split ' ', $sec->{IF_VER} || '')
		{
			next unless $constraint =~ /^(>=|<=|>|<|=|~)(.+)$/;
			return 0 unless ver_satisfies($pc->{pkg_ver}, $1, $2);
		}
		# same IF_USE=flag/!flag grammar as HOOKS_DIR/<phase>/hooks.conf --
		# a supplement to (not a replacement for) the use-<flag>/nouse-
		# <flag> directory convention: it lets a flat patches/*.patch also
		# be USE-gated without moving it into a subdirectory.
		for my $tok (split ' ', $sec->{IF_USE} || '')
		{
			my $wantit = ($tok =~ s/^!//) ? 0 : 1;
			return 0 if (($enabled->{$tok} ? 1 : 0) != $wantit);
		}
		return 1;
	};
	my @ordered = order_manifest($list, $mpath, $want);
 
	# a patch's own condition (IF_DEP/IF_VER/IF_USE) can also pull in an
	# extra dependency the patch itself needs -- DEPS+=<dep> <dep> in
	# that patch's own section, merged into pkg_deps for every patch
	# actually applied (in @ordered). this is what makes "USE flag on ->
	# patch applies -> patch needs xlib" a single declaration instead of
	# writing the same IF_USE=x condition twice (once via pkg_use's own
	# "x:xlib" gating, once via the patch's own IF_USE=x) to keep them in
	# sync by hand -- and it works for any patch condition, not just
	# IF_USE (e.g. an IF_VER=<2.0 compat patch needing an old-abi dep).
	# read a second time (order_manifest already read $mpath once, but
	# doesn't hand its parsed sections back to the caller) rather than
	# changing order_manifest's shared return contract, which hooks.pm's
	# run_hook also relies on -- patches.conf is small and this runs
	# once per port resolve, not per file.
	my (undef, $patchconf) = read_conf($mpath);
	my @extra;
	for my $p (@ordered)
	{
		push @extra, split ' ', ($patchconf->{$p}{'DEPS+'} || '');
	}
	if(@extra)
	{
		# deliberately evaluated against the @deps snapshot $want closed
		# over above (the port's OWN pkg_deps/pkg_use/DEPS-override
		# state), never against anything a patch itself adds here --
		# letting one patch's DEPS+= feed another's IF_DEP would make
		# the outcome depend on evaluation order, or cycle outright, for
		# no real benefit.
		$pc->{pkg_deps} = join(' ', grep { length } (($pc->{pkg_deps} || ''), @extra));
	}
 
	$pc->{_phases}{patch} = join('', map { "patch -p1 < ../patches/$_\n" } @ordered);
	return;
}
 
# --- pre-install dependency planning ------------------------------------
#
# resolve_dep/pick_provider's own %tag backtracking (above) only ever
# looks at $db as it stands at the moment ONE specific %tag is being
# decided -- correct when a conflict already pre-exists (from an earlier,
# separate "mp install"), but blind in both directions to a conflict that
# only exists because of ANOTHER package in the SAME batch: a %tag
# decided early can turn out to conflict with something the batch hasn't
# installed yet, and a package further down the batch can conflict with
# an early %tag pick that is already real and irreversible on disk by
# the time that is discovered. resolve_dep has no way to undo a real
# install to go back and try a different earlier choice, so today that
# second case aborts the whole batch instead of trying an alternative --
# even when one exists.
#
# plan_resolution runs once, BEFORE any of that: a pure, side-effect-free
# search over port METADATA and the current $db snapshot (never builds,
# installs, removes, or mutates $db) that looks for ONE globally
# consistent assignment -- every %tag anywhere in the transitive closure
# of the requested packages resolved to a specific provider such that
# nothing in the resulting whole set (chosen providers, plain pkg_deps
# targets, and whatever is already in $db) hard-conflicts with anything
# else in it, and no hard (non-soft) dependency edge closes a cycle.
# every genuinely ambiguous %tag (more than one still-viable candidate)
# is a real backtracking decision point, tried lowest-pkg_pref-first
# (same order pick_provider always has), backtracking to the next
# candidate -- and, from there, potentially re-trying every OTHER
# decision point too -- on conflict, cycle, or an unsatisfiable subtree.
# worst case is genuinely combinatorial: as bad as the product of every
# decision point's candidate count, i.e. as steep as O(N!) laid out as a
# chain of increasingly constrained choices. that is accepted on purpose
# -- a real ports tree has few genuinely ambiguous tags per invocation
# (most cost no search at all, see _plan_named's $db/$chosen fast paths
# below), and correctness matters more here than protecting against a
# pathological tree nobody actually has: never silently install a
# conflicting combination, and never abort mid-batch with partial state
# on disk when some other combination would have worked.
#
# deliberately conservative about what it claims to fully model: a
# per-package SYSROOT= override swaps $CFG/$db entirely for its own
# subtree (with_sysroot_scope, above), which this planner does not
# attempt to replay -- hitting one aborts planning outright (the
# PLAN_PUNT exception below), and the caller falls back to resolve_dep's
# own always-correct-if-shallower backtracking for the whole invocation,
# exactly as if plan_resolution had never run. planning is therefore
# always safe to attempt: it either fully validates the batch before
# touching anything, or gets out of the way and changes nothing.
# undef if $canon is installable; otherwise the exact message
# install_pkg_body's own real checks would fail with, so a rejection
# surfaced by the planner reads identically to one surfaced by actually
# attempting the install.
sub _plan_reject_reason {
	my ($canon, $pc, $db) = @_;
	if(!$db->{$canon})
	{
		if(my $why = held_reason($canon)) { return "$canon cannot be (re)installed: $why"; }
		if(my $why = masked_reason($canon)) { return "$canon cannot be installed: $why (--unmask to override)"; }
		if(my $why = keyword_reason($pc)) { return "$canon cannot be installed: $why (--unmask to override)"; }
	}
	if(!$db->{$canon} && version_held_mismatch($canon, $pc->{pkg_ver}))
	{
		return "$canon version $pc->{pkg_ver} does not match HOLD_VERSION $CFG->{pconf}{$canon}{HOLD_VERSION}";
	}
	return undef;
}
 
# pc for an arbitrary db/chosen key, loading it from the ports tree for a
# $db-only entry (mirroring check_static_conflicts' own installed-side
# loop) -- undef if its port directory is gone (an installed package
# whose port vanished from the tree entirely; check_static_conflicts
# itself just skips that case too, via its own "next unless $idir").
sub _plan_pc_for_key {
	my ($key, $db, $chosen) = @_;
	return $chosen->{$key} if $chosen->{$key};
	return undef unless $db->{$key};
	my ($base, $slot) = split_slot_key($key);
	my $dir = find_portdir($base, $slot);
	return undef unless $dir;
	return try_pkgconf($dir);
}
 
# both directions of check_static_conflicts' own hard-blocker check
# (candidate's declared conflicts against the universe, and the
# universe's declared conflicts against the candidate), but against the
# PLANNED set ($chosen, which already includes $canon itself by the time
# this is called) union $db, instead of the live install db alone --
# $chosen is what makes this planner see a conflict between two packages
# that are BOTH still just proposed, not yet real, which check_static_
# conflicts alone never could.
sub _plan_conflict {
	my ($canon, $pc, $db, $chosen) = @_;
	# --force: install_pkg_body's own real conflict check is skipped
	# entirely under FORCE (masked/keyword/held are NOT -- those stay
	# unconditional there, so this planner leaves them unconditional
	# here too, see _plan_candidate_viable), so a plan that would
	# otherwise fail only on a conflict must not reject it either, or
	# --force would stop meaning "install anyway" the moment planning
	# runs before it gets the chance to.
	return undef if $CFG->{FORCE};
	my %universe;
	$universe{$_} = 1 for keys %$chosen;
	$universe{$_} = 1 for keys %$db;
 
	for my $c (split ' ', effective_conflicts($canon, $pc))
	{
		my $hard = 1;
		if($c =~ /^!!(.+)$/)   { $hard = 1; $c = $1; }
		elsif($c =~ /^!(.+)$/) { $hard = 0; $c = $1; }
		next unless $hard;
		for my $k (keys %universe)
		{
			next if $k eq $canon;
			return $k if _conflict_matches($c, $k);
		}
	}
	for my $k (keys %universe)
	{
		next if $k eq $canon;
		my $opc = _plan_pc_for_key($k, $db, $chosen);
		next unless $opc;
		for my $c (split ' ', effective_conflicts($k, $opc))
		{
			my $hard = 1;
			if($c =~ /^!!(.+)$/)   { $hard = 1; $c = $1; }
			elsif($c =~ /^!(.+)$/) { $hard = 0; $c = $1; }
			next unless $hard;
			return $k if _conflict_matches($c, $canon);
		}
	}
	return undef;
}
 
# resolves ONE dependency token (a %tag, a "?"-prefixed soft dep, or a
# plain "name[:slot][op val]") against the in-progress plan. every
# success path returns (1, $decided, $chosen, $canon) -- possibly the
# very same $decided/$chosen hashrefs passed in, when nothing about the
# plan needed to change (the common case: already installed, or already
# resolved earlier in this same plan) -- and every failure path returns
# (0, "why"), EXCEPT a failed SOFT dep, which is not a failure of the
# plan at all (that is the whole point of a soft dep) and returns
# success with the plan unchanged (and no meaningful $canon), same as
# resolve_dep silently dropping one today.
# --- soft-dep resolution: self-contained, never backtracks anything
# outside itself -------------------------------------------------------
#
# a soft dep (pkg_deps_soft, or a "?"-prefixed edge already inside one)
# participates in ITS OWN resolution/ordering (including %tag candidate
# preference) but never blocks the plan and is never worth retrying
# because of something that fails LATER, elsewhere -- that would turn an
# intentionally-optional edge into a second, needless axis of the search
# (try the plan WITH it, then WITHOUT it, for every later failure), and
# "a soft dep never blocks" already has fixed, simple, well-tested
# meaning: resolve it in isolation, keep the result if it fully
# succeeds on its own merits, drop it silently otherwise. so this half
# has no continuation at all -- it is the plan's original, pre-CPS
# shape, kept exactly because that shape is exactly right for something
# self-contained.
sub _plan_soft_dep {
	my ($dep, $cur, $db, $decided, $chosen, $onstack) = @_;
	# same "@" strip as _plan_dep's own entry, and for the same reason:
	# no satisfiability meaning here, but left un-stripped it would
	# otherwise reach _plan_soft_named's parse_dep_token as an opaque,
	# unmatched literal name (or hide a real "%tag" one line below, since
	# a leading "@" means the "^%(.+)$" check here never even matches).
	# a single strip suffices -- by the time a token reaches here, any
	# "?" has already been consumed by whichever caller decided this was
	# the soft path, so at most one sigil is ever still in front.
	$dep =~ s/^@//;
	if($dep =~ /^%(.+)$/)
	{
		my $tag = $1;
		my $pin = portpin($cur, $tag);
		my $target = (defined $pin && $pin ne '') ? $pin : $decided->{$tag};
		if(defined $target)
		{
			my ($ok, $r1, $r2, $canon) = _plan_soft_named($target, $cur, $db, $decided, $chosen, $onstack);
			return (0) unless $ok;
			return (1, $r1, $r2) unless defined $canon;
			my %d2 = (%$r1, $tag => $canon);
			return (1, \%d2, $r2);
		}
		if(tag_satisfied($tag, $db))
		{
			my %d2 = (%$decided, $tag => installed_tag_provider($tag, $db));
			return (1, \%d2, $chosen);
		}
		my $providers = tag_providers()->{$tag};
		return (0) unless $providers && @$providers;
		for my $cand (sort { $a->{pref} <=> $b->{pref} } @$providers)
		{
			my ($ok, $r1, $r2, $canon) = _plan_soft_named($cand->{name}, $cur, $db, $decided, $chosen, $onstack);
			next unless $ok;
			my %d2 = (%$r1, $tag => $canon);
			return (1, \%d2, $r2);
		}
		return (0);
	}
	return _plan_soft_named($dep, $cur, $db, $decided, $chosen, $onstack);
}
 
sub _plan_soft_named {
	my ($tok_str, $cur, $db, $decided, $chosen, $onstack) = @_;
	my $tok = parse_dep_token($tok_str);
	my $key = slot_key($tok->{name}, $tok->{slot});
	if($db->{$key})
	{
		return (0) if defined $tok->{op} && !ver_satisfies($db->{$key}{ver}, $tok->{op}, $tok->{val});
		return (1, $decided, $chosen, $key);
	}
	my $portdir = find_portdir($tok->{name}, $tok->{slot});
	return (0) unless $portdir;
	my $pc = try_pkgconf($portdir);
	return (0) unless $pc;
	return (0) if defined $tok->{op} && $tok->{op} ne '=' && !ver_satisfies($pc->{pkg_ver}, $tok->{op}, $tok->{val});
	my $canon = slot_key($pc->{pkg_name}, resolve_slot($pc, $pc->{pkg_name}));
	die "PLAN_PUNT\n" if pconf_lookup($canon, 'SYSROOT');
	if($db->{$canon})
	{
		return (0) if defined $tok->{op} && !ver_satisfies($db->{$canon}{ver}, $tok->{op}, $tok->{val});
		return (1, $decided, $chosen, $canon);
	}
	return (0) if $onstack->{$canon};
	return (1, $decided, $chosen, $canon) if $chosen->{$canon};
	return (0) if _plan_reject_reason($canon, $pc, $db);
	my %c2 = (%$chosen, $canon => $pc);
	return (0) if _plan_conflict($canon, $pc, $db, \%c2);
	my %stack2 = (%$onstack, $canon => 1);
	my ($d, $c) = ($decided, \%c2);
	for my $sub (effective_deps($pc, $canon))
	{
		if($sub =~ s/^\?//)
		{
			my ($ok, $r1, $r2) = _plan_soft_dep($sub, $canon, $db, $d, $c, \%stack2);
			($d, $c) = ($r1, $r2) if $ok;
			next;
		}
		my ($ok, $r1, $r2) = _plan_soft_dep($sub, $canon, $db, $d, $c, \%stack2);
		return (0) unless $ok;
		($d, $c) = ($r1, $r2);
	}
	return (1, $d, $c, $canon);
}
 
# --- hard-dep resolution: continuation-passing, backtracks across the
# WHOLE remaining plan, not just the one tag being decided -------------
#
# every function below takes a final argument $k, "the rest of the
# entire search" -- everything that still needs to succeed AFTER this
# one dependency, all the way out to plan_resolution's own top-level
# request list. success means calling $k->($decided, $chosen) and
# returning WHATEVER it returns, unexamined; a %tag's candidate loop
# tries the next candidate not just when ITS OWN subtree fails, but
# whenever $k itself (the entire rest of the plan, tried under this
# candidate) ultimately fails too. this is what makes a conflict
# discovered arbitrarily far downstream -- a different top-level
# request, a sibling dependency three levels away, anything -- able to
# reach back and un-pick an EARLIER %tag choice that seemed perfectly
# fine when it was made, then re-try the rest of the plan under the
# alternative. a plain, non-tag edge has no alternative to retry, so it
# still just fails outright on conflict/cycle -- there is nothing here
# TO backtrack.
sub _plan_dep {
	my ($dep, $cur, $db, $decided, $chosen, $onstack, $k) = @_;
	spin_tick();
	# "@" (subslot-tracked, see record_subslot_dep's own comment) carries
	# no satisfiability meaning at all here -- only resolve_dep's own
	# REAL execution pass records a subslot edge, this planner only
	# checks whether a request CAN be satisfied -- so it is simply
	# stripped and ignored, same as resolve_dep's own while(1) loop
	# strips it before ever looking at the rest of the token (either
	# order, either alone, or combined with "?" below).
	my $soft = 0;
	while(1)
	{
		if($dep =~ s/^@//) { next; }
		if($dep =~ s/^\?//) { $soft = 1; next; }
		last;
	}
	if($soft)
	{
		my ($ok, $d2, $c2) = _plan_soft_dep($dep, $cur, $db, $decided, $chosen, $onstack);
		return $k->($ok ? $d2 : $decided, $ok ? $c2 : $chosen);
	}
	if($dep =~ /^%(.+)$/)
	{
		my $tag = $1;
		my $fold = sub {
			my ($tagk) = @_;
			return sub { my ($d2, $c2, $canon) = @_; my %d3 = (%$d2, $tag => $canon); return $tagk->(\%d3, $c2); };
		};
		# an explicit TARGET_$tag/TAG_$tag pin (global or under $cur's own
		# section) always wins, exactly like resolve_dep/portpin -- checked
		# BEFORE $decided, so a per-package pin can still legitimately
		# diverge from what every OTHER package sharing this tag agreed on.
		# neither has an ALTERNATIVE to try, so either just resolves once.
		my $pin = portpin($cur, $tag);
		if(defined $pin && $pin ne '')
		{
			return _plan_named($pin, $cur, $db, $decided, $chosen, $onstack, $fold->($k));
		}
		if(defined $decided->{$tag})
		{
			return _plan_named($decided->{$tag}, $cur, $db, $decided, $chosen, $onstack, $fold->($k));
		}
		if(tag_satisfied($tag, $db))
		{
			my $prov = installed_tag_provider($tag, $db);
			my %d2 = (%$decided, $tag => $prov);
			return $k->(\%d2, $chosen);
		}
		my $providers = tag_providers()->{$tag};
		unless($providers && @$providers)
		{
			return (0, "no port provides tag %$tag" . (defined $cur ? " (needed by $cur)" : ''));
		}
		my @sorted = sort { $a->{pref} <=> $b->{pref} } @$providers;
		my $lastfail;
		my $all_conflicts = 1;
		for my $cand (@sorted)
		{
			my ($ok, $r) = _plan_named($cand->{name}, $cur, $db, $decided, $chosen, $onstack, $fold->($k));
			return (1, $r) if $ok;
			$lastfail = $r;
			$all_conflicts = 0 unless $r =~ / conflicts with /;
		}
		# every candidate specifically conflicting (and nothing downstream
		# ever getting far enough to fail any OTHER way) is exactly the
		# case resolve_dep's own interactive_pick fallback already has
		# fixed wording for -- match it, rather than surfacing just the
		# LAST candidate's specific conflict (arbitrary, and silent about
		# every other candidate having failed the identical way).
		return (0, "no provider of %$tag could be installed without conflicting" . (defined $cur ? " (needed by $cur)" : ''))
		    if $all_conflicts && @sorted > 1;
		return (0, $lastfail);
	}
	return _plan_named($dep, $cur, $db, $decided, $chosen, $onstack, sub { my ($d2, $c2) = @_; return $k->($d2, $c2); });
}
 
# resolves a plain (tag-free) token to a concrete port, walking its own
# deps (via _plan_seq, below) if it is genuinely new to this plan, then
# invoking $k->($decided, $chosen, $canon) -- $canon is the fully
# resolved db-key form (pkg_name-based, slot included) even when
# $tok_str itself named a port by its directory instead (the static-
# multi-slot case). mirrors resolve_dep's own literal-key fast path (an
# exact installed match needs no portdir/pc at all) and install_pkg's
# canon computation (pkg_slot_use composition can only be known once pc
# is loaded, so a bare token that turns out
powered by btf.