packages feed

keiro-dsl-0.5.0.0: src/Keiro/Dsl/DiffReport.hs

{- | Pure rendering and JSON encoding for compatibility-vector diff reports.

The JSON schema identifier is @keiro-dsl/diff-report/1@.  Consumers must
ignore unknown object keys.  Vector keys and entries in the @paths@ array are
append-only so later nested type-expression work can refine findings without
invalidating version-1 readers. Workspace inputs add a top-level @workspace@
object and optional per-finding @declaration@ and @useSites@ keys; single-file
reports keep their original bytes.
-}
module Keiro.Dsl.DiffReport (
    Remedy (..),
    DiffReport,
    diffReport,
    OwnedSite (..),
    WorkspaceChange (..),
    WorkspaceMeta (..),
    WorkspaceDiffReport,
    workspaceDiffReport,
    remediationFor,
    renderRemedy,
    renderFinding,
    renderVectorLine,
    renderExplainBlock,
    surfaceName,
    parseSurfaceName,
    verdictName,
    rolloutName,
) where

import Data.Aeson (ToJSON (..), Value, object, (.=))
import Data.Aeson.Types (Pair)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NonEmpty
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.Diff
import Keiro.Dsl.Validate (DiagnosticCode (..))

data Remedy
    = RemedyVersionBump
    | RemedyUpcaster
    | RemedyDeploymentOrder RolloutConstraint
    | RemedyContractRevision
    | RemedyReplayOnlyEdge
    | RemedyStateCodecBump
    | RemedyRecompileConsumers
    | RemedyRescaffoldWorkspace
    | RemedyRunConformance
    | RemedyDoNotDeploy Text
    deriving stock (Eq, Show)

data DiffReport = DiffReport
    { reportGate :: !(Set CompatibilitySurface)
    , reportFindings :: ![Change]
    }
    deriving stock (Eq, Show)

diffReport :: Set CompatibilitySurface -> [Change] -> DiffReport
diffReport = DiffReport

-- | One source location from a composed workspace's ownership index.
data OwnedSite = OwnedSite
    { osFile :: !FilePath
    , osLine :: !Int
    }
    deriving stock (Eq, Show)

-- | A merged-graph finding enriched with declaration and use-site ownership.
data WorkspaceChange = WorkspaceChange
    { wcChange :: !Change
    , wcDeclarationSite :: !(Maybe OwnedSite)
    , wcUseSites :: ![(Text, Maybe OwnedSite)]
    }
    deriving stock (Eq, Show)

-- | Provenance for the two workspace graphs compared by one command.
data WorkspaceMeta = WorkspaceMeta
    { wmIdentity :: !Text
    , wmManifest :: !FilePath
    , wmSince :: !Text
    , wmMembersOld :: ![FilePath]
    , wmMembersNew :: ![FilePath]
    , wmAdoptionBaseline :: !Bool
    }
    deriving stock (Eq, Show)

data WorkspaceDiffReport = WorkspaceDiffReport
    { workspaceReportMeta :: !WorkspaceMeta
    , workspaceReportGate :: !(Set CompatibilitySurface)
    , workspaceReportFindings :: ![WorkspaceChange]
    }
    deriving stock (Eq, Show)

workspaceDiffReport :: WorkspaceMeta -> Set CompatibilitySurface -> [WorkspaceChange] -> WorkspaceDiffReport
workspaceDiffReport = WorkspaceDiffReport

instance ToJSON DiffReport where
    toJSON report =
        object
            [ "schema" .= ("keiro-dsl/diff-report/1" :: Text)
            , "gate" .= map surfaceName (Set.toAscList (reportGate report))
            , "breaking" .= any (gatedBreaking (reportGate report)) (reportFindings report)
            , "findings" .= map (findingValue (reportGate report)) (reportFindings report)
            ]

instance ToJSON WorkspaceDiffReport where
    toJSON report =
        object
            [ "schema" .= ("keiro-dsl/diff-report/1" :: Text)
            , "gate" .= map surfaceName (Set.toAscList (workspaceReportGate report))
            , "breaking" .= any (gatedBreaking (workspaceReportGate report) . wcChange) (workspaceReportFindings report)
            , "findings" .= map (workspaceFindingValue (workspaceReportGate report)) (workspaceReportFindings report)
            , "workspace" .= workspaceMetaValue (workspaceReportMeta report)
            ]

findingValue :: Set CompatibilitySurface -> Change -> Value
findingValue gate change = object (findingPairs gate change)

workspaceFindingValue :: Set CompatibilitySurface -> WorkspaceChange -> Value
workspaceFindingValue gate workspaceChange =
    object
        ( findingPairs gate (wcChange workspaceChange)
            <> maybe [] (\site -> ["declaration" .= ownedSiteValue site]) (wcDeclarationSite workspaceChange)
            <> ["useSites" .= map useSiteValue (wcUseSites workspaceChange) | not (null (wcUseSites workspaceChange))]
        )

findingPairs :: Set CompatibilitySurface -> Change -> [Pair]
findingPairs gate change =
    [ "label" .= labelName (deriveLabel gate (ckVector kind))
    , "node" .= ckNode kind
    , "facet" .= ckFacet kind
    , "subject" .= ckSubject kind
    , "code" .= T.pack (show (ckCode kind))
    , "paths" .= ckPaths kind
    , "vector" .= vectorValue (ckVector kind)
    , "detail" .= ckDetail kind
    , "remedies" .= map renderRemedy (NonEmpty.toList (remediationFor (ckContext kind) (ckCode kind)))
    ]
  where
    kind = changeKind change

ownedSiteValue :: OwnedSite -> Value
ownedSiteValue site = object ["file" .= osFile site, "line" .= osLine site]

useSiteValue :: (Text, Maybe OwnedSite) -> Value
useSiteValue (path, site) =
    object
        ( ["path" .= path]
            <> maybe [] (\owned -> ["file" .= osFile owned, "line" .= osLine owned]) site
        )

workspaceMetaValue :: WorkspaceMeta -> Value
workspaceMetaValue meta =
    object
        [ "identity" .= wmIdentity meta
        , "manifest" .= wmManifest meta
        , "since" .= wmSince meta
        , "membersOld" .= wmMembersOld meta
        , "membersNew" .= wmMembersNew meta
        , "adoptionBaseline" .= wmAdoptionBaseline meta
        ]

vectorValue :: CompatibilityVector -> Value
vectorValue vector =
    object
        [ "private-history-read" .= verdictName (cvPrivateHistoryRead vector)
        , "old-binary-read-new-events" .= verdictName (cvOldBinaryReadNewEvents vector)
        , "snapshot-hydration" .= verdictName (cvSnapshotHydration vector)
        , "public-consumer" .= verdictName (cvPublicConsumer vector)
        , "persisted-identity" .= verdictName (cvPersistedIdentity vector)
        , "consumer-build" .= verdictName (cvConsumerBuild vector)
        , "rollout" .= map rolloutName (Set.toAscList (cvRollout vector))
        ]

remediationFor :: ChangeContext -> DiagnosticCode -> NonEmpty Remedy
remediationFor context code
    | code == OwnershipMoved = RemedyRescaffoldWorkspace :| []
    | code == WorkspaceAuthorityChanged = RemedyRescaffoldWorkspace :| [RemedyRecompileConsumers]
    | code == AggGuardTightened = RemedyReplayOnlyEdge :| [RemedyRunConformance]
    | code == AggFoldSurfaceChanged = RemedyStateCodecBump :| [RemedyRunConformance]
    | code `elem` mappedWireCodes = mappedWireRemedy
    | code `elem` [MappedFieldAddedWithDefault, MappedArmAdded, MappedEnumValueAdded] = mappedAdditionRemedy
    | code `elem` [MappedHaskellSourceChanged, MappedRecordConstructorChanged] =
        RemedyRecompileConsumers :| [RemedyRunConformance]
    | code == MappedBindingChanged = mappedConformanceRemedy
    | code == MappedFixturesChanged = RemedyRunConformance :| []
    | code == MappedInitialChanged = mappedSnapshotConformanceRemedy
    | code == MappedCanonicalTypeChanged = mappedCanonicalRemedy
    | code == MappedDeclAdded = RemedyRunConformance :| []
    | code `elem` eventDecodeCodes =
        RemedyVersionBump :| [RemedyUpcaster, RemedyDeploymentOrder RolloutStopTheWorld]
    | code `elem` contractCodes =
        RemedyContractRevision :| [RemedyDeploymentOrder RolloutProducerLast]
    | code `elem` queueCodes =
        RemedyDeploymentOrder RolloutWorkersFirst :| [RemedyRunConformance]
    | code `elem` identityCodes =
        RemedyDoNotDeploy "revert the re-keying change or perform an explicit operational identity migration" :| []
    | code == EnumCtorAdded = case Set.toAscList (cvRollout vector) of
        rollout : _ -> RemedyDeploymentOrder rollout :| [snapshotRemedy]
        [] -> snapshotRemedy :| []
    | cvConsumerBuild vector `elem` [VAdvisory, VBreaking] =
        RemedyRecompileConsumers :| [RemedyRunConformance]
    | Just rollout <- firstRollout = RemedyDeploymentOrder rollout :| [RemedyRunConformance]
    | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]
    | otherwise = RemedyRunConformance :| []
  where
    vector = classifyCompatibility context code
    firstRollout = case Set.toAscList (cvRollout vector) of
        rollout : _ -> Just rollout
        [] -> Nothing
    snapshotRemedy
        | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump
        | otherwise = RemedyRunConformance
    mappedWireRemedy
        | cvPrivateHistoryRead vector == VBreaking =
            RemedyVersionBump :| [RemedyUpcaster, RemedyDeploymentOrder RolloutStopTheWorld]
        | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]
        | otherwise = RemedyRecompileConsumers :| [RemedyRunConformance]
    mappedAdditionRemedy
        | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]
        | Just rollout <- firstRollout = RemedyDeploymentOrder rollout :| [RemedyRunConformance]
        | otherwise = RemedyRunConformance :| []
    mappedConformanceRemedy
        | cvSnapshotHydration vector == VAdvisory = RemedyRunConformance :| [RemedyStateCodecBump]
        | otherwise = RemedyRunConformance :| []
    mappedSnapshotConformanceRemedy
        | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRunConformance]
        | otherwise = RemedyRunConformance :| []
    mappedCanonicalRemedy
        | cvSnapshotHydration vector == VAdvisory = RemedyStateCodecBump :| [RemedyRecompileConsumers, RemedyRunConformance]
        | otherwise = RemedyRecompileConsumers :| [RemedyRunConformance]
    mappedWireCodes =
        [ MappedFieldAddedNoDefault
        , MappedFieldRemoved
        , MappedFieldTypeChanged
        , MappedPresenceChanged
        , MappedNullabilityChanged
        , MappedDefaultRemoved
        , MappedDefaultChanged
        , MappedWireKeyChanged
        , MappedUnionEncodingChanged
        , MappedArmRemoved
        , MappedArmTagChanged
        , MappedEnumValueRemoved
        , MappedEnumSpellingChanged
        , MappedOpaqueCodecChanged
        , MappedModeCrossed
        , MappedDeclRemoved
        ]
    eventDecodeCodes =
        [ EvtFieldAddedWithoutBump
        , EvtFieldRemovedSameVersion
        , EvtFieldTypeChanged
        , EvtVersionDecreased
        , EvtVersionMissingUpcaster
        , UpcasterChainGap
        , EvtRemovedNotDeprecated
        , EnumCtorRemoved
        , EnumWireSpellingChanged
        , WireSpecChanged
        , ProcessInputChanged
        , WorkflowShapeChanged
        , WorkflowBodyChanged
        , WorkflowPatchRemoved
        , WorkflowContinueSeedChanged
        ]
    contractCodes =
        [ ContractEventRemoved
        , ContractFieldChanged
        , ContractDiscriminatorChanged
        , ContractTopicChanged
        , ContractSchemaVersionDecreased
        , ContractSchemaVersionBumped
        ]
    queueCodes = [WqPayloadFieldChanged, WqOrderingChanged, WqProvisionChanged, WqGroupKeyChanged, QueueIdentityChanged]
    identityCodes =
        [ DerivedIdentityChanged
        , IdPrefixChanged
        , DedupeIdentityChanged
        , RouterStableNameChanged
        , WorkflowStableNameChanged
        , ReadModelVersionDecreased
        , ReadModelShapeChangedWithoutBump
        , ReadModelFeedChanged
        , ReadModelConsistencyWeakened
        ]

renderRemedy :: Remedy -> Text
renderRemedy remedy = case remedy of
    RemedyVersionBump -> "bump the owning schema or event version"
    RemedyUpcaster -> "add and retain a contiguous upcaster for every historical version"
    RemedyDeploymentOrder rollout -> "deploy in " <> rolloutName rollout <> " order"
    RemedyContractRevision -> "revise the independently owned public contract"
    RemedyReplayOnlyEdge -> "add the computed replay-only edge described by docs/adr/0002-replay-only-edges-are-the-sanctioned-remedy-for-guard-tightening.md"
    RemedyStateCodecBump -> "invalidate and rebuild snapshots by bumping state-codec version when automatic fingerprinting cannot see the change"
    RemedyRecompileConsumers -> "recompile every affected consumer against the generated interface"
    RemedyRescaffoldWorkspace -> "re-run the whole-workspace scaffold so the record's ownership and golden roots follow the change"
    RemedyRunConformance -> "run the generated conformance and historical fixture suites"
    RemedyDoNotDeploy detail -> detail

renderFinding :: Change -> Text
renderFinding change =
    headline
        <> if vectorIsUniform (ckVector kind)
            then ""
            else "\n" <> renderVectorLine (ckVector kind)
  where
    kind = changeKind change
    headline =
        headlineName change
            <> ": "
            <> ckNode kind
            <> " "
            <> ckFacet kind
            <> " "
            <> ckSubject kind
            <> ": "
            <> ckDetail kind
            <> codeSuffix change kind

renderVectorLine :: CompatibilityVector -> Text
renderVectorLine vector =
    "    vector: "
        <> T.unwords
            ( [ surfaceName surface <> "=" <> verdictName verdict
              | surface <- [minBound .. maxBound]
              , let verdict = verdictFor surface vector
              , verdict /= VNotApplicable
              ]
                <> ["rollout=" <> T.intercalate "," (map rolloutName (Set.toAscList (cvRollout vector))) | not (Set.null (cvRollout vector))]
            )

renderExplainBlock :: Change -> Text
renderExplainBlock change =
    "explain ["
        <> T.pack (show (ckCode kind))
        <> "]\n"
        <> T.unlines ["  path: " <> path | path <- ckPaths kind]
        <> T.unlines (map ("  direction: " <>) directions)
        <> T.unlines ["  remedy: " <> renderRemedy remedy | remedy <- NonEmpty.toList remedies]
  where
    kind = changeKind change
    vector = ckVector kind
    directions =
        [ surfaceName surface <> " is " <> verdictName verdict <> "; " <> directionMeaning surface verdict
        | surface <- [minBound .. maxBound]
        , let verdict = verdictFor surface vector
        , verdict `elem` [VAdvisory, VBreaking]
        ]
    remedies = remediationFor (ckContext kind) (ckCode kind)

surfaceName :: CompatibilitySurface -> Text
surfaceName surface = case surface of
    PrivateHistoryRead -> "private-history-read"
    OldBinaryReadNewEvents -> "old-binary-read-new-events"
    SnapshotHydration -> "snapshot-hydration"
    PublicConsumer -> "public-consumer"
    PersistedIdentity -> "persisted-identity"
    ConsumerBuild -> "consumer-build"

parseSurfaceName :: String -> Either String CompatibilitySurface
parseSurfaceName raw = case lookup (T.pack raw) [(surfaceName surface, surface) | surface <- [minBound .. maxBound]] of
    Just surface -> Right surface
    Nothing ->
        Left
            ( "unknown compatibility surface '"
                <> raw
                <> "'; expected one of: "
                <> T.unpack (T.intercalate ", " (map surfaceName [minBound .. maxBound]))
            )

verdictName :: SurfaceVerdict -> Text
verdictName verdict = case verdict of
    VCompatible -> "compatible"
    VAdvisory -> "advisory"
    VBreaking -> "breaking"
    VNotApplicable -> "n/a"

rolloutName :: RolloutConstraint -> Text
rolloutName rollout = case rollout of
    RolloutStopTheWorld -> "stop-the-world"
    RolloutWorkersFirst -> "workers-first"
    RolloutDrainRequired -> "drain-required"
    RolloutProducerLast -> "producer-last"

labelName :: Label -> Text
labelName label = case label of
    LabelAdditive -> "additive"
    LabelAdvisory -> "warning"
    LabelBreaking -> "breaking"

headlineName :: Change -> Text
headlineName Additive{} = "ADDITIVE"
headlineName Advisory{} = "WARNING"
headlineName Breaking{} = "BREAKING"

codeSuffix :: Change -> ChangeKind -> Text
codeSuffix Additive{} _ = ""
codeSuffix _ kind = " [" <> T.pack (show (ckCode kind)) <> "]"

changeKind :: Change -> ChangeKind
changeKind (Additive kind) = kind
changeKind (Advisory kind) = kind
changeKind (Breaking kind) = kind

vectorIsUniform :: CompatibilityVector -> Bool
vectorIsUniform vector =
    Set.null (cvRollout vector)
        && all (`elem` [VCompatible, VNotApplicable]) [verdictFor surface vector | surface <- [minBound .. maxBound]]

directionMeaning :: CompatibilitySurface -> SurfaceVerdict -> Text
directionMeaning surface verdict = case (surface, verdict) of
    (PrivateHistoryRead, _) -> "the candidate binary may reinterpret or fail to read stored private history"
    (OldBinaryReadNewEvents, _) -> "a still-running old binary may reject events emitted by the candidate"
    (SnapshotHydration, _) -> "persisted snapshot seeds require invalidation or rebuild"
    (PublicConsumer, _) -> "an independently deployed consumer may reject the candidate contract"
    (PersistedIdentity, _) -> "replay or retry may derive a different persisted identity"
    (ConsumerBuild, _) -> "consumer or generated source must be rebuilt"