packages feed

heni-0.1.0.0: test/migration/Main.hs

module Main (main) where

import Control.Exception
import Control.Monad (forM, forM_, unless)
import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Pair)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import Data.Version (showVersion)
import GHC.Clock (getMonotonicTimeNSec)
import Legacy
import Paths_heni (getDataFileName)
import System.Directory qualified as Dir
import System.Environment (getArgs, lookupEnv)
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory, takeFileName, (</>))
import System.IO (BufferMode (..), hSetBuffering, stdout)
import System.Info qualified as Platform
import System.Posix.Signals (sigABRT)
import System.Posix.Temp (mkdtemp)
import System.Process (readProcessWithExitCode)
import System.Timeout (timeout)
import Test.Heni
import Test.Heni.Workspace

checked :: (Show e) => Either e a -> a
checked = either (error . show) id

assert' :: String -> Bool -> IO ()
assert' why good = unless good (error why)

jsonField :: Key -> Value -> Value
jsonField key (Object o) = maybe Null id (KM.lookup key o)
jsonField _ _ = Null

set :: Key -> Value -> Value -> Value
set key value (Object o) = Object (KM.insert key value o)
set _ _ other = other

signature :: RunResult -> [(Text, Verdict, Maybe DetectionKind, [(Stage, ProcessOutcome)])]
signature RunResult {results} = [(caseIdText caseId, verdict, detection, [(stage, outcome) | StageEvidence {stage, outcome} <- stages]) | CaseResult {caseId, verdict, detection, evidence = CaseEvidence {stages}} <- results]

main :: IO ()
main = do
  hSetBuffering stdout LineBuffering
  timeout 1200000000 suite >>= \case Nothing -> error "migration watchdog (20 minutes)"; Just () -> pure ()

suite :: IO ()
suite = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-migration-")) preserveAndRemove $ \root -> do
  cliExecutable <- lookupEnv "HENI_CLI_EXE" >>= maybe (error "set HENI_CLI_EXE to the separately built heni-cli executable") pure
  args <- getArgs
  let families = if "--rich-only" `elem` args then [] else ["replacement", "patch-catalogue", "generated-code", "diagnostic-checker"]
  records <- forM families $ \family -> do
    putStrLn ("migration family: " ++ family)
    sourceManifest <- getDataFileName ("examples/migrations/" ++ family ++ "/heni.json")
    original <- BS.readFile sourceManifest >>= either (error . show) pure . eitherDecodeStrict
    legacyStart <- getMonotonicTimeNSec
    legacyCaught <- runLegacy family (takeDirectory sourceManifest)
    legacyEnd <- getMonotonicTimeNSec
    assert' (family ++ " legacy intended detection") legacyCaught
    legacyWrong <- if family == "replacement" then Just <$> runLegacy "unrelated-compile-failure" (takeDirectory sourceManifest) else pure Nothing
    assert' "exit-only comparison demonstrates unrelated compiler false positive" (family /= "replacement" || legacyWrong == Just True)
    withSnapshot (takeDirectory sourceManifest) defaultSnapshotPolicy $ \snapshot -> withWorkspace snapshot $ \outer -> do
      let source = workspaceRoot outer
          edits path before after = [Replace (checked (mkRelativeTarget path)) before after 1]
          (ident, target, witness) = case family of
            "generated-code" -> ("generated-guard", "model.txt", "generated capacity: FAIL")
            "diagnostic-checker" -> ("invalid-capacity", "policy.txt", "policy.capacity: maximum must be positive")
            _ -> ("guard-bypass", "src/Capacity.hs", "rejects full capacity: FAIL")
          (old, new) = case family of "generated-code" -> ("mode=strict", "mode=open"); "diagnostic-checker" -> ("capacity=10", "capacity=-1"); _ -> ("used < limit", "True")
          base cid change expect = CaseDefinition (checked (mkCaseId cid)) "Fixture-specific migration" Set.empty change (checked (mkCommandId "build")) (Just (checked (mkCommandId "test"))) Nothing expect GenericCommands
          expected = Diagnostic Test 1 witness
      patch <- if family == "patch-catalogue" then Just <$> Text.readFile (source </> "patches/guard-bypass.patch") else pure Nothing
      survivorPatch <- if family == "patch-catalogue" then Just <$> Text.readFile (source </> "patches/survivor.patch") else pure Nothing
      let caught = base ident (maybe (edits target old new) (pure . Patch) patch) expected
          survivor = base "surviving-label" (maybe (edits target "baseline" "variant") (pure . Patch) survivorPatch) expected
          compileError = base "unrelated-compile-error" (edits target "used < limit" "missingCapacityGuard") expected
          rejection = base "expected-build-rejection" (edits target "used < limit" "missingCapacityGuard") (BuildRejection 1 "not in scope")
          portableCases = [caught, survivor] ++ (if family == "replacement" then [compileError, rejection] else [])
          value = set "execution" (object ["workers" .= (2 :: Int), "baselineReuse" .= object ["toolchain" .= String "local GHC/Cabal fixture contract; no external state"]]) (set "cases" (toJSON (map caseDefinitionJSON portableCases)) original)
          manifestPath = source </> "heni.json"
      LBS.writeFile manifestPath (encode value)
      manifest <- readManifest manifestPath >>= either (error . show) pure
      let customGenerated definition = case definition of
            CaseDefinition cid desc tags _ build test selector expect mode ->
              ( CaseDefinition cid desc tags [CustomEdit "generator-input"] build test selector expect mode,
                CaseHooks
                  [ ( "generator-input",
                      \WorkspaceOps {readTarget, writeTarget} -> do
                        let path = checked (mkRelativeTarget "model.txt")
                        content <- readTarget path
                        unless (Text.count "mode=strict" content == 1) (ioError (userError "generator input anchor mismatch"))
                        writeTarget path (Text.replace "mode=strict" "mode=open" content)
                    )
                  ]
                  Nothing
                  Nothing
              )
          typed = [if family == "generated-code" && index == 0 then customGenerated d else (d, noHooks) | (index, d) <- zip [0 :: Int ..] portableCases]
          campaign = checked (mkCampaign (campaignCommands (manifestCampaign manifest)) typed)
      start <- getMonotonicTimeNSec
      library <- runCampaign (defaultRunOptions source (root </> family </> "library")) campaign
      middle <- getMonotonicTimeNSec
      (exit, _, err) <- readProcessWithExitCode cliExecutable ["run", manifestPath, "--jobs", "2", "--output", root </> family </> "cli", "--json"] ""
      end <- getMonotonicTimeNSec
      assert' (family ++ " expected nonpassing survivor exit: " ++ err) (exit == ExitFailure 1)
      cliReport@StoredReport {result = cli} <- readReport (root </> family </> "cli/report.json") >>= either (error . show) pure
      libReport <- readReport (root </> family </> "library/report.json") >>= either (error . show) pure
      assert' (family ++ " typed/manifest semantic parity: " ++ show (signature library, signature cli)) (signature library == signature cli)
      assert' (family ++ " intended/surviving variants") (map (\(_, v, _, _) -> v) (signature library) == [Caught, Survived] ++ (if family == "replacement" then [UnexpectedBuildFailure, Caught] else []))
      let StoredReport {metadata = lm} = libReport; StoredReport {metadata = cm} = cliReport
      assert' "both paths capture identical selected source bytes" (jsonField "identity" (jsonField "snapshot" lm) == jsonField "identity" (jsonField "snapshot" cm))
      whenGenerated family $ do
        output <- BS.readFile (root </> family </> "library/generated-guard-candidate-build.stdout.log")
        assert' "mutated generator input reaches compiled artifact" ("artifact: admits used limit = True" `BS.isInfixOf` output)
      -- Reading a stale schema is a diagnostic action, never a rerun.
      LBS.writeFile (root </> family </> "stale-report.json") (encode (set "schemaVersion" (Number 999) (reportJSON cliReport)))
      stale <- readReport (root </> family </> "stale-report.json")
      assert' "stale report schema is rejected" (case stale of Left _ -> True; _ -> False)
      pure (object ["family" .= family, "legacyIntendedCaught" .= legacyCaught, "legacyUnrelatedCompileAccepted" .= legacyWrong, "legacySeconds" .= seconds legacyStart legacyEnd, "typedFreshSeconds" .= seconds start middle, "cliParallelReuseSeconds" .= seconds middle end, "outcomes" .= [show verdict | CaseResult {verdict} <- case library of RunResult {results} -> results], "typedReport" .= reportJSON libReport, "cliReport" .= reportJSON cliReport])
  rich <- richTrials cliExecutable root
  let output = object ["trialSelection" .= String (if null families then "rich-only" else "complete"), "operator" .= String "automated agent-authored trial; no human usability timing claimed", "platform" .= object ["os" .= Platform.os, "arch" .= Platform.arch, "compilerVersion" .= showVersion Platform.compilerVersion], "families" .= records, "richer" .= rich]
  destination <- lookupEnv "HENI_MIGRATION_RESULTS"
  forM_ destination $ \path -> do
    Dir.createDirectoryIfMissing True (takeDirectory path)
    let evidenceDirectory = takeDirectory path </> takeFileName root
    LBS.writeFile path (encode (set "evidenceDirectory" (String (Text.pack evidenceDirectory)) output))
  putStrLn (if null families then "Richer evidence trial passed" else "Four migration families, typed/CLI parity, precise negatives and richer evidence passed")
  where
    seconds start end = fromIntegral (end - start) / 1e9 :: Double
    whenGenerated family action = if family == "generated-code" then action else pure ()

richTrials :: FilePath -> FilePath -> IO Value
richTrials cliExecutable root = do
  let source = root </> "rich-source"
      build = "build" :: Text
      command args budget = object ["executable" .= String "heni-evidence-fixture", "arguments" .= (args :: [String]), "timeoutSeconds" .= (budget :: Double)]
      namedCommand mode = command ["protocol", "input", mode] 5
      replace old new = object ["kind" .= String "replace", "path" .= String "input", "old" .= (old :: Text), "new" .= (new :: Text)]
      named replay = object ["kind" .= String "named-tests", "failures" .= (["suite/intended"] :: [Text]), "replayRequired" .= replay]
      define :: Text -> Text -> Value -> Value -> [Value] -> [Pair] -> Value
      define cid test expect adapter edits extras = object (["id" .= (cid :: Text), "description" .= String "Combined evidence migration", "build" .= build, "test" .= (test :: Text), "expect" .= expect, "adapter" .= adapter, "edits" .= edits] ++ extras)
      diagnostic = object ["kind" .= String "diagnostic", "stage" .= String "test", "exitCode" .= (1 :: Int), "outputContains" .= String "capacity rule violated"]
      normal cid test = define cid test (named False) (String "heni-v1") [replace "anchor" "mutant"]
      resource :: Text -> Text -> Text -> Value
      resource name setup teardown = object ["name" .= name, "identity" .= String "fixture", "version" .= String "v1", "epoch" .= String "clean", "setup" .= setup, "reset" .= build, "teardown" .= teardown]
      cases = [normal "intended" "intended" [], normal "wrong-named" "wrong" [], normal "empty-selection" "intended" ["selector" .= String "absent"], define "stale-edit" "generic" diagnostic Null [replace "missing" "mutant"] [], define "hang" "hang" diagnostic Null [replace "anchor" "mutant"] [], normal "setup-failed" "intended" ["resources" .= (["SETUP"] :: [Text])], normal "teardown-failed" "intended" ["resources" .= (["TEARDOWN"] :: [Text])], define "replay" "replay" (named True) (String "heni-v1") [replace "anchor" "mutant"] [], define "crash" "crash" (object ["kind" .= String "crash", "signal" .= (fromIntegral sigABRT :: Int), "probe" .= String "operation-reached"]) Null [replace "anchor" "mutant"] [], define "control" "intended" (object ["kind" .= String "control"]) (String "heni-v1") [] []]
      value = object ["schemaVersion" .= (1 :: Int), "execution" .= object ["workers" .= (2 :: Int), "baselineReuse" .= object ["toolchain" .= String "standalone protocol fixture/v1", "adapter" .= String "fixture/v1"]], "commands" .= object ["build" .= command ["exit", "0"] 5, "fail" .= command ["exit", "1"] 5, "intended" .= namedCommand "intended", "wrong" .= namedCommand "wrong", "replay" .= namedCommand "replay", "crash" .= command ["crash", "input", "good"] 5, "generic" .= object ["executable" .= String "heni-fixture", "arguments" .= (["check", "input", "diagnostic"] :: [String])], "hang" .= object ["executable" .= String "heni-fixture", "arguments" .= (["check", "input", "timeout"] :: [String]), "timeoutSeconds" .= (0.5 :: Double)]], "resources" .= [resource ("SETUP" :: Text) ("fail" :: Text) build, resource ("TEARDOWN" :: Text) build ("fail" :: Text)], "cases" .= cases]
  Dir.createDirectory source
  BS.writeFile (source </> "input") "anchor"
  LBS.writeFile (source </> "heni.json") (encode value)
  manifest <- readManifest (source </> "heni.json") >>= either (error . show) pure
  lib <- runCampaign ((manifestRunOptions (root </> "rich-library") manifest) {workers = 1, reusePolicy = Fresh}) (manifestCampaign manifest)
  (exit, _, err) <- readProcessWithExitCode cliExecutable ["run", source </> "heni.json", "--output", root </> "rich-cli", "--json"] ""
  assert' ("rich infrastructure exit: " ++ err) (exit == ExitFailure 2)
  report@StoredReport {result = cli} <- readReport (root </> "rich-cli/report.json") >>= either (error . show) pure
  let issues RunResult {results} = [(caseIdText caseId, infrastructureErrors, cleanupErrors) | CaseResult {caseId, evidence = CaseEvidence {infrastructureErrors, cleanupErrors}} <- results]
  assert' ("richer typed/CLI parity: " ++ show (signature lib, signature cli) ++ "; issues: " ++ show (issues lib, issues cli)) (signature lib == signature cli)
  assert' "richer outcomes preserve named, replay, crash, resource and control meaning" (map (\(_, v, _, _) -> v) (signature lib) == [Caught, WrongFailure, NotRun, InvalidMutation, TimedOut, InfrastructureError, InfrastructureError, Caught, Caught, Caught])
  pure (reportJSON report)

preserveAndRemove :: FilePath -> IO ()
preserveAndRemove root = do
  destination <- lookupEnv "HENI_MIGRATION_RESULTS"
  forM_ destination $ \path -> do
    Dir.createDirectoryIfMissing True (takeDirectory path)
    let evidenceDirectory = takeDirectory path </> takeFileName root
    copyFixture root evidenceDirectory
    putStrLn ("migration evidence retained: " ++ evidenceDirectory)
  Dir.removePathForcibly root