packages feed

heni-0.1.0.0: src/Test/Heni/Internal/Model.hs

{-# OPTIONS_HADDOCK not-home #-}

-- | Implementation detail, not an exposed module or supported import API.
-- Public facades hide constrained constructors and own their documentation.
module Test.Heni.Internal.Model where

import Data.Char (isAlphaNum, isAscii)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Test.Heni.Internal.Prelude

-- | A field-specific configuration problem, detected before execution.
data ConfigError = ConfigError {field :: !Text, message :: !Text}
  deriving stock (Eq, Show, Generic)

-- | Stable portable case identifier.
newtype CaseId = CaseId Text deriving stock (Eq, Ord, Show, Generic)

-- | Stable portable command identifier.
newtype CommandId = CommandId Text deriving stock (Eq, Ord, Show, Generic)

-- | Slash-separated path relative to a workspace, never a parent or root.
newtype RelativeTarget = RelativeTarget FilePath deriving stock (Eq, Ord, Show, Generic)

-- | Positive wall-clock microseconds, bounded to avoid timer overflow.
newtype StageBudget = StageBudget Int deriving stock (Eq, Ord, Show, Generic)

-- | A nonempty set of selected IDs, preserving the requested order.
newtype SelectedCases = SelectedCases [CaseId] deriving stock (Eq, Show, Generic)

-- | Construct an identifier using ASCII letters, digits, dots, dashes and underscores.
mkCaseId :: Text -> Either ConfigError CaseId
mkCaseId t = CaseId <$> identifier "case.id" t

-- | Construct a reusable command identifier.
mkCommandId :: Text -> Either ConfigError CommandId
mkCommandId t = CommandId <$> identifier "command.id" t

-- | Shared ASCII identity check used by the public smart constructors.
identifier :: Text -> Text -> Either ConfigError Text
identifier f t
  | Text.null t || not (Text.all (\c -> isAscii c && (isAlphaNum c || c `elem` ("-_." :: String))) t) =
      Left (ConfigError f "expected a nonempty portable identifier")
  | otherwise = Right t

-- | Reject traversal, drive syntax, backslashes, empty segments and NULs.
-- Filesystem checks must additionally reject symlinks when opening the target.
mkRelativeTarget :: FilePath -> Either ConfigError RelativeTarget
mkRelativeTarget p
  | null p || any (`elem` p) ['\\', ':', '\0'] || any (`elem` ["", ".", ".."]) parts =
      Left (ConfigError "path" "expected a normalized relative path")
  | otherwise = Right (RelativeTarget p)
  where
    parts = Text.splitOn "/" (Text.pack p)

-- | Budget in microseconds; at most one day for a single stage.
mkStageBudget :: Int -> Either ConfigError StageBudget
mkStageBudget n
  | n > 0 && toInteger n <= 86400000000 = Right (StageBudget n)
  | otherwise = Left (ConfigError "budget" "expected positive microseconds, at most one day")

-- | Reject empty or duplicate selections rather than silently changing their meaning.
mkSelectedCases :: [CaseId] -> Either ConfigError SelectedCases
mkSelectedCases ids
  | null ids = Left (ConfigError "selection" "no cases selected")
  | Set.size (Set.fromList ids) /= length ids = Left (ConfigError "selection" "duplicate case IDs")
  | otherwise = Right (SelectedCases ids)

-- | Underlying portable ID.
caseIdText :: CaseId -> Text
caseIdText (CaseId t) = t

-- | Underlying command ID.
commandIdText :: CommandId -> Text
commandIdText (CommandId t) = t

-- | Path suitable for joining to a verified workspace root.
targetPath :: RelativeTarget -> FilePath
targetPath (RelativeTarget p) = p

-- | Timer argument in microseconds.
budgetMicroseconds :: StageBudget -> Int
budgetMicroseconds (StageBudget n) = n

-- | Requested case order.
selectedCaseIds :: SelectedCases -> [CaseId]
selectedCaseIds (SelectedCases ids) = ids

-- | Independently supervised execution phases.
data Stage = Setup | Build | Test | Replay | Teardown
  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)

-- | Exit, signal and runner intervention are deliberately distinct.
data ProcessOutcome = Exited !Int | Signaled !Int | Timeout | Cancelled | LaunchFailed !Text
  deriving stock (Eq, Show, Generic)

-- | Output retention policy. Exceeding the limit cannot prove a diagnostic.
data OutputPolicy = OutputPolicy {limitBytes :: !Int}
  deriving stock (Eq, Show, Generic)

-- | An executable and arguments, with no implicit shell expansion.
data Command = Command
  { executable :: !FilePath,
    arguments :: ![String],
    sensitiveArguments :: !(Set Int),
    workingDirectory :: !(Maybe RelativeTarget),
    environment :: ![(String, String)],
    budget :: !StageBudget,
    output :: !OutputPolicy
  }
  deriving stock (Eq, Show, Generic)

-- | Checked text, patch, or registered Haskell edit. Patches are UTF-8 content.
data Edit
  = Replace !RelativeTarget !Text !Text !Int
  | Create !RelativeTarget !Text
  | Delete !RelativeTarget !Text
  | Patch !Text
  | CustomEdit !Text
  deriving stock (Eq, Show, Generic)

-- | A replay token and its exact generator configuration.
data ReplayEvidence = ReplayEvidence
  {token :: !Text, generator :: !Text, failures :: !(Set Text)}
  deriving stock (Eq, Show, Generic)

-- | Whether matching counterexample replay is mandatory.
data ReplayPolicy = NoReplay | RequireReplay
  deriving stock (Eq, Show, Generic)

-- | Expectations declare their evidence strength rather than guessing from output.
data Expectation
  = NamedTests !(Set Text) !ReplayPolicy
  | Diagnostic !Stage !Int !Text
  | BuildRejection !Int !Text
  | CrashProbe !Int !Text
  | Control
  deriving stock (Eq, Show, Generic)

-- | Declared framework-neutral evidence transport.
data EvidenceMode = GenericCommands | StructuredTestsV1
  deriving stock (Eq, Show, Generic)

-- | Portable definition, including its declared evidence transport.
data CaseDefinition = CaseDefinition
  { caseId :: !CaseId,
    description :: !Text,
    tags :: !(Set Text),
    edits :: ![Edit],
    build :: !CommandId,
    test :: !(Maybe CommandId),
    selector :: !(Maybe Text),
    expectation :: !Expectation,
    evidenceMode :: !EvidenceMode
  }
  deriving stock (Eq, Show, Generic)

-- | Operations supplied by the workspace owner. Hooks are trusted IO, not a sandbox.
data WorkspaceOps = WorkspaceOps
  { readTarget :: !(RelativeTarget -> IO Text),
    writeTarget :: !(RelativeTarget -> Text -> IO ()),
    createTarget :: !(RelativeTarget -> Text -> IO ()),
    deleteTarget :: !(RelativeTarget -> Text -> IO ())
  }

-- | Haskell-only extension points. Returned evidence still passes all validity gates.
data CaseHooks = CaseHooks
  { customEdits :: ![(Text, WorkspaceOps -> IO ())],
    customEvidence :: !(Maybe (StageEvidence -> IO (Either Text StageEvidence))),
    replayBuilder :: !(Maybe (ReplayEvidence -> Command -> IO (Either Text Command)))
  }

-- | No custom behavior.
noHooks :: CaseHooks
noHooks = CaseHooks [] Nothing Nothing

-- | Counts are optional: unknown is different from zero. IDs identify attribution.
data SelectionEvidence = SelectionEvidence
  { discovered :: !(Maybe Int),
    selected :: !(Maybe Int),
    executed :: !(Maybe Int),
    executedIds :: !(Set Text),
    failedIds :: !(Set Text),
    skippedIds :: !(Set Text),
    setupErrors :: ![Text]
  }
  deriving stock (Eq, Show, Generic)

-- | Generic commands do not invent test counts.
unknownSelection :: SelectionEvidence
unknownSelection = SelectionEvidence Nothing Nothing Nothing Set.empty Set.empty Set.empty []

-- | Observations from a single stage. A truncated output cannot be a witness.
data StageEvidence = StageEvidence
  { stage :: !Stage,
    outcome :: !ProcessOutcome,
    transcript :: !Text,
    truncated :: !Bool,
    selection :: !SelectionEvidence,
    replay :: !(Maybe ReplayEvidence),
    durationSeconds :: !Double,
    logPaths :: ![FilePath]
  }
  deriving stock (Eq, Show, Generic)

-- | Whether edit preconditions and the final byte-change requirement passed.
data MutationEvidence = Changed | Unchanged | EditRejected !Text
  deriving stock (Eq, Show, Generic)

-- | A baseline failure stops the candidate; it is never a mutant kill.
data BaselineEvidence = BaselinePassed | BaselineFailed !Text | BaselineUnavailable
  deriving stock (Eq, Show, Generic)

-- | All validity gates and primary/secondary observations used by the classifier.
data CaseEvidence = CaseEvidence
  { baseline :: !BaselineEvidence,
    mutation :: !MutationEvidence,
    stages :: ![StageEvidence],
    infrastructureErrors :: ![Text],
    cleanupErrors :: ![Text],
    stopped :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)

-- | All nine public case outcomes.
data Verdict
  = Caught
  | Survived
  | InvalidMutation
  | Uncovered
  | UnexpectedBuildFailure
  | WrongFailure
  | TimedOut
  | InfrastructureError
  | NotRun
  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)

-- | Successful detections and passing controls are tallied separately.
data DetectionKind = SemanticNamed | GenericDiagnostic | ExpectedBuildRejection | ExplicitCrash | ControlPassed
  deriving stock (Eq, Ord, Show, Generic)

-- | Classifier output, retaining every observation and secondary error.
data CaseResult = CaseResult
  { caseId :: !CaseId,
    verdict :: !Verdict,
    detection :: !(Maybe DetectionKind),
    semantic :: !Bool,
    control :: !Bool,
    reason :: !Text,
    evidence :: !CaseEvidence
  }
  deriving stock (Eq, Show, Generic)

-- | Campaign failures keep affected IDs even when no candidate could start.
data RunFailure
  = BaselineFailure ![CaseId] !Text
  | ControlFailure !CaseId !Text
  | RunInfrastructure !Text
  | Interrupted !Text
  deriving stock (Eq, Show, Generic)

-- | CLI-neutral run contract. Infrastructure takes precedence over a failed contract.
data RunStatus = RunPassed | RunFailed | RunUntrustworthy | RunInterrupted
  deriving stock (Eq, Show, Generic)

-- | Only caught and survived semantic mutations contribute to the ratio.
data SemanticScore = SemanticScore {caught :: !Int, total :: !Int, complete :: !Bool}
  deriving stock (Eq, Show, Generic)

-- | Exactly one result per selected ID, in selection order.
data RunResult = RunResult
  { selectedIds :: ![CaseId],
    results :: ![CaseResult],
    failures :: ![RunFailure],
    status :: !RunStatus,
    score :: !(Maybe SemanticScore)
  }
  deriving stock (Eq, Show, Generic)