okf-cli-0.12.0.0: src/Okf/Cli/ProfileSweep.hs
-- | The @okf profile sweep@ command: a load-bearing sweep of one profile against
-- its acceptance bundles and rejection cases, streamed as text or NDJSON.
--
-- The engine is 'Okf.Profile.Sweep'. This module owns the options, the output
-- formats and the exit codes; the diagnostic renderers it needs live in
-- 'Okf.Cli' and are passed in as 'SweepRenderers' so the two modules do not
-- import each other.
module Okf.Cli.ProfileSweep
( ProfileSweepOptions (..),
SweepRenderers (..),
profileSweepOptionsParser,
runProfileSweep,
)
where
import Control.Exception (evaluate)
import Control.Monad (unless)
import Data.Aeson (object, (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy.Char8 qualified as LazyByteString
import Data.Foldable (for_, toList)
import Data.Generics.Labels ()
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import GHC.Clock (getMonotonicTime)
import GHC.Conc (setNumCapabilities)
import GHC.Generics (Generic)
import Okf.Bundle (BundleError)
import Okf.Cli.Version (appVersion)
import Okf.Prelude ((^.))
import Okf.Profile (CompiledProfile, ProfileDefinitionError, compiledProfileSpec)
import Okf.Profile.Sweep
import Okf.Validation (BundleValidationError, LogStaleness)
import Options.Applicative
import System.Directory (doesFileExist)
import System.Exit (ExitCode (..), exitWith)
import System.FilePath ((</>))
import System.IO (BufferMode (..), hSetBuffering, stderr, stdout)
import Text.Printf (printf)
data ProfileSweepOptions = ProfileSweepOptions
{ profilePath :: !FilePath,
accept :: !(NonEmpty FilePath),
casesDir :: !FilePath,
kinds :: !KindSet,
json :: !Bool,
only :: !(Maybe Text),
-- | defaults to @CASES/sweep-exceptions.json@ when that file exists
exceptionsPath :: !(Maybe FilePath)
}
deriving stock (Generic, Show, Eq)
-- | The CLI's renderers, so a sweep's diagnostics read exactly like
-- @okf validate@'s.
data SweepRenderers = SweepRenderers
{ violation :: Render,
bundleValidationError :: BundleValidationError -> Text,
logStaleness :: LogStaleness -> Text,
bundleError :: BundleError -> Text,
definitionError :: ProfileDefinitionError -> Text
}
deriving stock (Generic)
profileSweepOptionsParser :: Parser ProfileSweepOptions
profileSweepOptionsParser =
ProfileSweepOptions
<$> strArgument (metavar "PROFILE" <> help "Profile descriptor (.dhall) to sweep")
<*> ( NonEmpty.fromList
<$> some
( strOption
( long "accept"
<> metavar "BUNDLE"
<> help "Acceptance bundle that must validate strictly; repeat for several"
)
)
)
<*> strOption
( long "cases"
<> metavar "DIR"
<> help "Rejection cases: one bundle per subdirectory, named in DIR/expected.json"
)
<*> option
(maybeReader (parseKindSet . Text.pack))
( long "kinds"
<> metavar "legacy|default|all"
<> value DefaultKinds
<> showDefaultWith (Text.unpack . renderKindSet)
<> help "Which relaxation kinds to evaluate"
)
<*> switch (long "json" <> help "Emit one JSON object per line (NDJSON) instead of text")
<*> optional
( Text.pack
<$> strOption
( long "only"
<> metavar "FIELD"
<> help "Sweep only rules whose innermost field is FIELD, or whose relaxation ID starts with FIELD"
)
)
<*> optional
( strOption
( long "exceptions"
<> metavar "FILE"
<> help "Exception manifest; defaults to DIR/sweep-exceptions.json when it exists"
)
)
-- | Every outcome the summary counts, in the order it prints them.
summaryOutcomes :: [Text]
summaryOutcomes =
["protected", "inert", "covered", "subsumed", "exception", "unprotected", "descriptor-invalid", "acceptance-observable", "contradiction"]
-- | Run the sweep and exit: 0 when no relaxation is failing and no exception is
-- stale, 1 when one is, 2 when the suite or manifest cannot be loaded or the
-- unrelaxed profile does not hold on the suite, and 3 when okf contradicts its
-- own inertness proof.
runProfileSweep :: SweepRenderers -> ProfileSweepOptions -> CompiledProfile -> IO ()
runProfileSweep renderers options compiled = do
-- The sweep is serial, and the executable's -N makes every collection a
-- parallel one across all cores: on a busy machine that doubled the strategy
-- profile's wall time (18 s against 8.6 s with one capability).
setNumCapabilities 1
hSetBuffering stdout LineBuffering
hSetBuffering stderr LineBuffering
started <- getMonotonicTime
manifestPath <- case options ^. #exceptionsPath of
Just explicit -> pure (Just explicit)
Nothing -> do
let conventional = (options ^. #casesDir) </> "sweep-exceptions.json"
present <- doesFileExist conventional
pure (if present then Just conventional else Nothing)
manifest <- traverse (\path -> (,) path <$> loadExceptions path) manifestPath
loaded <- loadSuite (toList (options ^. #accept)) (options ^. #casesDir)
let unplanned = length (selected (enumerateRelaxations (options ^. #kinds) spec))
case (manifest, loaded) of
(Just (path, Left reason), _) -> do
emitStart Nothing unplanned
emitFault "exceptions" [Text.pack path <> ": " <> reason]
emitSummary started Nothing 2
(_, Left suiteError) -> do
emitStart Nothing unplanned
emitFault "suite" [renderSuiteError suiteError]
emitSummary started Nothing 2
(_, Right suite) -> case checkBaseline (renderers ^. #violation) compiled suite of
Left faults -> do
emitStart (Just (length (suite ^. #cases))) unplanned
emitFault "baseline" (concatMap renderFault (toList faults))
emitSummary started Nothing 2
Right prepared -> do
let exceptions = maybe [] (either (const []) id . snd) manifest
relaxations = selected (planRelaxations (options ^. #kinds) prepared)
total = length relaxations
width = maximum (0 : map (Text.length . relaxationId) relaxations)
emitStart (Just (length (suite ^. #cases))) total
baselineDone <- getMonotonicTime
emit
(object ["event" .= ("baseline" :: Text), "ok" .= True, "elapsedMs" .= milliseconds started baselineDone])
("baseline: ok (" <> seconds started baselineDone <> ")")
seen <- newIORef []
previous <- newIORef baselineDone
for_ (zip [1 :: Int ..] (sweepProfile exceptions prepared relaxations)) $ \(index, result) -> do
let line = relaxationLine width index total result
_ <- evaluate (Text.length line)
now <- getMonotonicTime
before <- readIORef previous
writeIORef previous now
modifyIORef' seen ((relaxationId (result ^. #relaxation), result ^. #outcome) :)
let elapsed = milliseconds before now
emit (relaxationJson index total result elapsed) (line <> " " <> tshow elapsed <> " ms")
results <- reverse <$> readIORef seen
let stale = staleExceptions (options ^. #kinds) (isJust' (options ^. #only)) exceptions results
failing = [identifier | (identifier, outcome) <- results, outcomeIsFailing outcome]
exitCode
| any ((== Contradiction) . snd) results = 3
| not (null failing) || not (null stale) = 1
| otherwise = 0
unless (options ^. #json) $
for_ stale $ \entry ->
Text.IO.putStrLn ("stale exception: " <> entry ^. #relaxation <> " (no longer unprotected, or not swept)")
emitSummary started (Just (total, map (outcomeName . snd) results, stale, failing)) exitCode
where
spec = compiledProfileSpec compiled
selected = maybe id (\wanted -> filter (matchesOnly wanted)) (options ^. #only)
matchesOnly wanted relaxation =
relaxationField relaxation == wanted || wanted `Text.isPrefixOf` relaxationId relaxation
isJust' = maybe False (const True)
emit :: Aeson.Value -> Text -> IO ()
emit event line
| options ^. #json = LazyByteString.putStrLn (Aeson.encode event)
| otherwise = Text.IO.putStrLn line
emitStart :: Maybe Int -> Int -> IO ()
emitStart caseCount total =
emit
( object
[ "event" .= ("start" :: Text),
"okf" .= appVersion,
"profile" .= (options ^. #profilePath),
"accept" .= toList (options ^. #accept),
"cases" .= (options ^. #casesDir),
"caseCount" .= caseCount,
"kinds" .= renderKindSet (options ^. #kinds),
"relaxationCount" .= total
]
)
( "sweep: okf "
<> appVersion
<> "; profile "
<> Text.pack (options ^. #profilePath)
<> "; "
<> tshow total
<> " relaxations ("
<> renderKindSet (options ^. #kinds)
<> " kinds); "
<> maybe "cases not loaded" (\n -> tshow n <> " cases") caseCount
)
emitFault :: Text -> [Text] -> IO ()
emitFault fault details = do
emit
(object ["event" .= ("baseline" :: Text), "ok" .= False, "fault" .= fault, "detail" .= Text.intercalate "\n" details])
("baseline: FAILED (" <> fault <> ")")
unless (options ^. #json) $ mapM_ (Text.IO.putStrLn . (" " <>)) details
emitSummary :: Double -> Maybe (Int, [Text], [SweepException], [Text]) -> Int -> IO ()
emitSummary started totals exitCode = do
finished <- getMonotonicTime
let (relaxationCount, outcomes, stale, failing) = fromMaybe (0, [], [], []) totals
counts = Map.fromListWith (+) ([(name', 0 :: Int) | name' <- summaryOutcomes] <> [(name', 1) | name' <- outcomes])
emit
( object
[ "event" .= ("summary" :: Text),
"relaxations" .= relaxationCount,
"counts" .= counts,
"staleExceptions" .= map (^. #relaxation) stale,
"failing" .= failing,
"elapsedMs" .= milliseconds started finished,
"exit" .= exitCode
]
)
( "sweep: "
<> tshow relaxationCount
<> " relaxations: "
<> Text.intercalate ", " [tshow (Map.findWithDefault 0 name' counts) <> " " <> name' | name' <- summaryOutcomes, name' /= "contradiction" || Map.findWithDefault 0 name' counts > 0]
<> (if null stale then "" else "; " <> tshow (length stale) <> " stale exception(s)")
<> "; "
<> seconds started finished
)
if exitCode == 0 then pure () else exitWith (ExitFailure exitCode)
renderSuiteError = \case
SuiteBundleError bundlePath err -> Text.pack bundlePath <> ": " <> (renderers ^. #bundleError) err
ExpectationsError expectedPath reason -> Text.pack expectedPath <> ": " <> reason
renderFault = \case
AcceptanceCoreInvalid bundlePath coreErrors staleness ->
("acceptance bundle " <> Text.pack bundlePath <> " fails core validation or has stale logs:")
: map ((" " <>) . (renderers ^. #bundleValidationError)) coreErrors
<> map ((" log: " <>) . (renderers ^. #logStaleness)) staleness
AcceptanceProfileDeviations bundlePath lines' ->
("acceptance bundle " <> Text.pack bundlePath <> " deviates from the unrelaxed profile:")
: map (" " <>) lines'
CaseExpectationUnmet caseName CaseExpectation {field = fieldName, count} lines' ->
( "case "
<> caseName
<> " expects "
<> tshow count
<> " deviation(s) naming "
<> tshow fieldName
<> ", got "
<> tshow (length lines')
<> ":"
)
: map (" " <>) lines'
definitionErrorLines observation = map (renderers ^. #definitionError) (observation ^. #definitionErrors)
acceptanceLines observation = concatMap snd (observation ^. #acceptanceDeviations)
relaxationJson index total result elapsed =
let observation = result ^. #observation
relaxation = result ^. #relaxation
outcome = result ^. #outcome
in object
[ "event" .= ("relaxation" :: Text),
"index" .= index,
"total" .= total,
"id" .= relaxationId relaxation,
"field" .= relaxationField relaxation,
"kind" .= fromMaybe (relaxationKindName (relaxation ^. #kind)) (legacyKindName (relaxation ^. #kind)),
"outcome" .= outcomeName outcome,
"legacyOutcome" .= legacyOutcome observation,
"witness" .= primaryWitness observation,
"witnesses" .= (observation ^. #witnesses),
"facetWitnesses" .= (observation ^. #facetWitnesses),
"coveredBy" .= case outcome of
Covered covering witness -> Just (object ["relaxation" .= covering, "witness" .= witness])
_ -> Nothing,
"reason" .= outcomeReason observation outcome,
"exception" .= case outcome of
ExceptionOutcome entry -> Just (exceptionJson entry)
_ -> Nothing,
"acceptanceDeviations" .= [object ["bundle" .= bundlePath, "deviations" .= lines'] | (bundlePath, lines') <- observation ^. #acceptanceDeviations],
"definitionErrors" .= definitionErrorLines observation,
"elapsedMs" .= elapsed
]
outcomeReason observation = \case
Subsumed siblings -> Just ("every other relaxation of this rule is non-failing: " <> Text.intercalate ", " siblings)
Inert reason -> Just reason
DescriptorInvalidOutcome -> Just (Text.intercalate "; " (definitionErrorLines observation))
AcceptanceObservableOutcome -> Just (Text.intercalate "; " (acceptanceLines observation))
Contradiction -> Just "okf bug: the behaviour key is unchanged but a case noticed the relaxation"
_ -> Nothing
exceptionJson entry =
object $
["relaxation" .= (entry ^. #relaxation), "rationale" .= (entry ^. #rationale)]
<> case entry ^. #exceptionClass of
SharedFamily home -> ["class" .= ("shared-family" :: Text), "home" .= home]
OverlappingRule overlapsWith -> ["class" .= ("overlapping-rule" :: Text), "overlapsWith" .= overlapsWith]
Deferred request -> ["class" .= ("deferred" :: Text), "request" .= request]
relaxationLine width index total result =
let observation = result ^. #observation
outcome = result ^. #outcome
marker
| outcome == Contradiction = "??"
| outcomeIsFailing outcome = "!!"
| otherwise = "OK"
detail = case outcome of
Protected -> "witness " <> fromMaybe "" (primaryWitness observation) <> moreWitnesses (length (observation ^. #witnesses) - 1)
Subsumed siblings -> "subsumed by " <> tshow (length siblings) <> " non-failing sibling(s)"
Covered covering witness -> "covered by " <> covering <> " (witness " <> witness <> ")" <> facets observation
Inert reason -> "inert: " <> reason
ExceptionOutcome entry -> "exception (" <> exceptionClassName entry <> "): " <> entry ^. #rationale
other -> outcomeName other <> maybe "" (": " <>) (outcomeReason observation other)
in Text.pack (printf "[%*d/%d] " (length (show total)) index total)
<> marker
<> " "
<> Text.justifyLeft width ' ' (relaxationId (result ^. #relaxation))
<> " "
<> detail
facets observation = case observation ^. #facetWitnesses of
[] -> ""
cases -> "; facet witnesses " <> Text.intercalate ", " cases
exceptionClassName entry = case entry ^. #exceptionClass of
SharedFamily _ -> "shared-family"
OverlappingRule _ -> "overlapping-rule"
Deferred _ -> "deferred"
moreWitnesses n
| n > 0 = " (+" <> tshow n <> " more)"
| otherwise = ""
milliseconds :: Double -> Double -> Int
milliseconds from to = round ((to - from) * 1000)
seconds :: Double -> Double -> Text
seconds from to = Text.pack (printf "%.1f s" (to - from))
tshow :: (Show a) => a -> Text
tshow = Text.pack . show