packages feed

seihou-cli-0.16.0.0: src/Seihou/CLI/Judgement/Report.hs

-- | Human and JSON reports of judgement decisions, and the exit status they
-- imply.
--
-- Every probability is labelled as model-reported. Every string that can
-- carry model-generated text is bounded; matched secret text never reaches a
-- report because the scanner adapter reads only rule IDs and locations.
module Seihou.CLI.Judgement.Report
  ( reportVersion,
    exitCodeFor,
    combinedStatus,
    renderHuman,
    renderHumanFor,
    renderJson,
  )
where

import Data.Aeson (Value, object, (.=))
import Data.Generics.Labels ()
import Data.List (sortOn)
import Data.Ord (Down (..))
import Data.Text qualified as T
import Seihou.Core.Judgement
import Seihou.Prelude

-- | The version of the JSON report contract.
reportVersion :: Int
reportVersion = 1

-- | 0 for pass or explicit no changes, 10 for a policy block, 11 for needs
-- human review, 1 for runtime, configuration or coverage errors.
exitCodeFor :: JudgementStatus -> Int
exitCodeFor = \case
  Passed -> 0
  NoChanges -> 0
  Blocked -> 10
  NeedsReview -> 11
  Failed -> 1

-- | The most severe status over several judgements: block, then
-- needs-review, then error. All no-changes stays no-changes.
combinedStatus :: [JudgementStatus] -> JudgementStatus
combinedStatus ss = case sortOn (Down . rank) ss of
  (s : _) -> s
  [] -> NoChanges
  where
    rank = \case
      Blocked -> 4 :: Int
      NeedsReview -> 3
      Failed -> 2
      Passed -> 1
      NoChanges -> 0

prob :: Double -> Text
prob = renderProbability

bound :: Int -> Text -> Text
bound n t = if T.length t <= n then t else T.take n t <> "…"

loc :: Location -> Text
loc l = T.pack (l ^. #path) <> maybe "" (\n -> ":" <> T.pack (show n)) (l ^. #line)

renderHuman :: JudgementDefinition -> JudgementDecision -> Text
renderHuman = renderHumanFor "staged changes"

-- | The human report for a named evidence scope, such as
-- @staged changes@ or @commits abc1234..def5678@.
renderHumanFor :: Text -> JudgementDefinition -> JudgementDecision -> Text
renderHumanFor scopeLabel def d =
  T.unlines $
    [ d ^. #judgement <> "  " <> renderJudgementStatus (d ^. #status),
      "Scope: " <> scopeLabel <> "; " <> coverageLine
    ]
      <> [ loc (f ^. #location) <> "  critical  " <> f ^. #rule <> " (deterministic " <> renderDeterministicCheck (f ^. #check) <> ")"
         | f <- d ^. #deterministic
         ]
      <> map ("error: " <>) (d ^. #deterministicErrors)
      <> concatMap checkLines (d ^. #checks)
      <> ["uncovered: " <> T.pack p <> " (" <> why <> ")" | (p, why) <- d ^. #coverage . #uncoveredFiles]
      <> nextStep
  where
    reviewed = length (d ^. #coverage . #reviewedFiles)
    uncovered = length (d ^. #coverage . #uncoveredFiles)
    coverageLine
      | not (null (d ^. #deterministic)) = "stopped by deterministic findings; nothing was sent for review"
      | d ^. #status == NoChanges = "nothing in scope"
      | uncovered == 0 = "complete for " <> T.pack (show reviewed) <> " changed text files"
      | otherwise = "incomplete: " <> T.pack (show uncovered) <> " files not reviewed"
    checkLines c =
      let pol = c ^. #policy
          advisory = if c ^. #enforcement == Advisory then " (advisory)" else ""
          probLine = case (c ^. #reviewConcern, c ^. #gateConcern) of
            (Just p, _) -> "  model-reported probability " <> prob p <> " (pass at " <> prob (pol ^. #review . #passAt) <> ", block at " <> prob (pol ^. #review . #blockAt) <> ")"
            (Nothing, Just g) -> "  gate probability " <> prob g <> " (gate pass at " <> prob (pol ^. #gate . #passAt) <> ")"
            _ -> ""
          direction = case [ch | ch <- toListChecks, ch ^. #name == c ^. #check, ch ^. #concernWhen == No] of
            [] -> []
            _ -> ["  probabilities are of the concern, a no answer: one minus the model's probability of yes"]
       in [c ^. #check <> "  " <> renderCheckStatus (c ^. #status) <> advisory <> probLine]
            <> direction
            <> ["  " <> loc (f ^. #location) <> "  " <> renderSeverity (f ^. #severity) <> "  " <> f ^. #category <> ": " <> bound 300 (f ^. #explanation) | f <- c ^. #findings]
            <> ["  " <> bound 300 (renderDecisionReason r) | r <- c ^. #reasons, isNotable r]
    isNotable = \case
      BlockingFinding _ -> False
      ConcernAtOrBelowPass _ _ -> False
      _ -> True
    toListChecks = foldr (:) [] (def ^. #checks)
    nextStep = case d ^. #status of
      Blocked -> ["Resolve the blocking findings in the staged content, then run seihou check."]
      NeedsReview -> ["Confirm the flagged content is acceptable or change it, then run seihou check."]
      Failed -> ["The review could not complete; fix the errors above, then run seihou check."]
      _ -> []

renderJson :: [(JudgementDefinition, JudgementDecision)] -> Value
renderJson pairs =
  object
    [ "version" .= reportVersion,
      "status" .= statusKey overall,
      "exitCode" .= exitCodeFor overall,
      "judgements" .= map one pairs
    ]
  where
    overall = combinedStatus [d ^. #status | (_, d) <- pairs]
    statusKey = \case
      Passed -> "passed" :: Text
      Blocked -> "blocked"
      NeedsReview -> "needs-review"
      Failed -> "error"
      NoChanges -> "no-changes"
    one (def, d) =
      object
        [ "name" .= (def ^. #name),
          "version" .= (def ^. #version),
          "status" .= statusKey (d ^. #status),
          "coverage"
            .= object
              [ "reviewed" .= (d ^. #coverage . #reviewedFiles),
                "excluded" .= (d ^. #coverage . #excludedFiles),
                "uncovered" .= [object ["path" .= p, "reason" .= why] | (p, why) <- d ^. #coverage . #uncoveredFiles]
              ],
          "deterministic" .= [object ["check" .= renderDeterministicCheck (f ^. #check), "rule" .= (f ^. #rule), "path" .= (f ^. #location . #path), "line" .= (f ^. #location . #line)] | f <- d ^. #deterministic],
          "errors" .= (d ^. #deterministicErrors),
          "checks" .= map check (d ^. #checks)
        ]
    check c =
      object
        [ "name" .= (c ^. #check),
          "status" .= renderCheckStatus (c ^. #status),
          "enforcement" .= renderEnforcement (c ^. #enforcement),
          "probabilitySource" .= ("model-reported" :: Text),
          "reviewConcern" .= (c ^. #reviewConcern),
          "gateConcern" .= (c ^. #gateConcern),
          "thresholds"
            .= object
              [ "review" .= object ["blockAt" .= (c ^. #policy . #review . #blockAt), "passAt" .= (c ^. #policy . #review . #passAt)],
                "gate" .= object ["blockAt" .= (c ^. #policy . #gate . #blockAt), "passAt" .= (c ^. #policy . #gate . #passAt)]
              ],
          "decidedBy" .= [route (ch ^. #decidedBy) | ch <- c ^. #chunks],
          "findings" .= [object ["category" .= (f ^. #category), "severity" .= renderSeverity (f ^. #severity), "path" .= (f ^. #location . #path), "line" .= (f ^. #location . #line), "explanation" .= bound 300 (f ^. #explanation)] | f <- c ^. #findings],
          "reasons" .= map (bound 300 . renderDecisionReason) (c ^. #reasons)
        ]
    route = \case
      DecidedByGate -> "gate" :: Text
      DecidedByReview -> "review"
      DecidedByNone -> "none"