packages feed

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

-- | @seihou check@: run the project's active judgements over the staged
-- changes and report a decision.
--
-- A check is read-only. It reads the recorded definition snapshots and
-- policy from the manifest, captures the staged index, and never stages,
-- unstages, writes the manifest or changes hook setup. A dry run makes no
-- model request and never claims approval.
--
-- The services a check uses are passed in, so tests drive the same path with
-- a stub backend and production builds them from local configuration.
module Seihou.CLI.Judgement.Check
  ( CheckRequest (..),
    CheckServices (..),
    CheckOutcome (..),
    BackendChoice (..),
    runCheck,
    productionServices,
    productionBackend,
    loadActiveJudgements,
    loadPolicyAt,
    parseRange,
  )
where

import Control.Monad (forM)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as LBS
import Data.Foldable (toList)
import Data.Generics.Labels ()
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Seihou.CLI.AgentCompletion (providerToText)
import Seihou.CLI.Judgement.Config (resolveGateRoute, resolveJudgementRoute)
import Seihou.CLI.Judgement.Evidence
import Seihou.CLI.Judgement.Execute
import Seihou.CLI.Judgement.Report
import Seihou.CLI.Judgement.Runtime (JudgementBackend, RouteObservation, gateProviderName, isRemoteRoute, productionGateRunner, productionRunner, shikumiBackend)
import Seihou.CLI.Judgement.Scanner (findScanner)
import Seihou.Core.Judgement
import Seihou.Core.Types (ActiveJudgement)
import Seihou.Effect.FilesystemInterp (runFilesystem)
import Seihou.Effect.ManifestStore (readManifest)
import Seihou.Effect.ManifestStoreInterp (runManifestStore)
import Seihou.Manifest.Types (manifestFromJSON)
import Seihou.Prelude
import System.Environment (lookupEnv)

data CheckRequest = CheckRequest
  { -- | One active judgement to run for diagnosis; 'Nothing' runs them all.
    judgement :: !(Maybe Text),
    -- | @BASE..HEAD@ instead of the staged changes.
    range :: !(Maybe (Text, Text)),
    dryRun :: !Bool,
    json :: !Bool
  }
  deriving stock (Eq, Show, Generic)

-- | The review route chosen for one judgement.
data BackendChoice = BackendChoice
  { backend :: !(Maybe JudgementBackend),
    -- | Whether the route sends material to a hosted service.
    remote :: !Bool,
    -- | How the route is named in reports, e.g. @claude-cli claude-sonnet-5-5@.
    label :: !(Maybe Text),
    -- | How the gate route is named, when one is configured locally. It runs
    -- only for judgements whose recorded policy permits a gate.
    gateLabel :: !(Maybe Text)
  }
  deriving stock (Generic)

data CheckServices = CheckServices
  { git :: !GitRunner,
    scanner :: !(Maybe FilePath),
    -- | Resolve the route for a definition. 'Left' is a configuration error.
    backendFor :: !(JudgementDefinition -> IO (Either Text BackendChoice))
  }
  deriving stock (Generic)

data CheckOutcome = CheckOutcome
  { exitCode :: !Int,
    output :: !Text
  }
  deriving stock (Eq, Show, Generic)

-- | Services built from this checkout and the local judgement route.
productionServices :: FilePath -> IO CheckServices
productionServices root = do
  sc <- findScanner
  pure
    CheckServices
      { git = gitBytes root,
        scanner = sc,
        backendFor = \def -> productionBackend (def ^. #limits)
      }

-- | The locally configured review route and, when configured, the gate.
-- 'Right' with no backend means no review route is configured.
productionBackend :: CoverageLimits -> IO (Either Text BackendChoice)
productionBackend limits =
  resolveJudgementRoute >>= \case
    Left err -> pure (Left err)
    Right Nothing -> pure (Right (BackendChoice Nothing False Nothing Nothing))
    Right (Just route) ->
      resolveGateRoute >>= \case
        Left err -> pure (Left err)
        Right gateRoute -> do
          (runner, observed) <- productionRunner limits route
          gateRunner <- traverse (productionGateRunner limits) gateRoute
          -- The provider reads its key itself; naming a missing one here
          -- explains the gate failures a check will then report.
          keyMissing <- maybe True (null . filter (/= ' ')) <$> lookupEnv "TYPESAFE_API_KEY"
          let keyNote = if keyMissing then " (TYPESAFE_API_KEY is not set, so the gate will fail and every check escalates to the review)" else ""
          case sequence gateRunner of
            Left err -> pure (Left err)
            Right g ->
              pure
                ( Right
                    ( BackendChoice
                        (Just (shikumiBackend runner (fst <$> g) ((<>) <$> maybe (pure []) snd g <*> observed)))
                        (isRemoteRoute route)
                        (Just (providerToText (route ^. #provider) <> " " <> route ^. #model))
                        ((\gr -> gateProviderName <> " " <> gr ^. #model <> keyNote) <$> gateRoute)
                    )
                )

-- | Read the active judgements from @.seihou/manifest.json@ under a project
-- root without changing anything. A missing manifest, or one older than
-- judgement state, has no active judgements.
loadActiveJudgements :: FilePath -> IO (Either Text [ActiveJudgement])
loadActiveJudgements root =
  runEff (runFilesystem (runManifestStore (root </> ".seihou" </> "manifest.json") readManifest)) <&> \case
    Left err -> Left ("Cannot read .seihou/manifest.json: " <> err)
    Right Nothing -> Right []
    Right (Just m) -> Right (m ^. #judgements)

-- | Read the active judgements recorded at a trusted ref, from Git objects
-- only: nothing is checked out, and the working tree's manifest, which the
-- change under test may have weakened, is not consulted.
loadPolicyAt :: GitRunner -> Text -> IO (Either Text [ActiveJudgement])
loadPolicyAt git ref =
  git ["rev-parse", "--verify", "--quiet", T.unpack ref <> "^{commit}"] >>= \case
    Left _ -> pure (Left ("The policy ref '" <> ref <> "' does not name a commit in this clone; fetch it first."))
    Right raw -> do
      let commit = T.unpack (T.strip (TE.decodeUtf8 raw))
      git ["cat-file", "-e", commit <> ":.seihou/manifest.json"] >>= \case
        Left _ -> pure (Left ("No trusted policy exists at " <> ref <> ": it has no .seihou/manifest.json. First adoption needs an explicit review of the proposed policy, not a check against it."))
        Right _ ->
          git ["cat-file", "blob", commit <> ":.seihou/manifest.json"] <&> \case
            Left err -> Left err
            Right bytes -> case manifestFromJSON (LBS.fromStrict bytes) of
              Left err -> Left ("The manifest at " <> ref <> " cannot be read: " <> T.pack err)
              Right m -> Right (m ^. #judgements)

-- | Parse @BASE..HEAD@.
parseRange :: Text -> Either Text (Text, Text)
parseRange t = case T.splitOn ".." t of
  [b, h] | not (T.null b), not (T.null h), not ("." `T.isPrefixOf` h) -> Right (b, h)
  _ -> Left ("'" <> t <> "' is not a range; write BASE..HEAD, for example origin/main..HEAD")

failure :: Bool -> Text -> CheckOutcome
failure asJson msg
  | asJson = CheckOutcome 1 (encodeJson (Aeson.object ["version" Aeson..= reportVersion, "status" Aeson..= ("error" :: Text), "exitCode" Aeson..= (1 :: Int), "error" Aeson..= msg, "judgements" Aeson..= ([] :: [Aeson.Value])]))
  | otherwise = CheckOutcome 1 (msg <> "\n")

encodeJson :: Aeson.Value -> Text
encodeJson = (<> "\n") . TE.decodeUtf8 . LBS.toStrict . Aeson.encode

runCheck :: CheckServices -> [ActiveJudgement] -> CheckRequest -> IO CheckOutcome
runCheck services active req = case selected of
  Left msg -> pure (failure (req ^. #json) msg)
  Right [] ->
    pure $
      if req ^. #json
        then CheckOutcome 0 (encodeJson (renderJson []))
        else CheckOutcome 0 "No judgements are active in this project; nothing was checked.\n"
  Right candidates ->
    capture >>= \case
      Left err -> pure (failure (req ^. #json) ("Cannot read the " <> scopeLabel <> ": " <> err))
      Right snap
        | null judgements -> pure (CheckOutcome 0 (T.unlines notApplicable <> "Nothing was checked.\n"))
        | req ^. #dryRun -> (\o -> o & #output %~ (T.unlines notApplicable <>)) <$> dryRunReport judgements snap
        | otherwise -> (\o -> if req ^. #json then o else o & #output %~ (T.unlines notApplicable <>)) <$> runAll judgements snap
    where
      wanted = maybe StagedChangesScope (const CommitRangeScope) (req ^. #range)
      declares j = wanted `elem` (j ^. #definition . #evidence . #scopes)
      judgements = filter declares candidates
      notApplicable = [j ^. #definition . #name <> ": not checked; it does not declare " <> renderScopeKind wanted <> " evidence" | j <- candidates, not (declares j)]
  where
    capture = case req ^. #range of
      Nothing -> captureStaged defaultCoverageLimits (services ^. #git)
      Just (b, h) -> captureRange defaultCoverageLimits (services ^. #git) b h
    scopeLabel = case req ^. #range of
      Nothing -> "staged changes"
      Just (b, h) -> "commits " <> b <> ".." <> h
    selected = case req ^. #judgement of
      Nothing -> Right active
      Just n -> case [j | j <- active, j ^. #definition . #name == n] of
        [] -> Left ("Judgement '" <> n <> "' is not active in this project; adopt it with seihou run " <> n)
        js -> Right js

    runAll judgements snap = do
      results <- forM judgements $ \j -> do
        let def = j ^. #definition
        (services ^. #backendFor) def >>= \case
          Left err -> pure (Left (def ^. #name <> ": " <> err))
          Right choice -> do
            let exec = JudgementExecution (j ^. #policy) (choice ^. #backend) (choice ^. #remote) (services ^. #scanner)
            obs <- runJudgement exec def snap
            routeSeen <- maybe (pure []) (^. #observed) (choice ^. #backend)
            let lbl = (\l -> l <> maybe "" ("; gate " <>) (if j ^. #policy . #gatePermitted then choice ^. #gateLabel else Nothing)) <$> choice ^. #label
            pure (Right (def, decideJudgement def (j ^. #policy) obs, lbl, routeSeen))
      case sequence results of
        Left err -> pure (failure (req ^. #json) err)
        Right decided -> do
          let overall = combinedStatus [d ^. #status | (_, d, _, _) <- decided]
              code = exitCodeFor overall
          pure $
            if req ^. #json
              then CheckOutcome code (encodeJson (renderJson [(def, d) | (def, d, _, _) <- decided]))
              else CheckOutcome code (T.concat [renderHumanFor scopeLabel def d <> routeLines lbl seen | (def, d, lbl, seen) <- decided])

    routeLines :: Maybe Text -> [RouteObservation] -> Text
    routeLines lbl seen = case (lbl, seen) of
      (Nothing, _) -> ""
      (Just l, []) -> "Route: " <> l <> " (no request was sent)\n"
      (Just l, obs) ->
        "Route: " <> l <> "; observed " <> T.intercalate ", " (dedupe [o ^. #observedModel | o <- obs]) <> " over " <> T.pack (show (length obs)) <> " request(s)\n"

    dedupe = foldr (\x acc -> if x `elem` acc then acc else x : acc) []

    dryRunReport judgements snap = do
      sections <- forM judgements $ \j -> do
        let def = j ^. #definition
            (sel, excluded, uncovered) = selectForDefinition def snap
            (chunks, overflow) = chunkEvidence (def ^. #limits) sel
        choice <- (services ^. #backendFor) def
        let route = case choice of
              Left err -> "not usable: " <> err
              Right c -> maybe "none configured" id (c ^. #label) <> gateText c
            gateText c = case (c ^. #gateLabel, j ^. #policy . #gatePermitted) of
              (Nothing, _) -> ""
              (Just g, True) -> "; gate: " <> g
              (Just g, False) -> "; gate " <> g <> " is configured, but this judgement's policy does not permit a gate"
        pure $
          T.unlines $
            [ def ^. #name <> " " <> maybe "" id (def ^. #version) <> "  (" <> T.take 12 (j ^. #digest . #unSHA256) <> ")",
              "  data policy: " <> renderDataPolicy (j ^. #policy . #dataPolicy) <> "; route: " <> route,
              "  scanner: " <> maybe "not found" T.pack (services ^. #scanner),
              "  scope: " <> scopeLabel <> "; " <> T.pack (show (length sel)) <> " file(s) selected in " <> T.pack (show (length chunks)) <> " chunk(s), " <> T.pack (show (length excluded)) <> " excluded"
            ]
              <> ["  would not review: " <> T.pack p <> " (" <> why <> ")" | (p, why) <- uncovered <> overflow]
              <> [ "  check "
                     <> c ^. #name
                     <> ": "
                     <> renderEnforcement (cp ^. #enforcement)
                     <> ", pass at "
                     <> num (cp ^. #review . #passAt)
                     <> ", block at "
                     <> num (cp ^. #review . #blockAt)
                     <> (if j ^. #policy . #gatePermitted && cp ^. #useGate then "; gate pass at " <> num (cp ^. #gate . #passAt) <> ", block at " <> num (cp ^. #gate . #blockAt) else "")
                 | c <- toList (def ^. #checks),
                   let cp = checkPolicyFor (j ^. #policy) c
                 ]
      pure (CheckOutcome 0 (T.concat sections <> "Dry run: no model request was made and nothing was approved.\n"))

    num = renderProbability