keiro-dsl-0.4.0.1: src/Keiro/Dsl/CodecCompare.hs
{-# OPTIONS_GHC -Werror=incomplete-patterns #-}
{- | Pure historical-codec comparison and migration-evidence reports.
The comparison is deliberately finite evidence over typed fixture cases and
historical JSON goldens. It never changes which codec owns the wire schema and
never upgrades an opaque declaration to a structural claim.
-}
module Keiro.Dsl.CodecCompare (
FixtureOrigin (..),
DecodeOutcome (..),
JsonPointer (..),
ComparisonDifference (..),
HistoricalCodec (..),
CompareObservation (..),
FixtureVerdict (..),
CompareInputIssue (..),
BranchKind (..),
DeclaredBranch (..),
ObservedBranch (..),
CoverageGap (..),
BranchSchema (..),
BranchField (..),
BranchArm (..),
CompareProvenance (..),
ClassifiedObservation (..),
CompareReport (..),
ReportWriteError (..),
authorityStatement,
canonicalJsonBytes,
classifyObservation,
declaredBranchesFor,
observedBranchesFor,
compareReport,
renderCompareReport,
reportSucceeded,
writeCompareReportAtomic,
) where
import Control.Exception (IOException, bracketOnError, displayException, try)
import Control.Monad (when)
import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), object, withObject, withText, (.:), (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.RFC8785 qualified as RFC8785
import Data.Aeson.Types (Parser)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Foldable (toList)
import Data.List (sort)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.TypeGraph (BindingVersion (..), CanonicalTypeId (..), QualifiedValueName (..))
import Keiro.Dsl.Validate (DiagnosticCode (..))
import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile, renameFile)
import System.FilePath (takeDirectory, takeFileName)
import System.IO (Handle, hClose, openBinaryTempFile)
data FixtureOrigin = HistoricalGolden | FromBinding
deriving stock (Eq, Ord, Show)
data DecodeOutcome
= DecodedShape !Value
| DecodeFailed !Text
deriving stock (Eq, Show)
newtype JsonPointer = JsonPointer {unJsonPointer :: Text}
deriving stock (Eq, Ord, Show)
data ComparisonDifference
= EncodedValueDifference !JsonPointer !Value !Value
| DecodedValueDifference !JsonPointer !Value !Value
| GeneratedDecodeRejected !Text
deriving stock (Eq, Show)
{- | A historical codec is an explicit value supplied by consumer-owned test
code. Its identity and version are report provenance, not dispatch keys.
-}
data HistoricalCodec a = HistoricalCodec
{ hcIdentity :: !Text
, hcVersion :: !Text
, hcEncode :: !(a -> Value)
, hcDecode :: !(Value -> Either Text a)
}
data CompareObservation
= EncodeObservation
{ coCaseName :: !Text
, coHistoricalValue :: !Value
, coGeneratedValue :: !Value
}
| DecodeObservation
{ coFixturePath :: !FilePath
, coInputValue :: !Value
, coHistoricalDecode :: !DecodeOutcome
, coGeneratedDecode :: !DecodeOutcome
}
deriving stock (Eq, Show)
data FixtureVerdict
= JsonParity
| RequiresVersionWork !ComparisonDifference
deriving stock (Eq, Show)
data CompareInputIssue
= HistoricalGoldenUnreadable !FilePath !Text
| HistoricalCodecRejected !FilePath !Text
| HistoricalCodecProvenanceInvalid !Text
deriving stock (Eq, Show)
data BranchKind
= UnionArm !Text
| OptionalPresent
| OptionalMissing
| ExplicitNull
deriving stock (Eq, Ord, Show)
data DeclaredBranch = DeclaredBranch
{ dbOrigin :: !FixtureOrigin
, dbPointer :: !JsonPointer
, dbKind :: !BranchKind
}
deriving stock (Eq, Ord, Show)
data ObservedBranch = ObservedBranch
{ obOrigin :: !FixtureOrigin
, obPointer :: !JsonPointer
, obKind :: !BranchKind
}
deriving stock (Eq, Ord, Show)
data CoverageGap = CoverageGap
{ cgOrigin :: !FixtureOrigin
, cgPointer :: !JsonPointer
, cgKind :: !BranchKind
}
deriving stock (Eq, Ord, Show)
{- | A codec-independent branch description embedded into generated
comparison runners. The generator constructs it through the checked type
graph's total algebras, so this module never has to interpret a consumer type.
-}
data BranchSchema
= BranchScalar
| BranchOptional !BranchSchema
| BranchList !BranchSchema
| BranchMap !BranchSchema
| BranchRecord ![BranchField]
| BranchUnion !Text !Text ![BranchArm]
deriving stock (Eq, Show)
data BranchField = BranchField
{ bfWireKey :: !Text
, bfPresenceOptional :: !Bool
, bfSchema :: !BranchSchema
}
deriving stock (Eq, Show)
data BranchArm = BranchArm
{ baWireTag :: !Text
, baPayloadSchema :: !(Maybe BranchSchema)
}
deriving stock (Eq, Show)
data CompareProvenance = CompareProvenance
{ cpHistoricalCodecIdentity :: !Text
, cpHistoricalCodecVersion :: !Text
, cpCanonicalType :: !CanonicalTypeId
, cpBindingSymbol :: !QualifiedValueName
, cpBindingVersion :: !BindingVersion
, cpWireFingerprint :: !Text
}
deriving stock (Eq, Show)
data ClassifiedObservation = ClassifiedObservation
{ classifiedOrigin :: !FixtureOrigin
, classifiedName :: !Text
, classifiedVerdict :: !FixtureVerdict
}
deriving stock (Eq, Show)
data CompareReport = CompareReport
{ crProvenance :: !CompareProvenance
, crObservations :: ![ClassifiedObservation]
, crInputIssues :: ![CompareInputIssue]
, crCoverageGaps :: ![CoverageGap]
, crAuthority :: !Text
}
deriving stock (Eq, Show)
data ReportWriteError = ReportWriteError
{ reportWritePath :: !FilePath
, reportWriteMessage :: !Text
}
deriving stock (Eq, Show)
authorityStatement :: Text
authorityStatement =
"This comparison is MIGRATION EVIDENCE ONLY. After cutover the generated structural codec is the sole wire authority. This runner is never a runtime fallback and never upgrades an opaque declaration to structural. Resolve each difference with an explicit version bump and upcaster, or correct the declaration to match the historical wire contract; \"close enough\" is not an outcome."
-- | Render a JSON value in RFC 8785 canonical form.
canonicalJsonBytes :: Value -> ByteString
canonicalJsonBytes = LazyByteString.toStrict . RFC8785.encodeCanonical
classifyObservation :: CompareObservation -> Either CompareInputIssue FixtureVerdict
classifyObservation observation = case observation of
EncodeObservation _ historical generated ->
Right (classifyValues EncodedValueDifference historical generated)
DecodeObservation fixturePath _ historical generated -> case historical of
DecodeFailed reason -> Left (HistoricalCodecRejected fixturePath reason)
DecodedShape historicalValue -> case generated of
DecodeFailed reason -> Right (RequiresVersionWork (GeneratedDecodeRejected reason))
DecodedShape generatedValue ->
Right (classifyValues DecodedValueDifference historicalValue generatedValue)
classifyValues :: (JsonPointer -> Value -> Value -> ComparisonDifference) -> Value -> Value -> FixtureVerdict
classifyValues difference historical generated
| canonicalJsonBytes historical == canonicalJsonBytes generated = JsonParity
| otherwise = RequiresVersionWork (difference (firstDivergentPointer historical generated) historical generated)
compareReport ::
CompareProvenance ->
[CompareInputIssue] ->
[CompareObservation] ->
[DeclaredBranch] ->
[ObservedBranch] ->
CompareReport
compareReport provenance suppliedIssues observations declaredBranches observedBranches =
CompareReport
{ crProvenance = provenance
, crObservations = classified
, crInputIssues = provenanceIssues provenance <> suppliedIssues <> classificationIssues
, crCoverageGaps = coverageGaps declaredBranches observedBranches
, crAuthority = authorityStatement
}
where
outcomes = map classify observations
classified = [value | Right value <- outcomes]
classificationIssues = [issue | Left issue <- outcomes]
classify observation = case classifyObservation observation of
Left issue -> Left issue
Right verdict ->
Right
ClassifiedObservation
{ classifiedOrigin = observationOrigin observation
, classifiedName = observationName observation
, classifiedVerdict = verdict
}
observationOrigin :: CompareObservation -> FixtureOrigin
observationOrigin EncodeObservation{} = FromBinding
observationOrigin DecodeObservation{} = HistoricalGolden
observationName :: CompareObservation -> Text
observationName EncodeObservation{coCaseName = name} = name
observationName DecodeObservation{coFixturePath = path} = T.pack path
provenanceIssues :: CompareProvenance -> [CompareInputIssue]
provenanceIssues provenance =
[ HistoricalCodecProvenanceInvalid "historical codec identity must not be blank"
| T.null (T.strip (cpHistoricalCodecIdentity provenance))
]
<> [ HistoricalCodecProvenanceInvalid "historical codec version must not be blank"
| T.null (T.strip (cpHistoricalCodecVersion provenance))
]
coverageGaps :: [DeclaredBranch] -> [ObservedBranch] -> [CoverageGap]
coverageGaps declared observed =
[ CoverageGap (dbOrigin branch) (dbPointer branch) (dbKind branch)
| branch <- declared
, branchKey branch `Set.notMember` observedKeys
]
where
observedKeys = Set.fromList (map observedBranchKey observed)
branchKey branch = (dbOrigin branch, dbPointer branch, dbKind branch)
observedBranchKey branch = (obOrigin branch, obPointer branch, obKind branch)
declaredBranchesFor :: FixtureOrigin -> BranchSchema -> [DeclaredBranch]
declaredBranchesFor origin = Set.toAscList . go ""
where
declared pointer kind = Set.singleton (DeclaredBranch origin (JsonPointer pointer) kind)
go pointer schema = case schema of
BranchScalar -> Set.empty
BranchOptional nested ->
declared pointer OptionalPresent
<> declared pointer ExplicitNull
<> go pointer nested
BranchList nested -> go (appendPointer pointer "*") nested
BranchMap nested -> go (appendPointer pointer "*") nested
BranchRecord fields ->
Set.unions
[ presenceBranches pointer field <> go (appendPointer pointer (bfWireKey field)) (bfSchema field)
| field <- fields
]
BranchUnion _tagField contentsField arms ->
Set.unions
[ declared pointer (UnionArm (baWireTag arm))
<> maybe Set.empty (go (appendPointer pointer contentsField)) (baPayloadSchema arm)
| arm <- arms
]
presenceBranches pointer field
| bfPresenceOptional field =
let fieldPointer = appendPointer pointer (bfWireKey field)
in case origin of
HistoricalGolden -> declared fieldPointer OptionalMissing <> declared fieldPointer OptionalPresent
FromBinding -> declared fieldPointer OptionalPresent
| otherwise = Set.empty
observedBranchesFor :: FixtureOrigin -> BranchSchema -> Value -> [ObservedBranch]
observedBranchesFor origin schema = Set.toAscList . go "" schema
where
observed pointer kind = Set.singleton (ObservedBranch origin (JsonPointer pointer) kind)
go pointer branchSchema value = case branchSchema of
BranchScalar -> Set.empty
BranchOptional nested -> case value of
Null -> observed pointer ExplicitNull
_ -> observed pointer OptionalPresent <> go pointer nested value
BranchList nested -> case value of
Array values -> Set.unions [go (appendPointer pointer "*") nested item | item <- toList values]
_ -> Set.empty
BranchMap nested -> case value of
Object values -> Set.unions [go (appendPointer pointer "*") nested item | item <- KeyMap.elems values]
_ -> Set.empty
BranchRecord fields -> case value of
Object values -> Set.unions (map (observeField pointer values) fields)
_ -> Set.empty
BranchUnion tagField contentsField arms -> case value of
Object values -> case KeyMap.lookup (Key.fromText tagField) values of
Just (String tag) -> case filter ((== tag) . baWireTag) arms of
arm : _ ->
observed pointer (UnionArm tag)
<> case (baPayloadSchema arm, KeyMap.lookup (Key.fromText contentsField) values) of
(Just nested, Just payload) -> go (appendPointer pointer contentsField) nested payload
_ -> Set.empty
[] -> Set.empty
_ -> Set.empty
_ -> Set.empty
observeField pointer values field =
let fieldPointer = appendPointer pointer (bfWireKey field)
in case KeyMap.lookup (Key.fromText (bfWireKey field)) values of
Nothing
| bfPresenceOptional field -> observed fieldPointer OptionalMissing
| otherwise -> Set.empty
Just fieldValue ->
(if bfPresenceOptional field then observed fieldPointer OptionalPresent else Set.empty)
<> go fieldPointer (bfSchema field) fieldValue
reportSucceeded :: CompareReport -> Bool
reportSucceeded report =
null (crInputIssues report)
&& null (crCoverageGaps report)
&& all ((== JsonParity) . classifiedVerdict) (crObservations report)
renderCompareReport :: CompareReport -> Text
renderCompareReport report =
T.unlines
( [ "codec comparison: "
<> unCanonicalTypeId (cpCanonicalType provenance)
<> " (binding-version \""
<> unBindingVersion (cpBindingVersion provenance)
<> "\")"
, "historical codec: \""
<> cpHistoricalCodecIdentity provenance
<> "\" version \""
<> cpHistoricalCodecVersion provenance
<> "\""
, "observations: " <> tshow (length observations)
, " encode parity: " <> ratio FromBinding
, " structural decode agreement: " <> ratio HistoricalGolden
, "requires explicit version/upcaster work: " <> tshow (length differences) <> " observations [" <> codeText CodecCompareDifference <> "]"
]
<> concatMap renderDifference differences
<> [ "input issues: " <> tshow (length (crInputIssues report)) <> " [" <> codeText CodecCompareInvalidInput <> "]"
]
<> map (" " <>) (map renderInputIssue (crInputIssues report))
<> [ "coverage gaps: " <> tshow (length (crCoverageGaps report)) <> " [" <> codeText CodecCompareCoverageGap <> "]"
]
<> map (" " <>) (map renderCoverageGap (crCoverageGaps report))
<> [ if reportSucceeded report
then "result: PARITY"
else "result: NOT PARITY — " <> tshow (length differences) <> " differences"
, crAuthority report
]
)
where
provenance = crProvenance report
observations = crObservations report
differences = filter ((/= JsonParity) . classifiedVerdict) observations
ratio origin =
let matching = filter ((== origin) . classifiedOrigin) observations
parityCount = length (filter ((== JsonParity) . classifiedVerdict) matching)
in tshow parityCount <> "/" <> tshow (length matching) <> suffix origin
suffix FromBinding = " (RFC 8785 canonical form)"
suffix HistoricalGolden = ""
renderDifference :: ClassifiedObservation -> [Text]
renderDifference observation = case classifiedVerdict observation of
JsonParity -> []
RequiresVersionWork difference ->
[ " " <> classifiedName observation <> " [" <> direction <> "] at " <> pointerOf difference
, " " <> reasonOf difference
]
where
direction = case classifiedOrigin observation of
FromBinding -> "encode"
HistoricalGolden -> "decode"
renderInputIssue :: CompareInputIssue -> Text
renderInputIssue issue = case issue of
HistoricalGoldenUnreadable path reason -> T.pack path <> ": unreadable historical golden: " <> reason
HistoricalCodecRejected path reason -> T.pack path <> ": historical codec rejected its alleged golden: " <> reason
HistoricalCodecProvenanceInvalid reason -> reason
renderCoverageGap :: CoverageGap -> Text
renderCoverageGap gap =
originName (cgOrigin gap)
<> " "
<> renderPointer (cgPointer gap)
<> ": "
<> branchKindName (cgKind gap)
pointerOf :: ComparisonDifference -> Text
pointerOf difference = case difference of
EncodedValueDifference pointer _ _ -> renderPointer pointer
DecodedValueDifference pointer _ _ -> renderPointer pointer
GeneratedDecodeRejected _ -> "<root>"
reasonOf :: ComparisonDifference -> Text
reasonOf difference = case difference of
EncodedValueDifference _ historical generated ->
"historical and generated encoders produced different JSON values: " <> valuePair historical generated
DecodedValueDifference _ historical generated ->
"historical and generated decoders normalized to different structural values: " <> valuePair historical generated
GeneratedDecodeRejected reason -> "generated structural decoder rejected historical JSON: " <> reason
valuePair :: Value -> Value -> Text
valuePair historical generated = "historical=" <> tshow historical <> "; generated=" <> tshow generated
renderPointer :: JsonPointer -> Text
renderPointer (JsonPointer pointer)
| T.null pointer = "<root>"
| otherwise = pointer
writeCompareReportAtomic :: FilePath -> CompareReport -> IO (Either ReportWriteError ())
writeCompareReportAtomic path report = do
let directory = takeDirectory path
template = takeFileName path <> ".tmp"
result <- try $ do
createDirectoryIfMissing True directory
bracketOnError
(openBinaryTempFile directory template)
cleanupTemporary
( \(temporary, handle) -> do
LazyByteString.hPut handle (Aeson.encode report)
hClose handle
renameFile temporary path
)
pure $ case result of
Left err -> Left (ReportWriteError path (T.pack (displayException (err :: IOException))))
Right () -> Right ()
cleanupTemporary :: (FilePath, Handle) -> IO ()
cleanupTemporary (temporary, handle) = do
_ <- try (hClose handle) :: IO (Either IOException ())
exists <- doesFileExist temporary
when exists (removeFile temporary)
firstDivergentPointer :: Value -> Value -> JsonPointer
firstDivergentPointer = go ""
where
go pointer (Object historical) (Object generated) =
case firstDifferentKey historical generated of
Nothing -> JsonPointer pointer
Just key -> case (KeyMap.lookup (Key.fromText key) historical, KeyMap.lookup (Key.fromText key) generated) of
(Just historicalValue, Just generatedValue) -> go (appendPointer pointer key) historicalValue generatedValue
_ -> JsonPointer (appendPointer pointer key)
go pointer (Array historical) (Array generated) =
let historicalValues = toList historical
generatedValues = toList generated
in case firstDifferentIndex historicalValues generatedValues of
Nothing -> JsonPointer pointer
Just index -> case (indexMaybe index historicalValues, indexMaybe index generatedValues) of
(Just historicalValue, Just generatedValue) -> go (appendPointer pointer (tshow index)) historicalValue generatedValue
_ -> JsonPointer (appendPointer pointer (tshow index))
go pointer _ _ = JsonPointer pointer
firstDifferentKey :: KeyMap.KeyMap Value -> KeyMap.KeyMap Value -> Maybe Text
firstDifferentKey historical generated =
firstMatch differs allKeys
where
allKeys = sort (map Key.toText (KeyMap.keys historical <> KeyMap.keys generated))
differs key = KeyMap.lookup (Key.fromText key) historical /= KeyMap.lookup (Key.fromText key) generated
firstDifferentIndex :: [Value] -> [Value] -> Maybe Int
firstDifferentIndex historical generated =
firstMatch differs [0 .. max (length historical) (length generated) - 1]
where
differs index = indexMaybe index historical /= indexMaybe index generated
indexMaybe :: Int -> [a] -> Maybe a
indexMaybe index values = case drop index values of
value : _ -> Just value
[] -> Nothing
firstMatch :: (a -> Bool) -> [a] -> Maybe a
firstMatch predicate = \case
[] -> Nothing
value : rest
| predicate value -> Just value
| otherwise -> firstMatch predicate rest
appendPointer :: Text -> Text -> Text
appendPointer base segment = base <> "/" <> escapePointerSegment segment
escapePointerSegment :: Text -> Text
escapePointerSegment = T.replace "/" "~1" . T.replace "~" "~0"
tshow :: (Show a) => a -> Text
tshow = T.pack . show
codeText :: DiagnosticCode -> Text
codeText = T.pack . show
originName :: FixtureOrigin -> Text
originName HistoricalGolden = "historical-golden"
originName FromBinding = "typed-fixture"
parseOrigin :: Text -> Parser FixtureOrigin
parseOrigin "historical-golden" = pure HistoricalGolden
parseOrigin "typed-fixture" = pure FromBinding
parseOrigin value = fail ("unknown fixture origin: " <> T.unpack value)
branchKindName :: BranchKind -> Text
branchKindName kind = case kind of
UnionArm arm -> "union-arm:" <> arm
OptionalPresent -> "optional-present"
OptionalMissing -> "optional-missing"
ExplicitNull -> "explicit-null"
parseBranchKind :: Text -> Parser BranchKind
parseBranchKind value
| Just arm <- T.stripPrefix "union-arm:" value = pure (UnionArm arm)
| value == "optional-present" = pure OptionalPresent
| value == "optional-missing" = pure OptionalMissing
| value == "explicit-null" = pure ExplicitNull
| otherwise = fail ("unknown branch kind: " <> T.unpack value)
instance ToJSON FixtureOrigin where
toJSON = String . originName
instance FromJSON FixtureOrigin where
parseJSON = withText "FixtureOrigin" parseOrigin
instance ToJSON JsonPointer where
toJSON = String . unJsonPointer
instance FromJSON JsonPointer where
parseJSON = withText "JsonPointer" (pure . JsonPointer)
instance ToJSON BranchKind where
toJSON = String . branchKindName
instance FromJSON BranchKind where
parseJSON = withText "BranchKind" parseBranchKind
instance ToJSON ComparisonDifference where
toJSON difference = case difference of
EncodedValueDifference pointer historical generated ->
differenceObject "encoded-value-difference" pointer "encoder outputs differ" historical generated
DecodedValueDifference pointer historical generated ->
differenceObject "decoded-value-difference" pointer "normalized decoder outputs differ" historical generated
GeneratedDecodeRejected reason ->
object
[ "kind" .= ("generated-decode-rejected" :: Text)
, "pointer" .= JsonPointer ""
, "reason" .= reason
]
where
differenceObject kind pointer reason historical generated =
object
[ "kind" .= (kind :: Text)
, "pointer" .= pointer
, "reason" .= (reason :: Text)
, "historical" .= historical
, "generated" .= generated
]
instance FromJSON ComparisonDifference where
parseJSON = withObject "ComparisonDifference" $ \value -> do
kind <- value .: "kind" :: Parser Text
case kind of
"encoded-value-difference" -> EncodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"
"decoded-value-difference" -> DecodedValueDifference <$> value .: "pointer" <*> value .: "historical" <*> value .: "generated"
"generated-decode-rejected" -> GeneratedDecodeRejected <$> value .: "reason"
_ -> fail ("unknown comparison difference: " <> T.unpack kind)
instance ToJSON FixtureVerdict where
toJSON JsonParity = object ["verdict" .= ("json-parity" :: Text)]
toJSON (RequiresVersionWork difference) =
object
[ "verdict" .= ("requires-version-work" :: Text)
, "code" .= codeText CodecCompareDifference
, "difference" .= difference
]
instance FromJSON FixtureVerdict where
parseJSON = withObject "FixtureVerdict" $ \value -> do
verdict <- value .: "verdict" :: Parser Text
case verdict of
"json-parity" -> pure JsonParity
"requires-version-work" -> RequiresVersionWork <$> value .: "difference"
_ -> fail ("unknown fixture verdict: " <> T.unpack verdict)
instance ToJSON CompareInputIssue where
toJSON issue = case issue of
HistoricalGoldenUnreadable path reason -> issueObject "historical-golden-unreadable" path reason
HistoricalCodecRejected path reason -> issueObject "historical-codec-rejected" path reason
HistoricalCodecProvenanceInvalid reason ->
object
[ "code" .= codeText CodecCompareInvalidInput
, "kind" .= ("historical-codec-provenance-invalid" :: Text)
, "reason" .= reason
]
where
issueObject kind path reason =
object
[ "code" .= codeText CodecCompareInvalidInput
, "kind" .= (kind :: Text)
, "path" .= path
, "reason" .= reason
]
instance FromJSON CompareInputIssue where
parseJSON = withObject "CompareInputIssue" $ \value -> do
kind <- value .: "kind" :: Parser Text
case kind of
"historical-golden-unreadable" -> HistoricalGoldenUnreadable <$> value .: "path" <*> value .: "reason"
"historical-codec-rejected" -> HistoricalCodecRejected <$> value .: "path" <*> value .: "reason"
"historical-codec-provenance-invalid" -> HistoricalCodecProvenanceInvalid <$> value .: "reason"
_ -> fail ("unknown comparison input issue: " <> T.unpack kind)
instance ToJSON DeclaredBranch where
toJSON branch =
object
[ "origin" .= dbOrigin branch
, "pointer" .= dbPointer branch
, "branch" .= dbKind branch
]
instance FromJSON DeclaredBranch where
parseJSON = withObject "DeclaredBranch" $ \value ->
DeclaredBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"
instance ToJSON ObservedBranch where
toJSON branch =
object
[ "origin" .= obOrigin branch
, "pointer" .= obPointer branch
, "branch" .= obKind branch
]
instance FromJSON ObservedBranch where
parseJSON = withObject "ObservedBranch" $ \value ->
ObservedBranch <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"
instance ToJSON CoverageGap where
toJSON gap =
object
[ "code" .= codeText CodecCompareCoverageGap
, "origin" .= cgOrigin gap
, "pointer" .= cgPointer gap
, "branch" .= cgKind gap
]
instance FromJSON CoverageGap where
parseJSON = withObject "CoverageGap" $ \value ->
CoverageGap <$> value .: "origin" <*> value .: "pointer" <*> value .: "branch"
instance ToJSON CompareProvenance where
toJSON provenance =
object
[ "historicalCodecIdentity" .= cpHistoricalCodecIdentity provenance
, "historicalCodecVersion" .= cpHistoricalCodecVersion provenance
, "canonicalType" .= unCanonicalTypeId (cpCanonicalType provenance)
, "bindingSymbol" .= unQualifiedValueName (cpBindingSymbol provenance)
, "bindingVersion" .= unBindingVersion (cpBindingVersion provenance)
, "wireFingerprint" .= cpWireFingerprint provenance
]
instance FromJSON CompareProvenance where
parseJSON = withObject "CompareProvenance" $ \value ->
CompareProvenance
<$> value .: "historicalCodecIdentity"
<*> value .: "historicalCodecVersion"
<*> (CanonicalTypeId <$> value .: "canonicalType")
<*> (QualifiedValueName <$> value .: "bindingSymbol")
<*> (BindingVersion <$> value .: "bindingVersion")
<*> value .: "wireFingerprint"
instance ToJSON ClassifiedObservation where
toJSON observation =
object
[ "origin" .= classifiedOrigin observation
, "name" .= classifiedName observation
, "result" .= classifiedVerdict observation
]
instance FromJSON ClassifiedObservation where
parseJSON = withObject "ClassifiedObservation" $ \value ->
ClassifiedObservation <$> value .: "origin" <*> value .: "name" <*> value .: "result"
instance ToJSON CompareReport where
toJSON report =
object
[ "schema" .= ("keiro-dsl/codec-compare-report/1" :: Text)
, "authority" .= crAuthority report
, "provenance" .= crProvenance report
, "success" .= reportSucceeded report
, "summary"
.= object
[ "observations" .= length (crObservations report)
, "parity" .= length (filter ((== JsonParity) . classifiedVerdict) (crObservations report))
, "differences" .= length (filter ((/= JsonParity) . classifiedVerdict) (crObservations report))
, "inputIssues" .= length (crInputIssues report)
, "coverageGaps" .= length (crCoverageGaps report)
]
, "observations" .= crObservations report
, "inputIssues" .= crInputIssues report
, "coverageGaps" .= crCoverageGaps report
]
instance FromJSON CompareReport where
parseJSON = withObject "CompareReport" $ \value ->
CompareReport
<$> value .: "provenance"
<*> value .: "observations"
<*> value .: "inputIssues"
<*> value .: "coverageGaps"
<*> value .: "authority"