seihou-core-0.16.0.0: src/Seihou/Core/JudgementExamples.hs
-- | Labelled examples for a judgement: synthetic changes and the outcome
-- each check should reach on them.
--
-- An @examples.json@ file sits beside @judgement.dhall@. Its cases are
-- synthetic by contract: evaluation submits them to a model, and a scanner
-- pass over them runs first. Validation here is structural and needs only
-- the definition, so @validate-judgement@ can name every problem before any
-- provider call.
--
-- Format, version 1:
--
-- > { "version": 1,
-- > "examples": [
-- > { "id": "benign-readme",
-- > "description": "A documentation-only change",
-- > "files": [ { "path": "README.md", "content": "..." } ],
-- > "expect": { "stale-docs": "passed", "has-example": "blocked" },
-- > "findings": [ { "check": "has-example", "category": "missing-example", "path": "README.md", "line": 3 } ] } ] }
--
-- @expect@ maps check names to @passed@, @blocked@ or @needs-review@; a
-- check left out is not scored on that case. @findings@ is optional.
module Seihou.Core.JudgementExamples
( ExampleFile (..),
ExpectedFinding (..),
JudgementExample (..),
JudgementExamples (..),
ExpectedStatus (..),
renderExpectedStatus,
parseExamples,
validateExamples,
)
where
import Control.Monad (forM_, when)
import Data.Aeson (FromJSON (..), withObject, (.:), (.:?))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as LBS
import Data.Foldable (toList)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Seihou.Core.Judgement
import Seihou.Prelude hiding ((.=))
data ExpectedStatus = ExpectPassed | ExpectBlocked | ExpectNeedsReview
deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)
renderExpectedStatus :: ExpectedStatus -> Text
renderExpectedStatus = \case
ExpectPassed -> "passed"
ExpectBlocked -> "blocked"
ExpectNeedsReview -> "needs-review"
data ExampleFile = ExampleFile {path :: !FilePath, content :: !Text}
deriving stock (Eq, Show, Generic)
data ExpectedFinding = ExpectedFinding
{ check :: !Text,
category :: !Text,
path :: !FilePath,
line :: !(Maybe Int)
}
deriving stock (Eq, Show, Generic)
data JudgementExample = JudgementExample
{ id :: !Text,
description :: !(Maybe Text),
files :: ![ExampleFile],
expect :: !(Map Text ExpectedStatus),
findings :: ![ExpectedFinding]
}
deriving stock (Eq, Show, Generic)
data JudgementExamples = JudgementExamples
{ version :: !Int,
examples :: ![JudgementExample]
}
deriving stock (Eq, Show, Generic)
instance FromJSON ExpectedStatus where
parseJSON = Aeson.withText "ExpectedStatus" $ \t -> case [s | s <- [minBound .. maxBound], renderExpectedStatus s == t] of
[s] -> pure s
_ -> fail ("unknown expected status '" <> T.unpack t <> "'; expected passed, blocked or needs-review")
instance FromJSON ExampleFile where
parseJSON = withObject "ExampleFile" $ \o -> ExampleFile <$> o .: "path" <*> o .: "content"
instance FromJSON ExpectedFinding where
parseJSON = withObject "ExpectedFinding" $ \o -> ExpectedFinding <$> o .: "check" <*> o .: "category" <*> o .: "path" <*> o .:? "line"
instance FromJSON JudgementExample where
parseJSON = withObject "JudgementExample" $ \o ->
JudgementExample
<$> o .: "id"
<*> o .:? "description"
<*> o .: "files"
<*> o .: "expect"
<*> (fromMaybe [] <$> o .:? "findings")
instance FromJSON JudgementExamples where
parseJSON = withObject "JudgementExamples" $ \o -> JudgementExamples <$> o .: "version" <*> o .: "examples"
-- | Decode @examples.json@ bytes, naming the file in the error.
parseExamples :: FilePath -> LBS.ByteString -> Either JudgementError JudgementExamples
parseExamples file bytes = case Aeson.eitherDecode bytes of
Left err -> Left (judgementError "judgement_examples_malformed" (T.pack file <> " is not a valid examples file: " <> T.pack err))
Right ex
| ex ^. #version /= 1 -> Left (judgementError "judgement_examples_version" (T.pack file <> " has version " <> T.pack (show (ex ^. #version)) <> "; this seihou reads version 1"))
| otherwise -> Right ex
-- | Every structural problem, in file order, as named diagnostics.
validateExamples :: JudgementDefinition -> JudgementExamples -> [JudgementError]
validateExamples def ex = either pure (const []) checkIds <> concatMap one (ex ^. #examples)
where
checks = Map.fromList [(c ^. #name, c) | c <- toList (def ^. #checks)]
checkIds = do
let ids = map (^. #id) (ex ^. #examples)
when (null ids) $ Left (judgementError "judgement_examples_empty" "the examples file has no examples")
forM_ (Map.keys (Map.filter (> (1 :: Int)) (Map.fromListWith (+) [(i, 1) | i <- ids]))) $ \dup ->
Left (judgementError "judgement_example_duplicate_id" ("example id '" <> dup <> "' is used more than once"))
one e =
let eid = "example '" <> e ^. #id <> "'"
paths = map (^. #path) (e ^. #files)
in [judgementError "judgement_example_bad_id" (eid <> ": ids must be non-empty") | T.null (T.strip (e ^. #id))]
<> [judgementError "judgement_example_no_files" (eid <> " has no files") | null (e ^. #files)]
<> [ judgementError "judgement_example_bad_path" (eid <> " file path '" <> T.pack p <> "' must be project-relative without '..'")
| p <- paths,
null p || "/" `T.isPrefixOf` T.pack p || ".." `elem` T.splitOn "/" (T.pack p)
]
<> [judgementError "judgement_example_unknown_check" (eid <> " expects an outcome for check '" <> c <> "', which the judgement does not declare") | c <- Map.keys (e ^. #expect), not (Map.member c checks)]
<> [judgementError "judgement_example_no_expectation" (eid <> " expects no outcome for any check") | Map.null (e ^. #expect)]
<> concatMap (finding eid paths) (e ^. #findings)
finding eid paths f = case Map.lookup (f ^. #check) checks of
Nothing -> [judgementError "judgement_example_unknown_check" (eid <> " lists a finding for check '" <> f ^. #check <> "', which the judgement does not declare")]
Just c ->
[ judgementError "judgement_example_unknown_category" (eid <> " lists finding category '" <> f ^. #category <> "', which check '" <> c ^. #name <> "' does not declare")
| f ^. #category `notElem` map (^. #name) (c ^. #categories)
]
<> [judgementError "judgement_example_unknown_file" (eid <> " lists a finding in '" <> T.pack (f ^. #path) <> "', which is not one of its files") | f ^. #path `notElem` paths]
<> [judgementError "judgement_example_bad_line" (eid <> " lists a finding at line " <> T.pack (show l) <> "; lines start at 1") | Just l <- [f ^. #line], l < 1]