-- SPDX-FileCopyrightText: 2026 Alexandra de Wit
--
-- SPDX-License-Identifier: MIT

{- | The package domain model -- ecosystem-agnostic vocabulary for the rules
engine.

These types capture everything the proxy needs to reason about a package
version while staying decoupled from any registry's wire format. Registry
adapters (npm, PyPI, RubyGems) are responsible for projecting their responses
into these types; nothing above the registry layer sees registry-specific
structures.

Three pieces of this vocabulary earn their own sibling module: the 'Ecosystem' tag
lives in "Ecluse.Core.Ecosystem" (shared with the version engine and the registry
adapters), version identity and ordering live in "Ecluse.Core.Version" (a
'Version' is embedded here in 'PackageDetails'), and the integrity-digest
vocabulary ('Hash', 'HashAlg', and the Subresource-Integrity forms) lives in
"Ecluse.Core.Package.Hash" and is re-exported here in full. Import those modules
directly when you need to name or build their types.

The design follows two principles synthesised from the protocol research (see
@docs\/research\/synthesis.md@):

* __Rules consume normalised signals, not raw fields.__ The risky behaviours
  differ on the wire (npm install scripts, PyPI sdist builds, RubyGems native
  extensions) but collapse to one signal -- 'CodeExecSignal'. Trust likewise
  collapses to 'Trust'. A rule never learns which ecosystem it is looking at.

* __Signal availability is explicit.__ A signal the adapter has not (or cannot
  cheaply) determine is 'CodeExecUnknown' \/ 'TrustUnknown' \/ 'Nothing', so a
  pure rule abstains rather than guessing and the effectful tier can resolve it
  later (see @docs\/architecture.md@ → "Rules Engine").
-}
module Ecluse.Core.Package (
    -- * Scopes
    Scope,
    mkScope,
    unScope,
    renderScope,

    -- * Package identity
    PackageName,
    mkPackageName,
    pkgEcosystem,
    pkgNamespace,
    pkgCanonical,
    pkgDisplay,
    pkgBaseName,
    renderPackageName,
    unscopedName,

    -- * Normalised signals
    CodeExecSignal (..),
    Trust (..),
    TrustEvidence (..),
    Availability (..),

    -- * Artifacts
    Artifact (..),
    ArtifactKind (..),
    Hash,
    hashAlg,
    hashValue,
    mkHash,
    mkSriHashes,
    HashAlg (..),

    -- * Algorithm vocabulary
    renderHashAlg,
    parseHashAlg,
    sriPrefix,
    sriBody,
    sriAlgorithm,

    -- * Digest computation
    computeDigest,
    isComputable,

    -- * Dependencies

    -- * People
    Person (..),

    -- * Per-version details
    PackageDetails (..),

    -- * Packument-level view
    PackageInfo (..),
    InvalidEntry (..),
    InvalidEntryKind (..),
) where

import Data.Aeson (Value)
import Data.Text qualified as T
import Data.Text.Short (ShortText)
import Data.Text.Short qualified as TS
import Data.Time (UTCTime)

import Ecluse.Core.Ecosystem (Ecosystem (..))
import Ecluse.Core.Package.Hash (
    Hash,
    HashAlg (..),
    computeDigest,
    hashAlg,
    hashValue,
    isComputable,
    mkHash,
    mkSriHashes,
    parseHashAlg,
    renderHashAlg,
    sriAlgorithm,
    sriBody,
    sriPrefix,
 )
import Ecluse.Core.Version (Version)

{- | An npm scope, stored without its leading @\'\@\'@ (the scope of
@\@myorg\/pkg@ is @"myorg"@). Construct via 'mkScope', which normalises away
a leading @\'\@\'@ so equality is independent of how the scope was written.

A scope is a bulk-stored, equality-only identifier (an allow-list key and part
of 'PackageName' identity), so it is held as 'ShortText': the @'Text' -> 'ShortText'@
conversion happens once in 'mkScope' and the reverse once in 'unScope'\/'renderScope',
never in a hot loop (see STYLE.md §6).
-}
newtype Scope = Scope ShortText
    deriving stock (Scope -> Scope -> Bool
(Scope -> Scope -> Bool) -> (Scope -> Scope -> Bool) -> Eq Scope
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: Scope -> Scope -> Bool
== :: Scope -> Scope -> Bool
$c/= :: Scope -> Scope -> Bool
/= :: Scope -> Scope -> Bool
Eq, Eq Scope
Eq Scope =>
(Scope -> Scope -> Ordering)
-> (Scope -> Scope -> Bool)
-> (Scope -> Scope -> Bool)
-> (Scope -> Scope -> Bool)
-> (Scope -> Scope -> Bool)
-> (Scope -> Scope -> Scope)
-> (Scope -> Scope -> Scope)
-> Ord Scope
Scope -> Scope -> Bool
Scope -> Scope -> Ordering
Scope -> Scope -> Scope
forall a.
Eq a =>
(a -> a -> Ordering)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> a)
-> (a -> a -> a)
-> Ord a
$ccompare :: Scope -> Scope -> Ordering
compare :: Scope -> Scope -> Ordering
$c< :: Scope -> Scope -> Bool
< :: Scope -> Scope -> Bool
$c<= :: Scope -> Scope -> Bool
<= :: Scope -> Scope -> Bool
$c> :: Scope -> Scope -> Bool
> :: Scope -> Scope -> Bool
$c>= :: Scope -> Scope -> Bool
>= :: Scope -> Scope -> Bool
$cmax :: Scope -> Scope -> Scope
max :: Scope -> Scope -> Scope
$cmin :: Scope -> Scope -> Scope
min :: Scope -> Scope -> Scope
Ord, Int -> Scope -> ShowS
[Scope] -> ShowS
Scope -> String
(Int -> Scope -> ShowS)
-> (Scope -> String) -> ([Scope] -> ShowS) -> Show Scope
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> Scope -> ShowS
showsPrec :: Int -> Scope -> ShowS
$cshow :: Scope -> String
show :: Scope -> String
$cshowList :: [Scope] -> ShowS
showList :: [Scope] -> ShowS
Show)

-- | Build a 'Scope', tolerating an optional leading @\'\@\'@.
mkScope :: Text -> Scope
mkScope :: Text -> Scope
mkScope Text
raw = ShortText -> Scope
Scope (Text -> ShortText
TS.fromText (Text -> Maybe Text -> Text
forall a. a -> Maybe a -> a
fromMaybe Text
raw (Text -> Text -> Maybe Text
T.stripPrefix Text
"@" Text
raw)))

-- | The bare scope text, without the leading @\'\@\'@.
unScope :: Scope -> Text
unScope :: Scope -> Text
unScope (Scope ShortText
s) = ShortText -> Text
TS.toText ShortText
s

-- | Render a scope in npm wire form, with the leading @\'\@\'@.
renderScope :: Scope -> Text
renderScope :: Scope -> Text
renderScope (Scope ShortText
s) = Text
"@" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> ShortText -> Text
TS.toText ShortText
s

{- | A package identity, decoupled from any registry's wire format.

Identity differs by ecosystem -- npm has scopes and is case-sensitive, PyPI
normalises per PEP 503, RubyGems is verbatim -- so the type is __opaque__:
build it with 'mkPackageName', which records the ecosystem, computes a
'pkgCanonical' key used for equality\/matching, and keeps a 'pkgDisplay' form
for faithful rendering. Equality and ordering are on
@('pkgEcosystem', 'pkgNamespace', 'pkgCanonical')@ only -- never the display
or base form -- so @Flask@ and @flask@ are the same PyPI package but different npm ones.
-}
data PackageName = PackageName
    { PackageName -> Ecosystem
pkgEcosystem :: Ecosystem
    -- ^ The ecosystem this name belongs to.
    , PackageName -> Maybe Scope
pkgNamespace :: Maybe Scope
    -- ^ The scope, if scoped (npm @\@scope\/name@). 'Nothing' for PyPI/RubyGems.
    , PackageName -> ShortText
pkgCanonical :: ShortText
    {- ^ The normalised key for equality and matching (PEP 503 for PyPI;
    verbatim for npm/RubyGems). Held as 'ShortText': it is an equality\/'Ord' key
    that is normalised once at 'mkPackageName' and never sliced afterwards.
    -}
    , PackageName -> ShortText
pkgDisplay :: ShortText
    {- ^ The name as published, for rendering and round-tripping. Held as
    'ShortText'; read it back as 'Text' through 'renderPackageName'.
    -}
    , PackageName -> ShortText
pkgBaseName :: ShortText
    {- ^ The unscoped base name: the published name with any @\@scope\/@ prefix
    dropped (@\@babel\/code-frame@ → @code-frame@). Stored structurally at
    'mkPackageName' (it is exactly the bare name the constructor is given), so the
    npm tarball\/path layer and the mirror queue read it as a field rather than
    re-slicing the display form. Not part of identity (like 'pkgDisplay'); held as
    'ShortText' and read back as 'Text' through 'unscopedName'.
    -}
    }
    deriving stock (Int -> PackageName -> ShowS
[PackageName] -> ShowS
PackageName -> String
(Int -> PackageName -> ShowS)
-> (PackageName -> String)
-> ([PackageName] -> ShowS)
-> Show PackageName
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> PackageName -> ShowS
showsPrec :: Int -> PackageName -> ShowS
$cshow :: PackageName -> String
show :: PackageName -> String
$cshowList :: [PackageName] -> ShowS
showList :: [PackageName] -> ShowS
Show)

-- The fields that constitute identity (the display form is excluded).
nameKey :: PackageName -> (Ecosystem, Maybe Scope, ShortText)
nameKey :: PackageName -> (Ecosystem, Maybe Scope, ShortText)
nameKey PackageName
n = (PackageName -> Ecosystem
pkgEcosystem PackageName
n, PackageName -> Maybe Scope
pkgNamespace PackageName
n, PackageName -> ShortText
pkgCanonical PackageName
n)

instance Eq PackageName where
    PackageName
a == :: PackageName -> PackageName -> Bool
== PackageName
b = PackageName -> (Ecosystem, Maybe Scope, ShortText)
nameKey PackageName
a (Ecosystem, Maybe Scope, ShortText)
-> (Ecosystem, Maybe Scope, ShortText) -> Bool
forall a. Eq a => a -> a -> Bool
== PackageName -> (Ecosystem, Maybe Scope, ShortText)
nameKey PackageName
b

instance Ord PackageName where
    compare :: PackageName -> PackageName -> Ordering
compare PackageName
a PackageName
b = (Ecosystem, Maybe Scope, ShortText)
-> (Ecosystem, Maybe Scope, ShortText) -> Ordering
forall a. Ord a => a -> a -> Ordering
compare (PackageName -> (Ecosystem, Maybe Scope, ShortText)
nameKey PackageName
a) (PackageName -> (Ecosystem, Maybe Scope, ShortText)
nameKey PackageName
b)

{- | Build a 'PackageName', normalising the canonical key for the ecosystem.

The display form is the scope-joined raw name (@\@scope\/name@ when scoped);
the canonical key is that form normalised: PEP 503 lower-casing and
@[-_.]+@→@-@ collapsing for PyPI, verbatim for npm and RubyGems.
-}
mkPackageName :: Ecosystem -> Maybe Scope -> Text -> PackageName
mkPackageName :: Ecosystem -> Maybe Scope -> Text -> PackageName
mkPackageName Ecosystem
eco Maybe Scope
ns Text
raw =
    PackageName
        { pkgEcosystem :: Ecosystem
pkgEcosystem = Ecosystem
eco
        , pkgNamespace :: Maybe Scope
pkgNamespace = Maybe Scope
ns
        , pkgCanonical :: ShortText
pkgCanonical = Text -> ShortText
TS.fromText (Ecosystem -> Text -> Text
canonicalise Ecosystem
eco Text
display)
        , pkgDisplay :: ShortText
pkgDisplay = Text -> ShortText
TS.fromText Text
display
        , pkgBaseName :: ShortText
pkgBaseName = Text -> ShortText
TS.fromText Text
raw
        }
  where
    display :: Text
display = case Maybe Scope
ns of
        Just Scope
s -> Scope -> Text
renderScope Scope
s Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"/" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
raw
        Maybe Scope
Nothing -> Text
raw

-- Normalise a display name into its canonical matching key for an ecosystem.
canonicalise :: Ecosystem -> Text -> Text
canonicalise :: Ecosystem -> Text -> Text
canonicalise = \case
    Ecosystem
Npm -> Text -> Text
forall a. a -> a
id
    Ecosystem
RubyGems -> Text -> Text
forall a. a -> a
id
    Ecosystem
PyPI -> Text -> Text
normalisePyPI

{- PEP 503 name normalisation: lower-case, and collapse each run of
@\'-\'@\/@\'_\'@\/@\'.\'@ to a single @\'-\'@.
-}
normalisePyPI :: Text -> Text
normalisePyPI :: Text -> Text
normalisePyPI Text
t =
    Text -> [Text] -> Text
T.intercalate Text
"-"
        ([Text] -> Text) -> (Text -> [Text]) -> Text -> Text
forall b c a. (b -> c) -> (a -> b) -> a -> c
. (Text -> Bool) -> [Text] -> [Text]
forall a. (a -> Bool) -> [a] -> [a]
filter (Bool -> Bool
not (Bool -> Bool) -> (Text -> Bool) -> Text -> Bool
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Text -> Bool
T.null)
        ([Text] -> [Text]) -> (Text -> [Text]) -> Text -> [Text]
forall b c a. (b -> c) -> (a -> b) -> a -> c
. HasCallStack => Text -> Text -> [Text]
Text -> Text -> [Text]
T.splitOn Text
"-"
        (Text -> Text) -> Text -> Text
forall a b. (a -> b) -> a -> b
$ (Char -> Char) -> Text -> Text
T.map (\Char
c -> if Char
c Char -> Char -> Bool
forall a. Eq a => a -> a -> Bool
== Char
'_' Bool -> Bool -> Bool
|| Char
c Char -> Char -> Bool
forall a. Eq a => a -> a -> Bool
== Char
'.' then Char
'-' else Char
c) (Text -> Text
T.toLower Text
t)

-- | Render a package name in its native wire form (the display name).
renderPackageName :: PackageName -> Text
renderPackageName :: PackageName -> Text
renderPackageName = ShortText -> Text
TS.toText (ShortText -> Text)
-> (PackageName -> ShortText) -> PackageName -> Text
forall b c a. (b -> c) -> (a -> b) -> a -> c
. PackageName -> ShortText
pkgDisplay

{- | The unscoped (base) name as 'Text' (@\@babel\/code-frame@ → @code-frame@): the
'ShortText' 'pkgBaseName' field read back. The single home for the bare-name the npm
tarball/path layer and the mirror queue all need; it is stored structurally at
'mkPackageName' rather than reconstructed by rendering then string-stripping the scope.
-}
unscopedName :: PackageName -> Text
unscopedName :: PackageName -> Text
unscopedName = ShortText -> Text
TS.toText (ShortText -> Text)
-> (PackageName -> ShortText) -> PackageName -> Text
forall b c a. (b -> c) -> (a -> b) -> a -> c
. PackageName -> ShortText
pkgBaseName

{- | Whether installing a version executes code (the cross-ecosystem unification
of npm install scripts, PyPI sdist builds, and RubyGems native extensions).
-}
data CodeExecSignal
    = -- | Determined: installation runs no code.
      NoCodeOnInstall
    | -- | Determined: installation runs code; the text says how (audit trail).
      RunsCodeOnInstall Text
    | {- | Not yet determined (e.g. the RubyGems gemspec has not been fetched).
      Pure rules abstain; the effectful tier may resolve it.
      -}
      CodeExecUnknown
    deriving stock (CodeExecSignal -> CodeExecSignal -> Bool
(CodeExecSignal -> CodeExecSignal -> Bool)
-> (CodeExecSignal -> CodeExecSignal -> Bool) -> Eq CodeExecSignal
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: CodeExecSignal -> CodeExecSignal -> Bool
== :: CodeExecSignal -> CodeExecSignal -> Bool
$c/= :: CodeExecSignal -> CodeExecSignal -> Bool
/= :: CodeExecSignal -> CodeExecSignal -> Bool
Eq, Int -> CodeExecSignal -> ShowS
[CodeExecSignal] -> ShowS
CodeExecSignal -> String
(Int -> CodeExecSignal -> ShowS)
-> (CodeExecSignal -> String)
-> ([CodeExecSignal] -> ShowS)
-> Show CodeExecSignal
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> CodeExecSignal -> ShowS
showsPrec :: Int -> CodeExecSignal -> ShowS
$cshow :: CodeExecSignal -> String
show :: CodeExecSignal -> String
$cshowList :: [CodeExecSignal] -> ShowS
showList :: [CodeExecSignal] -> ShowS
Show)

{- | The trust\/provenance signal for a version. The /how/ of trust differs by
ecosystem (npm @dist.signatures@, PyPI PEP 740 attestations, RubyGems signed
gems\/MFA) but is captured as 'TrustEvidence' so rules stay ecosystem-blind.
-}
data Trust
    = -- | Determined trusted, with the evidence supporting it.
      Trusted (NonEmpty TrustEvidence)
    | -- | Determined: no trust signal established.
      Untrusted
    | -- | Not yet determined (e.g. signature verification needs a fetch).
      TrustUnknown
    deriving stock (Trust -> Trust -> Bool
(Trust -> Trust -> Bool) -> (Trust -> Trust -> Bool) -> Eq Trust
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: Trust -> Trust -> Bool
== :: Trust -> Trust -> Bool
$c/= :: Trust -> Trust -> Bool
/= :: Trust -> Trust -> Bool
Eq, Int -> Trust -> ShowS
[Trust] -> ShowS
Trust -> String
(Int -> Trust -> ShowS)
-> (Trust -> String) -> ([Trust] -> ShowS) -> Show Trust
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> Trust -> ShowS
showsPrec :: Int -> Trust -> ShowS
$cshow :: Trust -> String
show :: Trust -> String
$cshowList :: [Trust] -> ShowS
showList :: [Trust] -> ShowS
Show)

{- | A normalised reason a version is trusted; the adapter maps its ecosystem's
mechanism onto this vocabulary.
-}
data TrustEvidence
    = -- | The artifact is cryptographically signed.
      Signed
    | -- | The artifact carries a provenance attestation (e.g. Sigstore).
      Attested
    | -- | The version was published under enforced multi-factor auth.
      MfaPublished
    | -- | An ecosystem mechanism not yet in this vocabulary (escape hatch).
      OtherEvidence Text
    deriving stock (TrustEvidence -> TrustEvidence -> Bool
(TrustEvidence -> TrustEvidence -> Bool)
-> (TrustEvidence -> TrustEvidence -> Bool) -> Eq TrustEvidence
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: TrustEvidence -> TrustEvidence -> Bool
== :: TrustEvidence -> TrustEvidence -> Bool
$c/= :: TrustEvidence -> TrustEvidence -> Bool
/= :: TrustEvidence -> TrustEvidence -> Bool
Eq, Int -> TrustEvidence -> ShowS
[TrustEvidence] -> ShowS
TrustEvidence -> String
(Int -> TrustEvidence -> ShowS)
-> (TrustEvidence -> String)
-> ([TrustEvidence] -> ShowS)
-> Show TrustEvidence
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> TrustEvidence -> ShowS
showsPrec :: Int -> TrustEvidence -> ShowS
$cshow :: TrustEvidence -> String
show :: TrustEvidence -> String
$cshowList :: [TrustEvidence] -> ShowS
showList :: [TrustEvidence] -> ShowS
Show)

-- | Whether a version is offered, advisory-deprecated, or withdrawn.
data Availability
    = -- | Offered normally.
      Available
    | -- | Advisory deprecation (npm); still resolvable. Carries the message.
      Deprecated Text
    | {- | Withdrawn from resolution (PyPI yank keeps the file; RubyGems yank
      removes it). Carries the reason, if given.
      -}
      Yanked (Maybe Text)
    deriving stock (Availability -> Availability -> Bool
(Availability -> Availability -> Bool)
-> (Availability -> Availability -> Bool) -> Eq Availability
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: Availability -> Availability -> Bool
== :: Availability -> Availability -> Bool
$c/= :: Availability -> Availability -> Bool
/= :: Availability -> Availability -> Bool
Eq, Int -> Availability -> ShowS
[Availability] -> ShowS
Availability -> String
(Int -> Availability -> ShowS)
-> (Availability -> String)
-> ([Availability] -> ShowS)
-> Show Availability
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> Availability -> ShowS
showsPrec :: Int -> Availability -> ShowS
$cshow :: Availability -> String
show :: Availability -> String
$cshowList :: [Availability] -> ShowS
showList :: [Availability] -> ShowS
Show)

-- | What kind of distribution file an artifact is.
data ArtifactKind
    = -- | An npm tarball.
      Tarball
    | -- | A PyPI source distribution (building it may execute code).
      Sdist
    | -- | A PyPI wheel; carries its compatibility tag (e.g. @"cp310-…"@).
      Wheel Text
    | -- | A RubyGems gem; carries its platform (@"ruby"@ = pure).
      Gem Text
    deriving stock (ArtifactKind -> ArtifactKind -> Bool
(ArtifactKind -> ArtifactKind -> Bool)
-> (ArtifactKind -> ArtifactKind -> Bool) -> Eq ArtifactKind
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: ArtifactKind -> ArtifactKind -> Bool
== :: ArtifactKind -> ArtifactKind -> Bool
$c/= :: ArtifactKind -> ArtifactKind -> Bool
/= :: ArtifactKind -> ArtifactKind -> Bool
Eq, Int -> ArtifactKind -> ShowS
[ArtifactKind] -> ShowS
ArtifactKind -> String
(Int -> ArtifactKind -> ShowS)
-> (ArtifactKind -> String)
-> ([ArtifactKind] -> ShowS)
-> Show ArtifactKind
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> ArtifactKind -> ShowS
showsPrec :: Int -> ArtifactKind -> ShowS
$cshow :: ArtifactKind -> String
show :: ArtifactKind -> String
$cshowList :: [ArtifactKind] -> ShowS
showList :: [ArtifactKind] -> ShowS
Show)

{- | One distribution file for a version. A version owns a 'NonEmpty' list of
these: npm has exactly one, PyPI has an sdist plus many wheels, RubyGems has one
per platform.
-}
data Artifact = Artifact
    { Artifact -> Text
artFilename :: Text
    , Artifact -> Text
artUrl :: Text
    , Artifact -> ArtifactKind
artKind :: ArtifactKind
    , Artifact -> [Hash]
artHashes :: [Hash]
    -- ^ Integrity digests; the client verifies the download against these.
    , Artifact -> Maybe Int
artSize :: Maybe Int
    {- ^ The registry-declared size, if reported. Not guaranteed to be the tarball
    byte count: npm populates it from @dist.unpackedSize@, the size of the unpacked
    tree.
    -}
    , Artifact -> Maybe Text
artInterpreter :: Maybe Text
    -- ^ Interpreter constraint (@requires-python@ \/ @required_ruby_version@).
    , Artifact -> Bool
artYanked :: Bool
    {- ^ Whether this individual file is yanked (PyPI per-file yank). For
    ecosystems that yank whole versions this stays 'False' and
    'pkgAvailability' carries the status instead.
    -}
    , Artifact -> Maybe Text
artProvenance :: Maybe Text
    -- ^ URL of a provenance\/attestation bundle, if any.
    }
    deriving stock (Artifact -> Artifact -> Bool
(Artifact -> Artifact -> Bool)
-> (Artifact -> Artifact -> Bool) -> Eq Artifact
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: Artifact -> Artifact -> Bool
== :: Artifact -> Artifact -> Bool
$c/= :: Artifact -> Artifact -> Bool
/= :: Artifact -> Artifact -> Bool
Eq, Int -> Artifact -> ShowS
[Artifact] -> ShowS
Artifact -> String
(Int -> Artifact -> ShowS)
-> (Artifact -> String) -> ([Artifact] -> ShowS) -> Show Artifact
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> Artifact -> ShowS
showsPrec :: Int -> Artifact -> ShowS
$cshow :: Artifact -> String
show :: Artifact -> String
$cshowList :: [Artifact] -> ShowS
showList :: [Artifact] -> ShowS
Show)

-- | A person associated with a package (author, maintainer, or publisher).
data Person = Person
    { Person -> Text
personName :: Text
    -- ^ The person's name, as declared by the package.
    , Person -> Maybe Text
personEmail :: Maybe Text
    -- ^ Their email address, if given.
    , Person -> Maybe Text
personUrl :: Maybe Text
    -- ^ A homepage / profile URL, if given.
    }
    deriving stock (Person -> Person -> Bool
(Person -> Person -> Bool)
-> (Person -> Person -> Bool) -> Eq Person
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: Person -> Person -> Bool
== :: Person -> Person -> Bool
$c/= :: Person -> Person -> Bool
/= :: Person -> Person -> Bool
Eq, Eq Person
Eq Person =>
(Person -> Person -> Ordering)
-> (Person -> Person -> Bool)
-> (Person -> Person -> Bool)
-> (Person -> Person -> Bool)
-> (Person -> Person -> Bool)
-> (Person -> Person -> Person)
-> (Person -> Person -> Person)
-> Ord Person
Person -> Person -> Bool
Person -> Person -> Ordering
Person -> Person -> Person
forall a.
Eq a =>
(a -> a -> Ordering)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> a)
-> (a -> a -> a)
-> Ord a
$ccompare :: Person -> Person -> Ordering
compare :: Person -> Person -> Ordering
$c< :: Person -> Person -> Bool
< :: Person -> Person -> Bool
$c<= :: Person -> Person -> Bool
<= :: Person -> Person -> Bool
$c> :: Person -> Person -> Bool
> :: Person -> Person -> Bool
$c>= :: Person -> Person -> Bool
>= :: Person -> Person -> Bool
$cmax :: Person -> Person -> Person
max :: Person -> Person -> Person
$cmin :: Person -> Person -> Person
min :: Person -> Person -> Person
Ord, Int -> Person -> ShowS
[Person] -> ShowS
Person -> String
(Int -> Person -> ShowS)
-> (Person -> String) -> ([Person] -> ShowS) -> Show Person
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> Person -> ShowS
showsPrec :: Int -> Person -> ShowS
$cshow :: Person -> String
show :: Person -> String
$cshowList :: [Person] -> ShowS
showList :: [Person] -> ShowS
Show)

{- | The ecosystem-agnostic snapshot of a single package /version/ that the
rules engine evaluates. A registry adapter projects its wire format into this;
the rules engine never sees anything else, and never branches on the ecosystem.
-}
data PackageDetails = PackageDetails
    { PackageDetails -> PackageName
pkgName :: PackageName
    -- ^ The package identity this snapshot belongs to.
    , PackageDetails -> Version
pkgVersion :: Version
    -- ^ The specific version this snapshot describes.
    , PackageDetails -> Maybe UTCTime
pkgPublishedAt :: Maybe UTCTime
    {- ^ When this version was published, if known (absent from some cheap
    metadata views).
    -}
    , PackageDetails -> CodeExecSignal
pkgInstallCode :: CodeExecSignal
    -- ^ Whether installing the version executes code.
    , PackageDetails -> Trust
pkgTrust :: Trust
    -- ^ The trust\/provenance signal for the version.
    , PackageDetails -> Availability
pkgAvailability :: Availability
    -- ^ Whether the version is offered, deprecated, or withdrawn.
    , PackageDetails -> NonEmpty Artifact
pkgArtifacts :: NonEmpty Artifact
    -- ^ The version's distribution files (one for npm; many for PyPI/RubyGems).
    , PackageDetails -> [Text]
pkgLicenses :: [Text]
    -- ^ Declared licenses (SPDX expressions/ids); may be several.
    , PackageDetails -> Maybe Person
pkgPublisher :: Maybe Person
    {- ^ Who published __this__ version, if known (provenance).

    Dependencies and maintainers are __deliberately not modelled__ (architect
    ruling, 2026-07-02). Dependencies are structurally redundant on the decision
    surface: a dependency only ever matters when it is itself fetched, and that
    fetch comes back through this same gate and receives its own verdict, so
    gating a parent's dependency /list/ would duplicate the gate that already
    sits on every child request. Not modelling them means the wire layer does
    not even parse them (a heavy packument carries thousands of per-version
    dependency entries of pure parse cost on the hot path), and a malformed
    entry there can no longer drop the version -- it degrades, per the same
    ruling. The raw document still carries everything to the client untouched;
    the served surface is lossless regardless of what the decision surface
    models. If a dependency-reading rule ever genuinely lands, restore the
    @Dependency@\/@DepKind@ vocabulary from history and re-model then.
    -}
    }
    deriving stock (PackageDetails -> PackageDetails -> Bool
(PackageDetails -> PackageDetails -> Bool)
-> (PackageDetails -> PackageDetails -> Bool) -> Eq PackageDetails
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: PackageDetails -> PackageDetails -> Bool
== :: PackageDetails -> PackageDetails -> Bool
$c/= :: PackageDetails -> PackageDetails -> Bool
/= :: PackageDetails -> PackageDetails -> Bool
Eq, Int -> PackageDetails -> ShowS
[PackageDetails] -> ShowS
PackageDetails -> String
(Int -> PackageDetails -> ShowS)
-> (PackageDetails -> String)
-> ([PackageDetails] -> ShowS)
-> Show PackageDetails
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> PackageDetails -> ShowS
showsPrec :: Int -> PackageDetails -> ShowS
$cshow :: PackageDetails -> String
show :: PackageDetails -> String
$cshowList :: [PackageDetails] -> ShowS
showList :: [PackageDetails] -> ShowS
Show)

{- | The packument-level view of a package: the whole-package metadata document
('PackageDetails' is the per-/version/ snapshot embedded within it). A registry
adapter projects a registry's packument (the npm full-metadata document) into
this; the proxy core reasons over it without ever seeing the wire format.
-}
data PackageInfo = PackageInfo
    { PackageInfo -> PackageName
infoName :: PackageName
    -- ^ The package identity this document describes.
    , PackageInfo -> Map Text PackageDetails
infoVersions :: Map Text PackageDetails
    {- ^ Every published version, keyed by its __raw version string__ (the
    packument's own key). Each 'PackageDetails' still carries its parsed
    'Version'; the map is keyed by 'Text' because a 'Version' has no 'Ord'
    (ordering goes through 'Ecluse.Core.Version.compareVersions', never a derived
    instance) -- see "Ecluse.Core.Version".
    -}
    , PackageInfo -> Map Text Version
infoDistTags :: Map Text Version
    {- ^ Distribution tags (e.g. @"latest"@, @"next"@) to the 'Version' they
    point at.
    -}
    , PackageInfo -> [InvalidEntry]
infoInvalidEntries :: [InvalidEntry]
    {- ^ The malformed entries the projection __dropped__ rather than failing the
    whole document on, retained so the serve path can surface them to an operator.
    A version's publish time lives on its 'PackageDetails.pkgPublishedAt' (the npm
    @time@ object is reconstructed at serialisation), so it is __not__ duplicated
    here; only the /dropped/ entries are.
    -}
    }
    deriving stock (PackageInfo -> PackageInfo -> Bool
(PackageInfo -> PackageInfo -> Bool)
-> (PackageInfo -> PackageInfo -> Bool) -> Eq PackageInfo
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: PackageInfo -> PackageInfo -> Bool
== :: PackageInfo -> PackageInfo -> Bool
$c/= :: PackageInfo -> PackageInfo -> Bool
/= :: PackageInfo -> PackageInfo -> Bool
Eq, Int -> PackageInfo -> ShowS
[PackageInfo] -> ShowS
PackageInfo -> String
(Int -> PackageInfo -> ShowS)
-> (PackageInfo -> String)
-> ([PackageInfo] -> ShowS)
-> Show PackageInfo
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> PackageInfo -> ShowS
showsPrec :: Int -> PackageInfo -> ShowS
$cshow :: PackageInfo -> String
show :: PackageInfo -> String
$cshowList :: [PackageInfo] -> ShowS
showList :: [PackageInfo] -> ShowS
Show)

{- | A single packument entry a registry projection __dropped__ as malformed rather
than failing the entire document, kept so the drop is observable rather than silent
(an operator can see that an upstream served a malformed entry, and which). Each
ecosystem's projection populates this from its own wire shape, so the
drop-and-track contract is the same across npm, PyPI, and RubyGems.
-}
data InvalidEntry = InvalidEntry
    { InvalidEntry -> InvalidEntryKind
invalidKind :: InvalidEntryKind
    -- ^ Which kind of packument entry was dropped.
    , InvalidEntry -> Text
invalidKey :: Text
    {- ^ The map key the dropped entry sat under: the raw version string for a
    version manifest or publish time, the tag name for a dist-tag.
    -}
    , InvalidEntry -> Value
invalidValue :: Value
    {- ^ The __raw offending value__, preserved verbatim ('Value' is lossless), so an
    operator can see exactly what the upstream sent rather than only a reason string. A
    dropped publish time keeps its raw bad date here even though the version's
    'pkgPublishedAt' folds to 'Nothing'; the gating value (absent) and the diagnostic
    (the raw bytes) are kept separate. Render it (truncating if large) at log time.
    -}
    , InvalidEntry -> Text
invalidReason :: Text
    -- ^ Why the entry could not be projected (the decode error), for the operator log.
    }
    deriving stock (InvalidEntry -> InvalidEntry -> Bool
(InvalidEntry -> InvalidEntry -> Bool)
-> (InvalidEntry -> InvalidEntry -> Bool) -> Eq InvalidEntry
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: InvalidEntry -> InvalidEntry -> Bool
== :: InvalidEntry -> InvalidEntry -> Bool
$c/= :: InvalidEntry -> InvalidEntry -> Bool
/= :: InvalidEntry -> InvalidEntry -> Bool
Eq, Int -> InvalidEntry -> ShowS
[InvalidEntry] -> ShowS
InvalidEntry -> String
(Int -> InvalidEntry -> ShowS)
-> (InvalidEntry -> String)
-> ([InvalidEntry] -> ShowS)
-> Show InvalidEntry
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> InvalidEntry -> ShowS
showsPrec :: Int -> InvalidEntry -> ShowS
$cshow :: InvalidEntry -> String
show :: InvalidEntry -> String
$cshowList :: [InvalidEntry] -> ShowS
showList :: [InvalidEntry] -> ShowS
Show)

{- | Which kind of registry-document entry a dropped 'InvalidEntry' came from. A version
manifest drop removes a serve candidate (fail-closed for that one version); a dist-tag or
publish-time drop loses only that advisory datum while the version it referred to still
resolves.
-}
data InvalidEntryKind
    = -- | A @versions@ entry whose manifest did not project (no @dist@\/@tarball@, an unusable @version@).
      InvalidVersionManifest
    | -- | A @dist-tags@ entry whose target was not a usable version string.
      InvalidDistTag
    | -- | A @time@ entry, keyed by a present version, that was not a decodable instant.
      InvalidPublishTime
    deriving stock (InvalidEntryKind -> InvalidEntryKind -> Bool
(InvalidEntryKind -> InvalidEntryKind -> Bool)
-> (InvalidEntryKind -> InvalidEntryKind -> Bool)
-> Eq InvalidEntryKind
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: InvalidEntryKind -> InvalidEntryKind -> Bool
== :: InvalidEntryKind -> InvalidEntryKind -> Bool
$c/= :: InvalidEntryKind -> InvalidEntryKind -> Bool
/= :: InvalidEntryKind -> InvalidEntryKind -> Bool
Eq, Int -> InvalidEntryKind -> ShowS
[InvalidEntryKind] -> ShowS
InvalidEntryKind -> String
(Int -> InvalidEntryKind -> ShowS)
-> (InvalidEntryKind -> String)
-> ([InvalidEntryKind] -> ShowS)
-> Show InvalidEntryKind
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> InvalidEntryKind -> ShowS
showsPrec :: Int -> InvalidEntryKind -> ShowS
$cshow :: InvalidEntryKind -> String
show :: InvalidEntryKind -> String
$cshowList :: [InvalidEntryKind] -> ShowS
showList :: [InvalidEntryKind] -> ShowS
Show)