{- | The ecosystem-agnostic filtering /decision/ for a single public-upstream packument: which versions survive a rule set, which version @dist-tags.latest@ resolves to, and the per-version decisions a no-survivors outcome must report. This mirrors "Ecluse.Core.Package.Merge" -- the pure fold above the registry handle that emits a __plan__ rather than a finished document. It reasons over the typed 'Ecluse.Core.Package.PackageInfo' domain model only; it never touches a registry's wire format. The per-ecosystem adapter __replays__ this plan onto the raw upstream document, so unmodeled wire keys survive (the typed model is lossy, so re-encoding it would drop them). See @docs\/architecture\/registry-model.md@ → "Decision surface vs served surface". __Decision, not served surface.__ A 'FilterPlan' carries exactly the decisions the filter owns: * __Survivors.__ A version key survives iff the rules engine 'Admitted' it; every other verdict -- a denial, deny-by-default, or an undecidable outcome -- drops it. Presence in the served packument /is/ availability (see @docs\/research\/reverse-engineering\/npm.md@ §8), so a non-approved version is removed rather than flagged. * __Resolved @latest@.__ The surviving @dist-tags.latest@ under the shared __keep-unless-denied, stable-preferring__ rule ('Ecluse.Core.Version.selectLatest'): the upstream @latest@ is kept untouched while it survives, and only repointed -- to the highest /stable/ survivor -- when it was itself denied. This is the @latest@ /within the public set/, which the cross-upstream merge then re-resolves over the union; it is not the final served @latest@. * __Decisions.__ Every version's 'Decision', in version-key order, so a no-survivors outcome can render each denial and choose a status. What the plan deliberately omits is any "dropped tags" list: a stale tag -- one whose target did not survive -- is droppable __structurally__ from the survivor set alone (a tag is kept iff its target is in 'fpSurvivors'), so the replay needs no extra field to find them. The plan stays minimal: the decisions the filter owns, nothing the replay can recompute. This filters a __single public packument__ (the gated set). Combining it with the trusted /private/ set is the cross-upstream merge ("Ecluse.Core.Package.Merge"). -} module Ecluse.Core.Package.Filter ( FilterPlan (..), filterPlan, filterPlanFromDecisions, restrictToSurvivors, ) where import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Ecluse.Core.Package (PackageInfo (infoDistTags, infoVersions), pkgVersion) import Ecluse.Core.Rules (RuleDeps, evalRules, prepare) import Ecluse.Core.Rules.Types (Decision (Admitted), EvalContext, PrecededRule) import Ecluse.Core.Version (Version, renderVersion, selectLatest, unVersion) {- | The decisions filtering a single public packument owns, for the adapter to replay onto the raw upstream @Value@. Carries only what the filter decides over the typed model -- never a finished, re-serialisable document (see this module's header). The replay derives everything else (which stale tags to drop, which @time@ entries to prune) from these fields. -} data FilterPlan = FilterPlan { FilterPlan -> Set Text fpSurvivors :: Set Text {- ^ The surviving version keys (the raw 'Ecluse.Core.Package.infoVersions' keys): exactly those the rules engine approved. Empty when no version survived. -} , FilterPlan -> Maybe Version fpLatest :: Maybe Version {- ^ @dist-tags.latest@ resolved over the survivors by the shared selector -- kept as published while it survives, else repointed (stable-preferring) to the highest survivor. 'Nothing' when nothing survives. When present it is always one of 'fpSurvivors', so the replay can point @latest@ at a key that is served. -} , FilterPlan -> [Decision] fpDecisions :: [Decision] {- ^ Every version's 'Decision', in version-key order, for the no-survivors status and denial body. Carried for every version (not only the denied ones) so the adapter can zip them back onto the same-ordered versions. -} } deriving stock (FilterPlan -> FilterPlan -> Bool (FilterPlan -> FilterPlan -> Bool) -> (FilterPlan -> FilterPlan -> Bool) -> Eq FilterPlan forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a $c== :: FilterPlan -> FilterPlan -> Bool == :: FilterPlan -> FilterPlan -> Bool $c/= :: FilterPlan -> FilterPlan -> Bool /= :: FilterPlan -> FilterPlan -> Bool Eq, Int -> FilterPlan -> ShowS [FilterPlan] -> ShowS FilterPlan -> String (Int -> FilterPlan -> ShowS) -> (FilterPlan -> String) -> ([FilterPlan] -> ShowS) -> Show FilterPlan forall a. (Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a $cshowsPrec :: Int -> FilterPlan -> ShowS showsPrec :: Int -> FilterPlan -> ShowS $cshow :: FilterPlan -> String show :: FilterPlan -> String $cshowList :: [FilterPlan] -> ShowS showList :: [FilterPlan] -> ShowS Show) {- | Decide a single public packument against a rule set: which versions survive, where @latest@ resolves, and every version's decision. 'IO' and total -- it reasons over the typed 'PackageInfo' alone, with no registry wire format in sight, deciding each version through the one engine ('Ecluse.Core.Rules.evalRules', over the 'prepare'd policy), so the filter's per-version decision and the serve path share it. A version survives iff the engine 'Admitted' it; every other verdict drops it. @latest@ is resolved by 'Ecluse.Core.Version.selectLatest' from the upstream-tagged @latest@ (looked up among the versions, so a tag aimed at an absent version contributes nothing) and the surviving versions -- kept while it survives, else repointed downward to the highest stable survivor. The decisions are returned for __every__ version in key order, so the adapter has each denial's reason when nothing survives. -} filterPlan :: RuleDeps -> EvalContext -> [PrecededRule] -> PackageInfo -> IO FilterPlan filterPlan :: RuleDeps -> EvalContext -> [PrecededRule] -> PackageInfo -> IO FilterPlan filterPlan RuleDeps deps EvalContext ctx [PrecededRule] rules PackageInfo info = do prepared <- RuleDeps -> [PrecededRule] -> IO [PreparedRule] prepare RuleDeps deps [PrecededRule] rules decisions <- traverse (evalRules ctx prepared) (infoVersions info) pure (filterPlanFromDecisions decisions info) {- | Build a 'FilterPlan' from per-version 'Decision's already taken, rather than evaluating the pure tier here. This is the path the __effectful__ tier feeds: it decides each version in IO (see "Ecluse.Core.Rules.Effectful"), then hands the decisions here for the same pure survivor\/@latest@ resolution 'filterPlan' performs. The decision map is keyed by raw version string and __must__ cover exactly the packument's versions; a version with no decision is treated as not surviving. A version survives iff its decision is an 'Admitted'; every other verdict -- denial, deny-by-default, or 'Ecluse.Core.Rules.Types.Undecidable' -- drops it, so a fail-closed undecidable version is filtered out exactly like a denial, while its decision is still carried in 'fpDecisions' for the no-survivors status. -} filterPlanFromDecisions :: Map Text Decision -> PackageInfo -> FilterPlan filterPlanFromDecisions :: Map Text Decision -> PackageInfo -> FilterPlan filterPlanFromDecisions Map Text Decision decisions PackageInfo info = FilterPlan { fpSurvivors :: Set Text fpSurvivors = Set Text survivors , fpLatest :: Maybe Version fpLatest = Maybe Version -> [Version] -> Maybe Version selectLatest Maybe Version chosen [Version] survivingVersions , fpDecisions :: [Decision] fpDecisions = Map Text Decision -> [Decision] forall k a. Map k a -> [a] Map.elems Map Text Decision decisions } where -- A version survives only on an explicit approval; every other outcome (deny, -- deny-by-default, undecidable) drops it. survivors :: Set Text survivors :: Set Text survivors = Map Text Decision -> Set Text forall k a. Map k a -> Set k Map.keysSet ((Decision -> Bool) -> Map Text Decision -> Map Text Decision forall a k. (a -> Bool) -> Map k a -> Map k a Map.filter Decision -> Bool isApproved Map Text Decision decisions) isApproved :: Decision -> Bool isApproved :: Decision -> Bool isApproved = \case Admitted{} -> Bool True Decision _ -> Bool False -- The parsed 'Version' a raw key projects to, if present in the packument. -- Used both to map surviving keys to 'Version's and to resolve @latest@. versionOf :: Text -> Maybe Version versionOf :: Text -> Maybe Version versionOf Text raw = PackageDetails -> Version pkgVersion (PackageDetails -> Version) -> Maybe PackageDetails -> Maybe Version forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b <$> Text -> Map Text PackageDetails -> Maybe PackageDetails forall k a. Ord k => k -> Map k a -> Maybe a Map.lookup Text raw (PackageInfo -> Map Text PackageDetails infoVersions PackageInfo info) -- 'selectLatest'\'s @chosen@: the upstream @latest@ tag's target as a 'Version' -- (the tag's raw string looked up among the versions). It decides /survival/ -- itself, so the version need only be present, not surviving. chosen :: Maybe Version chosen :: Maybe Version chosen = Text -> Map Text Version -> Maybe Version forall k a. Ord k => k -> Map k a -> Maybe a Map.lookup Text "latest" (PackageInfo -> Map Text Version infoDistTags PackageInfo info) Maybe Version -> (Version -> Maybe Version) -> Maybe Version forall a b. Maybe a -> (a -> Maybe b) -> Maybe b forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b >>= Text -> Maybe Version versionOf (Text -> Maybe Version) -> (Version -> Text) -> Version -> Maybe Version forall b c a. (b -> c) -> (a -> b) -> a -> c . Version -> Text unVersion -- 'selectLatest'\'s @survivors@: the surviving versions' parsed 'Version's. survivingVersions :: [Version] survivingVersions :: [Version] survivingVersions = (Text -> Maybe Version) -> [Text] -> [Version] forall a b. (a -> Maybe b) -> [a] -> [b] mapMaybe Text -> Maybe Version versionOf (Set Text -> [Text] forall a. Set a -> [a] Set.toList Set Text survivors) {- | Restrict a 'PackageInfo' to the version keys that survived filtering -- the 'FilterPlan'\'s own 'fpSurvivors' -- so the typed view handed to the cross-upstream merge carries exactly the gated set ('Ecluse.Core.Package.Merge.mergePackuments' treats a gated source as already filtered and never re-filters). @dist-tags@ is pruned to the surviving keys likewise (the merge reconciles tags over the union); @dist-tags@ targets absent from the survivors are dropped. Each surviving version carries its own publish time, so restricting the versions carries the times with it (the merge reconstructs the served @time@ from the survivors). -} restrictToSurvivors :: Set Text -> PackageInfo -> PackageInfo restrictToSurvivors :: Set Text -> PackageInfo -> PackageInfo restrictToSurvivors Set Text survivors PackageInfo info = PackageInfo info { infoVersions = Map.restrictKeys (infoVersions info) survivors , infoDistTags = Map.filter ((`Set.member` survivors) . renderVersion) (infoDistTags info) }