keiro-dsl-0.6.0.0: 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
| ExprInteger
| 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,
onInteger = ExprInteger,
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