packages feed

keiro-dsl-0.4.0.1: src/Keiro/Dsl/MappedDiff.hs

{-# OPTIONS_GHC -Werror=incomplete-patterns #-}

{- | Recursive, wire-aware differences for consumer-owned mapped types.

This module deliberately returns mapped findings rather than importing the
ordinary 'Change' type: 'Keiro.Dsl.Diff' owns compatibility vectors and turns
each complete mapped use path into the appropriate event, snapshot, or build
finding. Keeping that seam acyclic also makes the recursive comparison usable
by mutation coverage without rendering a report.
-}
module Keiro.Dsl.MappedDiff (
    MappedFinding (..),
    diffMapped,
    renderMappedSubject,
) where

import Data.List (find, nubBy, sortOn)
import Data.Map.Strict qualified as Map
import Data.Maybe (isNothing)
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.Grammar
import Keiro.Dsl.TypeGraph
import Keiro.Dsl.Validate (DiagnosticCode (..))

data MappedFinding = MappedFinding
    { mfDeclaration :: !Name
    , mfLeaf :: !Text
    , mfCode :: !DiagnosticCode
    , mfDetail :: !Text
    , mfUsePaths :: ![UsePath]
    , mfOldUnknownFields :: !(Maybe UnknownFields)
    }
    deriving stock (Eq, Show)

{- | Compare valid old/new mapped graphs. A spec that cannot resolve has
already failed @check@; the ordinary differ therefore emits no speculative
mapped compatibility claim for it.
-}
diffMapped :: Spec -> Spec -> [MappedFinding]
diffMapped oldSpec newSpec = case (resolveTypeGraph oldSpec, resolveTypeGraph newSpec) of
    (Right oldGraph, Right newGraph) ->
        concatMap (uncurry (diffDeclaration oldGraph newGraph)) matched
            ++ map (addedDeclaration newGraph) added
            ++ map (removedDeclaration oldGraph) removed
      where
        oldDeclarations = tgDeclarations oldGraph
        newDeclarations = tgDeclarations newGraph
        matched =
            [ (oldDeclaration, newDeclaration)
            | (key, newDeclaration) <- Map.toList newDeclarations
            , oldDeclaration <- maybeToList (Map.lookup key oldDeclarations)
            ]
        added =
            [ (key, declaration)
            | (key, declaration) <- Map.toList newDeclarations
            , Map.notMember key oldDeclarations
            ]
        removed =
            [ (key, declaration)
            | (key, declaration) <- Map.toList oldDeclarations
            , Map.notMember key newDeclarations
            ]
    _ -> []

renderMappedSubject :: UsePath -> Text -> Text
renderMappedSubject path leaf =
    renderUsePath path <> if T.null leaf then "" else " " <> leaf

data DeclView
    = StructuralView !StructuralDecl !ShapeView
    | OpaqueView !OpaqueDecl

data ShapeView
    = RecordView !Name !UnknownFields ![ResolvedWireField]
    | EnumView ![WireEnum]
    | UnionView !UnionEncoding ![ResolvedWireArm]

data ExprView
    = ExprText
    | ExprInt
    | ExprBool
    | ExprNatural
    | ExprTime
    | ExprJson
    | ExprOptional !ExprView
    | ExprList !ExprView
    | ExprMap !ExprView
    | ExprRef !MappedKey
    deriving stock (Eq, Show)

declView :: ResolvedMappedDecl -> DeclView
declView =
    foldMappedDecl
        MappedDeclAlgebra
            { onStructuralDecl = \declaration shape -> StructuralView declaration (shapeView shape)
            , onOpaqueDecl = OpaqueView
            }

shapeView :: ResolvedMappedShape -> ShapeView
shapeView =
    foldMappedShape
        MappedShapeAlgebra
            { onRecord = RecordView
            , onEnum = EnumView
            , onUnion = UnionView
            }

exprView :: ResolvedTypeExpr -> ExprView
exprView =
    foldTypeExpr
        TypeExprAlgebra
            { onText = ExprText
            , onInt = ExprInt
            , onBool = ExprBool
            , onNatural = ExprNatural
            , onTime = ExprTime
            , onJson = ExprJson
            , onOptional = ExprOptional
            , onList = ExprList
            , onMap = ExprMap
            , onRef = ExprRef
            }

diffDeclaration :: TypeGraph -> TypeGraph -> ResolvedMappedDecl -> ResolvedMappedDecl -> [MappedFinding]
diffDeclaration oldGraph newGraph oldResolved newResolved =
    case (declView oldResolved, declView newResolved) of
        (StructuralView oldDeclaration oldShape, StructuralView newDeclaration newShape) ->
            metadataDiff paths oldDeclaration newDeclaration
                ++ diffShape paths name oldShape newShape
        (OpaqueView oldDeclaration, OpaqueView newDeclaration) ->
            opaqueMetadataDiff paths oldDeclaration newDeclaration
        _ ->
            [ finding
                paths
                name
                ""
                MappedModeCrossed
                "mapped declaration crossed the structural/opaque boundary; no structural proof can establish codec parity"
            ]
  where
    name = resolvedName newResolved
    paths = pathsFor oldGraph newGraph name

metadataDiff :: [UsePath] -> StructuralDecl -> StructuralDecl -> [MappedFinding]
metadataDiff paths oldDeclaration newDeclaration =
    [ finding
        paths
        name
        "haskell"
        MappedHaskellSourceChanged
        "consumer package, module, or type changed without changing declared wire identity; recompile every affected consumer"
    | sdHaskell oldDeclaration /= sdHaskell newDeclaration
    ]
        ++ [ finding
                paths
                name
                "binding"
                MappedBindingChanged
                "binding symbol or binding-version changed; diff cannot inspect binding behavior, so run the two-law, codec, and historical-fixture conformance suite"
           | (sdBinding oldDeclaration, sdBindingVersion oldDeclaration)
                /= (sdBinding newDeclaration, sdBindingVersion newDeclaration)
           ]
        ++ [ finding
                paths
                name
                "fixtures"
                MappedFixturesChanged
                "fixture evidence symbol changed; runtime wire policy is unchanged, but the complete conformance suite must run"
           | sdFixtures oldDeclaration /= sdFixtures newDeclaration
           ]
        ++ [ finding
                paths
                name
                "initial"
                MappedInitialChanged
                "mapped initial symbol changed; new streams and snapshot fingerprints may change while historical event decoding does not"
           | sdInitial oldDeclaration /= sdInitial newDeclaration
           ]
        ++ [ finding
                paths
                name
                "canonical-type"
                MappedCanonicalTypeChanged
                "canonical type identity changed; rebuild generated projections and invalidate mapped snapshots while declared event bytes remain unchanged"
           | sdCanonical oldDeclaration /= sdCanonical newDeclaration
           ]
  where
    name = sdName newDeclaration

opaqueMetadataDiff :: [UsePath] -> OpaqueDecl -> OpaqueDecl -> [MappedFinding]
opaqueMetadataDiff paths oldDeclaration newDeclaration =
    [ finding
        paths
        name
        "haskell"
        MappedHaskellSourceChanged
        "consumer package, module, or type changed without changing the opaque codec claim; recompile every affected consumer"
    | odHaskell oldDeclaration /= odHaskell newDeclaration
    ]
        ++ [ finding
                paths
                name
                "codec"
                MappedOpaqueCodecChanged
                "opaque codec identity or version changed; Keiro cannot inspect the codec and historical payload compatibility is unproven"
           | (odCodecIdentity oldDeclaration, odCodecVersion oldDeclaration)
                /= (odCodecIdentity newDeclaration, odCodecVersion newDeclaration)
           ]
        ++ [ finding
                paths
                name
                "fixtures"
                MappedFixturesChanged
                "fixture evidence symbol changed; runtime codec identity is unchanged, but the complete conformance suite must run"
           | odFixtures oldDeclaration /= odFixtures newDeclaration
           ]
        ++ [ finding
                paths
                name
                "initial"
                MappedInitialChanged
                "mapped initial symbol changed; new streams and snapshot fingerprints may change while historical event decoding does not"
           | odInitial oldDeclaration /= odInitial newDeclaration
           ]
  where
    name = odName newDeclaration

diffShape :: [UsePath] -> Name -> ShapeView -> ShapeView -> [MappedFinding]
diffShape paths declaration oldShape newShape = case (oldShape, newShape) of
    (RecordView oldConstructor oldUnknown oldFields, RecordView newConstructor newUnknown newFields) ->
        [ finding
            paths
            declaration
            "constructor"
            MappedRecordConstructorChanged
            "record constructor changed without changing the JSON wire identity; recompile affected consumers"
        | oldConstructor /= newConstructor
        ]
            ++ [ finding
                    paths
                    declaration
                    "unknown-fields"
                    MappedUnionEncodingChanged
                    "record unknown-fields policy changed; historical and mixed-version decoding posture is no longer the same"
               | oldUnknown /= newUnknown
               ]
            ++ diffRecord paths declaration oldUnknown oldFields newFields
    (EnumView oldEntries, EnumView newEntries) -> diffEnum paths declaration oldEntries newEntries
    (UnionView oldEncoding oldArms, UnionView newEncoding newArms) ->
        [ finding
            paths
            declaration
            "encoding"
            MappedUnionEncodingChanged
            "tagged-object encoding changed; version and upcast every affected private event root"
        | oldEncoding /= newEncoding
        ]
            ++ diffUnion paths declaration oldArms newArms
    _ ->
        [ finding
            paths
            declaration
            "shape"
            MappedUnionEncodingChanged
            "structural shape kind changed; version and upcast every affected private event root"
        ]

diffRecord :: [UsePath] -> Name -> UnknownFields -> [ResolvedWireField] -> [ResolvedWireField] -> [MappedFinding]
diffRecord paths declaration oldUnknown oldFields newFields =
    concatMap (uncurry (diffField paths declaration)) matched
        ++ map addedFinding added
        ++ map removedFinding removed
  where
    (matched, added, removed) = pairFields oldFields newFields
    addedFinding field =
        (findingWithUnknown paths declaration (fieldLeaf field) code detail (Just oldUnknown))
      where
        hasDefault = isJustValue (rwfOnMissing field)
        code
            | hasDefault = MappedFieldAddedWithDefault
            | otherwise = MappedFieldAddedNoDefault
        oldPolicy = case oldUnknown of RejectUnknown -> "reject"; IgnoreUnknown -> "ignore"
        detail
            | hasDefault =
                "field added with an explicit on-missing default; new readers preserve old meaning, while old readers use unknown-fields="
                    <> oldPolicy
            | otherwise =
                "field added without an on-missing default; old payloads do not contain it, so version and upcast every affected private event root"
    removedFinding field =
        finding
            paths
            declaration
            (fieldLeaf field)
            MappedFieldRemoved
            "field removed; replay-relevant removal remains breaking even when a tolerant decoder would ignore the historical key"

pairFields :: [ResolvedWireField] -> [ResolvedWireField] -> ([(ResolvedWireField, ResolvedWireField)], [ResolvedWireField], [ResolvedWireField])
pairFields oldFields newFields = (exact <> fallback, added, removed)
  where
    exact =
        [ (oldField, newField)
        | newField <- newFields
        , oldField <- maybeToList (find ((== rwfHaskell newField) . rwfHaskell) oldFields)
        ]
    matchedOld = map (rwfHaskell . fst) exact
    matchedNew = map (rwfHaskell . snd) exact
    unmatchedOld = [field | field <- oldFields, rwfHaskell field `notElem` matchedOld]
    unmatchedNew = [field | field <- newFields, rwfHaskell field `notElem` matchedNew]
    fallback =
        [ (oldField, newField)
        | newField <- unmatchedNew
        , oldField <- maybeToList (find ((== rwfKey newField) . rwfKey) unmatchedOld)
        ]
    fallbackOld = map (rwfHaskell . fst) fallback
    fallbackNew = map (rwfHaskell . snd) fallback
    removed = [field | field <- unmatchedOld, rwfHaskell field `notElem` fallbackOld]
    added = [field | field <- unmatchedNew, rwfHaskell field `notElem` fallbackNew]

diffField :: [UsePath] -> Name -> ResolvedWireField -> ResolvedWireField -> [MappedFinding]
diffField paths declaration oldField newField =
    [ finding
        paths
        declaration
        leaf
        MappedWireKeyChanged
        ("wire key changed '" <> rwfKey oldField <> "' -> '" <> rwfKey newField <> "'; version and upcast every affected private event root")
    | rwfKey oldField /= rwfKey newField
    ]
        ++ [ finding
                paths
                declaration
                leaf
                MappedPresenceChanged
                "field presence changed between required and optional; historical decode policy changed"
           | rwfPresence oldField /= rwfPresence newField
           ]
        ++ defaultChanges
        ++ diffExpr paths declaration (leaf <> ".type") (rwfType oldField) (rwfType newField)
  where
    leaf = fieldLeaf newField
    defaultChanges = case (rwfOnMissing oldField, rwfOnMissing newField) of
        (Just _, Nothing) ->
            [ finding
                paths
                declaration
                leaf
                MappedDefaultRemoved
                "on-missing default was removed; old payloads may no longer decode with preserved meaning"
            ]
        (oldDefault, newDefault)
            | oldDefault /= newDefault ->
                [ finding
                    paths
                    declaration
                    leaf
                    MappedDefaultChanged
                    "on-missing default changed; the same historical bytes now construct a different consumer value"
                ]
        _ -> []

diffExpr :: [UsePath] -> Name -> Text -> ResolvedTypeExpr -> ResolvedTypeExpr -> [MappedFinding]
diffExpr paths declaration leaf oldExpression newExpression =
    case (exprView oldExpression, exprView newExpression) of
        (oldView, newView)
            | oldView == newView -> []
        (ExprOptional oldValue, ExprOptional newValue) -> recurse ".optional" oldValue newValue
        (ExprList oldValue, ExprList newValue) -> recurse "[]" oldValue newValue
        (ExprMap oldValue, ExprMap newValue) -> recurse "{}" oldValue newValue
        (ExprOptional _, _) -> nullability
        (_, ExprOptional _) -> nullability
        _ ->
            [ finding
                paths
                declaration
                leaf
                MappedFieldTypeChanged
                "wire type changed; version and upcast every affected private event root"
            ]
  where
    recurse suffix oldView newView = diffExprViews paths declaration (leaf <> suffix) oldView newView
    nullability =
        [ finding
            paths
            declaration
            leaf
            MappedNullabilityChanged
            "Optional nullability changed; historical null and non-null meanings are no longer stable"
        ]

diffExprViews :: [UsePath] -> Name -> Text -> ExprView -> ExprView -> [MappedFinding]
diffExprViews paths declaration leaf oldView newView = case (oldView, newView) of
    _ | oldView == newView -> []
    (ExprOptional oldValue, ExprOptional newValue) -> diffExprViews paths declaration (leaf <> ".optional") oldValue newValue
    (ExprList oldValue, ExprList newValue) -> diffExprViews paths declaration (leaf <> "[]") oldValue newValue
    (ExprMap oldValue, ExprMap newValue) -> diffExprViews paths declaration (leaf <> "{}") oldValue newValue
    (ExprOptional _, _) -> nullability
    (_, ExprOptional _) -> nullability
    _ -> [finding paths declaration leaf MappedFieldTypeChanged "wire type changed; version and upcast every affected private event root"]
  where
    nullability = [finding paths declaration leaf MappedNullabilityChanged "Optional nullability changed; historical null and non-null meanings are no longer stable"]

diffEnum :: [UsePath] -> Name -> [WireEnum] -> [WireEnum] -> [MappedFinding]
diffEnum paths declaration oldEntries newEntries =
    [ finding
        paths
        declaration
        (enumLeaf newEntry)
        MappedEnumSpellingChanged
        ("enum wire spelling changed '" <> weTag oldEntry <> "' -> '" <> weTag newEntry <> "'")
    | newEntry <- newEntries
    , oldEntry <- maybeToList (find ((== weCtor newEntry) . weCtor) oldEntries)
    , weTag oldEntry /= weTag newEntry
    ]
        ++ [ finding
                paths
                declaration
                (enumLeaf entry)
                MappedEnumValueAdded
                "enum value added; existing history remains readable, but deploy readers before writers emit the new spelling; a future public surface exposing this closed enum would classify the addition as consumer-breaking"
           | entry <- newEntries
           , isNothing (find ((== weCtor entry) . weCtor) oldEntries)
           ]
        ++ [ finding
                paths
                declaration
                (enumLeaf entry)
                MappedEnumValueRemoved
                "enum value removed; historical payloads carrying its wire spelling no longer decode"
           | entry <- oldEntries
           , isNothing (find ((== weCtor entry) . weCtor) newEntries)
           ]

diffUnion :: [UsePath] -> Name -> [ResolvedWireArm] -> [ResolvedWireArm] -> [MappedFinding]
diffUnion paths declaration oldArms newArms =
    concatMap (uncurry pairedArm) matched
        ++ map addedArm added
        ++ map removedArm removed
  where
    (matched, added, removed) = pairArms oldArms newArms
    pairedArm oldArm newArm =
        [ finding
            paths
            declaration
            (armLeaf newArm)
            MappedArmTagChanged
            ("union arm tag changed '" <> rwaTag oldArm <> "' -> '" <> rwaTag newArm <> "'")
        | rwaTag oldArm /= rwaTag newArm
        ]
            ++ case (rwaPayload oldArm, rwaPayload newArm) of
                (Nothing, Nothing) -> []
                (Just oldPayload, Just newPayload) -> diffExpr paths declaration (armLeaf newArm <> ".payload") oldPayload newPayload
                _ -> [finding paths declaration (armLeaf newArm) MappedFieldTypeChanged "union arm payload presence changed; historical tagged objects no longer share one wire shape"]
    addedArm arm = finding paths declaration (armLeaf arm) MappedArmAdded "union arm added; existing history remains readable, but older binaries cannot read the new arm once emitted, so deploy readers before writers; a future public surface exposing this closed union would classify the addition as consumer-breaking"
    removedArm arm = finding paths declaration (armLeaf arm) MappedArmRemoved "union arm removed; historical tagged objects carrying that tag no longer decode"

pairArms :: [ResolvedWireArm] -> [ResolvedWireArm] -> ([(ResolvedWireArm, ResolvedWireArm)], [ResolvedWireArm], [ResolvedWireArm])
pairArms oldArms newArms = (exact <> fallback, added, removed)
  where
    exact =
        [ (oldArm, newArm)
        | newArm <- newArms
        , oldArm <- maybeToList (find ((== rwaCtor newArm) . rwaCtor) oldArms)
        ]
    matchedOld = map (rwaCtor . fst) exact
    matchedNew = map (rwaCtor . snd) exact
    unmatchedOld = [arm | arm <- oldArms, rwaCtor arm `notElem` matchedOld]
    unmatchedNew = [arm | arm <- newArms, rwaCtor arm `notElem` matchedNew]
    fallback =
        [ (oldArm, newArm)
        | newArm <- unmatchedNew
        , oldArm <- maybeToList (find ((== rwaTag newArm) . rwaTag) unmatchedOld)
        ]
    fallbackOld = map (rwaCtor . fst) fallback
    fallbackNew = map (rwaCtor . snd) fallback
    removed = [arm | arm <- unmatchedOld, rwaCtor arm `notElem` fallbackOld]
    added = [arm | arm <- unmatchedNew, rwaCtor arm `notElem` fallbackNew]

addedDeclaration :: TypeGraph -> (MappedKey, ResolvedMappedDecl) -> MappedFinding
addedDeclaration _ (key, _) =
    finding
        []
        (unMappedKey key)
        ""
        MappedDeclAdded
        "new mapped declaration; use-site changes retain their own compatibility classification"

removedDeclaration :: TypeGraph -> (MappedKey, ResolvedMappedDecl) -> MappedFinding
removedDeclaration graph (key, _) =
    finding
        (usePaths graph (unMappedKey key))
        (unMappedKey key)
        ""
        MappedDeclRemoved
        "mapped declaration removed; persisted roots using its historical decoder require migration, while an unused source-only declaration requires consumer rebuild only"

pathsFor :: TypeGraph -> TypeGraph -> Name -> [UsePath]
pathsFor oldGraph newGraph name =
    nubBy sameRendered . sortOn renderUsePath $ usePaths oldGraph name <> usePaths newGraph name
  where
    sameRendered left right = renderUsePath left == renderUsePath right

resolvedName :: ResolvedMappedDecl -> Name
resolvedName =
    foldMappedDecl
        MappedDeclAlgebra
            { onStructuralDecl = \declaration _ -> sdName declaration
            , onOpaqueDecl = odName
            }

fieldLeaf :: ResolvedWireField -> Text
fieldLeaf field = ".field " <> rwfHaskell field <> "[\"" <> rwfKey field <> "\"]"

armLeaf :: ResolvedWireArm -> Text
armLeaf arm = ".arm " <> rwaCtor arm <> "[\"" <> rwaTag arm <> "\"]"

enumLeaf :: WireEnum -> Text
enumLeaf entry = ".enum " <> weCtor entry <> "[\"" <> weTag entry <> "\"]"

finding :: [UsePath] -> Name -> Text -> DiagnosticCode -> Text -> MappedFinding
finding paths declaration leaf code detail =
    findingWithUnknown paths declaration leaf code detail Nothing

findingWithUnknown :: [UsePath] -> Name -> Text -> DiagnosticCode -> Text -> Maybe UnknownFields -> MappedFinding
findingWithUnknown paths declaration leaf code detail unknownFields =
    MappedFinding
        { mfDeclaration = declaration
        , mfLeaf = leaf
        , mfCode = code
        , mfDetail = detail
        , mfUsePaths = paths
        , mfOldUnknownFields = unknownFields
        }

isJustValue :: Maybe a -> Bool
isJustValue = not . isNothing

maybeToList :: Maybe a -> [a]
maybeToList = maybe [] pure