packages feed

seihou-core 0.15.0.0 → 0.16.0.0

raw patch · 44 files changed

+3615/−203 lines, 44 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Seihou.Core.ArtifactFiles: definitionFiles :: [FilePath]
+ Seihou.Core.ArtifactFiles: judgementAmbiguity :: FilePath -> IO (Maybe [FilePath])
+ Seihou.Core.ArtifactFiles: presentDefinitionFiles :: FilePath -> IO [FilePath]
+ Seihou.Core.ArtifactFiles: renderAmbiguousArtifact :: ModuleName -> FilePath -> [FilePath] -> Text
+ Seihou.Core.ArtifactIdentity: data RepositoryOriginKey
+ Seihou.Core.ArtifactIdentity: instance GHC.Classes.Eq Seihou.Core.ArtifactIdentity.RepositoryOriginKey
+ Seihou.Core.ArtifactIdentity: instance GHC.Classes.Ord Seihou.Core.ArtifactIdentity.RepositoryOriginKey
+ Seihou.Core.ArtifactIdentity: instance GHC.Internal.Generics.Generic Seihou.Core.ArtifactIdentity.RepositoryOriginKey
+ Seihou.Core.ArtifactIdentity: instance GHC.Internal.Show.Show Seihou.Core.ArtifactIdentity.RepositoryOriginKey
+ Seihou.Core.ArtifactIdentity: repositoryOriginKey :: Text -> RepositoryOriginKey
+ Seihou.Core.ArtifactIdentity: sameRepositoryOrigin :: Text -> Text -> Bool
+ Seihou.Core.Judgement: Advisory :: Enforcement
+ Seihou.Core.Judgement: ApprovedRemote :: DataPolicy
+ Seihou.Core.Judgement: Blocked :: JudgementStatus
+ Seihou.Core.Judgement: BlockingFinding :: !Finding -> DecisionReason
+ Seihou.Core.Judgement: CheckBlocked :: CheckStatus
+ Seihou.Core.Judgement: CheckDecision :: !Text -> !CheckStatus -> !Enforcement -> !CheckPolicy -> ![ChunkDecision] -> !Maybe Double -> !Maybe Double -> ![Finding] -> ![DecisionReason] -> CheckDecision
+ Seihou.Core.Judgement: CheckError :: !Text -> DecisionReason
+ Seihou.Core.Judgement: CheckFailed :: CheckStatus
+ Seihou.Core.Judgement: CheckNeedsReview :: CheckStatus
+ Seihou.Core.Judgement: CheckNoChanges :: CheckStatus
+ Seihou.Core.Judgement: CheckObservation :: !Text -> ![ChunkObservation] -> ![Text] -> CheckObservation
+ Seihou.Core.Judgement: CheckPassed :: CheckStatus
+ Seihou.Core.Judgement: CheckPolicy :: !Enforcement -> !Severity -> !Thresholds -> !Thresholds -> !Bool -> CheckPolicy
+ Seihou.Core.Judgement: ChunkDecision :: !Int -> !CheckStatus -> !DecidedBy -> !Maybe Double -> !Maybe Double -> ![Finding] -> ![DecisionReason] -> ChunkDecision
+ Seihou.Core.Judgement: ChunkObservation :: !Int -> !Maybe GateObservation -> !Maybe (Either Text ReviewObservation) -> ChunkObservation
+ Seihou.Core.Judgement: CommitRangeScope :: EvidenceScopeKind
+ Seihou.Core.Judgement: ConcernAtOrAboveBlock :: !Double -> !Double -> DecisionReason
+ Seihou.Core.Judgement: ConcernAtOrBelowPass :: !Double -> !Double -> DecisionReason
+ Seihou.Core.Judgement: ConcernInBand :: !Double -> !Thresholds -> DecisionReason
+ Seihou.Core.Judgement: Coverage :: ![FilePath] -> ![FilePath] -> ![(FilePath, Text)] -> Coverage
+ Seihou.Core.Judgement: CoverageLimits :: !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> CoverageLimits
+ Seihou.Core.Judgement: Critical :: Severity
+ Seihou.Core.Judgement: DecidedByGate :: DecidedBy
+ Seihou.Core.Judgement: DecidedByNone :: DecidedBy
+ Seihou.Core.Judgement: DecidedByReview :: DecidedBy
+ Seihou.Core.Judgement: DeterministicFinding :: !DeterministicCheck -> !Text -> !Location -> DeterministicFinding
+ Seihou.Core.Judgement: Enforced :: Enforcement
+ Seihou.Core.Judgement: EvidenceDeclaration :: ![EvidenceScopeKind] -> ![Text] -> ![Text] -> EvidenceDeclaration
+ Seihou.Core.Judgement: EvidenceIndex :: Map FilePath Int -> EvidenceIndex
+ Seihou.Core.Judgement: Failed :: JudgementStatus
+ Seihou.Core.Judgement: Finding :: !Text -> !Severity -> !Location -> !Text -> Finding
+ Seihou.Core.Judgement: FindingCategory :: !Text -> !Severity -> !Maybe Text -> FindingCategory
+ Seihou.Core.Judgement: GateAnswered :: !Double -> GateObservation
+ Seihou.Core.Judgement: GateCleared :: !Double -> !Double -> DecisionReason
+ Seihou.Core.Judgement: GateDisagreed :: !Double -> !Double -> DecisionReason
+ Seihou.Core.Judgement: GateEscalated :: !Double -> !Double -> DecisionReason
+ Seihou.Core.Judgement: GateFailed :: !Text -> GateObservation
+ Seihou.Core.Judgement: GateUnavailable :: !Text -> DecisionReason
+ Seihou.Core.Judgement: High :: Severity
+ Seihou.Core.Judgement: Info :: Severity
+ Seihou.Core.Judgement: JudgementCheck :: !Text -> !Text -> !Answer -> !Maybe Text -> !Maybe Text -> ![FindingCategory] -> !Enforcement -> !Severity -> !Thresholds -> !Thresholds -> !Bool -> JudgementCheck
+ Seihou.Core.Judgement: JudgementDecision :: !Text -> !JudgementStatus -> ![DeterministicFinding] -> ![Text] -> ![CheckDecision] -> !Coverage -> JudgementDecision
+ Seihou.Core.Judgement: JudgementDefinition :: !Text -> !Maybe Text -> !Maybe Text -> !Text -> !EvidenceDeclaration -> !CoverageLimits -> ![DeterministicCheck] -> !NonEmpty JudgementCheck -> JudgementDefinition
+ Seihou.Core.Judgement: JudgementError :: !Text -> !Text -> JudgementError
+ Seihou.Core.Judgement: JudgementObservation :: !Coverage -> ![DeterministicFinding] -> ![Text] -> ![CheckObservation] -> !Bool -> JudgementObservation
+ Seihou.Core.Judgement: JudgementPolicy :: !DataPolicy -> !Bool -> !Map Text CheckPolicy -> JudgementPolicy
+ Seihou.Core.Judgement: JudgementRef :: !Text -> !Maybe Text -> !Maybe Text -> JudgementRef
+ Seihou.Core.Judgement: LocalOnly :: DataPolicy
+ Seihou.Core.Judgement: Location :: !FilePath -> !Maybe Int -> Location
+ Seihou.Core.Judgement: Low :: Severity
+ Seihou.Core.Judgement: Medium :: Severity
+ Seihou.Core.Judgement: ModelRequestedReview :: DecisionReason
+ Seihou.Core.Judgement: NeedsReview :: JudgementStatus
+ Seihou.Core.Judgement: No :: Answer
+ Seihou.Core.Judgement: NoChanges :: JudgementStatus
+ Seihou.Core.Judgement: Passed :: JudgementStatus
+ Seihou.Core.Judgement: RawFinding :: !Text -> !Text -> !Maybe Int -> !Text -> RawFinding
+ Seihou.Core.Judgement: RawReview :: !Double -> ![RawFinding] -> !Bool -> RawReview
+ Seihou.Core.Judgement: ReviewError :: !Text -> DecisionReason
+ Seihou.Core.Judgement: ReviewMissing :: DecisionReason
+ Seihou.Core.Judgement: ReviewObservation :: !Double -> ![Finding] -> !Bool -> ReviewObservation
+ Seihou.Core.Judgement: SecretScan :: DeterministicCheck
+ Seihou.Core.Judgement: StagedChangesScope :: EvidenceScopeKind
+ Seihou.Core.Judgement: Thresholds :: !Double -> !Double -> Thresholds
+ Seihou.Core.Judgement: Yes :: Answer
+ Seihou.Core.Judgement: [blockAt] :: Thresholds -> !Double
+ Seihou.Core.Judgement: [blockingSeverity] :: CheckPolicy -> !Severity
+ Seihou.Core.Judgement: [categories] :: JudgementCheck -> ![FindingCategory]
+ Seihou.Core.Judgement: [category] :: RawFinding -> !Text
+ Seihou.Core.Judgement: [check] :: CheckDecision -> !Text
+ Seihou.Core.Judgement: [checks] :: JudgementDecision -> ![CheckDecision]
+ Seihou.Core.Judgement: [chunk] :: ChunkDecision -> !Int
+ Seihou.Core.Judgement: [chunks] :: CheckDecision -> ![ChunkDecision]
+ Seihou.Core.Judgement: [code] :: JudgementError -> !Text
+ Seihou.Core.Judgement: [concernWhen] :: JudgementCheck -> !Answer
+ Seihou.Core.Judgement: [concern] :: RawReview -> !Double
+ Seihou.Core.Judgement: [coverage] :: JudgementDecision -> !Coverage
+ Seihou.Core.Judgement: [dataPolicy] :: JudgementPolicy -> !DataPolicy
+ Seihou.Core.Judgement: [decidedBy] :: ChunkDecision -> !DecidedBy
+ Seihou.Core.Judgement: [description] :: FindingCategory -> !Maybe Text
+ Seihou.Core.Judgement: [deterministicChecks] :: JudgementDefinition -> ![DeterministicCheck]
+ Seihou.Core.Judgement: [deterministicErrors] :: JudgementDecision -> ![Text]
+ Seihou.Core.Judgement: [deterministic] :: JudgementDecision -> ![DeterministicFinding]
+ Seihou.Core.Judgement: [enforcement] :: CheckDecision -> !Enforcement
+ Seihou.Core.Judgement: [errors] :: CheckObservation -> ![Text]
+ Seihou.Core.Judgement: [evidence] :: JudgementDefinition -> !EvidenceDeclaration
+ Seihou.Core.Judgement: [exclude] :: EvidenceDeclaration -> ![Text]
+ Seihou.Core.Judgement: [excludedFiles] :: Coverage -> ![FilePath]
+ Seihou.Core.Judgement: [explanation] :: RawFinding -> !Text
+ Seihou.Core.Judgement: [files] :: EvidenceIndex -> Map FilePath Int
+ Seihou.Core.Judgement: [findings] :: CheckDecision -> ![Finding]
+ Seihou.Core.Judgement: [gateConcern] :: CheckDecision -> !Maybe Double
+ Seihou.Core.Judgement: [gatePermitted] :: JudgementPolicy -> !Bool
+ Seihou.Core.Judgement: [gate] :: ChunkObservation -> !Maybe GateObservation
+ Seihou.Core.Judgement: [include] :: EvidenceDeclaration -> ![Text]
+ Seihou.Core.Judgement: [instructions] :: JudgementDefinition -> !Text
+ Seihou.Core.Judgement: [judgement] :: JudgementDecision -> !Text
+ Seihou.Core.Judgement: [limits] :: JudgementDefinition -> !CoverageLimits
+ Seihou.Core.Judgement: [line] :: RawFinding -> !Maybe Int
+ Seihou.Core.Judgement: [location] :: DeterministicFinding -> !Location
+ Seihou.Core.Judgement: [maxAttempts] :: CoverageLimits -> !Int
+ Seihou.Core.Judgement: [maxChunkBytes] :: CoverageLimits -> !Int
+ Seihou.Core.Judgement: [maxChunks] :: CoverageLimits -> !Int
+ Seihou.Core.Judgement: [maxFileBytes] :: CoverageLimits -> !Int
+ Seihou.Core.Judgement: [maxFindings] :: CoverageLimits -> !Int
+ Seihou.Core.Judgement: [message] :: JudgementError -> !Text
+ Seihou.Core.Judgement: [name] :: FindingCategory -> !Text
+ Seihou.Core.Judgement: [noChanges] :: JudgementObservation -> !Bool
+ Seihou.Core.Judgement: [noMeans] :: JudgementCheck -> !Maybe Text
+ Seihou.Core.Judgement: [passAt] :: Thresholds -> !Double
+ Seihou.Core.Judgement: [path] :: RawFinding -> !Text
+ Seihou.Core.Judgement: [policy] :: CheckDecision -> !CheckPolicy
+ Seihou.Core.Judgement: [question] :: JudgementCheck -> !Text
+ Seihou.Core.Judgement: [reasons] :: CheckDecision -> ![DecisionReason]
+ Seihou.Core.Judgement: [requestedHumanReview] :: RawReview -> !Bool
+ Seihou.Core.Judgement: [reviewConcern] :: CheckDecision -> !Maybe Double
+ Seihou.Core.Judgement: [review] :: ChunkObservation -> !Maybe (Either Text ReviewObservation)
+ Seihou.Core.Judgement: [reviewedFiles] :: Coverage -> ![FilePath]
+ Seihou.Core.Judgement: [rule] :: DeterministicFinding -> !Text
+ Seihou.Core.Judgement: [scopes] :: EvidenceDeclaration -> ![EvidenceScopeKind]
+ Seihou.Core.Judgement: [severity] :: Finding -> !Severity
+ Seihou.Core.Judgement: [source] :: JudgementRef -> !Maybe Text
+ Seihou.Core.Judgement: [status] :: JudgementDecision -> !JudgementStatus
+ Seihou.Core.Judgement: [timeoutSeconds] :: CoverageLimits -> !Int
+ Seihou.Core.Judgement: [uncoveredFiles] :: Coverage -> ![(FilePath, Text)]
+ Seihou.Core.Judgement: [useGate] :: CheckPolicy -> !Bool
+ Seihou.Core.Judgement: [version] :: JudgementDefinition -> !Maybe Text
+ Seihou.Core.Judgement: [when] :: JudgementRef -> !Maybe Text
+ Seihou.Core.Judgement: [yesMeans] :: JudgementCheck -> !Maybe Text
+ Seihou.Core.Judgement: checkPolicyFor :: JudgementPolicy -> JudgementCheck -> CheckPolicy
+ Seihou.Core.Judgement: concernProbability :: Answer -> Double -> Double
+ Seihou.Core.Judgement: data Answer
+ Seihou.Core.Judgement: data CheckDecision
+ Seihou.Core.Judgement: data CheckObservation
+ Seihou.Core.Judgement: data CheckPolicy
+ Seihou.Core.Judgement: data CheckStatus
+ Seihou.Core.Judgement: data ChunkDecision
+ Seihou.Core.Judgement: data ChunkObservation
+ Seihou.Core.Judgement: data Coverage
+ Seihou.Core.Judgement: data CoverageLimits
+ Seihou.Core.Judgement: data DataPolicy
+ Seihou.Core.Judgement: data DecidedBy
+ Seihou.Core.Judgement: data DecisionReason
+ Seihou.Core.Judgement: data DeterministicCheck
+ Seihou.Core.Judgement: data DeterministicFinding
+ Seihou.Core.Judgement: data Enforcement
+ Seihou.Core.Judgement: data EvidenceDeclaration
+ Seihou.Core.Judgement: data EvidenceScopeKind
+ Seihou.Core.Judgement: data Finding
+ Seihou.Core.Judgement: data FindingCategory
+ Seihou.Core.Judgement: data GateObservation
+ Seihou.Core.Judgement: data JudgementCheck
+ Seihou.Core.Judgement: data JudgementDecision
+ Seihou.Core.Judgement: data JudgementDefinition
+ Seihou.Core.Judgement: data JudgementError
+ Seihou.Core.Judgement: data JudgementObservation
+ Seihou.Core.Judgement: data JudgementPolicy
+ Seihou.Core.Judgement: data JudgementRef
+ Seihou.Core.Judgement: data JudgementStatus
+ Seihou.Core.Judgement: data Location
+ Seihou.Core.Judgement: data RawFinding
+ Seihou.Core.Judgement: data RawReview
+ Seihou.Core.Judgement: data ReviewObservation
+ Seihou.Core.Judgement: data Severity
+ Seihou.Core.Judgement: data Thresholds
+ Seihou.Core.Judgement: decideCheck :: Bool -> Coverage -> CheckPolicy -> CheckObservation -> CheckDecision
+ Seihou.Core.Judgement: decideChunk :: Bool -> CheckPolicy -> ChunkObservation -> ChunkDecision
+ Seihou.Core.Judgement: decideJudgement :: JudgementDefinition -> JudgementPolicy -> JudgementObservation -> JudgementDecision
+ Seihou.Core.Judgement: defaultCoverageLimits :: CoverageLimits
+ Seihou.Core.Judgement: defaultEvidenceDeclaration :: EvidenceDeclaration
+ Seihou.Core.Judgement: defaultPolicy :: DataPolicy -> Bool -> JudgementDefinition -> JudgementPolicy
+ Seihou.Core.Judgement: findingBlocks :: CheckPolicy -> Finding -> Bool
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.Answer
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.CheckDecision
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.CheckObservation
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.CheckPolicy
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.CheckStatus
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.ChunkDecision
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.ChunkObservation
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.Coverage
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.CoverageLimits
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.DataPolicy
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.DecidedBy
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.DecisionReason
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.DeterministicCheck
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.DeterministicFinding
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.Enforcement
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.EvidenceDeclaration
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.EvidenceIndex
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.EvidenceScopeKind
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.Finding
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.FindingCategory
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.GateObservation
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementCheck
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementDecision
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementDefinition
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementError
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementObservation
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementPolicy
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementRef
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.JudgementStatus
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.Location
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.RawFinding
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.RawReview
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.ReviewObservation
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.Severity
+ Seihou.Core.Judgement: instance GHC.Classes.Eq Seihou.Core.Judgement.Thresholds
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.Answer
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.CheckStatus
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.DataPolicy
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.DecidedBy
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.DeterministicCheck
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.Enforcement
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.EvidenceScopeKind
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.JudgementStatus
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.Location
+ Seihou.Core.Judgement: instance GHC.Classes.Ord Seihou.Core.Judgement.Severity
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.Answer
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.CheckStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.DataPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.DeterministicCheck
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.Enforcement
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.EvidenceScopeKind
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.JudgementStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Bounded Seihou.Core.Judgement.Severity
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.Answer
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.CheckStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.DataPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.DeterministicCheck
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.Enforcement
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.EvidenceScopeKind
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.JudgementStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Enum.Enum Seihou.Core.Judgement.Severity
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.Answer
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.CheckDecision
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.CheckObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.CheckPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.CheckStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.ChunkDecision
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.ChunkObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.Coverage
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.CoverageLimits
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.DataPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.DecidedBy
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.DecisionReason
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.DeterministicCheck
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.DeterministicFinding
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.Enforcement
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.EvidenceDeclaration
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.EvidenceIndex
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.EvidenceScopeKind
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.Finding
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.FindingCategory
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.GateObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementCheck
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementDecision
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementDefinition
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementError
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementRef
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.JudgementStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.Location
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.RawFinding
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.RawReview
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.ReviewObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.Severity
+ Seihou.Core.Judgement: instance GHC.Internal.Generics.Generic Seihou.Core.Judgement.Thresholds
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.Answer
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.CheckDecision
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.CheckObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.CheckPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.CheckStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.ChunkDecision
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.ChunkObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.Coverage
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.CoverageLimits
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.DataPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.DecidedBy
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.DecisionReason
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.DeterministicCheck
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.DeterministicFinding
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.Enforcement
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.EvidenceDeclaration
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.EvidenceIndex
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.EvidenceScopeKind
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.Finding
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.FindingCategory
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.GateObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementCheck
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementDecision
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementDefinition
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementError
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementPolicy
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementRef
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.JudgementStatus
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.Location
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.RawFinding
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.RawReview
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.ReviewObservation
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.Severity
+ Seihou.Core.Judgement: instance GHC.Internal.Show.Show Seihou.Core.Judgement.Thresholds
+ Seihou.Core.Judgement: judgementError :: Text -> Text -> JudgementError
+ Seihou.Core.Judgement: newtype EvidenceIndex
+ Seihou.Core.Judgement: renderAnswer :: Answer -> Text
+ Seihou.Core.Judgement: renderCheckStatus :: CheckStatus -> Text
+ Seihou.Core.Judgement: renderDataPolicy :: DataPolicy -> Text
+ Seihou.Core.Judgement: renderDecisionReason :: DecisionReason -> Text
+ Seihou.Core.Judgement: renderDeterministicCheck :: DeterministicCheck -> Text
+ Seihou.Core.Judgement: renderEnforcement :: Enforcement -> Text
+ Seihou.Core.Judgement: renderJudgementStatus :: JudgementStatus -> Text
+ Seihou.Core.Judgement: renderProbability :: Double -> Text
+ Seihou.Core.Judgement: renderScopeKind :: EvidenceScopeKind -> Text
+ Seihou.Core.Judgement: renderSeverity :: Severity -> Text
+ Seihou.Core.Judgement: statusSeverity :: CheckStatus -> Int
+ Seihou.Core.Judgement: validProbability :: Double -> Bool
+ Seihou.Core.Judgement: validateDefinition :: JudgementDefinition -> Either JudgementError ()
+ Seihou.Core.Judgement: validateReview :: CoverageLimits -> JudgementCheck -> EvidenceIndex -> RawReview -> Either Text ReviewObservation
+ Seihou.Core.Judgement: validateThresholds :: Text -> Thresholds -> Either JudgementError ()
+ Seihou.Core.JudgementExamples: ExampleFile :: !FilePath -> !Text -> ExampleFile
+ Seihou.Core.JudgementExamples: ExpectBlocked :: ExpectedStatus
+ Seihou.Core.JudgementExamples: ExpectNeedsReview :: ExpectedStatus
+ Seihou.Core.JudgementExamples: ExpectPassed :: ExpectedStatus
+ Seihou.Core.JudgementExamples: ExpectedFinding :: !Text -> !Text -> !FilePath -> !Maybe Int -> ExpectedFinding
+ Seihou.Core.JudgementExamples: JudgementExample :: !Text -> !Maybe Text -> ![ExampleFile] -> !Map Text ExpectedStatus -> ![ExpectedFinding] -> JudgementExample
+ Seihou.Core.JudgementExamples: JudgementExamples :: !Int -> ![JudgementExample] -> JudgementExamples
+ Seihou.Core.JudgementExamples: [category] :: ExpectedFinding -> !Text
+ Seihou.Core.JudgementExamples: [check] :: ExpectedFinding -> !Text
+ Seihou.Core.JudgementExamples: [content] :: ExampleFile -> !Text
+ Seihou.Core.JudgementExamples: [description] :: JudgementExample -> !Maybe Text
+ Seihou.Core.JudgementExamples: [examples] :: JudgementExamples -> ![JudgementExample]
+ Seihou.Core.JudgementExamples: [expect] :: JudgementExample -> !Map Text ExpectedStatus
+ Seihou.Core.JudgementExamples: [files] :: JudgementExample -> ![ExampleFile]
+ Seihou.Core.JudgementExamples: [findings] :: JudgementExample -> ![ExpectedFinding]
+ Seihou.Core.JudgementExamples: [id] :: JudgementExample -> !Text
+ Seihou.Core.JudgementExamples: [line] :: ExpectedFinding -> !Maybe Int
+ Seihou.Core.JudgementExamples: [path] :: ExpectedFinding -> !FilePath
+ Seihou.Core.JudgementExamples: [version] :: JudgementExamples -> !Int
+ Seihou.Core.JudgementExamples: data ExampleFile
+ Seihou.Core.JudgementExamples: data ExpectedFinding
+ Seihou.Core.JudgementExamples: data ExpectedStatus
+ Seihou.Core.JudgementExamples: data JudgementExample
+ Seihou.Core.JudgementExamples: data JudgementExamples
+ Seihou.Core.JudgementExamples: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.JudgementExamples.ExampleFile
+ Seihou.Core.JudgementExamples: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.JudgementExamples.ExpectedFinding
+ Seihou.Core.JudgementExamples: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.JudgementExamples.ExpectedStatus
+ Seihou.Core.JudgementExamples: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.JudgementExamples.JudgementExample
+ Seihou.Core.JudgementExamples: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.JudgementExamples.JudgementExamples
+ Seihou.Core.JudgementExamples: instance GHC.Classes.Eq Seihou.Core.JudgementExamples.ExampleFile
+ Seihou.Core.JudgementExamples: instance GHC.Classes.Eq Seihou.Core.JudgementExamples.ExpectedFinding
+ Seihou.Core.JudgementExamples: instance GHC.Classes.Eq Seihou.Core.JudgementExamples.ExpectedStatus
+ Seihou.Core.JudgementExamples: instance GHC.Classes.Eq Seihou.Core.JudgementExamples.JudgementExample
+ Seihou.Core.JudgementExamples: instance GHC.Classes.Eq Seihou.Core.JudgementExamples.JudgementExamples
+ Seihou.Core.JudgementExamples: instance GHC.Classes.Ord Seihou.Core.JudgementExamples.ExpectedStatus
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Enum.Bounded Seihou.Core.JudgementExamples.ExpectedStatus
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Enum.Enum Seihou.Core.JudgementExamples.ExpectedStatus
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Generics.Generic Seihou.Core.JudgementExamples.ExampleFile
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Generics.Generic Seihou.Core.JudgementExamples.ExpectedFinding
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Generics.Generic Seihou.Core.JudgementExamples.ExpectedStatus
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Generics.Generic Seihou.Core.JudgementExamples.JudgementExample
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Generics.Generic Seihou.Core.JudgementExamples.JudgementExamples
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Show.Show Seihou.Core.JudgementExamples.ExampleFile
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Show.Show Seihou.Core.JudgementExamples.ExpectedFinding
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Show.Show Seihou.Core.JudgementExamples.ExpectedStatus
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Show.Show Seihou.Core.JudgementExamples.JudgementExample
+ Seihou.Core.JudgementExamples: instance GHC.Internal.Show.Show Seihou.Core.JudgementExamples.JudgementExamples
+ Seihou.Core.JudgementExamples: parseExamples :: FilePath -> ByteString -> Either JudgementError JudgementExamples
+ Seihou.Core.JudgementExamples: renderExpectedStatus :: ExpectedStatus -> Text
+ Seihou.Core.JudgementExamples: validateExamples :: JudgementDefinition -> JudgementExamples -> [JudgementError]
+ Seihou.Core.JudgementState: AdoptedNew :: AdoptionChange
+ Seihou.Core.JudgementState: AdoptedReplacing :: !ActiveJudgement -> AdoptionChange
+ Seihou.Core.JudgementState: AdoptedUnchanged :: AdoptionChange
+ Seihou.Core.JudgementState: adoptWithOwner :: JudgementOwner -> ActiveJudgement -> [ActiveJudgement] -> Either Text ([ActiveJudgement], AdoptionChange)
+ Seihou.Core.JudgementState: data AdoptionChange
+ Seihou.Core.JudgementState: instance GHC.Classes.Eq Seihou.Core.JudgementState.AdoptionChange
+ Seihou.Core.JudgementState: instance GHC.Internal.Generics.Generic Seihou.Core.JudgementState.AdoptionChange
+ Seihou.Core.JudgementState: instance GHC.Internal.Show.Show Seihou.Core.JudgementState.AdoptionChange
+ Seihou.Core.JudgementState: policyWeakenings :: [ActiveJudgement] -> [ActiveJudgement] -> [Text]
+ Seihou.Core.JudgementState: pruneJudgementOwners :: Manifest -> (Manifest, [Text])
+ Seihou.Core.JudgementState: retireOwner :: JudgementOwner -> [ActiveJudgement] -> ([ActiveJudgement], [Text])
+ Seihou.Core.Migration: RemoveLine :: !FilePath -> !Text -> MigrationOp
+ Seihou.Core.Migration: [line] :: MigrationOp -> !Text
+ Seihou.Core.Module: KindJudgement :: RunnableKind
+ Seihou.Core.Module: checkModuleStateDestinations :: Module -> [Text]
+ Seihou.Core.Path: ControlPath :: !Text -> ProjectPathClass
+ Seihou.Core.Path: DirectoryOperand :: PathOperand
+ Seihou.Core.Path: FileTarget :: PathOperand
+ Seihou.Core.Path: ModuleStatePath :: !Text -> !Bool -> ProjectPathClass
+ Seihou.Core.Path: OrdinaryProjectPath :: ProjectPathClass
+ Seihou.Core.Path: SeihouRootDirectory :: ProjectPathClass
+ Seihou.Core.Path: classifyProjectPath :: FilePath -> ProjectPathClass
+ Seihou.Core.Path: controlPathReason :: PathOperand -> FilePath -> Maybe Text
+ Seihou.Core.Path: data PathOperand
+ Seihou.Core.Path: data ProjectPathClass
+ Seihou.Core.Path: instance GHC.Classes.Eq Seihou.Core.Path.PathOperand
+ Seihou.Core.Path: instance GHC.Classes.Eq Seihou.Core.Path.ProjectPathClass
+ Seihou.Core.Path: instance GHC.Internal.Generics.Generic Seihou.Core.Path.PathOperand
+ Seihou.Core.Path: instance GHC.Internal.Generics.Generic Seihou.Core.Path.ProjectPathClass
+ Seihou.Core.Path: instance GHC.Internal.Show.Show Seihou.Core.Path.PathOperand
+ Seihou.Core.Path: instance GHC.Internal.Show.Show Seihou.Core.Path.ProjectPathClass
+ Seihou.Core.Path: isControlDirectory :: FilePath -> Bool
+ Seihou.Core.Path: isControlFileTarget :: FilePath -> Bool
+ Seihou.Core.Path: ownStateDirectoryViolation :: Text -> PathOperand -> FilePath -> Maybe Text
+ Seihou.Core.Path: reservedSeihouEntries :: [Text]
+ Seihou.Core.Registry: JudgementEntry :: EntryKind
+ Seihou.Core.Registry: SingleJudgement :: FilePath -> RepoContents
+ Seihou.Core.Registry: [judgementCount] :: RegistryValidationReport -> !Int
+ Seihou.Core.Registry: [judgements] :: Registry -> ![RegistryEntry]
+ Seihou.Core.Scaffold: judgementDhall :: Text -> Text -> Text -> Text
+ Seihou.Core.Scaffold: judgementExamplesJson :: Text
+ Seihou.Core.Types: ActiveJudgement :: !JudgementSource -> !JudgementDefinition -> !SHA256 -> !JudgementPolicy -> ![JudgementOwner] -> ActiveJudgement
+ Seihou.Core.Types: AmbiguousArtifact :: ModuleName -> FilePath -> [FilePath] -> ModuleLoadError
+ Seihou.Core.Types: ApplicationOwner :: !ApplicationId -> JudgementOwner
+ Seihou.Core.Types: ArtifactJudgement :: !ArtifactOrigin -> JudgementSource
+ Seihou.Core.Types: BundledJudgement :: JudgementSource
+ Seihou.Core.Types: DirectAdoption :: JudgementOwner
+ Seihou.Core.Types: JudgementState :: ManifestCapability
+ Seihou.Core.Types: RunnableJudgement :: JudgementDefinition -> FilePath -> Runnable
+ Seihou.Core.Types: [definition] :: ActiveJudgement -> !JudgementDefinition
+ Seihou.Core.Types: [digest] :: ActiveJudgement -> !SHA256
+ Seihou.Core.Types: [judgements] :: Manifest -> ![ActiveJudgement]
+ Seihou.Core.Types: [policy] :: ActiveJudgement -> !JudgementPolicy
+ Seihou.Core.Types: data ActiveJudgement
+ Seihou.Core.Types: data JudgementOwner
+ Seihou.Core.Types: data JudgementSource
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.ActiveJudgement
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.JudgementOwner
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.JudgementSource
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.JudgementOwner
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.ActiveJudgement
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.JudgementOwner
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.JudgementSource
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.ActiveJudgement
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.JudgementOwner
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.JudgementSource
+ Seihou.Dhall.Eval: evalJudgementFromFile :: FilePath -> IO (Either ModuleLoadError JudgementDefinition)
+ Seihou.Dhall.Eval: evalJudgementFromText :: FilePath -> Text -> IO (Either ModuleLoadError JudgementDefinition)
+ Seihou.Dhall.Eval: evalJudgementRefsFromFile :: FilePath -> IO (Either ModuleLoadError [JudgementRef])
+ Seihou.Dhall.Eval: judgementDecoder :: Decoder JudgementDefinition
+ Seihou.Engine.Migrate: RemoveLineInst :: FilePath -> Text -> MigrationOpInstance
+ Seihou.Engine.Migrate: removeExactLine :: Text -> Text -> Text
+ Seihou.Engine.Reconcile: reconciliationDifferences :: ReconciliationPlan -> ReconciliationPlan -> Map FilePath [Text]
+ Seihou.Manifest.Judgement: definitionDigest :: JudgementDefinition -> SHA256
+ Seihou.Manifest.Judgement: definitionToJSON :: JudgementDefinition -> Value
+ Seihou.Manifest.Judgement: parseDefinition :: Value -> Parser JudgementDefinition
+ Seihou.Manifest.Judgement: parsePolicy :: JudgementDefinition -> Value -> Parser JudgementPolicy
+ Seihou.Manifest.Judgement: policyToJSON :: JudgementPolicy -> Value
+ Seihou.Manifest.Upgrade: AddEmptyJudgements :: UpgradeStepAction
- Seihou.Core.Registry: Registry :: !Text -> !Maybe Text -> ![RegistryEntry] -> ![RegistryEntry] -> ![RegistryEntry] -> ![RegistryEntry] -> Registry
+ Seihou.Core.Registry: Registry :: !Text -> !Maybe Text -> ![RegistryEntry] -> ![RegistryEntry] -> ![RegistryEntry] -> ![RegistryEntry] -> ![RegistryEntry] -> Registry
- Seihou.Core.Registry: RegistryValidationReport :: ![RegistryValidationIssue] -> !Int -> !Int -> !Int -> !Int -> RegistryValidationReport
+ Seihou.Core.Registry: RegistryValidationReport :: ![RegistryValidationIssue] -> !Int -> !Int -> !Int -> !Int -> !Int -> RegistryValidationReport
- Seihou.Core.Types: Manifest :: !ManifestSchemaVersion -> !UTCTime -> ![AppliedModule] -> !Map VarName Text -> !Map FilePath FileRecord -> !Map FilePath SeedRecord -> !Map FilePath CheckoutOutputRecord -> ![AppliedComposition] -> !Maybe AppliedRecipe -> !Maybe AppliedBlueprint -> ![AppliedBlueprintMigration] -> Manifest
+ Seihou.Core.Types: Manifest :: !ManifestSchemaVersion -> !UTCTime -> ![AppliedModule] -> !Map VarName Text -> !Map FilePath FileRecord -> !Map FilePath SeedRecord -> !Map FilePath CheckoutOutputRecord -> ![AppliedComposition] -> !Maybe AppliedRecipe -> !Maybe AppliedBlueprint -> ![AppliedBlueprintMigration] -> ![ActiveJudgement] -> Manifest
- Seihou.Core.Types: [owners] :: CheckoutOutputRecord -> !Map ApplicationId (Set ModuleName)
+ Seihou.Core.Types: [owners] :: ActiveJudgement -> ![JudgementOwner]

Files

seihou-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-core-version: 0.15.0.0+version: 0.16.0.0 synopsis: Core library for Seihou project scaffolding description:   Core library for Seihou, a composable project scaffolding system.@@ -42,6 +42,7 @@     Seihou.Composition.Resolve     Seihou.Core.AgentPrompt     Seihou.Core.Application+    Seihou.Core.ArtifactFiles     Seihou.Core.ArtifactIdentity     Seihou.Core.ArtifactOriginDetect     Seihou.Core.ArtifactRef@@ -52,6 +53,9 @@     Seihou.Core.Context     Seihou.Core.Expr     Seihou.Core.Install+    Seihou.Core.Judgement+    Seihou.Core.JudgementExamples+    Seihou.Core.JudgementState     Seihou.Core.Migration     Seihou.Core.Module     Seihou.Core.Path@@ -115,6 +119,7 @@     Seihou.Interaction.Prompt     Seihou.Manifest.Hash     Seihou.Manifest.JSON+    Seihou.Manifest.Judgement     Seihou.Manifest.Types     Seihou.Manifest.Upgrade     Seihou.Prelude@@ -174,9 +179,12 @@     Seihou.Core.EntailmentSpec     Seihou.Core.ExprSpec     Seihou.Core.InstallSpec+    Seihou.Core.JudgementArtifactSpec+    Seihou.Core.JudgementSpec     Seihou.Core.ListSpec     Seihou.Core.MigrationSpec     Seihou.Core.ModuleSpec+    Seihou.Core.PathSpec     Seihou.Core.RecipeSpec     Seihou.Core.RegistryEmitSpec     Seihou.Core.RegistrySpec
src/Seihou/Composition/RecipeChain.hs view
@@ -10,7 +10,8 @@ import Data.Text qualified as T import Seihou.Composition.Recipe import Seihou.Composition.Resolve (verifyDependencySources)-import Seihou.Core.ArtifactIdentity (normalizeOriginUrl, sameArtifactIdentity)+import Seihou.Core.ArtifactFiles (renderAmbiguousArtifact)+import Seihou.Core.ArtifactIdentity (sameArtifactIdentity, sameRepositoryOrigin) import Seihou.Core.ArtifactOriginDetect (detectArtifactOrigin, readOriginInfo) import Seihou.Core.Module (discoverModule, discoverRunnable) import Seihou.Core.Recipe (validateRecipe, validateRecipeVersion)@@ -55,7 +56,7 @@                 Nothing -> pure ()                 Just required -> do                   actual <- liftIO (readOriginInfo baseDir)-                  unless (maybe False (\info -> normalizeOriginUrl (info ^. #sourceUrl) == normalizeOriginUrl required) actual) $+                  unless (maybe False (\info -> sameRepositoryOrigin (info ^. #sourceUrl) required) actual) $                     failure ("Base '" <> ref ^. #recipe . #unRecipeName <> "' requires " <> required <> "; resolved origin is different or unknown")               case validateRecipeVersion ref base of                 Left errors -> failure ("Base '" <> ref ^. #recipe . #unRecipeName <> "' from " <> maybe "local discovery" id (ref ^. #source) <> ": " <> T.intercalate "; " errors)@@ -78,6 +79,7 @@ describe (CircularDependency names) = "Dependency cycle: " <> T.intercalate " -> " [name | ModuleName name <- names] describe (MissingSourceFile (ModuleName name) path) = "Member '" <> name <> "' is missing " <> T.pack path describe (RegistryEvalError source reason) = "Cannot evaluate registry '" <> source <> "': " <> reason+describe (AmbiguousArtifact name dir files) = renderAmbiguousArtifact name dir files  -- | Descriptor evidence is normalized Dhall, independent of import/cache paths. recordRecipeChain :: ExpandedRecipe -> IO [AppliedRecipeNode]
src/Seihou/Composition/Resolve.hs view
@@ -27,7 +27,7 @@ import Data.Text qualified as T import Seihou.Composition.Graph (buildGraph, topoSort) import Seihou.Composition.Instance (ModuleInstance (..), mkInstance, primaryInstance)-import Seihou.Core.ArtifactIdentity (normalizeOriginUrl)+import Seihou.Core.ArtifactIdentity (sameRepositoryOrigin) import Seihou.Core.ArtifactOriginDetect (OriginInfo (..), readOriginInfo) import Seihou.Core.Module (discoverModule, validateModule) import Seihou.Core.Types@@ -593,7 +593,7 @@         let name = dep ^. #module_         dir <- ExceptT (discoverModule paths name)         actual <- liftIO (readOriginInfo dir)-        unless (maybe False (\info -> normalizeOriginUrl (info ^. #sourceUrl) == normalizeOriginUrl required) actual) $+        unless (maybe False (\info -> sameRepositoryOrigin (info ^. #sourceUrl) required) actual) $           ExceptT $             pure $               Left $
+ src/Seihou/Core/ArtifactFiles.hs view
@@ -0,0 +1,53 @@+-- | The definition files that identify an artifact directory's kind, and the+-- ambiguity rule introduced with judgements.+--+-- Before judgements, a directory holding several definition files resolved by+-- a fixed precedence (module > recipe > blueprint > prompt). Judgements do not+-- join that precedence: a @judgement.dhall@ that shares its directory with any+-- other definition file is reported as ambiguous instead of being silently+-- hidden by, or silently hiding, the other artifact.+module Seihou.Core.ArtifactFiles+  ( definitionFiles,+    presentDefinitionFiles,+    judgementAmbiguity,+    renderAmbiguousArtifact,+  )+where++import Control.Monad (filterM)+import Data.Generics.Labels ()+import Data.Text qualified as T+import Seihou.Core.Types (ModuleName (..))+import Seihou.Prelude+import System.Directory (doesFileExist)++-- | Every artifact definition file, in the historical precedence order.+definitionFiles :: [FilePath]+definitionFiles = ["module.dhall", "recipe.dhall", "blueprint.dhall", "prompt.dhall", "judgement.dhall"]++-- | The definition files present directly in a directory, in 'definitionFiles'+-- order.+presentDefinitionFiles :: FilePath -> IO [FilePath]+presentDefinitionFiles dir = filterM (doesFileExist . (dir </>)) definitionFiles++-- | @Just files@ when the directory holds a @judgement.dhall@ together with at+-- least one other definition file; 'Nothing' otherwise. Combinations that do+-- not involve a judgement keep their existing precedence and are not reported.+judgementAmbiguity :: FilePath -> IO (Maybe [FilePath])+judgementAmbiguity dir = do+  present <- presentDefinitionFiles dir+  pure $+    if "judgement.dhall" `elem` present && length present > 1+      then Just present+      else Nothing++-- | One-line diagnostic for an ambiguous artifact directory.+renderAmbiguousArtifact :: ModuleName -> FilePath -> [FilePath] -> Text+renderAmbiguousArtifact name dir files =+  "ambiguous artifact '"+    <> name ^. #unModuleName+    <> "' at "+    <> T.pack dir+    <> ": it contains "+    <> T.intercalate ", " (map T.pack files)+    <> "; an artifact directory must hold exactly one definition file"
src/Seihou/Core/ArtifactIdentity.hs view
@@ -18,14 +18,18 @@ -- not, with no unverifiable middle ground. module Seihou.Core.ArtifactIdentity   ( sameArtifactIdentity,+    sameRepositoryOrigin,+    RepositoryOriginKey,+    repositoryOriginKey,     normalizeOriginUrl,     normalizeProjectPath,     isMachineLocalOriginUrl,   ) where -import Data.Char (isAsciiLower, isAsciiUpper)-import Data.Maybe (fromMaybe)+import Control.Monad (guard)+import Data.Char (isAsciiLower, isAsciiUpper, isControl, isDigit, isHexDigit, isSpace)+import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text qualified as T import Seihou.Core.Types (ArtifactOrigin (..)) import Seihou.Prelude@@ -34,8 +38,9 @@ -- -- Two origins of different kinds are never the same artifact. Within a kind -- the comparison is structural, after normalising away spellings that differ--- without meaning anything: a trailing @.git@ on a git URL, and a @.\/@--- prefix or trailing slash on a project-relative path.+-- without meaning anything: remote URLs are compared with+-- 'sameRepositoryOrigin', and project-relative paths ignore a @.\/@ prefix+-- and trailing slashes. -- -- A 'LocalOrigin' carries no provenance at all, so two of them compare equal -- exactly when they carry the same name. That is deliberately weak — it is@@ -45,30 +50,213 @@ sameArtifactIdentity :: ArtifactOrigin -> ArtifactOrigin -> Bool sameArtifactIdentity left right = case (left, right) of   (RemoteOrigin leftUrl leftName _, RemoteOrigin rightUrl rightName _) ->-    normalizeOriginUrl leftUrl == normalizeOriginUrl rightUrl+    sameRepositoryOrigin leftUrl rightUrl       && leftName == rightName   (ProjectOrigin leftPath, ProjectOrigin rightPath) ->     normalizeProjectPath leftPath == normalizeProjectPath rightPath   (LocalOrigin leftName, LocalOrigin rightName) -> leftName == rightName   _ -> False --- | Reduce a git URL to a form two spellings of the same repository share.+-- | Reduce a git URL to a form two spellings of the same repository share,+-- lexically. -- -- @https:\/\/host\/repo@, @https:\/\/host\/repo.git@ and--- @https:\/\/host\/repo\/@ all name the same repository, and a manifest--- written by a developer who typed one of them must not read as a different--- artifact to a developer who typed another. Public GitHub SSH spellings also--- normalize to HTTPS; no equivalence is inferred for other hosts or paths.+-- @https:\/\/host\/repo\/@ all reduce to one text. This is a cosmetic+-- helper for keys that must stay specific to one connection method — a+-- session clone cache, the grouping of machine-local paths during repair —+-- and it never equates two transports. Identity decisions use+-- 'sameRepositoryOrigin' or 'repositoryOriginKey' instead. Never use the+-- result as a clone URL. normalizeOriginUrl :: Text -> Text-normalizeOriginUrl =-  dropTrailingSlashes . dropGitSuffix . dropTrailingSlashes . githubTransport . T.strip+normalizeOriginUrl = cosmeticPath . T.strip++-- | Trailing slashes and at most one terminal @.git@ of a non-empty final+-- component, so @repo\/.git@ keeps its separate @.git@ component.+cosmeticPath :: Text -> Text+cosmeticPath path =+  let trimmed = dropTrailingSlashes path+   in case T.stripSuffix ".git" trimmed of+        Just base | not (T.null base), T.last base /= '/' -> dropTrailingSlashes base+        _ -> trimmed   where-    githubTransport url-      | Just path <- T.stripPrefix "git@github.com:" url = "https://github.com/" <> path-      | Just path <- T.stripPrefix "ssh://git@github.com/" url = "https://github.com/" <> path-      | otherwise = url     dropTrailingSlashes = T.dropWhileEnd (== '/')-    dropGitSuffix url = fromMaybe url (T.stripSuffix ".git" url)++-- | Whether two recorded git URLs name the same repository.+--+-- This is the one repository-identity comparison seihou makes: the manifest+-- guard, install collision checks, migration receipts, repair evidence and+-- update staging all ask it. The recorded text is never rewritten; this only+-- answers the question. See 'repositoryOriginKey' for the rules.+sameRepositoryOrigin :: Text -> Text -> Bool+sameRepositoryOrigin left right = repositoryOriginKey left == repositoryOriginKey right++-- | The repository a recorded git URL identifies, as an opaque comparable+-- key. Two URLs have equal keys exactly when 'sameRepositoryOrigin' says+-- they name the same repository, so the key can index a map.+--+-- The constructors are private. Their distinct tags keep classes apart: a+-- recognised public GitHub repository, a generic scheme URL, a generic+-- scp-style address, and opaque text (local paths, @file:@, unsupported+-- schemes, and anything malformed) can never compare equal to one another.+data RepositoryOriginKey+  = -- | Owner and repository on public GitHub.+    GitHubRepository !Text !Text+  | -- | Scheme, user information, host, non-default port, path, query and+    -- fragment of a generic network URL. Absent and empty query or+    -- fragment differ.+    SchemeEndpoint !Text !(Maybe Text) !Text !(Maybe Int) !Text !(Maybe Text) !(Maybe Text)+  | -- | User, host and path of a generic scp-style address. A relative path+    -- may be relative to the user's home and is kept as written.+    ScpEndpoint !(Maybe Text) !Text !Text+  | -- | Text no supported form recognised, after cosmetic normalisation.+    OpaqueOrigin !Text+  deriving stock (Eq, Ord, Show, Generic)++-- | Parse a recorded git URL into its 'RepositoryOriginKey'. Total, pure and+-- offline: no DNS, filesystem, git configuration or network.+--+-- The text is parsed before anything is normalised, so cleanup can never+-- manufacture a match. Public GitHub has one documented equivalence class:+-- @https:\/\/github.com\/OWNER\/REPO@ (port 443 allowed, user information+-- ignored), @ssh:\/\/git\@github.com\/OWNER\/REPO@ (port 22 allowed),+-- @git\@github.com:OWNER\/REPO@, and @ssh:\/\/git\@ssh.github.com:443\/OWNER\/REPO@,+-- each with an optional terminal @.git@ and trailing slashes. The path must be+-- exactly two ordinary components with no query, fragment, escape or empty+-- segment. Everything else compares conservatively: generic URLs keep their+-- scheme, user, non-default port, path, query and fragment; scp-style+-- addresses keep their user and relative path and never equal a scheme URL.+repositoryOriginKey :: Text -> RepositoryOriginKey+repositoryOriginKey raw+  | T.any (\c -> isSpace c || isControl c) url = opaque+  | isMachineLocalOriginUrl url = opaque+  | (scheme, rest) <- T.breakOn "://" url,+    not (T.null rest) =+      fromMaybe opaque (parseSchemeUrl (T.toLower scheme) (T.drop 3 rest))+  | otherwise = fromMaybe opaque (parseScp url)+  where+    url = T.strip raw+    opaque = OpaqueOrigin (normalizeOriginUrl url)++parseSchemeUrl :: Text -> Text -> Maybe RepositoryOriginKey+parseSchemeUrl scheme rest = do+  defaultPort <- lookup scheme [("https", 443), ("http", 80), ("ssh", 22), ("git", 9418)]+  let (beforeFragment, fragment) = splitDelimiter '#' rest+      (beforeQuery, query) = splitDelimiter '?' beforeFragment+      (authority, path) = T.break (== '/') beforeQuery+  (user, hostPort) <- case T.splitOn "@" authority of+    [hostPort] -> Just (Nothing, hostPort)+    [user, hostPort] -> Just (Just user, hostPort)+    _ -> Nothing+  (host, port) <- parseHostPort hostPort+  let explicitPort = if port == Just defaultPort then Nothing else port+      generic = do+        let cleaned = cosmeticPath path+        if T.null cleaned+          then Nothing+          else Just (SchemeEndpoint scheme user host explicitPort cleaned query fragment)+      github = do+        guard (isNothing query && isNothing fragment)+        guard $ case (scheme, user, host, port) of+          ("https", _, "github.com", Nothing) -> True+          ("https", _, "github.com", Just 443) -> True+          ("ssh", Just "git", "github.com", Nothing) -> True+          ("ssh", Just "git", "github.com", Just 22) -> True+          ("ssh", Just "git", "ssh.github.com", Just 443) -> True+          _ -> False+        githubPath =<< T.stripPrefix "/" path+  maybe generic Just github++-- | Split at the first delimiter, keeping an empty value distinct from an+-- absent delimiter.+splitDelimiter :: Char -> Text -> (Text, Maybe Text)+splitDelimiter delimiter text = case T.break (== delimiter) text of+  (before, after)+    | T.null after -> (before, Nothing)+    | otherwise -> (before, Just (T.drop 1 after))++-- | A lowercased DNS host or bracketed IPv6 literal, and an optional port in+-- 1..65535. An empty or out-of-range port is malformed.+parseHostPort :: Text -> Maybe (Text, Maybe Int)+parseHostPort text = do+  (host, portPart) <-+    if "[" `T.isPrefixOf` text+      then case T.breakOn "]" text of+        (_, close) | T.null close -> Nothing+        (open, close) -> do+          let literal = T.drop 1 open+          guard (not (T.null literal) && T.all (\c -> isHexDigit c || c == ':' || c == '.') literal)+          Just (T.toLower (open <> "]"), T.drop 1 close)+      else do+        let (name, portPart) = T.break (== ':') text+        guard (validDnsHost name)+        Just (T.toLower name, portPart)+  port <- case T.uncons portPart of+    Nothing -> Just Nothing+    Just (':', digits) -> Just <$> parsePort digits+    Just _ -> Nothing+  Just (host, port)++parsePort :: Text -> Maybe Int+parsePort digits = do+  guard (not (T.null digits) && T.length digits <= 5 && T.all isDigit digits)+  let value = T.foldl' (\acc c -> acc * 10 + fromEnum c - fromEnum '0') 0 digits+  guard (value >= 1 && value <= 65535)+  Just value++validDnsHost :: Text -> Bool+validDnsHost name =+  not (T.null name)+    && T.all (\c -> isAsciiLower c || isAsciiUpper c || isDigit c || c == '-' || c == '.') name+    && not ("." `T.isPrefixOf` name)+    && not (".." `T.isInfixOf` name)++-- | Git's scp-like syntax, @[user\@]host:path@, recognised only when no+-- slash precedes the first colon. Local and Windows paths were excluded+-- before this is tried.+parseScp :: Text -> Maybe RepositoryOriginKey+parseScp text = do+  let (beforeColon, afterColon) = T.break (== ':') text+  guard (not (T.null afterColon) && not ("/" `T.isInfixOf` beforeColon))+  (user, rest) <- case T.break (== '@') text of+    (candidate, at)+      | not (T.null at),+        T.all (\c -> c /= ':' && c /= '/' && c /= '[') candidate ->+          if T.null candidate then Nothing else Just (Just candidate, T.drop 1 at)+    _ -> Just (Nothing, text)+  (hostPart, path) <- splitHost rest+  (host, port) <- parseHostPort hostPart+  let cleaned = cosmeticPath path+  guard (isNothing port && not (T.null cleaned))+  -- A one-letter host with no user is a drive designator, not a server.+  guard (isJust user || T.length host > 1)+  let github = do+        guard (user == Just "git" && host == "github.com")+        githubPath path+  maybe (Just (ScpEndpoint user host cleaned)) Just github+  where+    splitHost rest+      | "[" `T.isPrefixOf` rest = case T.breakOn "]:" rest of+          (_, close) | T.null close -> Nothing+          (open, close) -> Just (open <> "]", T.drop 2 close)+      | otherwise = case T.break (== ':') rest of+          (host, colonPath) | not (T.null colonPath) -> Just (host, T.drop 1 colonPath)+          _ -> Nothing++-- | @OWNER\/REPO@ with optional trailing slashes and at most one terminal+-- @.git@, both components ordinary ASCII names. Case is preserved.+githubPath :: Text -> Maybe RepositoryOriginKey+githubPath path = case T.splitOn "/" (T.dropWhileEnd (== '/') path) of+  [owner, repoWithSuffix] -> do+    let repo = fromMaybe repoWithSuffix (T.stripSuffix ".git" repoWithSuffix)+    guard (ordinaryComponent owner && ordinaryComponent repo)+    Just (GitHubRepository owner repo)+  _ -> Nothing+  where+    ordinaryComponent component =+      not (T.null component)+        && component /= "."+        && component /= ".."+        && T.all (\c -> isAsciiLower c || isAsciiUpper c || isDigit c || c == '-' || c == '_' || c == '.') component  -- | Reduce a project-relative path to a comparable form. The manifest stores -- these with forward slashes; @.\/@ prefixes and trailing slashes are noise.
+ src/Seihou/Core/Judgement.hs view
@@ -0,0 +1,758 @@+-- | The domain of judgements: author-defined yes/no checks over captured+-- evidence, and the deterministic project policy that turns model-reported+-- evidence into a decision.+--+-- A judgement is a list of checks. Each check is one question about the+-- evidence, with a declared concern direction (whether a yes or a no answer is+-- the concern), its own enforcement, and its own threshold band for the+-- generative review and, separately, for an optional judgment-only gate.+--+-- Every probability in this module is the probability of the /concern/,+-- already converted from the model's probability of yes by+-- 'concernProbability'. A higher number is always worse, whichever answer the+-- concern is, so the policy functions never consult the concern direction.+--+-- Nothing in this module refers to secrets or security: the secret scan is one+-- member of the closed set of deterministic checks, and scanner results reach+-- the policy only as deterministic findings.+--+-- This module is pure and knows nothing about Shikumi, provider transports or+-- Git. The runtime adapter in seihou-cli converts provider replies into the+-- observation types below; 'decideCheck' and 'decideJudgement' then decide.+module Seihou.Core.Judgement+  ( -- * Definitions+    JudgementDefinition (..),+    JudgementRef (..),+    JudgementCheck (..),+    Answer (..),+    Enforcement (..),+    Thresholds (..),+    Severity (..),+    FindingCategory (..),+    EvidenceDeclaration (..),+    EvidenceScopeKind (..),+    CoverageLimits (..),+    DeterministicCheck (..),+    defaultCoverageLimits,+    defaultEvidenceDeclaration,+    renderAnswer,+    renderEnforcement,+    renderSeverity,+    renderDeterministicCheck,+    renderScopeKind,++    -- * Validation+    JudgementError (..),+    judgementError,+    validateDefinition,+    validateThresholds,++    -- * Policy+    DataPolicy (..),+    renderDataPolicy,+    JudgementPolicy (..),+    CheckPolicy (..),+    defaultPolicy,+    checkPolicyFor,++    -- * Probabilities+    concernProbability,+    validProbability,+    renderProbability,++    -- * Observations+    Location (..),+    Finding (..),+    DeterministicFinding (..),+    ReviewObservation (..),+    GateObservation (..),+    ChunkObservation (..),+    CheckObservation (..),+    Coverage (..),+    JudgementObservation (..),++    -- * Output validation+    EvidenceIndex (..),+    RawFinding (..),+    RawReview (..),+    validateReview,+    findingBlocks,++    -- * Decisions+    CheckStatus (..),+    renderCheckStatus,+    DecidedBy (..),+    DecisionReason (..),+    renderDecisionReason,+    ChunkDecision (..),+    CheckDecision (..),+    JudgementStatus (..),+    renderJudgementStatus,+    JudgementDecision (..),+    decideChunk,+    decideCheck,+    decideJudgement,+    statusSeverity,+  )+where++import Control.Monad (forM_, unless, when)+import Data.Foldable (toList)+import Data.Generics.Labels ()+import Data.List (sortOn)+import Data.List.NonEmpty (NonEmpty)+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Ord (Down (..))+import Data.Text qualified as T+import Seihou.Prelude++-- ---------------------------------------------------------------------------+-- Definitions++-- | Which answer to a check's question is the concern.+data Answer = Yes | No+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderAnswer :: Answer -> Text+renderAnswer Yes = "yes"+renderAnswer No = "no"++-- | Whether a check's outcome can prevent the gated action.+data Enforcement = Enforced | Advisory+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderEnforcement :: Enforcement -> Text+renderEnforcement Enforced = "enforced"+renderEnforcement Advisory = "advisory"++-- | A threshold band over the probability of the concern. At or above+-- 'blockAt' the check blocks; at or below 'passAt' it may pass; in between it+-- needs human review. Equal values remove the needs-review band.+data Thresholds = Thresholds+  { blockAt :: !Double,+    passAt :: !Double+  }+  deriving stock (Eq, Show, Generic)++-- | Declared severity of a finding category, least severe first.+data Severity = Info | Low | Medium | High | Critical+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderSeverity :: Severity -> Text+renderSeverity = \case+  Info -> "info"+  Low -> "low"+  Medium -> "medium"+  High -> "high"+  Critical -> "critical"++-- | A category a check's findings may use. The model names the category; the+-- severity is the author's, never the model's.+data FindingCategory = FindingCategory+  { name :: !Text,+    severity :: !Severity,+    description :: !(Maybe Text)+  }+  deriving stock (Eq, Show, Generic)++-- | The closed, versioned set of evidence scopes in this delivery.+data EvidenceScopeKind = StagedChangesScope | CommitRangeScope+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderScopeKind :: EvidenceScopeKind -> Text+renderScopeKind StagedChangesScope = "staged-changes"+renderScopeKind CommitRangeScope = "commit-range"++-- | Which evidence a judgement may inspect. Path patterns are Git-style+-- globs relative to the project root; an empty include list means every path.+data EvidenceDeclaration = EvidenceDeclaration+  { scopes :: ![EvidenceScopeKind],+    include :: ![Text],+    exclude :: ![Text]+  }+  deriving stock (Eq, Show, Generic)++defaultEvidenceDeclaration :: EvidenceDeclaration+defaultEvidenceDeclaration = EvidenceDeclaration [StagedChangesScope, CommitRangeScope] [] []++-- | Declared bounds on one review. Exceeding a bound never truncates+-- silently: the uncovered files are reported and the check cannot pass.+data CoverageLimits = CoverageLimits+  { maxFileBytes :: !Int,+    maxChunkBytes :: !Int,+    maxChunks :: !Int,+    maxAttempts :: !Int,+    timeoutSeconds :: !Int,+    maxFindings :: !Int+  }+  deriving stock (Eq, Show, Generic)++defaultCoverageLimits :: CoverageLimits+defaultCoverageLimits =+  CoverageLimits+    { maxFileBytes = 262144,+      maxChunkBytes = 49152,+      maxChunks = 16,+      maxAttempts = 3,+      timeoutSeconds = 120,+      maxFindings = 20+    }++-- | The closed, versioned set of deterministic checks.+data DeterministicCheck = SecretScan+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderDeterministicCheck :: DeterministicCheck -> Text+renderDeterministicCheck SecretScan = "secret-scan"++-- | One author-written yes/no question.+data JudgementCheck = JudgementCheck+  { name :: !Text,+    question :: !Text,+    concernWhen :: !Answer,+    yesMeans :: !(Maybe Text),+    noMeans :: !(Maybe Text),+    categories :: ![FindingCategory],+    enforcement :: !Enforcement,+    -- | Findings whose category severity is at or above this block.+    blockingSeverity :: !Severity,+    review :: !Thresholds,+    gate :: !Thresholds,+    useGate :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data JudgementDefinition = JudgementDefinition+  { name :: !Text,+    version :: !(Maybe Text),+    description :: !(Maybe Text),+    instructions :: !Text,+    evidence :: !EvidenceDeclaration,+    limits :: !CoverageLimits,+    deterministicChecks :: ![DeterministicCheck],+    checks :: !(NonEmpty JudgementCheck)+  }+  deriving stock (Eq, Show, Generic)++-- | A module's or recipe's reference to a judgement it activates. The+-- condition is the raw @when@ text, parsed and evaluated by the caller+-- against the referencing module's variables.+data JudgementRef = JudgementRef+  { name :: !Text,+    source :: !(Maybe Text),+    when :: !(Maybe Text)+  }+  deriving stock (Eq, Show, Generic)++-- ---------------------------------------------------------------------------+-- Validation++-- | A stable machine code and an actionable explanation naming the artifact+-- part as an author writes it.+data JudgementError = JudgementError {code :: !Text, message :: !Text}+  deriving stock (Eq, Show, Generic)++judgementError :: Text -> Text -> JudgementError+judgementError = JudgementError++validProbability :: Double -> Bool+validProbability p = not (isNaN p || isInfinite p) && p >= 0 && p <= 1++validateThresholds :: Text -> Thresholds -> Either JudgementError ()+validateThresholds what t = do+  forM_ [("blockAt", t ^. #blockAt), ("passAt", t ^. #passAt)] $ \(label, p) ->+    unless (validProbability p) $+      Left (judgementError "judgement_threshold_out_of_range" (what <> " " <> label <> " must be between 0 and 1, got " <> T.pack (show p)))+  when (t ^. #passAt > t ^. #blockAt) $+    Left+      ( judgementError+          "judgement_threshold_inverted"+          (what <> " passAt (" <> T.pack (show (t ^. #passAt)) <> ") must not exceed blockAt (" <> T.pack (show (t ^. #blockAt)) <> ")")+      )++-- | Structural validation of a definition. Free of discovery, configuration+-- and prompts, so it can run on a descriptor alone.+validateDefinition :: JudgementDefinition -> Either JudgementError ()+validateDefinition def = do+  let jname = def ^. #name+  validName "judgement" jname+  when (T.null (T.strip (def ^. #instructions)) && all (T.null . T.strip . (^. #question)) (def ^. #checks)) $+    Left (judgementError "judgement_empty" ("judgement '" <> jname <> "' has no instructions and no check questions"))+  when (null (def ^. #evidence . #scopes)) $+    Left (judgementError "judgement_no_scope" ("judgement '" <> jname <> "' declares no evidence scope"))+  forM_ ((def ^. #evidence . #include) <> (def ^. #evidence . #exclude)) $ \pat ->+    when (T.null pat || "/" `T.isPrefixOf` pat || ".." `elem` T.splitOn "/" pat) $+      Left (judgementError "judgement_bad_path_pattern" ("judgement '" <> jname <> "' path pattern '" <> pat <> "' must be a non-empty project-relative pattern without '..'"))+  validateLimits jname (def ^. #limits)+  let names = map (^. #name) (toList (def ^. #checks))+  forM_ (duplicates names) $ \dup ->+    Left (judgementError "judgement_duplicate_check" ("judgement '" <> jname <> "' declares check '" <> dup <> "' more than once"))+  forM_ (def ^. #checks) $ \check -> do+    let cname = check ^. #name+        what = "check '" <> cname <> "' of judgement '" <> jname <> "'"+    validName "check" cname+    when (T.null (T.strip (check ^. #question))) $+      Left (judgementError "judgement_empty_question" (what <> " has an empty question"))+    validateThresholds (what <> " review") (check ^. #review)+    validateThresholds (what <> " gate") (check ^. #gate)+    let cats = map (^. #name) (check ^. #categories)+    forM_ cats (validName "finding category")+    forM_ (duplicates cats) $ \dup ->+      Left (judgementError "judgement_duplicate_category" (what <> " declares finding category '" <> dup <> "' more than once"))+  where+    validName kind n =+      unless (not (T.null n) && T.all (\c -> c `elem` ['a' .. 'z'] || c `elem` ['0' .. '9'] || c == '-') n && T.head n /= '-') $+        Left (judgementError "judgement_bad_name" (kind <> " name '" <> n <> "' must be lowercase letters, digits and hyphens, starting with a letter or digit"))++validateLimits :: Text -> CoverageLimits -> Either JudgementError ()+validateLimits jname l =+  forM_+    [ ("maxFileBytes", l ^. #maxFileBytes),+      ("maxChunkBytes", l ^. #maxChunkBytes),+      ("maxChunks", l ^. #maxChunks),+      ("maxAttempts", l ^. #maxAttempts),+      ("timeoutSeconds", l ^. #timeoutSeconds),+      ("maxFindings", l ^. #maxFindings)+    ]+    $ \(label, v) ->+      when (v <= 0) $+        Left (judgementError "judgement_bad_limit" ("judgement '" <> jname <> "' limit " <> label <> " must be positive, got " <> T.pack (show v)))++duplicates :: [Text] -> [Text]+duplicates xs = Map.keys (Map.filter (> (1 :: Int)) (Map.fromListWith (+) [(x, 1) | x <- xs]))++-- ---------------------------------------------------------------------------+-- Policy++-- | Where review material may be sent. 'LocalOnly' never permits a hosted+-- destination, even through a locally running executable.+data DataPolicy = LocalOnly | ApprovedRemote+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderDataPolicy :: DataPolicy -> Text+renderDataPolicy LocalOnly = "local-only"+renderDataPolicy ApprovedRemote = "approved-remote"++-- | The effective, project-owned policy for one check.+data CheckPolicy = CheckPolicy+  { enforcement :: !Enforcement,+    blockingSeverity :: !Severity,+    review :: !Thresholds,+    gate :: !Thresholds,+    useGate :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data JudgementPolicy = JudgementPolicy+  { dataPolicy :: !DataPolicy,+    gatePermitted :: !Bool,+    checks :: !(Map Text CheckPolicy)+  }+  deriving stock (Eq, Show, Generic)++-- | The policy a definition's authored defaults imply.+defaultPolicy :: DataPolicy -> Bool -> JudgementDefinition -> JudgementPolicy+defaultPolicy dp gatePerm def =+  JudgementPolicy+    { dataPolicy = dp,+      gatePermitted = gatePerm,+      checks = Map.fromList [(c ^. #name, fromCheck c) | c <- toList (def ^. #checks)]+    }+  where+    fromCheck c =+      CheckPolicy+        { enforcement = c ^. #enforcement,+          blockingSeverity = c ^. #blockingSeverity,+          review = c ^. #review,+          gate = c ^. #gate,+          useGate = c ^. #useGate+        }++-- | The recorded policy for a check, falling back to its authored defaults.+checkPolicyFor :: JudgementPolicy -> JudgementCheck -> CheckPolicy+checkPolicyFor policy c =+  fromMaybe+    (CheckPolicy (c ^. #enforcement) (c ^. #blockingSeverity) (c ^. #review) (c ^. #gate) (c ^. #useGate))+    (Map.lookup (c ^. #name) (policy ^. #checks))++-- ---------------------------------------------------------------------------+-- Probabilities++-- | Convert a model's probability of yes into the probability of the+-- concern. This is the only place the concern direction is consulted.+concernProbability :: Answer -> Double -> Double+concernProbability Yes p = p+concernProbability No p = 1 - p++-- ---------------------------------------------------------------------------+-- Observations++-- | A validated location inside captured evidence. Lines are 1-based and+-- refer to the post-change content.+data Location = Location+  { path :: !FilePath,+    line :: !(Maybe Int)+  }+  deriving stock (Eq, Ord, Show, Generic)++data Finding = Finding+  { category :: !Text,+    severity :: !Severity,+    location :: !Location,+    explanation :: !Text+  }+  deriving stock (Eq, Show, Generic)++-- | A finding from a deterministic check. It always blocks.+data DeterministicFinding = DeterministicFinding+  { check :: !DeterministicCheck,+    rule :: !Text,+    location :: !Location+  }+  deriving stock (Eq, Show, Generic)++-- | A validated generative review reply for one chunk.+data ReviewObservation = ReviewObservation+  { concern :: !Double,+    findings :: ![Finding],+    requestedHumanReview :: !Bool+  }+  deriving stock (Eq, Show, Generic)++-- | What the judgment-only gate reported for one chunk.+data GateObservation+  = GateAnswered !Double+  | GateFailed !Text+  deriving stock (Eq, Show, Generic)++-- | Everything observed for one check over one evidence chunk.+data ChunkObservation = ChunkObservation+  { chunk :: !Int,+    gate :: !(Maybe GateObservation),+    -- | 'Nothing' when no review ran (cleared by the gate, or never sent).+    review :: !(Maybe (Either Text ReviewObservation))+  }+  deriving stock (Eq, Show, Generic)++-- | Seihou's own coverage measurement, never the model's.+data Coverage = Coverage+  { reviewedFiles :: ![FilePath],+    excludedFiles :: ![FilePath],+    uncoveredFiles :: ![(FilePath, Text)]+  }+  deriving stock (Eq, Show, Generic)++data CheckObservation = CheckObservation+  { check :: !Text,+    chunks :: ![ChunkObservation],+    -- | Errors that prevented review of this check, such as material the+    -- transmission safeguard refused to send.+    errors :: ![Text]+  }+  deriving stock (Eq, Show, Generic)++data JudgementObservation = JudgementObservation+  { coverage :: !Coverage,+    deterministic :: ![DeterministicFinding],+    deterministicErrors :: ![Text],+    checks :: ![CheckObservation],+    noChanges :: !Bool+  }+  deriving stock (Eq, Show, Generic)++-- ---------------------------------------------------------------------------+-- Output validation++-- | The files a reply may cite, with their post-change line counts.+newtype EvidenceIndex = EvidenceIndex {files :: Map FilePath Int}+  deriving stock (Eq, Show, Generic)++-- | A decoded but not yet validated finding from the runtime adapter.+data RawFinding = RawFinding+  { category :: !Text,+    path :: !Text,+    line :: !(Maybe Int),+    explanation :: !Text+  }+  deriving stock (Eq, Show, Generic)++-- | A decoded but not yet validated review, with the probability already+-- converted to the probability of the concern.+data RawReview = RawReview+  { concern :: !Double,+    findings :: ![RawFinding],+    requestedHumanReview :: !Bool+  }+  deriving stock (Eq, Show, Generic)++-- | Enforce the domain after decoding: declared categories, locations inside+-- the captured evidence, bounded output and a probability in range. The+-- model reports evidence; it never chooses a category's severity.+validateReview :: CoverageLimits -> JudgementCheck -> EvidenceIndex -> RawReview -> Either Text ReviewObservation+validateReview limits check index raw = do+  unless (validProbability (raw ^. #concern)) $+    Left ("the reply's probability " <> renderProbability (raw ^. #concern) <> " is outside 0 to 1")+  when (length (raw ^. #findings) > limits ^. #maxFindings) $+    Left ("the reply has " <> T.pack (show (length (raw ^. #findings))) <> " findings, more than the limit of " <> T.pack (show (limits ^. #maxFindings)))+  let declared = Map.fromList [(c ^. #name, c ^. #severity) | c <- check ^. #categories]+  found <- traverse (validateFinding declared) (raw ^. #findings)+  pure (ReviewObservation (raw ^. #concern) found (raw ^. #requestedHumanReview))+  where+    validateFinding declared f = do+      sev <- case Map.lookup (f ^. #category) declared of+        Just s -> Right s+        Nothing -> Left ("the reply uses finding category '" <> bounded 64 (f ^. #category) <> "', which check '" <> check ^. #name <> "' does not declare")+      let p = T.unpack (f ^. #path)+      lineCount <- case Map.lookup p (index ^. #files) of+        Just n -> Right n+        Nothing -> Left ("the reply cites '" <> bounded 200 (f ^. #path) <> "', which is not in the reviewed evidence")+      forM_ (f ^. #line) $ \l ->+        when (l < 1 || l > max 1 lineCount) $+          Left ("the reply cites line " <> T.pack (show l) <> " of '" <> T.pack p <> "', which has " <> T.pack (show lineCount) <> " lines")+      pure (Finding (f ^. #category) sev (Location p (f ^. #line)) (bounded 500 (f ^. #explanation)))++bounded :: Int -> Text -> Text+bounded n t+  | T.length t <= n = t+  | otherwise = T.take n t <> "…"++findingBlocks :: CheckPolicy -> Finding -> Bool+findingBlocks policy f = f ^. #severity >= policy ^. #blockingSeverity++-- ---------------------------------------------------------------------------+-- Decisions++data CheckStatus = CheckPassed | CheckBlocked | CheckNeedsReview | CheckFailed | CheckNoChanges+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderCheckStatus :: CheckStatus -> Text+renderCheckStatus = \case+  CheckPassed -> "passed"+  CheckBlocked -> "blocked"+  CheckNeedsReview -> "needs review"+  CheckFailed -> "error"+  CheckNoChanges -> "no changes"++-- | Ranking used to combine outcomes: block above needs-review above error+-- above pass above no-changes.+statusSeverity :: CheckStatus -> Int+statusSeverity = \case+  CheckBlocked -> 4+  CheckNeedsReview -> 3+  CheckFailed -> 2+  CheckPassed -> 1+  CheckNoChanges -> 0++-- | Which route decided a chunk.+data DecidedBy = DecidedByGate | DecidedByReview | DecidedByNone+  deriving stock (Eq, Ord, Show, Generic)++-- | Why a check reached its status. Rendered for humans; the constructors+-- are stable for JSON reports.+data DecisionReason+  = BlockingFinding !Finding+  | ConcernAtOrAboveBlock !Double !Double+  | ConcernInBand !Double !Thresholds+  | ConcernAtOrBelowPass !Double !Double+  | ModelRequestedReview+  | GateCleared !Double !Double+  | GateEscalated !Double !Double+  | GateDisagreed !Double !Double+  | GateUnavailable !Text+  | ReviewMissing+  | ReviewError !Text+  | CheckError !Text+  deriving stock (Eq, Show, Generic)++renderDecisionReason :: DecisionReason -> Text+renderDecisionReason = \case+  BlockingFinding f -> "blocking " <> renderSeverity (f ^. #severity) <> " finding '" <> f ^. #category <> "' at " <> renderLoc (f ^. #location)+  ConcernAtOrAboveBlock p t -> "model-reported probability " <> fmt p <> " is at or above block threshold " <> fmt t <> "; the review located no blocking finding"+  ConcernInBand p t -> "model-reported probability " <> fmt p <> " is between pass at " <> fmt (t ^. #passAt) <> " and block at " <> fmt (t ^. #blockAt)+  ConcernAtOrBelowPass p t -> "model-reported probability " <> fmt p <> " is at or below pass threshold " <> fmt t+  ModelRequestedReview -> "the model requested human review"+  GateCleared p t -> "cleared by gate, probability " <> fmt p <> " at or below " <> fmt t+  GateEscalated p t -> "gate probability " <> fmt p <> " above gate pass threshold " <> fmt t <> "; escalated to review"+  GateDisagreed p t -> "gate probability " <> fmt p <> " reached gate block threshold " <> fmt t <> "; the review cannot pass this check"+  GateUnavailable why -> "gate unavailable (" <> why <> "); escalated to review"+  ReviewMissing -> "escalated, but no review result was produced"+  ReviewError why -> "review failed: " <> why+  CheckError why -> why+  where+    renderLoc l = T.pack (l ^. #path) <> maybe "" (\n -> ":" <> T.pack (show n)) (l ^. #line)++fmt :: Double -> Text+fmt = renderProbability++-- | A probability as a reader expects it, with two decimals: @0.02@, not+-- @2.0e-2@. Values outside 0 to 1 are rendered the same way so a diagnostic+-- can name them.+renderProbability :: Double -> Text+renderProbability p+  | isNaN p || isInfinite p = T.pack (show p)+  | otherwise =+      let hundredths = round (abs p * 100) :: Integer+          (whole, frac) = hundredths `divMod` 100+       in (if p < 0 && hundredths /= 0 then "-" else "") <> T.pack (show whole) <> "." <> (if frac < 10 then "0" else "") <> T.pack (show frac)++data ChunkDecision = ChunkDecision+  { chunk :: !Int,+    status :: !CheckStatus,+    decidedBy :: !DecidedBy,+    gateConcern :: !(Maybe Double),+    reviewConcern :: !(Maybe Double),+    findings :: ![Finding],+    reasons :: ![DecisionReason]+  }+  deriving stock (Eq, Show, Generic)++data CheckDecision = CheckDecision+  { check :: !Text,+    status :: !CheckStatus,+    enforcement :: !Enforcement,+    policy :: !CheckPolicy,+    chunks :: ![ChunkDecision],+    -- | The governing (highest) concern probabilities over all chunks.+    gateConcern :: !(Maybe Double),+    reviewConcern :: !(Maybe Double),+    findings :: ![Finding],+    reasons :: ![DecisionReason]+  }+  deriving stock (Eq, Show, Generic)++-- | Apply one check's policy to one chunk's observation.+--+-- * The gate clears a chunk at or below its pass threshold; no review is+--   needed. A higher gate probability escalates, and a gate probability at or+--   above its block threshold forbids the review from passing the chunk.+-- * A gate failure escalates visibly; it is neither an error nor a clearance.+-- * In the review, a blocking finding blocks; a probability at or above the+--   block threshold blocks even without a location; a probability above the+--   pass threshold, or a request for human review, needs review; only a+--   probability at or below the pass threshold passes.+decideChunk :: Bool -> CheckPolicy -> ChunkObservation -> ChunkDecision+decideChunk gateActive policy obs =+  case (if gateActive && policy ^. #useGate then obs ^. #gate else Nothing) of+    Just (GateAnswered g)+      | g <= gatePass && validProbability g ->+          ChunkDecision (obs ^. #chunk) CheckPassed DecidedByGate (Just g) Nothing [] [GateCleared g gatePass]+      | validProbability g ->+          let escalation = GateEscalated g gatePass+              disagree = g >= gateBlock+              d = fromReview (Just g) [escalation]+           in if disagree && d ^. #status == CheckPassed+                then d & #status .~ CheckNeedsReview & #reasons %~ (<> [GateDisagreed g gateBlock])+                else d+      | otherwise ->+          fromReview Nothing [GateUnavailable ("probability " <> renderProbability g <> " is outside 0 to 1")]+    Just (GateFailed why) -> fromReview Nothing [GateUnavailable why]+    Nothing -> fromReview Nothing []+  where+    gatePass = policy ^. #gate . #passAt+    gateBlock = policy ^. #gate . #blockAt+    reviewT = policy ^. #review+    fromReview gateP prior = case obs ^. #review of+      Nothing -> ChunkDecision (obs ^. #chunk) CheckFailed DecidedByNone gateP Nothing [] (prior <> [ReviewMissing])+      Just (Left why) -> ChunkDecision (obs ^. #chunk) CheckFailed DecidedByReview gateP Nothing [] (prior <> [ReviewError why])+      Just (Right r) ->+        let p = r ^. #concern+            blocking = filter (findingBlocks policy) (r ^. #findings)+            (status, reasons)+              | not (validProbability p) = (CheckFailed, [ReviewError ("probability " <> renderProbability p <> " is outside 0 to 1")])+              | not (null blocking) = (CheckBlocked, map BlockingFinding blocking)+              | p >= reviewT ^. #blockAt = (CheckBlocked, [ConcernAtOrAboveBlock p (reviewT ^. #blockAt)])+              | p > reviewT ^. #passAt = (CheckNeedsReview, [ConcernInBand p reviewT] <> [ModelRequestedReview | r ^. #requestedHumanReview])+              | r ^. #requestedHumanReview = (CheckNeedsReview, [ModelRequestedReview])+              | otherwise = (CheckPassed, [ConcernAtOrBelowPass p (reviewT ^. #passAt)])+         in ChunkDecision (obs ^. #chunk) status DecidedByReview gateP (Just p) (r ^. #findings) (prior <> reasons)++-- | Combine a check's chunks. The most severe chunk governs, and the highest+-- probability over all chunks is reported. A check with no chunks and no+-- errors had nothing in scope.+decideCheck :: Bool -> Coverage -> CheckPolicy -> CheckObservation -> CheckDecision+decideCheck gateActive coverage policy obs =+  CheckDecision+    { check = obs ^. #check,+      status = finalStatus,+      enforcement = policy ^. #enforcement,+      policy = policy,+      chunks = decided,+      gateConcern = maximumMaybe (mapMaybe (^. #gateConcern) decided),+      reviewConcern = maximumMaybe (mapMaybe (^. #reviewConcern) decided),+      findings = concatMap (^. #findings) decided,+      reasons = map CheckError (obs ^. #errors) <> uncoveredReasons <> concatMap (^. #reasons) decided+    }+  where+    decided = map (decideChunk gateActive policy) (obs ^. #chunks)+    uncovered = coverage ^. #uncoveredFiles+    uncoveredReasons = [CheckError ("'" <> T.pack p <> "' was not reviewed: " <> why) | (p, why) <- uncovered]+    chunkStatus = case sortOn (Down . statusSeverity) (map (^. #status) decided) of+      (s : _) -> s+      [] -> CheckNoChanges+    finalStatus+      | not (null (obs ^. #errors)) = worst [chunkStatus, CheckFailed]+      | not (null uncovered) = worst [chunkStatus, CheckFailed]+      | otherwise = chunkStatus+    worst = NE.head . NE.sortWith (Down . statusSeverity) . NE.fromList++maximumMaybe :: [Double] -> Maybe Double+maximumMaybe [] = Nothing+maximumMaybe xs = Just (maximum xs)++data JudgementStatus = Passed | Blocked | NeedsReview | Failed | NoChanges+  deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++renderJudgementStatus :: JudgementStatus -> Text+renderJudgementStatus = \case+  Passed -> "PASSED"+  Blocked -> "BLOCKED"+  NeedsReview -> "NEEDS REVIEW"+  Failed -> "ERROR"+  NoChanges -> "NO CHANGES"++data JudgementDecision = JudgementDecision+  { judgement :: !Text,+    status :: !JudgementStatus,+    deterministic :: ![DeterministicFinding],+    deterministicErrors :: ![Text],+    checks :: ![CheckDecision],+    coverage :: !Coverage+  }+  deriving stock (Eq, Show, Generic)++-- | Combine a judgement's checks under its policy. A deterministic finding+-- blocks regardless of any probability. Among enforced checks the most+-- severe outcome governs: block, then needs-review, then error. Advisory+-- checks are reported with their real status and never affect the result.+-- Empty evidence is 'NoChanges', an explicit no-op rather than a review.+decideJudgement :: JudgementDefinition -> JudgementPolicy -> JudgementObservation -> JudgementDecision+decideJudgement def policy obs =+  JudgementDecision+    { judgement = def ^. #name,+      status = overall,+      deterministic = obs ^. #deterministic,+      deterministicErrors = obs ^. #deterministicErrors,+      checks = decisions,+      coverage = obs ^. #coverage+    }+  where+    gateActive = policy ^. #gatePermitted+    observed = Map.fromList [(o ^. #check, o) | o <- obs ^. #checks]+    decisions =+      [ decideCheck gateActive (obs ^. #coverage) (checkPolicyFor policy c) (Map.findWithDefault (missing c) (c ^. #name) observed)+      | c <- toList (def ^. #checks)+      ]+    missing c+      | obs ^. #noChanges = CheckObservation (c ^. #name) [] []+      | otherwise = CheckObservation (c ^. #name) [] ["check '" <> c ^. #name <> "' produced no observation"]+    enforced = [d ^. #status | d <- decisions, d ^. #enforcement == Enforced]+    overall+      | not (null (obs ^. #deterministic)) = Blocked+      | CheckBlocked `elem` enforced = Blocked+      | CheckNeedsReview `elem` enforced = NeedsReview+      | CheckFailed `elem` enforced || not (null (obs ^. #deterministicErrors)) = Failed+      | obs ^. #noChanges = NoChanges+      | all (== CheckNoChanges) enforced && not (null enforced) = NoChanges+      | otherwise = Passed
+ src/Seihou/Core/JudgementExamples.hs view
@@ -0,0 +1,141 @@+-- | 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]
+ src/Seihou/Core/JudgementState.hs view
@@ -0,0 +1,104 @@+-- | Pure rules for the manifest's active judgements: adopting with an owner,+-- and retiring owners.+--+-- Agreeing owners share one record. Re-adopting an unchanged definition keeps+-- the project's recorded policy, so a policy changed with+-- @seihou judgement configure@ survives re-application. A changed definition+-- replaces the record only when no other owner holds the old one; otherwise+-- adoption is refused before anything is published.+module Seihou.Core.JudgementState+  ( AdoptionChange (..),+    adoptWithOwner,+    pruneJudgementOwners,+    retireOwner,+    policyWeakenings,+  )+where++import Data.Generics.Labels ()+import Data.List (nub)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as T+import Seihou.Core.Judgement (Enforcement (..), renderProbability)+import Seihou.Core.Types+import Seihou.Prelude++data AdoptionChange+  = AdoptedNew+  | -- | Same definition: only the owner set may have grown.+    AdoptedUnchanged+  | -- | The definition changed and replaced the recorded one.+    AdoptedReplacing !ActiveJudgement+  deriving stock (Eq, Show, Generic)++-- | Add @candidate@ for @owner@ to the active list.+adoptWithOwner :: JudgementOwner -> ActiveJudgement -> [ActiveJudgement] -> Either Text ([ActiveJudgement], AdoptionChange)+adoptWithOwner owner candidate active = case break sameName active of+  (_, []) -> Right (active <> [candidate & #owners .~ [owner]], AdoptedNew)+  (before, prior : after)+    | prior ^. #digest == candidate ^. #digest ->+        Right (before <> [prior & #owners %~ (nub . (<> [owner]))] <> after, AdoptedUnchanged)+    | any (/= owner) (prior ^. #owners) ->+        Left+          ( "Judgement '"+              <> name+              <> "' is already active with a different definition for "+              <> T.intercalate ", " (map renderOwner (filter (/= owner) (prior ^. #owners)))+              <> ". Update that owner, or remove it, before adopting this definition."+          )+    | otherwise -> Right (before <> [candidate & #owners .~ [owner]] <> after, AdoptedReplacing prior)+  where+    name = candidate ^. #definition . #name+    sameName j = j ^. #definition . #name == name++renderOwner :: JudgementOwner -> Text+renderOwner = \case+  DirectAdoption -> "a direct seihou run"+  ApplicationOwner (ApplicationId i) -> "application " <> T.take 12 i++-- | Remove one owner everywhere; returns the remaining records and the names+-- of judgements that lost their last owner.+retireOwner :: JudgementOwner -> [ActiveJudgement] -> ([ActiveJudgement], [Text])+retireOwner owner = settle . map (#owners %~ filter (/= owner))++-- | Drop application owners whose application is no longer recorded.+pruneJudgementOwners :: Manifest -> (Manifest, [Text])+pruneJudgementOwners m =+  let live = Set.fromList (map (^. #applicationId) (m ^. #applications))+      keep = \case+        ApplicationOwner a -> Set.member a live+        DirectAdoption -> True+      (remaining, retired) = settle (map (#owners %~ filter keep) (m ^. #judgements))+   in (m & #judgements .~ remaining, retired)++settle :: [ActiveJudgement] -> ([ActiveJudgement], [Text])+settle js = ([j | j <- js, not (null (j ^. #owners))], [j ^. #definition . #name | j <- js, null (j ^. #owners)])++-- | How a proposed set of active judgements is weaker than a trusted one:+-- a judgement removed, a check made advisory, or a review threshold raised.+-- Used to report a staged policy change instead of letting it pass as an+-- ordinary clean result.+policyWeakenings :: [ActiveJudgement] -> [ActiveJudgement] -> [Text]+policyWeakenings trusted proposed = concatMap one trusted+  where+    one t = case [p | p <- proposed, p ^. #definition . #name == name] of+      [] -> ["judgement '" <> name <> "' is no longer active"]+      (p : _) ->+        concat+          [ [ "check '" <> c <> "' of '" <> name <> "' is now advisory"+            | tc ^. #enforcement == Enforced,+              pc ^. #enforcement == Advisory+            ]+              <> [ "check '" <> c <> "' of '" <> name <> "' blocks at " <> renderProbability (pc ^. #review . #blockAt) <> " instead of " <> renderProbability (tc ^. #review . #blockAt)+                 | pc ^. #review . #blockAt > tc ^. #review . #blockAt+                 ]+              <> [ "check '" <> c <> "' of '" <> name <> "' passes at " <> renderProbability (pc ^. #review . #passAt) <> " instead of " <> renderProbability (tc ^. #review . #passAt)+                 | pc ^. #review . #passAt > tc ^. #review . #passAt+                 ]+          | (c, tc) <- Map.toList (t ^. #policy . #checks),+            Just pc <- [Map.lookup c (p ^. #policy . #checks)]+          ]+          <> ["check '" <> c <> "' of '" <> name <> "' was removed" | c <- Map.keys (t ^. #policy . #checks), not (Map.member c (p ^. #policy . #checks))]+      where+        name = t ^. #definition . #name
src/Seihou/Core/Migration.hs view
@@ -39,6 +39,8 @@ --     manifest. --   * 'DeleteDir'  — remove a directory recursively and drop every manifest --     entry under that prefix.+--   * 'RemoveLine' — remove every exact logical line, preserving unrelated+--     bytes and terminators. Missing targets or matches are no-ops. --   * 'RunCommand' — execute a shell command. The manifest is not rewritten --     by this op; if the command moves files, the migration author is --     responsible for following it with explicit move/delete ops.@@ -47,6 +49,7 @@   | MoveDir {src :: !FilePath, dest :: !FilePath}   | DeleteFile {path :: !FilePath}   | DeleteDir {path :: !FilePath}+  | RemoveLine {path :: !FilePath, line :: !Text}   | RunCommand {run :: !Text, workDir :: !(Maybe FilePath)}   deriving stock (Eq, Show, Generic) 
src/Seihou/Core/Module.hs view
@@ -23,6 +23,7 @@     checkDependencyNames,     checkDependencyVarBindings,     checkSafeDestinations,+    checkModuleStateDestinations,     checkDestVarRefs,     checkCommandSafety,     checkSeedSteps,@@ -38,10 +39,11 @@ import Data.Maybe (isJust, maybeToList) import Data.Set qualified as Set import Data.Text qualified as T+import Seihou.Core.ArtifactFiles (judgementAmbiguity) import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutPaths)-import Seihou.Core.Path (validateProjectRelativePath)+import Seihou.Core.Path (PathOperand (..), controlPathReason, ownStateDirectoryViolation, validateProjectRelativePath) import Seihou.Core.Types-import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, evalModuleFromFile, evalRecipeFromFile)+import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, evalJudgementFromFile, evalModuleFromFile, evalRecipeFromFile) import Seihou.Prelude import System.Directory (XdgDirectory (..), doesDirectoryExist, doesFileExist, getCurrentDirectory, getXdgDirectory, listDirectory) @@ -69,7 +71,9 @@ -- (returning 'RunnableAgentPrompt'). Within a single candidate directory -- the priority is module > recipe > blueprint > prompt, so a stray -- @module.dhall@ next to a @blueprint.dhall@ silently surfaces the--- module — the more specific, deterministic artifact. Returns+-- module — the more specific, deterministic artifact. A @judgement.dhall@+-- (returning 'RunnableJudgement') does not join that precedence: next to any+-- other definition file it is 'AmbiguousArtifact'. Returns -- 'ModuleNotFound' if none is found in any search path. discoverRunnable :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError Runnable) discoverRunnable searchPaths name = go searchPaths@@ -78,7 +82,12 @@     go [] = pure $ Left (ModuleNotFound name searchPaths)     go (dir : rest) = do       let candidate = dir </> nameStr-          moduleDhall = candidate </> "module.dhall"+      ambiguity <- judgementAmbiguity candidate+      case ambiguity of+        Just files -> pure (Left (AmbiguousArtifact name candidate files))+        Nothing -> probe candidate rest+    probe candidate rest = do+      let moduleDhall = candidate </> "module.dhall"           recipeDhall = candidate </> "recipe.dhall"           blueprintDhall = candidate </> "blueprint.dhall"           promptDhall = candidate </> "prompt.dhall"@@ -113,7 +122,14 @@                       case result of                         Left err -> pure (Left err)                         Right p -> pure (Right (RunnableAgentPrompt p candidate))-                    else go rest+                    else do+                      let judgementDhall = candidate </> "judgement.dhall"+                      isJudgement <- doesFileExist judgementDhall+                      if isJudgement+                        then do+                          result <- evalJudgementFromFile judgementDhall+                          pure (fmap (`RunnableJudgement` candidate) result)+                        else go rest  -- | Search for a blueprint by name in the given directories. Returns -- the path to the directory containing @blueprint.dhall@, or@@ -293,15 +309,45 @@     )     (m ^. #dependencies) --- Rule 7: Every step destination must be a safe relative path+-- Rule 7: Every step destination must be a safe relative path and must not+-- target Seihou's or Git's control data. Only the control rule applies when a+-- module loads; the rule that a module writes under @.seihou/@ only inside its+-- own directory is 'checkModuleStateDestinations', enforced when output is+-- generated, so that existing applications of an older module still load for+-- @seihou remove@ and @seihou status@. checkSafeDestinations :: Module -> [Text] checkSafeDestinations m = concatMap checkDest (m ^. #steps)   where     checkDest s =       case validateProjectRelativePath (s ^. #dest) of         Left err -> ["step destination " <> err]-        Right _ -> []+        Right safePath+          | literalControlPrefix (s ^. #dest),+            Just reason <- controlPathReason FileTarget safePath ->+              ["step destination " <> reason]+          | otherwise -> [] +-- | A module may write under @.seihou/@ only inside @.seihou/<its name>/@.+-- Checks each literal step destination; one whose first two segments contain a+-- placeholder is left to plan time. Control paths are reported by+-- 'checkSafeDestinations' and are not repeated here.+checkModuleStateDestinations :: Module -> [Text]+checkModuleStateDestinations m = concatMap checkDest (m ^. #steps)+  where+    checkDest s = case validateProjectRelativePath (s ^. #dest) of+      Right safePath+        | literalControlPrefix (s ^. #dest),+          Nothing <- controlPathReason FileTarget safePath,+          Just violation <- ownStateDirectoryViolation (m ^. #name . #unModuleName) FileTarget safePath ->+            ["step destination " <> violation]+      _ -> []++-- | Whether the segments that decide a path's control class (the first two)+-- are free of placeholders, so the literal destination can be judged now.+literalControlPrefix :: Text -> Bool+literalControlPrefix dest =+  not (any ("{{" `T.isInfixOf`) (take 2 (filter (not . T.null) (T.split (\c -> c == '/' || c == '\\') dest))))+ -- Rule 8: Variables referenced in step dest placeholders must be declared checkDestVarRefs :: Module -> [Text] checkDestVarRefs m =@@ -411,7 +457,7 @@       pure DiscoveredModule {result = result, source = src, dir = moduleDir}  -- | Whether a discovered item is a module, recipe, blueprint, or prompt.-data RunnableKind = KindModule | KindRecipe | KindBlueprint | KindPrompt+data RunnableKind = KindModule | KindRecipe | KindBlueprint | KindPrompt | KindJudgement   deriving stock (Eq, Show, Generic)  -- | A runnable discovered during enumeration, with its load result, kind, and source.@@ -426,8 +472,10 @@   }   deriving stock (Generic, Show) --- | Enumerate all modules, recipes, blueprints, and prompts across the given search paths.--- Returns a unified list of discovered items, each tagged with its kind.+-- | Enumerate all modules, recipes, blueprints, prompts, and judgements across+-- the given search paths. Returns a unified list of discovered items, each+-- tagged with its kind. A directory whose @judgement.dhall@ shares it with+-- another definition file is listed once, as an erroring judgement. discoverAllRunnables :: [FilePath] -> IO [DiscoveredRunnable] discoverAllRunnables searchPaths = do   let tagged = zip searchPaths (sources ++ repeat SourceInstalled)@@ -446,6 +494,24 @@      loadRunnable :: FilePath -> ModuleSource -> FilePath -> IO [DiscoveredRunnable]     loadRunnable dir src entry = do+      ambiguity <- judgementAmbiguity (dir </> entry)+      case ambiguity of+        Just files ->+          pure+            [ DiscoveredRunnable+                { name = T.pack entry,+                  description = Nothing,+                  kind = KindJudgement,+                  source = src,+                  dir = dir </> entry,+                  isError = True,+                  error = Just ("ambiguous: " <> T.intercalate ", " (map T.pack files))+                }+            ]+        Nothing -> loadUnambiguous dir src entry++    loadUnambiguous :: FilePath -> ModuleSource -> FilePath -> IO [DiscoveredRunnable]+    loadUnambiguous dir src entry = do       let entryDir = dir </> entry           moduleDhall = entryDir </> "module.dhall"           recipeDhall = entryDir </> "recipe.dhall"@@ -562,8 +628,40 @@                                   error = Nothing                                 }                         ]-                    else pure []+                    else loadJudgement src entry entryDir +    loadJudgement :: ModuleSource -> FilePath -> FilePath -> IO [DiscoveredRunnable]+    loadJudgement src entry entryDir = do+      let judgementDhall = entryDir </> "judgement.dhall"+      isJudgement <- doesFileExist judgementDhall+      if not isJudgement+        then pure []+        else do+          decoded <- evalJudgementFromFile judgementDhall+          pure+            [ case decoded of+                Left err ->+                  DiscoveredRunnable+                    { name = T.pack entry,+                      description = Nothing,+                      kind = KindJudgement,+                      source = src,+                      dir = entryDir,+                      isError = True,+                      error = Just (briefLoadError err)+                    }+                Right j ->+                  DiscoveredRunnable+                    { name = j ^. #name,+                      description = j ^. #description,+                      kind = KindJudgement,+                      source = src,+                      dir = entryDir,+                      isError = False,+                      error = Nothing+                    }+            ]+     briefLoadError :: ModuleLoadError -> Text     briefLoadError (DhallEvalError _ _) = "Dhall evaluation failed"     briefLoadError (DhallDecodeError _ _) = "Dhall decode failed"@@ -572,6 +670,7 @@     briefLoadError (MissingSourceFile _ _) = "missing source file"     briefLoadError (CircularDependency _) = "circular dependency"     briefLoadError (RegistryEvalError _ _) = "registry eval failed"+    briefLoadError (AmbiguousArtifact _ _ _) = "ambiguous artifact"  -- | A seed creates an entire file and must never be subject to automatic removal. checkSeedSteps :: Module -> [Text]
src/Seihou/Core/Path.hs view
@@ -2,9 +2,20 @@   ( validateProjectRelativePath,     validateCanonicalProjectPath,     pathsHaveConflictingSpelling,++    -- * Seihou and Git control paths+    ProjectPathClass (..),+    PathOperand (..),+    classifyProjectPath,+    reservedSeihouEntries,+    isControlFileTarget,+    isControlDirectory,+    controlPathReason,+    ownStateDirectoryViolation,   ) where +import Data.Maybe (isJust) import Data.Text qualified as T import Seihou.Prelude import System.FilePath qualified as FilePath@@ -29,6 +40,113 @@ pathSegments =   filter (not . T.null)     . T.split (\c -> c == '/' || c == '\\')++-- | What a validated project-relative path names, for the rules that keep+-- module output away from Seihou's and Git's own data.+--+-- A module owns exactly one directory under @.seihou/@, named after itself;+-- everything on 'reservedSeihouEntries' belongs to Seihou. See+-- docs/adr/0025-a-module-owns-only-its-own-directory-under-seihou.md.+data ProjectPathClass+  = -- | An ordinary project path, outside @.seihou@ and @.git@.+    OrdinaryProjectPath+  | -- | The @.seihou@ directory itself: creatable as a directory, never a file.+    SeihouRootDirectory+  | -- | Under @.seihou/<area>@, where @<area>@ (as written) is not Seihou's own.+    -- The 'Bool' says whether the path continues below the area directory.+    ModuleStatePath !Text !Bool+  | -- | Seihou's own state or Git's; the 'Text' is the user-facing reason.+    ControlPath !Text+  deriving stock (Eq, Show, Generic)++-- | Whether a path names a file to write or a directory to create.+data PathOperand = FileTarget | DirectoryOperand+  deriving stock (Eq, Show, Generic)++-- | The entries directly under @.seihou/@ that Seihou itself owns, case-folded.+-- Every name beginning with @.@ is reserved as well. New Seihou state under+-- @.seihou/@ must be added here and must use a name that is not a valid module+-- name (one not matching @[a-z][a-z0-9-]*@), so it never collides with a+-- module's state directory.+reservedSeihouEntries :: [Text]+reservedSeihouEntries =+  [ "manifest.json",+    "manifest.json.tmp",+    "baselines",+    "transactions",+    "transactions-quarantine",+    "modules",+    "context",+    "config.dhall",+    "trace.jsonl"+  ]++-- | Classify a path that has already passed 'validateProjectRelativePath'.+-- Segments split on either separator; empty and @.@ segments are ignored, and+-- the @.seihou@, @.git@ and reserved names compare case-insensitively because+-- the default macOS volume does.+classifyProjectPath :: FilePath -> ProjectPathClass+classifyProjectPath path = case controlSegments path of+  first' : rest+    | T.toCaseFold first' == ".git" -> ControlPath "path targets Git control data"+    | T.toCaseFold first' == ".seihou" -> case rest of+        [] -> SeihouRootDirectory+        entry : below+          | isReservedSeihouEntry entry ->+              ControlPath ("path targets Seihou's own state at " <> first' <> "/" <> entry)+          | otherwise -> ModuleStatePath entry (not (null below))+  _ -> OrdinaryProjectPath++controlSegments :: FilePath -> [Text]+controlSegments = filter (/= ".") . pathSegments . T.pack++isReservedSeihouEntry :: Text -> Bool+isReservedSeihouEntry entry =+  "." `T.isPrefixOf` entry || T.toCaseFold entry `elem` reservedSeihouEntries++-- | A path no module output may write as a file: Seihou's or Git's control+-- data, or the @.seihou@ directory itself.+isControlFileTarget :: FilePath -> Bool+isControlFileTarget = isJust . controlPathReason FileTarget++-- | A directory no module output may create or replace. The @.seihou@+-- directory itself is allowed: creating it when it exists is a no-op.+isControlDirectory :: FilePath -> Bool+isControlDirectory = isJust . controlPathReason DirectoryOperand++-- | Why a path is refused as the given kind of operand, in user words, or+-- 'Nothing' when it is not control data.+controlPathReason :: PathOperand -> FilePath -> Maybe Text+controlPathReason operand path = case classifyProjectPath path of+  ControlPath reason -> Just reason+  SeihouRootDirectory+    | operand == FileTarget -> Just "path names Seihou's state directory .seihou itself"+  _ -> Nothing++-- | 'Nothing' when the module named by the first argument may write (as a file+-- target) or create (as a directory operand) the path. A module may write under+-- @.seihou/@ only inside @.seihou/<its own name>/@, spelled exactly; any+-- other answer is a message that names the directory it may use.+ownStateDirectoryViolation :: Text -> PathOperand -> FilePath -> Maybe Text+ownStateDirectoryViolation moduleName operand path = case classifyProjectPath path of+  OrdinaryProjectPath -> Nothing+  ControlPath reason -> Just reason+  SeihouRootDirectory+    | operand == DirectoryOperand -> Nothing+    | otherwise -> Just (T.pack path <> " is Seihou's state directory and cannot be written as a file")+  ModuleStatePath area below+    | ownSpelling && area == moduleName && (below || operand == DirectoryOperand) -> Nothing+    | ownSpelling && area == moduleName ->+        Just (T.pack path <> " is this module's state directory and cannot be written as a file")+    | otherwise ->+        Just+          ( T.pack path+              <> " is outside this module's state directory; a module may write under .seihou/ only inside "+              <> ownDirectory+          )+  where+    ownSpelling = take 1 (controlSegments path) == [".seihou"]+    ownDirectory = ".seihou/" <> moduleName <> "/"  -- | Checkout declarations use a canonical, portable spelling and reserve metadata. -- This deliberately strengthens rather than changes the legacy validator above.
src/Seihou/Core/Registry.hs view
@@ -21,6 +21,7 @@  import Data.Generics.Labels () import Data.Text qualified as T+import Seihou.Core.ArtifactFiles (judgementAmbiguity, renderAmbiguousArtifact) import Seihou.Core.Types (ModuleLoadError, ModuleName (..)) import Seihou.Prelude import System.Directory (doesFileExist)@@ -43,7 +44,8 @@     modules :: ![RegistryEntry],     recipes :: ![RegistryEntry],     blueprints :: ![RegistryEntry],-    prompts :: ![RegistryEntry]+    prompts :: ![RegistryEntry],+    judgements :: ![RegistryEntry]   }   deriving stock (Eq, Show, Generic) @@ -57,6 +59,8 @@     SingleBlueprint FilePath   | -- | Repo root has @prompt.dhall@ (single prompt)     SinglePrompt FilePath+  | -- | Repo root has @judgement.dhall@ (single judgement)+    SingleJudgement FilePath   | -- | Repo root has @seihou-registry.dhall@     MultiModule Registry   | -- | Neither found@@ -82,7 +86,9 @@     else probeSingleArtifact repoRoot  -- | Probe for a single-artifact repo at @repoRoot@. Precedence:--- @module.dhall@ → @recipe.dhall@ → @blueprint.dhall@ → @prompt.dhall@ → 'EmptyRepo'.+-- @module.dhall@ → @recipe.dhall@ → @blueprint.dhall@ → @prompt.dhall@ →+-- @judgement.dhall@ → 'EmptyRepo'. A @judgement.dhall@ next to another+-- definition file is ambiguous; install rejects it when it inspects the root. -- A stray @module.dhall@ next to a @blueprint.dhall@ resolves as the -- module: the more specific, deterministic artifact wins. probeSingleArtifact :: FilePath -> IO RepoContents@@ -106,21 +112,59 @@               hasPrompt <- doesFileExist promptFile               if hasPrompt                 then pure (SinglePrompt repoRoot)-                else pure EmptyRepo+                else do+                  hasJudgement <- doesFileExist (repoRoot </> "judgement.dhall")+                  pure (if hasJudgement then SingleJudgement repoRoot else EmptyRepo)  -- | Validate a registry's entries against the filesystem. -- Returns a list of error messages (empty means valid). -- Checks: module name format, path safety, entry file existence,--- and no name collisions between modules, recipes, blueprints, and prompts.+-- no name collisions between modules, recipes, blueprints, prompts, and+-- judgements, and no entry directory whose @judgement.dhall@ shares it with+-- another definition file. validateRegistry :: FilePath -> Registry -> IO [Text] validateRegistry repoRoot reg = do   modErrs <- concat <$> mapM (validateModuleEntry repoRoot) (reg ^. #modules)   recErrs <- concat <$> mapM (validateRecipeEntry repoRoot) (reg ^. #recipes)   bpErrs <- concat <$> mapM (validateBlueprintEntry repoRoot) (reg ^. #blueprints)   promptErrs <- concat <$> mapM (validatePromptEntry repoRoot) (reg ^. #prompts)-  let collisionErrs = checkNameCollisions (reg ^. #modules) (reg ^. #recipes) (reg ^. #blueprints) (reg ^. #prompts)-  pure (modErrs <> recErrs <> bpErrs <> promptErrs <> collisionErrs)+  judgementErrs <- concat <$> mapM (validateJudgementEntry repoRoot) (reg ^. #judgements)+  ambiguityErrs <- concat <$> mapM (checkEntryAmbiguity repoRoot) (registryEntries reg)+  let collisionErrs =+        checkNameCollisions+          [ ("module", reg ^. #modules),+            ("recipe", reg ^. #recipes),+            ("blueprint", reg ^. #blueprints),+            ("prompt", reg ^. #prompts),+            ("judgement", reg ^. #judgements)+          ]+  pure (modErrs <> recErrs <> bpErrs <> promptErrs <> judgementErrs <> ambiguityErrs <> collisionErrs) +-- | Every entry of every kind, in registry order, deduplicated by path so a+-- path listed twice is reported once.+registryEntries :: Registry -> [RegistryEntry]+registryEntries reg = dedupe (reg ^. #modules <> reg ^. #recipes <> reg ^. #blueprints <> reg ^. #prompts <> reg ^. #judgements)+  where+    dedupe = go []+    go _ [] = []+    go seen (e : rest)+      | (e ^. #path) `elem` seen = go seen rest+      | otherwise = e : go ((e ^. #path) : seen) rest++-- | Report an entry directory where @judgement.dhall@ sits next to another+-- artifact definition file. Unsafe paths are skipped; the per-kind checks+-- already report them.+checkEntryAmbiguity :: FilePath -> RegistryEntry -> IO [Text]+checkEntryAmbiguity repoRoot entry+  | unsafePath (T.pack (entry ^. #path)) = pure []+  | otherwise = do+      ambiguity <- judgementAmbiguity (repoRoot </> entry ^. #path)+      pure $ case ambiguity of+        Nothing -> []+        Just files -> [renderAmbiguousArtifact (entry ^. #name) (entry ^. #path) files]+  where+    unsafePath p = T.isPrefixOf "/" p || ".." `T.isInfixOf` p+ validateModuleEntry :: FilePath -> RegistryEntry -> IO [Text] validateModuleEntry repoRoot entry = do   let nameText = (entry ^. #name . #unModuleName)@@ -213,28 +257,46 @@       | ".." `T.isInfixOf` path = ["registry prompt path must not contain '..': " <> path]       | otherwise = [] --- | Detect name collisions across module, recipe, blueprint, and prompt entries.--- Each cross-kind pair sharing a name produces one message; a name that--- appears in all four kinds produces six messages (one per pair).-checkNameCollisions :: [RegistryEntry] -> [RegistryEntry] -> [RegistryEntry] -> [RegistryEntry] -> [Text]-checkNameCollisions mods recs bps prompts =-  let modNames = map (\e -> e ^. #name . #unModuleName) mods-      recNames = map (\e -> e ^. #name . #unModuleName) recs-      bpNames = map (\e -> e ^. #name . #unModuleName) bps-      promptNames = map (\e -> e ^. #name . #unModuleName) prompts-      modRec = [n | n <- modNames, n `elem` recNames]-      modBp = [n | n <- modNames, n `elem` bpNames]-      modPrompt = [n | n <- modNames, n `elem` promptNames]-      recBp = [n | n <- recNames, n `elem` bpNames]-      recPrompt = [n | n <- recNames, n `elem` promptNames]-      bpPrompt = [n | n <- bpNames, n `elem` promptNames]-   in map (\n -> "name collision: '" <> n <> "' appears as both a module and a recipe") modRec-        <> map (\n -> "name collision: '" <> n <> "' appears as both a module and a blueprint") modBp-        <> map (\n -> "name collision: '" <> n <> "' appears as both a module and a prompt") modPrompt-        <> map (\n -> "name collision: '" <> n <> "' appears as both a recipe and a blueprint") recBp-        <> map (\n -> "name collision: '" <> n <> "' appears as both a recipe and a prompt") recPrompt-        <> map (\n -> "name collision: '" <> n <> "' appears as both a blueprint and a prompt") bpPrompt+validateJudgementEntry :: FilePath -> RegistryEntry -> IO [Text]+validateJudgementEntry repoRoot entry = do+  let nameText = (entry ^. #name . #unModuleName)+      nameErrors = checkJudgementName nameText+      pathText = T.pack (entry ^. #path)+      pathErrors = checkJudgementPath pathText+  let judgementDhall = repoRoot </> entry ^. #path </> "judgement.dhall"+  fileExists <- doesFileExist judgementDhall+  let fileErrors =+        if fileExists+          then []+          else ["registry judgement entry '" <> nameText <> "' points to missing judgement.dhall at " <> pathText]+  pure (nameErrors <> pathErrors <> fileErrors)+  where+    checkJudgementName name+      | validModuleName name = []+      | otherwise = ["registry judgement name must match [a-z][a-z0-9-]*, got: " <> name] +    checkJudgementPath path+      | T.isPrefixOf "/" path = ["registry judgement path must be relative: " <> path]+      | ".." `T.isInfixOf` path = ["registry judgement path must not contain '..': " <> path]+      | otherwise = []++-- | Detect name collisions across the entry kinds, given in display order.+-- Each cross-kind pair sharing a name produces one message, grouped by pair+-- in the order the kinds are given (module/recipe, module/blueprint, ...).+checkNameCollisions :: [(Text, [RegistryEntry])] -> [Text]+checkNameCollisions kinds =+  [ "name collision: '" <> n <> "' appears as both a " <> kindA <> " and a " <> kindB+  | (i, (kindA, as)) <- indexed,+    (j, (kindB, bs)) <- indexed,+    i < j,+    let bNames = names bs,+    n <- names as,+    n `elem` bNames+  ]+  where+    indexed = zip [0 :: Int ..] kinds+    names = map (\e -> e ^. #name . #unModuleName)+ -- | Check that a text matches @[a-z][a-z0-9-]*@. -- Duplicated from @Seihou.Core.Module.isValidModuleName@ to avoid -- a circular module dependency through @Seihou.Dhall.Eval@.@@ -246,7 +308,7 @@       && T.all (\ch -> (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') rest  -- | What a registry entry points at on disk.-data EntryKind = ModuleEntry | RecipeEntry | BlueprintEntry | PromptEntry+data EntryKind = ModuleEntry | RecipeEntry | BlueprintEntry | PromptEntry | JudgementEntry   deriving stock (Eq, Show, Generic)  -- | Classification of a single registry entry relative to the on-disk@@ -295,7 +357,7 @@   SyncReport computeRegistrySync reg lookups =   SyncReport-    { diffs = moduleDiffs <> recipeDiffs <> blueprintDiffs <> promptDiffs,+    { diffs = moduleDiffs <> recipeDiffs <> blueprintDiffs <> promptDiffs <> judgementDiffs,       updated =         reg           & #modules@@ -306,12 +368,15 @@           %~ zipWith applyDiff blueprintDiffs           & #prompts           %~ zipWith applyDiff promptDiffs+          & #judgements+          %~ zipWith applyDiff judgementDiffs     }   where     moduleDiffs = map (classify ModuleEntry) (reg ^. #modules)     recipeDiffs = map (classify RecipeEntry) (reg ^. #recipes)     blueprintDiffs = map (classify BlueprintEntry) (reg ^. #blueprints)     promptDiffs = map (classify PromptEntry) (reg ^. #prompts)+    judgementDiffs = map (classify JudgementEntry) (reg ^. #judgements)      classify :: EntryKind -> RegistryEntry -> SyncDiff     classify kind entry =@@ -387,10 +452,12 @@     kindWord RecipeEntry = "recipe"     kindWord BlueprintEntry = "blueprint"     kindWord PromptEntry = "prompt"+    kindWord JudgementEntry = "judgement"     entryFile ModuleEntry = "module.dhall"     entryFile RecipeEntry = "recipe.dhall"     entryFile BlueprintEntry = "blueprint.dhall"     entryFile PromptEntry = "prompt.dhall"+    entryFile JudgementEntry = "judgement.dhall"  -- | One row of the unified validation report. Either reuses an existing -- structural error message (path/name/file-existence/collisions) or@@ -407,7 +474,8 @@     moduleCount :: !Int,     recipeCount :: !Int,     blueprintCount :: !Int,-    promptCount :: !Int+    promptCount :: !Int,+    judgementCount :: !Int   }   deriving stock (Eq, Show, Generic) @@ -438,7 +506,8 @@         moduleCount = length (reg ^. #modules),         recipeCount = length (reg ^. #recipes),         blueprintCount = length (reg ^. #blueprints),-        promptCount = length (reg ^. #prompts)+        promptCount = length (reg ^. #prompts),+        judgementCount = length (reg ^. #judgements)       }   where     isVersionDrift SyncMissing = True@@ -466,12 +535,14 @@     entryFile RecipeEntry = "recipe.dhall"     entryFile BlueprintEntry = "blueprint.dhall"     entryFile PromptEntry = "prompt.dhall"+    entryFile JudgementEntry = "judgement.dhall"  validationKindPrefix :: EntryKind -> Text validationKindPrefix ModuleEntry = "modules." validationKindPrefix RecipeEntry = "recipes." validationKindPrefix BlueprintEntry = "blueprints." validationKindPrefix PromptEntry = "prompts."+validationKindPrefix JudgementEntry = "judgements."  validationRenderVersion :: Maybe Text -> Text validationRenderVersion Nothing = "(none)"@@ -493,6 +564,8 @@       renderEntryList (reg ^. #blueprints),       ", prompts =",       renderEntryList (reg ^. #prompts),+      ", judgements =",+      renderEntryList (reg ^. #judgements),       "}"     ] 
src/Seihou/Core/Scaffold.hs view
@@ -5,6 +5,8 @@     examplePromptMarkdown,     promptDhall,     exampleAgentPromptMarkdown,+    judgementDhall,+    judgementExamplesJson,   ) where @@ -202,4 +204,77 @@       "",       "Ask before destructive changes such as deleting files, force-pushing,",       "or rewriting history."+    ]++-- | A new judgement with one check whose rubric is meant to be edited in+-- the @instructions@ field, rather than a vacuous "review carefully".+judgementDhall :: Text -> Text -> Text -> Text+judgementDhall name url hash =+  T.unlines+    [ "let S =",+      "      " <> url,+      "        " <> hash,+      "",+      "-- A judgement asks a model yes/no questions about the staged changes before",+      "-- each commit. Edit the instructions to describe what this project cares about",+      "-- and what is acceptable, then add labelled cases to examples.json.",+      "--",+      "-- Every threshold applies to the probability of the concern: at or above",+      "-- blockAt the check blocks, at or below passAt it may pass, and in between it",+      "-- needs human review. concernWhen says which answer is the concern.",+      "in  S.Judgement::{",+      "    , name = \"" <> name <> "\"",+      "    , version = Some \"0.1.0\"",+      "    , description = Some \"Checks staged changes before each commit\"",+      "    , instructions =",+      "        ''",+      "        You review a change that is about to be committed. Decide only the",+      "        question you are asked; ignore style and unrelated problems.",+      "",+      "        Acceptable: synthetic test data, documented examples and placeholders.",+      "        A concern: replace this line with what this project must not commit.",+      "        ''",+      "    , evidence = S.JudgementEvidence::{ scopes = [ S.EvidenceScope.StagedChanges, S.EvidenceScope.CommitRange ] }",+      "    , checks =",+      "      [ S.Check::{",+      "        , name = \"concern-present\"",+      "        , question = \"Does this change add something the instructions describe as a concern?\"",+      "        , yesMeans = Some \"At least one shown line matches a concern in the instructions.\"",+      "        , noMeans = Some \"Everything shown is acceptable under the instructions.\"",+      "        , categories =",+      "          [ S.FindingCategory::{",+      "            , name = \"concern\"",+      "            , severity = S.Severity.High",+      "            , description = Some \"A place where the concern appears\"",+      "            }",+      "          ]",+      "        , blockAt = 0.7",+      "        , passAt = 0.3",+      "        }",+      "      ]",+      "    }"+    ]++-- | Synthetic labelled cases for 'judgementDhall''s single check.+judgementExamplesJson :: Text+judgementExamplesJson =+  T.unlines+    [ "{",+      "  \"version\": 1,",+      "  \"examples\": [",+      "    {",+      "      \"id\": \"benign-docs\",",+      "      \"description\": \"A documentation edit with nothing the instructions call a concern\",",+      "      \"files\": [{ \"path\": \"README.md\", \"content\": \"# Demo\\n\\nRun make test before opening a pull request.\\n\" }],",+      "      \"expect\": { \"concern-present\": \"passed\" }",+      "    },",+      "    {",+      "      \"id\": \"replace-me-concern\",",+      "      \"description\": \"Replace with a synthetic change this judgement must block\",",+      "      \"files\": [{ \"path\": \"notes/todo.md\", \"content\": \"Replace this file with a synthetic example of the concern.\\n\" }],",+      "      \"expect\": { \"concern-present\": \"blocked\" },",+      "      \"findings\": [{ \"check\": \"concern-present\", \"category\": \"concern\", \"path\": \"notes/todo.md\", \"line\": 1 }]",+      "    }",+      "  ]",+      "}"     ]
src/Seihou/Core/Types.hs view
@@ -45,6 +45,9 @@     AgentPrompt (..),     Runnable (..),     recipeNameToModuleName,+    ActiveJudgement (..),+    JudgementSource (..),+    JudgementOwner (..),     Operation (..),     isAdditiveOperation,     ModuleLoadError (..),@@ -99,6 +102,7 @@ import Data.Time (UTCTime) import GHC.Generics (Generic) import Numeric.Natural (Natural)+import Seihou.Core.Judgement (JudgementDefinition, JudgementPolicy) import Seihou.Core.Migration (BlueprintMigration, Migration)  -- | A module identifier such as @"haskell-base"@.@@ -472,6 +476,7 @@   | RunnableRecipe Recipe FilePath   | RunnableBlueprint Blueprint FilePath   | RunnableAgentPrompt AgentPrompt FilePath+  | RunnableJudgement JudgementDefinition FilePath   deriving stock (Show)  -- | Convert a 'RecipeName' to a 'ModuleName' (they share a namespace).@@ -541,6 +546,10 @@   | CircularDependency [ModuleName]   | MissingSourceFile ModuleName FilePath   | RegistryEvalError Text Text+  | -- | An artifact directory holds a @judgement.dhall@ next to another+    --   artifact definition file, so its kind is ambiguous. Carries the+    --   requested name, the directory, and every definition file present.+    AmbiguousArtifact ModuleName FilePath [FilePath]   deriving stock (Eq, Show, Generic)  -- | Tracks where a variable's value came from (for provenance / @--explain@).@@ -608,10 +617,45 @@     applications :: ![AppliedComposition],     recipe :: !(Maybe AppliedRecipe),     blueprint :: !(Maybe AppliedBlueprint),-    blueprintMigrations :: ![AppliedBlueprintMigration]+    blueprintMigrations :: ![AppliedBlueprintMigration],+    -- | Active judgements: the resolved definition snapshot and the+    -- project-owned policy for each, keyed by definition name. Introduced by+    -- schema 15; older manifests decode with none.+    judgements :: ![ActiveJudgement]   }   deriving stock (Eq, Show, Generic) +-- | Where an active judgement's definition was resolved from.+data JudgementSource+  = -- | The catalogue compiled into the seihou binary.+    BundledJudgement+  | -- | An ordinary artifact found by discovery or installed from a registry.+    ArtifactJudgement !ArtifactOrigin+  deriving stock (Eq, Show, Generic)++-- | One judgement a project has adopted. The definition is a snapshot, so a+-- later artifact or binary cannot silently change what is checked, and the+-- digest identifies the reviewed definition bytes. The policy is project+-- intent: the data policy and per-check enforcement and thresholds.+data ActiveJudgement = ActiveJudgement+  { source :: !JudgementSource,+    definition :: !JudgementDefinition,+    digest :: !SHA256,+    policy :: !JudgementPolicy,+    -- | Who keeps the judgement active. The last owner's retirement removes+    -- the record.+    owners :: ![JudgementOwner]+  }+  deriving stock (Eq, Show, Generic)++-- | One reason a judgement is active.+data JudgementOwner+  = -- | Adopted directly with @seihou run NAME@.+    DirectAdoption+  | -- | Referenced by a recorded application's module or recipe.+    ApplicationOwner !ApplicationId+  deriving stock (Eq, Ord, Show, Generic)+ -- | The numeric schema of @.seihou/manifest.json@. Every semantic change to -- the serialized manifest advances it (see -- docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md).@@ -635,6 +679,8 @@   | SeedOwnerClaims   | CheckoutLocalOutputs   | CheckoutLocalCommands+  | -- | Active judgement definitions and their policy (schema 15).+    JudgementState   deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)  -- | What the manifest knows about how the owners of a path write it.
src/Seihou/Dhall/Eval.hs view
@@ -7,6 +7,10 @@     evalBlueprintFromFile,     evalAgentPromptFromFile,     evalRegistryFromFile,+    evalJudgementFromFile,+    evalJudgementFromText,+    evalJudgementRefsFromFile,+    judgementDecoder,     moduleDecoder,     recipeDecoder,     blueprintDecoder,@@ -39,6 +43,7 @@ import Data.Either.Validation (Validation (..)) import Data.Generics.Labels () import Data.List (foldl')+import Data.List.NonEmpty qualified as NE import Data.Map.Strict qualified as Map import Data.Text qualified as T import Data.Text.IO qualified as TIO@@ -47,10 +52,11 @@ import Dhall.Core (Chunks (..), makeRecordField, recordFieldValue) import Dhall.Core qualified as Dhall (Expr (..)) import Dhall.Map qualified as DhallMap-import Dhall.Marshal.Decode (Decoder (..), Extractor, bool, constructor, extractError, field, maybe, natural, string, union)+import Dhall.Marshal.Decode (Decoder (..), Extractor, bool, constructor, double, extractError, field, maybe, natural, string, union, unit) import Dhall.Pretty (prettyExpr) import Dhall.Src (Src) import Seihou.Core.Expr (parseExpr)+import Seihou.Core.Judgement qualified as J import Seihou.Core.Migration (BlueprintMigration (..), EntailedEdge (..), Migration (..), MigrationOp (..)) import Seihou.Core.Registry (Registry (..), RegistryEntry (..)) import Seihou.Core.Types@@ -256,15 +262,22 @@ -- | Decoder for a 'MigrationOp' from a Dhall union value. -- The union variants must match @schema/MigrationOp.dhall@. migrationOpDecoder :: Decoder MigrationOp-migrationOpDecoder =-  union-    ( (mkMoveFile <$> constructor "MoveFile" srcDestRecord)-        <> (mkMoveDir <$> constructor "MoveDir" srcDestRecord)-        <> (mkDeleteFile <$> constructor "DeleteFile" pathRecord)-        <> (mkDeleteDir <$> constructor "DeleteDir" pathRecord)-        <> (mkRunCommand <$> constructor "RunCommand" runCommandRecord)-    )+migrationOpDecoder = Decoder decode (expected extended)   where+    decode expr = case extract extended expr of+      Success value -> Success value+      Failure _ -> extract legacy expr+    extended = union (variants <> (mkRemoveLine <$> constructor "RemoveLine" removeLineRecord))+    legacy = union variants+    variants =+      ( (mkMoveFile <$> constructor "MoveFile" srcDestRecord)+          <> (mkMoveDir <$> constructor "MoveDir" srcDestRecord)+          <> (mkDeleteFile <$> constructor "DeleteFile" pathRecord)+          <> (mkDeleteDir <$> constructor "DeleteDir" pathRecord)+          <> (mkRunCommand <$> constructor "RunCommand" runCommandRecord)+      )+    removeLineRecord = record ((,) <$> field "path" string <*> field "line" strictText)+    mkRemoveLine (p, l) = RemoveLine {path = p, line = l}     srcDestRecord :: Decoder (FilePath, FilePath)     srcDestRecord =       record ((,) <$> field "src" string <*> field "dest" string)@@ -816,14 +829,15 @@  -- | Decoder for a registry metadata file from Dhall. -- Uses 'withDefaults' to handle registries that omit the @recipes@ or--- @blueprints@ or @prompts@ field (backwards compatibility with existing--- seihou-registry.dhall files).+-- @blueprints@ or @prompts@ or @judgements@ field (backwards compatibility+-- with existing seihou-registry.dhall files). registryDecoder :: Decoder Registry registryDecoder =   withDefaults     [ ("recipes", emptyRegistryEntryList),       ("blueprints", emptyRegistryEntryList),-      ("prompts", emptyRegistryEntryList)+      ("prompts", emptyRegistryEntryList),+      ("judgements", emptyRegistryEntryList)     ]     $ record       ( Registry@@ -833,6 +847,7 @@           <*> field "recipes" (list registryEntryDecoder)           <*> field "blueprints" (list registryEntryDecoder)           <*> field "prompts" (list registryEntryDecoder)+          <*> field "judgements" (list registryEntryDecoder)       )  -- | A Dhall expression representing an empty list of registry entries.@@ -917,3 +932,145 @@         | not (null values) ->             DhallMap.insert key (makeRecordField (Dhall.ListLit annotation (fmap edge values))) fields       _ -> fields++-- ---------------------------------------------------------------------------+-- Judgements++-- | Evaluate a @judgement.dhall@ file and decode it into a+-- 'J.JudgementDefinition'. Structural validation ('J.validateDefinition') is+-- a separate step so a caller can name the failing part precisely.+evalJudgementFromFile :: FilePath -> IO (Either ModuleLoadError J.JudgementDefinition)+evalJudgementFromFile path = TIO.readFile path >>= evalJudgementFromText path++-- | Evaluate judgement Dhall source text. Relative imports resolve against+-- the directory of the given path, which also names the source in errors;+-- the bundled catalogue passes self-contained text with a virtual path.+evalJudgementFromText :: FilePath -> Text -> IO (Either ModuleLoadError J.JudgementDefinition)+evalJudgementFromText path text = do+  result <- try $ do+    let settings =+          set rootDirectory (takeDirectory path) $+            set sourceName path defaultInputSettings+    expr <- inputExprWithSettings settings text+    case extract judgementDecoder expr of+      Success d -> evaluate d+      Failure e -> throwIO e+  case result of+    Left (e :: SomeException) -> pure (Left (DhallEvalError (guessModuleName path) (T.pack (show e))))+    Right d -> pure (Right d)++-- | Decoder for the top-level Judgement type. An empty @checks@ list is a+-- decode error: a judgement without a question cannot decide anything.+judgementDecoder :: Decoder J.JudgementDefinition+judgementDecoder =+  record+    ( J.JudgementDefinition+        <$> field "name" strictText+        <*> field "version" (maybe strictText)+        <*> field "description" (maybe strictText)+        <*> field "instructions" strictText+        <*> field "evidence" evidenceDecoder+        <*> field "limits" limitsDecoder+        <*> field "deterministicChecks" (list deterministicDecoder)+        <*> field "checks" (nonEmptyList "a judgement needs at least one check" checkDecoder)+    )+  where+    nonEmptyList msg (Decoder ext ty) =+      let Decoder lext lty = list (Decoder ext ty)+       in Decoder+            ( \e -> case lext e of+                Success xs -> case NE.nonEmpty xs of+                  Just ne -> Success ne+                  Nothing -> extractError msg+                Failure err -> Failure err+            )+            lty+    evidenceDecoder =+      record+        ( J.EvidenceDeclaration+            <$> field "scopes" (list scopeDecoder)+            <*> field "include" (list strictText)+            <*> field "exclude" (list strictText)+        )+    scopeDecoder =+      union+        ( (J.StagedChangesScope <$ constructor "StagedChanges" unit)+            <> (J.CommitRangeScope <$ constructor "CommitRange" unit)+        )+    deterministicDecoder = union (J.SecretScan <$ constructor "SecretScan" unit)+    limitsDecoder =+      record+        ( J.CoverageLimits+            <$> field "maxFileBytes" int+            <*> field "maxChunkBytes" int+            <*> field "maxChunks" int+            <*> field "maxAttempts" int+            <*> field "timeoutSeconds" int+            <*> field "maxFindings" int+        )+    int = fromIntegral . min (fromIntegral (maxBound :: Int)) <$> natural+    answerDecoder = union ((J.Yes <$ constructor "Yes" unit) <> (J.No <$ constructor "No" unit))+    enforcementDecoder =+      union ((J.Enforced <$ constructor "Enforced" unit) <> (J.Advisory <$ constructor "Advisory" unit))+    severityDecoder =+      union+        ( (J.Info <$ constructor "Info" unit)+            <> (J.Low <$ constructor "Low" unit)+            <> (J.Medium <$ constructor "Medium" unit)+            <> (J.High <$ constructor "High" unit)+            <> (J.Critical <$ constructor "Critical" unit)+        )+    categoryDecoder =+      record+        ( J.FindingCategory+            <$> field "name" strictText+            <*> field "severity" severityDecoder+            <*> field "description" (maybe strictText)+        )+    checkDecoder =+      record+        ( mkCheck+            <$> field "name" strictText+            <*> field "question" strictText+            <*> field "concernWhen" answerDecoder+            <*> field "yesMeans" (maybe strictText)+            <*> field "noMeans" (maybe strictText)+            <*> field "categories" (list categoryDecoder)+            <*> field "enforcement" enforcementDecoder+            <*> field "blockingSeverity" severityDecoder+            <*> field "blockAt" double+            <*> field "passAt" double+            <*> field "gateBlockAt" double+            <*> field "gatePassAt" double+            <*> field "useGate" bool+        )+    mkCheck n q c y no cats enf sev bAt pAt gbAt gpAt useGate =+      J.JudgementCheck n q c y no cats enf sev (J.Thresholds bAt pAt) (J.Thresholds gbAt gpAt) useGate++-- | The @judgements@ references of a @module.dhall@ or @recipe.dhall@. The+-- field is decoded on its own, so artifacts written against a schema pin+-- without it decode with none, and the module and recipe records stay as+-- they are.+evalJudgementRefsFromFile :: FilePath -> IO (Either ModuleLoadError [J.JudgementRef])+evalJudgementRefsFromFile path = do+  result <- try $ do+    text <- TIO.readFile path+    let settings =+          set rootDirectory (takeDirectory path) $+            set sourceName path defaultInputSettings+    expr <- inputExprWithSettings settings text+    case extract decoder expr of+      Success refs -> evaluate refs+      Failure e -> throwIO e+  case result of+    Left (e :: SomeException) -> pure (Left (DhallEvalError (guessModuleName path) (T.pack (show e))))+    Right refs -> pure (Right refs)+  where+    decoder = withDefaults [("judgements", Dhall.ListLit (Just (Dhall.Record mempty)) mempty)] (record (field "judgements" (list refDecoder)))+    refDecoder =+      record+        ( J.JudgementRef+            <$> field "name" strictText+            <*> field "source" (maybe strictText)+            <*> field "when" (maybe strictText)+        )
src/Seihou/Engine/Migrate.hs view
@@ -9,12 +9,15 @@     classifyMigration,     executeMigration,     validateSeedMigration,+    removeExactLine,   ) where +import Control.Monad (when) import Data.Generics.Labels () import Data.List (nub, sortBy) import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe) import Data.Ord (Down (..)) import Data.Set qualified as Set import Data.Text qualified as T@@ -25,7 +28,7 @@     MigrationOp (..),     MigrationPlan (..),   )-import Seihou.Core.Path (pathsHaveConflictingSpelling, validateProjectRelativePath)+import Seihou.Core.Path (PathOperand (..), controlPathReason, pathsHaveConflictingSpelling, validateProjectRelativePath) import Seihou.Core.Types   ( AppliedModule (..),     FileRecord (..),@@ -35,13 +38,16 @@ import Seihou.Core.Version (renderVersion) import Seihou.Effect.Filesystem   ( Filesystem,+    PathEntry (..),     createDirectoryIfMissing,     doesFileExist,+    inspectPath,     readFileText,     removeDirectoryIfEmpty,     removeDirectoryRecursive,     removeFile,     renamePath,+    writeFileText,   ) import Seihou.Effect.Process (Process, runProcess) import Seihou.Manifest.Hash (hashContent)@@ -75,6 +81,7 @@   | MoveDirInst FilePath FilePath   | DeleteFileInst FilePath MigrationFileStatus   | DeleteDirInst FilePath+  | RemoveLineInst FilePath Text   | RunCommandInst Text (Maybe FilePath)   deriving stock (Eq, Show, Generic) @@ -157,14 +164,33 @@       case validateMigrationPath "delete-dir path" path of         Left err -> Left err         Right safePath -> Right (DeleteDirInst safePath)+  RemoveLine {path, line} -> pure $ do+    safePath <- validateMigrationPath "remove-line path" path+    if T.null line || T.any (`elem` ['\r', '\n']) line+      then Left (MigrationUnsafePath "remove-line line" path "line must be nonempty and contain no newline")+      else Right (RemoveLineInst safePath line)   RunCommand {run, workDir} ->     pure $-      case traverse (validateMigrationPath "run-command workDir") workDir of+      case traverse (validateMigrationWorkDir "run-command workDir") workDir of         Left err -> Left err         Right safeWorkDir -> Right (RunCommandInst run safeWorkDir) +-- | Validate a path a migration moves, deletes or creates. It must stay in the+-- project and must not name Seihou's or Git's control data or the @.seihou@+-- directory itself. Unlike generation, a migration may touch any other entry+-- under @.seihou/@, so it can move a legacy @.seihou/<name>.conf@ into the+-- module's own @.seihou/<name>/@ directory. validateMigrationPath :: Text -> FilePath -> Either MigrationExecError FilePath-validateMigrationPath label path =+validateMigrationPath label path = do+  safePath <- validateMigrationWorkDir label path+  case controlPathReason FileTarget safePath of+    Just reason -> Left (MigrationUnsafePath label path reason)+    Nothing -> Right safePath++-- | A command's working directory need only stay in the project: the command+-- itself can already reach any file.+validateMigrationWorkDir :: Text -> FilePath -> Either MigrationExecError FilePath+validateMigrationWorkDir label path =   case validateProjectRelativePath (T.pack path) of     Left reason -> Left (MigrationUnsafePath label path reason)     Right safePath -> Right safePath@@ -221,6 +247,7 @@     instancePaths (MoveDirInst source dest) = [source, dest]     instancePaths (DeleteFileInst path _) = [path]     instancePaths (DeleteDirInst path) = [path]+    instancePaths (RemoveLineInst path _) = [path]     instancePaths RunCommandInst {} = []  executeUnprotectedMigration :: (Filesystem :> es, Process :> es) => Bool -> ExecutedMigrationPlan -> Manifest -> UTCTime -> Eff es (Either MigrationExecError Manifest)@@ -296,6 +323,17 @@   DeleteDirInst p -> do     removeDirectoryRecursive p     runOps rest (dropDirFromManifest p manifest) (p : acc)+  RemoveLineInst path line -> do+    safe <- validateEditablePath path+    case safe of+      Left err -> pure (Left err)+      Right () -> do+        exists <- doesFileExist path+        when exists $ do+          content <- readFileText path+          let edited = removeExactLine line content+          when (edited /= content) (writeFileText path edited)+        runOps rest manifest acc   RunCommandInst run mWorkDir -> do     (code, _stdout, stderr) <- runProcess "/bin/sh" ["-c", run] mWorkDir     case code of@@ -395,9 +433,35 @@     paths MoveDir {src, dest} = [src, dest]     paths DeleteFile {path} = [path]     paths DeleteDir {path} = [path]+    paths RemoveLine {path} = [path]     paths RunCommand {} = []  validateProtectedMutationPaths :: Set FilePath -> [FilePath] -> Either MigrationExecError () validateProtectedMutationPaths seeds paths = case [path | path <- paths, seed <- Set.toAscList seeds, pathsOverlap (P.normalise path) (P.normalise seed) || pathsHaveConflictingSpelling (P.normalise path) (P.normalise seed)] of   path : _ -> Left (MigrationUnsafePath "seed ownership" path "declarative migrations must not mutate a project-owned seed or a path being released to the project; publish a compatible producer migration")   [] -> Right ()++-- | Remove matching logical lines without rewriting surviving terminators.+removeExactLine :: Text -> Text -> Text+removeExactLine target = go+  where+    go content+      | T.null content = ""+      | otherwise =+          let (payload, suffix) = T.breakOn "\n" content+              logical = if T.null suffix then payload else fromMaybe payload (T.stripSuffix "\r" payload)+              kept = if logical == target then "" else payload <> if T.null suffix then "" else "\n"+           in kept <> if T.null suffix then "" else go (T.drop 1 suffix)++-- Inspect every component without following links, including parent directories.+validateEditablePath :: (Filesystem :> es) => FilePath -> Eff es (Either MigrationExecError ())+validateEditablePath path = go (reverse (path : allParents path))+  where+    go [] = pure (Right ())+    go (component : rest) = do+      entry <- inspectPath component+      case entry of+        EntryMissing -> pure (Right ())+        EntryDirectory | component /= path -> go rest+        EntryFile | component == path -> go rest+        _ -> pure (Left (MigrationUnsafePath "remove-line path" path "target must be a regular file with no symbolic-link components"))
src/Seihou/Engine/Plan.hs view
@@ -23,7 +23,7 @@ import Seihou.Core.Checkout (CheckoutError (..), pathsOverlap, validateCheckoutPaths) import Seihou.Core.Expr (evalExpr) import Seihou.Core.Module (checkCheckoutSteps, checkSeedSteps)-import Seihou.Core.Path (validateCanonicalProjectPath, validateProjectRelativePath)+import Seihou.Core.Path (PathOperand (..), ownStateDirectoryViolation, validateCanonicalProjectPath, validateProjectRelativePath) import Seihou.Core.Types import Seihou.Engine.DhallJSON (dhallExprToJSON) import Seihou.Engine.Template (renderCommand, renderDestPath, renderTemplate, renderTemplateText)@@ -134,10 +134,10 @@     compileActiveStep = case step ^. #patch of       Just _ -> compilePatchStep baseDir vars modName step       Nothing -> case step ^. #strategy of-        Copy -> compileCopyStep baseDir vars step-        Template -> compileTemplateStep baseDir vars step-        DhallText -> compileDhallTextStep baseDir vars step-        Structured -> compileStructuredStep baseDir vars step+        Copy -> compileCopyStep baseDir modName vars step+        Template -> compileTemplateStep baseDir modName vars step+        DhallText -> compileDhallTextStep baseDir modName vars step+        Structured -> compileStructuredStep baseDir modName vars step         Symlink -> pure $ case (renderCommand (T.pack (step ^. #src)) vars, renderCommand (step ^. #dest) vars) of           (Right source, Right dest) -> case validateCheckoutPaths (T.unpack source) (T.unpack dest) of             Left err -> Left [err ^. #message]@@ -148,10 +148,11 @@ -- | Compile a Copy step: read the source file and write it to the destination. compileCopyStep ::   FilePath ->+  ModuleName ->   Map VarName VarValue ->   Step ->   IO (Either [Text] [Operation])-compileCopyStep baseDir vars step = do+compileCopyStep baseDir modName vars step = do   let srcPath = baseDir </> "files" </> (step ^. #src)   result <- tryReadFile srcPath   case result of@@ -161,15 +162,16 @@         Left placeholderErrors ->           pure (Left (map formatPlaceholderError placeholderErrors))         Right dest ->-          pure (writeFileOps dest content Copy)+          pure (writeFileOps modName dest content Copy)  -- | Compile a Template step: read, render placeholders, write. compileTemplateStep ::   FilePath ->+  ModuleName ->   Map VarName VarValue ->   Step ->   IO (Either [Text] [Operation])-compileTemplateStep baseDir vars step = do+compileTemplateStep baseDir modName vars step = do   let srcPath = baseDir </> "files" </> (step ^. #src)   result <- tryReadFile srcPath   case result of@@ -183,15 +185,16 @@             Left placeholderErrors ->               pure (Left (map formatPlaceholderError placeholderErrors))             Right dest ->-              pure (writeFileOps dest rendered Template)+              pure (writeFileOps modName dest rendered Template)  -- | Compile a DhallText step: read source, substitute placeholders, evaluate as Dhall. compileDhallTextStep ::   FilePath ->+  ModuleName ->   Map VarName VarValue ->   Step ->   IO (Either [Text] [Operation])-compileDhallTextStep baseDir vars step = do+compileDhallTextStep baseDir modName vars step = do   let srcPath = baseDir </> "files" </> (step ^. #src)   result <- tryReadFile srcPath   case result of@@ -209,16 +212,17 @@                 Left placeholderErrors ->                   pure (Left (map formatPlaceholderError placeholderErrors))                 Right dest ->-                  pure (writeFileOps dest evaluated DhallText)+                  pure (writeFileOps modName dest evaluated DhallText)  -- | Compile a Structured step: read source, substitute placeholders, evaluate as Dhall, -- then serialize to JSON or YAML based on the destination file extension. compileStructuredStep ::   FilePath ->+  ModuleName ->   Map VarName VarValue ->   Step ->   IO (Either [Text] [Operation])-compileStructuredStep baseDir vars step = do+compileStructuredStep baseDir modName vars step = do   let srcPath = baseDir </> "files" </> (step ^. #src)   result <- tryReadFile srcPath   case result of@@ -239,12 +243,12 @@                     Left placeholderErrors ->                       pure (Left (map formatPlaceholderError placeholderErrors))                     Right dest ->-                      case validateRenderedDestination dest of+                      case validateRenderedDestination modName dest of                         Left err -> pure (Left [err])                         Right safeDest ->                           case serializeByExtension (T.unpack safeDest) jsonValue of                             Left err -> pure (Left [err])-                            Right serialized -> pure (Right (writeFileOpsUnchecked safeDest serialized Structured))+                            Right serialized -> pure (first pure (writeFileOpsUnchecked modName safeDest serialized Structured))  -- | Compile a patch step: read source, render template, produce PatchFileOp. -- The content rendering follows the step's strategy (Template renders placeholders,@@ -318,38 +322,52 @@       pure (Left ("Dhall evaluation failed: " <> T.pack (show e)))     Right t -> pure (Right t) -validateRenderedDestination :: Text -> Either Text Text-validateRenderedDestination dest =+-- | Validate a rendered step destination as a file the module writes. Beyond+-- staying inside the project, a module may write under @.seihou/@ only inside+-- @.seihou/<its own name>/@ and never into Seihou's or Git's control data.+-- See docs/adr/0025-a-module-owns-only-its-own-directory-under-seihou.md.+validateRenderedDestination :: ModuleName -> Text -> Either Text Text+validateRenderedDestination modName dest =   case validateProjectRelativePath dest of     Left err -> Left ("rendered destination " <> err)-    Right safePath -> Right (T.pack safePath)+    Right safePath -> case ownStateDirectoryViolation (modName ^. #unModuleName) FileTarget safePath of+      Just violation -> Left ("step destination " <> violation)+      Nothing -> Right (T.pack safePath) +-- | The 'CreateDirOp's for a validated destination's parent directories, each+-- checked as a directory operand of the module.+parentDirOps :: ModuleName -> Text -> Either Text [Operation]+parentDirOps modName dest = traverse checked (parentDirs dest)+  where+    checked dir = case ownStateDirectoryViolation (modName ^. #unModuleName) DirectoryOperand (T.unpack dir) of+      Just violation -> Left ("step directory " <> violation)+      Nothing -> Right (CreateDirOp (T.unpack dir))+ validateRenderedCommandWorkDir :: Text -> Either Text Text validateRenderedCommandWorkDir workDir =   case validateProjectRelativePath workDir of     Left err -> Left ("rendered command workDir " <> err)     Right safePath -> Right (T.pack safePath) -writeFileOps :: Text -> Text -> Strategy -> Either [Text] [Operation]-writeFileOps dest content strategy =-  case validateRenderedDestination dest of+writeFileOps :: ModuleName -> Text -> Text -> Strategy -> Either [Text] [Operation]+writeFileOps modName dest content strategy =+  case validateRenderedDestination modName dest of     Left err -> Left [err]-    Right safeDest -> Right (writeFileOpsUnchecked safeDest content strategy)+    Right safeDest -> first pure (writeFileOpsUnchecked modName safeDest content strategy) -writeFileOpsUnchecked :: Text -> Text -> Strategy -> [Operation]-writeFileOpsUnchecked dest content strategy =-  let destStr = T.unpack dest-      dirOps = map (CreateDirOp . T.unpack) (parentDirs dest)-   in dirOps ++ [WriteFileOp destStr content strategy]+-- | Operations for a destination 'validateRenderedDestination' already accepted.+writeFileOpsUnchecked :: ModuleName -> Text -> Text -> Strategy -> Either Text [Operation]+writeFileOpsUnchecked modName dest content strategy = do+  dirOps <- parentDirOps modName dest+  pure (dirOps ++ [WriteFileOp (T.unpack dest) content strategy])  patchFileOps :: Text -> Text -> PatchOp -> Strategy -> ModuleName -> Either [Text] [Operation] patchFileOps dest content patchOp' strategy modName =-  case validateRenderedDestination dest of+  case validateRenderedDestination modName dest of     Left err -> Left [err]-    Right safeDest ->-      let destStr = T.unpack safeDest-          dirOps = map (CreateDirOp . T.unpack) (parentDirs safeDest)-       in Right (dirOps ++ [PatchFileOp destStr content patchOp' strategy modName])+    Right safeDest -> do+      dirOps <- first pure (parentDirOps modName safeDest)+      Right (dirOps ++ [PatchFileOp (T.unpack safeDest) content patchOp' strategy modName])  -- | Extract parent directories from a path. -- @"src/Lib.hs"@ produces @["src"]@.
src/Seihou/Engine/Reconcile.hs view
@@ -19,6 +19,7 @@     resolveFileConflict,     resolveEditedOrphan,     reconciliationSummary,+    reconciliationDifferences,     reconciliationMutationPaths,     unresolvedPaths,     recordedSharedWriteMode,@@ -34,7 +35,7 @@ import Data.Text qualified as T import Data.Time (UTCTime) import Seihou.Core.Checkout (pathsOverlap)-import Seihou.Core.Path (pathsHaveConflictingSpelling, validateProjectRelativePath)+import Seihou.Core.Path (PathOperand (..), controlPathReason, pathsHaveConflictingSpelling, validateProjectRelativePath) import Seihou.Core.Seed (seedApplicationIds) import Seihou.Core.Types hiding (KeepCurrent) import Seihou.Effect.BaselineStore (BaselineError, BaselineStore, readBaseline)@@ -390,8 +391,8 @@ validateInputs selected operations ownerMap manifest = do   let grouped = groupFileOperations operations       directories = Set.fromList [path | CreateDirOp path <- operations]-  traverse_ validateManagedPath (Map.keys grouped)-  traverse_ validateManagedPath (Set.toList directories)+  traverse_ (validateManagedPath FileTarget) (Map.keys grouped)+  traverse_ (validateManagedPath DirectoryOperand) (Set.toList directories)   traverse_ (validateOwner selected ownerMap manifest) (Map.toList grouped)   pure (grouped, directories) @@ -427,26 +428,21 @@           | record ^. #sharedWriteMode == SharedWriteAdditiveOnly && all isAdditiveOperation pathOperations -> Right ()           | otherwise -> Left (SharedPathRequiresApplications path (record ^. #applicationIds)) -validateManagedPath :: FilePath -> Either ReconciliationError ()-validateManagedPath rawPath = case validateProjectRelativePath (T.pack rawPath) of+-- | Refuse paths that leave the project or name Seihou's or Git's control data.+-- A module's own directory under @.seihou/@ is an ordinary managed location;+-- the @.seihou@ directory itself may be created but never written as a file.+validateManagedPath :: PathOperand -> FilePath -> Either ReconciliationError ()+validateManagedPath operand rawPath = case validateProjectRelativePath (T.pack rawPath) of   Left err -> Left (InvalidReconciliationPath rawPath err)   Right safePath     | safePath /= rawPath ->         Left (InvalidReconciliationPath rawPath "path must not contain surrounding whitespace")     | safePath == "." ->         Left (InvalidReconciliationPath rawPath "path must name a project file or directory")-    | targetsControlPath safePath ->-        Left (InvalidReconciliationPath rawPath "path targets Seihou or Git control data")+    | Just reason <- controlPathReason operand safePath ->+        Left (InvalidReconciliationPath rawPath reason)     | otherwise -> Right () -targetsControlPath :: FilePath -> Bool-targetsControlPath path = case pathSegments path of-  firstSegment : _ -> firstSegment == ".seihou" || firstSegment == ".git"-  [] -> False--pathSegments :: FilePath -> [Text]-pathSegments = filter (not . T.null) . T.split (\character -> character == '/' || character == '\\') . T.pack- groupFileOperations :: [Operation] -> Map FilePath [Operation] groupFileOperations = foldl' addOperation Map.empty   where@@ -762,3 +758,58 @@     unresolved (FileConflict _ _ _ _ _ _ Nothing) = True     unresolved (FileOrphanEdited _ _ _ _ Nothing) = True     unresolved _ = False++-- | Explain exact plan inequality without disclosing file contents or hashes.+-- This is diagnostic only: callers must continue comparing complete plans.+reconciliationDifferences :: ReconciliationPlan -> ReconciliationPlan -> Map FilePath [Text]+reconciliationDifferences planned actual =+  Map.fromList+    [ (path, categories before after)+    | path <- Set.toAscList (Map.keysSet (planned ^. #files) `Set.union` Map.keysSet (actual ^. #files)),+      let before = Map.lookup path (planned ^. #files),+      let after = Map.lookup path (actual ^. #files),+      before /= after+    ]+    `Map.union` Map.fromSet (const ["metadata"]) directoryChanges+  where+    directoryChanges = ((planned ^. #requiredDirectories) Set.\\ (actual ^. #requiredDirectories)) `Set.union` ((actual ^. #requiredDirectories) Set.\\ (planned ^. #requiredDirectories))+    categories before after =+      let changes =+            ["classification" | fmap classification before /= fmap classification after]+              <> ["observed content" | fmap observation before /= fmap observation after]+              <> ["planned output" | fmap output before /= fmap output after]+       in if null changes then ["metadata"] else changes+    classification = \case+      FileCreate {} -> "create" :: Text+      FileUpdate {} -> "update"+      FileAutoMerge {} -> "auto-merge"+      FileUnchanged {} -> "unchanged"+      FileConflict {} -> "conflict"+      FileDeleteSafe {} -> "delete"+      FileOrphanEdited {} -> "edited-orphan"+      FileReleaseSharedOwnership {} -> "release-shared-ownership"+      FileAlreadyAbsent {} -> "already-absent"+      FileSeedCreate {} -> "seed-create"+      FileSeedSettle {} -> "seed-settle"+      FileSeedRelease {} -> "seed-release"+      FileSeedAbandon {} -> "seed-abandon"+    observation = \case+      FileCreate _ _ observed -> Just observed+      FileUpdate _ _ observed _ -> Just observed+      FileAutoMerge _ _ observed _ -> Just observed+      FileUnchanged _ _ observed _ -> Just observed+      FileConflict _ _ _ _ observed _ _ -> Just observed+      FileDeleteSafe _ _ observed -> Just observed+      FileOrphanEdited _ _ _ observed _ -> Just observed+      FileReleaseSharedOwnership _ _ observed -> Just observed+      FileAlreadyAbsent _ _ observed -> Just observed+      _ -> Nothing+    output = \case+      FileCreate desired state _ -> Just (desired ^. #generatedContent, state ^. #appliedContent)+      FileUpdate desired state _ _ -> Just (desired ^. #generatedContent, state ^. #appliedContent)+      FileAutoMerge desired state _ _ -> Just (desired ^. #generatedContent, state ^. #appliedContent)+      FileUnchanged desired state _ _ -> Just (desired ^. #generatedContent, state ^. #appliedContent)+      FileConflict desired current _ _ _ _ _ -> Just (desired ^. #generatedContent, current)+      FileOrphanEdited _ _ current _ _ -> Just ("", current)+      FileSeedCreate action _ -> ("",) <$> action ^. #newContent+      _ -> Nothing
src/Seihou/Engine/Remove.hs view
@@ -29,7 +29,7 @@ import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName) import Seihou.Core.Checkout import Seihou.Core.Expr (settleRemovalConditions)-import Seihou.Core.Path (pathsHaveConflictingSpelling, validateProjectRelativePath)+import Seihou.Core.Path (PathOperand (..), controlPathReason, pathsHaveConflictingSpelling, validateProjectRelativePath) import Seihou.Core.Seed import Seihou.Core.Types import Seihou.Effect.Filesystem (Filesystem, doesFileExist, readFileText, removeDirectoryIfEmpty, removeFile, writeFileText)@@ -220,12 +220,23 @@ buildCommandOp :: Command -> Either RemovalError RemovalOp buildCommandOp command   | command ^. #lifecycle == CheckoutCommandLifecycle = Left (RemovalUnsafePath "remove-command" "" "checkout setup is not a removal command")-  | otherwise = case traverse (validateRemovalPath "remove-command workDir") (command ^. #workDir) of+  | otherwise = case traverse (validateRemovalWorkDir "remove-command workDir") (command ^. #workDir) of       Left err -> Left err       Right safeWorkDir -> Right (RemovalCommandOp (command ^. #run) (fmap T.pack safeWorkDir)) +-- | Validate a path a removal step deletes, strips or rewrites. It must stay in+-- the project and must not name Seihou's or Git's control data or the+-- @.seihou@ directory itself. validateRemovalPath :: Text -> Text -> Either RemovalError FilePath-validateRemovalPath label path =+validateRemovalPath label path = do+  safePath <- validateRemovalWorkDir label path+  case controlPathReason FileTarget safePath of+    Just reason -> Left (RemovalUnsafePath label path reason)+    Nothing -> Right safePath++-- | A command's working directory need only stay in the project.+validateRemovalWorkDir :: Text -> Text -> Either RemovalError FilePath+validateRemovalWorkDir label path =   case validateProjectRelativePath path of     Left reason -> Left (RemovalUnsafePath label path reason)     Right safePath -> Right safePath
src/Seihou/Engine/UpdateTransaction.hs view
@@ -32,7 +32,7 @@ import Data.Text.Encoding qualified as TE import Data.Text.IO qualified as TIO import Data.Time (UTCTime, getCurrentTime)-import Seihou.Core.Path (validateProjectRelativePath)+import Seihou.Core.Path (PathOperand (..), controlPathReason, validateProjectRelativePath) import Seihou.Core.Seed (renderSeedOwnershipError, replaceSelectedSeedClaims) import Seihou.Core.Types import Seihou.Effect.BaselineStore (putBaseline)@@ -191,7 +191,8 @@         pure (JournalEntry relativePath (Just backupName) Nothing)       EntryMissing -> pure (JournalEntry relativePath Nothing Nothing)       EntryDirectory -> ioError (userError ("transaction target is a directory: " <> relativePath))-  -- The manifest is never a target (validateTransactionPath rejects .seihou/),+  -- The manifest is never a target (validateTransactionPath rejects Seihou's+  -- own state under .seihou/),   -- but an orchestrator may publish it before the transaction commits. A byte   -- copy lets rollback and crash recovery return it to this exact state.   verifySafeParents (transaction ^. #projectRoot) manifestRelativePath@@ -343,7 +344,7 @@                 Right () -> pure (Right candidate)  transactionPreflight :: UpdateTransaction -> ReconciliationPlan -> IO (Either TransactionError ())-transactionPreflight transaction plan = case traverse validateTransactionPath (Set.toAscList (plan ^. #requiredDirectories)) of+transactionPreflight transaction plan = case traverse validateTransactionDirectory (Set.toAscList (plan ^. #requiredDirectories)) of   Left err -> pure (Left err)   Right _     | not (Set.null unjournaled) -> pure (Left (TransactionUnjournaledPaths unjournaled))@@ -508,7 +509,8 @@       blueprint = manifest ^. #blueprint,       seeds = manifest ^. #seeds,       checkoutOutputs = manifest ^. #checkoutOutputs,-      blueprintMigrations = manifest ^. #blueprintMigrations+      blueprintMigrations = manifest ^. #blueprintMigrations,+      judgements = manifest ^. #judgements     }  writeBaselineBlob :: FilePath -> Text -> IO BaselineRef@@ -872,7 +874,7 @@   traverse_ validateBackupName [name | JournalEntry _ (Just name) _ <- metadata ^. #entries]   traverse_ validateBackupName (metadata ^. #manifestBackup)   traverse_ validateBaselineName (metadata ^. #setAsideBaselines)-  traverse_ (first renderTransactionPathError . validateTransactionPath) (metadata ^. #newDirectories)+  traverse_ (first renderTransactionPathError . validateTransactionDirectory) (metadata ^. #newDirectories)   pure metadata   where     validateObjectId oid@@ -900,23 +902,25 @@ journalPath :: FilePath -> FilePath journalPath transactionDirectory = transactionDirectory </> "journal.json" +-- | Validate a file the transaction journals, writes, or restores. validateTransactionPath :: FilePath -> Either TransactionError FilePath-validateTransactionPath rawPath = case validateProjectRelativePath (T.pack rawPath) of+validateTransactionPath = validateTransactionOperand FileTarget++-- | Validate a directory the transaction creates and may remove on rollback.+-- The @.seihou@ directory itself is allowed: it always exists while a+-- transaction does, so it is never recorded as newly created.+validateTransactionDirectory :: FilePath -> Either TransactionError FilePath+validateTransactionDirectory = validateTransactionOperand DirectoryOperand++validateTransactionOperand :: PathOperand -> FilePath -> Either TransactionError FilePath+validateTransactionOperand operand rawPath = case validateProjectRelativePath (T.pack rawPath) of   Left err -> Left (InvalidTransactionPath rawPath err)   Right safePath     | safePath /= rawPath -> Left (InvalidTransactionPath rawPath "path must not contain surrounding whitespace")     | safePath == "." -> Left (InvalidTransactionPath rawPath "path must name a project file")-    | targetsControlPath safePath ->-        Left (InvalidTransactionPath rawPath "path targets Seihou or Git control data")+    | Just reason <- controlPathReason operand safePath ->+        Left (InvalidTransactionPath rawPath reason)     | otherwise -> Right safePath--targetsControlPath :: FilePath -> Bool-targetsControlPath path = case pathSegments path of-  firstSegment : _ -> firstSegment == ".seihou" || firstSegment == ".git"-  [] -> False--pathSegments :: FilePath -> [Text]-pathSegments = filter (not . T.null) . T.split (\character -> character == '/' || character == '\\') . T.pack  atomicWriteText :: FilePath -> Text -> IO () atomicWriteText path content = do
src/Seihou/Engine/Validate.hs view
@@ -23,6 +23,7 @@     checkDestVarRefs,     checkExportRefs,     checkFileExistence,+    checkModuleStateDestinations,     checkNameFormat,     checkPromptRefs,     checkSafeDestinations,@@ -78,6 +79,7 @@           DiagCheck "Source file existence" DiagError fileErrors,           DiagCheck "Dependency names" DiagError (checkDependencyNames m),           DiagCheck "Safe step destinations" DiagError (checkSafeDestinations m),+          DiagCheck "Module state destinations" DiagError (checkModuleStateDestinations m),           DiagCheck "Checkout steps" DiagError (checkCheckoutSteps m),           DiagCheck "Seed steps" DiagError (checkSeedSteps m),           DiagCheck "Destination variable references" DiagError (checkDestVarRefs m),
+ src/Seihou/Manifest/Judgement.hs view
@@ -0,0 +1,184 @@+-- | The manifest encoding of judgement definitions and judgement policy.+--+-- The encoding is Seihou's own and is versioned by the manifest schema+-- (introduced in schema 15). No provider, Shikumi or transport type appears+-- in it: thresholds are plain numbers, enumerations are stable kebab-case+-- strings, and every key is always written so a diff is reviewable.+--+-- 'definitionDigest' hashes the canonical encoding of a definition, so the+-- digest identifies exactly the reviewed definition independently of the+-- Dhall source it was decoded from.+module Seihou.Manifest.Judgement+  ( definitionToJSON,+    parseDefinition,+    policyToJSON,+    parsePolicy,+    definitionDigest,+  )+where++import Control.Monad (forM_, unless)+import Data.Aeson (Value, object, withObject, (.:), (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Types (Parser)+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Seihou.Core.Judgement+import Seihou.Core.Types (SHA256)+import Seihou.Manifest.Hash (hashContent)+import Seihou.Prelude hiding ((.=))++-- | SHA-256 of the canonical encoding. Aeson writes object keys in sorted+-- order, so the bytes do not depend on construction order.+definitionDigest :: JudgementDefinition -> SHA256+definitionDigest = hashContent . TE.decodeUtf8 . LBS.toStrict . Aeson.encode . definitionToJSON++enumText :: (Bounded a, Enum a) => (a -> Text) -> Text -> Text -> Parser a+enumText render what t = case [x | x <- [minBound .. maxBound], render x == t] of+  [x] -> pure x+  _ -> fail ("unknown " <> T.unpack what <> " '" <> T.unpack t <> "'")++thresholdsToJSON :: Thresholds -> Value+thresholdsToJSON t = object ["blockAt" .= (t ^. #blockAt), "passAt" .= (t ^. #passAt)]++parseThresholds :: Value -> Parser Thresholds+parseThresholds = withObject "Thresholds" $ \o -> Thresholds <$> o .: "blockAt" <*> o .: "passAt"++definitionToJSON :: JudgementDefinition -> Value+definitionToJSON d =+  object+    [ "name" .= (d ^. #name),+      "version" .= (d ^. #version),+      "description" .= (d ^. #description),+      "instructions" .= (d ^. #instructions),+      "evidence"+        .= object+          [ "scopes" .= map renderScopeKind (d ^. #evidence . #scopes),+            "include" .= (d ^. #evidence . #include),+            "exclude" .= (d ^. #evidence . #exclude)+          ],+      "limits"+        .= object+          [ "maxFileBytes" .= (d ^. #limits . #maxFileBytes),+            "maxChunkBytes" .= (d ^. #limits . #maxChunkBytes),+            "maxChunks" .= (d ^. #limits . #maxChunks),+            "maxAttempts" .= (d ^. #limits . #maxAttempts),+            "timeoutSeconds" .= (d ^. #limits . #timeoutSeconds),+            "maxFindings" .= (d ^. #limits . #maxFindings)+          ],+      "deterministicChecks" .= map renderDeterministicCheck (d ^. #deterministicChecks),+      "checks" .= map checkToJSON (toList (d ^. #checks))+    ]+  where+    checkToJSON c =+      object+        [ "name" .= (c ^. #name),+          "question" .= (c ^. #question),+          "concernWhen" .= renderAnswer (c ^. #concernWhen),+          "yesMeans" .= (c ^. #yesMeans),+          "noMeans" .= (c ^. #noMeans),+          "categories"+            .= [ object ["name" .= (cat ^. #name), "severity" .= renderSeverity (cat ^. #severity), "description" .= (cat ^. #description)]+               | cat <- c ^. #categories+               ],+          "enforcement" .= renderEnforcement (c ^. #enforcement),+          "blockingSeverity" .= renderSeverity (c ^. #blockingSeverity),+          "review" .= thresholdsToJSON (c ^. #review),+          "gate" .= thresholdsToJSON (c ^. #gate),+          "useGate" .= (c ^. #useGate)+        ]++-- | Decode and structurally validate a definition snapshot.+parseDefinition :: Value -> Parser JudgementDefinition+parseDefinition = withObject "JudgementDefinition" $ \o -> do+  evidence <- o .: "evidence" >>= withObject "EvidenceDeclaration" (\e -> EvidenceDeclaration <$> (mapM (enumText renderScopeKind "evidence scope") =<< e .: "scopes") <*> e .: "include" <*> e .: "exclude")+  limits <-+    o .: "limits"+      >>= withObject+        "CoverageLimits"+        ( \l ->+            CoverageLimits+              <$> l .: "maxFileBytes"+              <*> l .: "maxChunkBytes"+              <*> l .: "maxChunks"+              <*> l .: "maxAttempts"+              <*> l .: "timeoutSeconds"+              <*> l .: "maxFindings"+        )+  rawChecks <- o .: "checks"+  checks <- maybe (fail "a judgement needs at least one check") pure . NE.nonEmpty =<< mapM parseCheck rawChecks+  d <-+    JudgementDefinition+      <$> o .: "name"+      <*> o .: "version"+      <*> o .: "description"+      <*> o .: "instructions"+      <*> pure evidence+      <*> pure limits+      <*> (mapM (enumText renderDeterministicCheck "deterministic check") =<< o .: "deterministicChecks")+      <*> pure checks+  either (fail . T.unpack . (^. #message)) (const (pure d)) (validateDefinition d)+  where+    parseCheck = withObject "JudgementCheck" $ \c ->+      JudgementCheck+        <$> c .: "name"+        <*> c .: "question"+        <*> (enumText renderAnswer "answer" =<< c .: "concernWhen")+        <*> c .: "yesMeans"+        <*> c .: "noMeans"+        <*> (mapM parseCategory =<< c .: "categories")+        <*> (enumText renderEnforcement "enforcement" =<< c .: "enforcement")+        <*> (enumText renderSeverity "severity" =<< c .: "blockingSeverity")+        <*> (parseThresholds =<< c .: "review")+        <*> (parseThresholds =<< c .: "gate")+        <*> c .: "useGate"+    parseCategory = withObject "FindingCategory" $ \cat ->+      FindingCategory <$> cat .: "name" <*> (enumText renderSeverity "severity" =<< cat .: "severity") <*> cat .: "description"++policyToJSON :: JudgementPolicy -> Value+policyToJSON p =+  object+    [ "dataPolicy" .= renderDataPolicy (p ^. #dataPolicy),+      "gatePermitted" .= (p ^. #gatePermitted),+      "checks" .= Map.map checkPolicyToJSON (p ^. #checks)+    ]+  where+    checkPolicyToJSON c =+      object+        [ "enforcement" .= renderEnforcement (c ^. #enforcement),+          "blockingSeverity" .= renderSeverity (c ^. #blockingSeverity),+          "review" .= thresholdsToJSON (c ^. #review),+          "gate" .= thresholdsToJSON (c ^. #gate),+          "useGate" .= (c ^. #useGate)+        ]++-- | Decode a policy and check it against the definition it governs: every+-- check has exactly one policy entry and every threshold pair is valid.+parsePolicy :: JudgementDefinition -> Value -> Parser JudgementPolicy+parsePolicy def = withObject "JudgementPolicy" $ \o -> do+  raw <- o .: "checks" :: Parser (Map Text Value)+  checks <- Map.traverseWithKey (const parseCheckPolicy) raw+  let declared = map (^. #name) (toList (def ^. #checks))+  unless (Map.keys checks == sortedUnique declared) $+    fail ("judgement '" <> T.unpack (def ^. #name) <> "' policy must name exactly its checks: " <> T.unpack (T.intercalate ", " declared))+  forM_ (Map.toList checks) $ \(name, c) -> do+    either (fail . T.unpack . (^. #message)) pure (validateThresholds ("check '" <> name <> "' review") (c ^. #review))+    either (fail . T.unpack . (^. #message)) pure (validateThresholds ("check '" <> name <> "' gate") (c ^. #gate))+  JudgementPolicy+    <$> (enumText renderDataPolicy "data policy" =<< o .: "dataPolicy")+    <*> o .: "gatePermitted"+    <*> pure checks+  where+    sortedUnique = Map.keys . Map.fromList . map (,())+    parseCheckPolicy = withObject "CheckPolicy" $ \c ->+      CheckPolicy+        <$> (enumText renderEnforcement "enforcement" =<< c .: "enforcement")+        <*> (enumText renderSeverity "severity" =<< c .: "blockingSeverity")+        <*> (parseThresholds =<< c .: "review")+        <*> (parseThresholds =<< c .: "gate")+        <*> c .: "useGate"
src/Seihou/Manifest/Types.hs view
@@ -41,6 +41,7 @@ import Seihou.Core.Types import Seihou.Manifest.Hash (baselineRefFromText) import Seihou.Manifest.JSON (decodeManifestValue)+import Seihou.Manifest.Judgement (definitionDigest, definitionToJSON, parseDefinition, parsePolicy, policyToJSON) import Seihou.Prelude hiding ((.=)) import System.FilePath (takeFileName) @@ -86,8 +87,10 @@ -- Schema 14 records conditional removal policies and single-root closure retirement. -- Missing removal conditions remain unconditional; 13 -> 14 adds null instance -- policies where accepted typed evidence is unknown.+-- Schema 15 records active judgements: a definition snapshot, its digest and+-- the project policy. 14 -> 15 adds an empty judgement list. currentManifestVersion :: ManifestSchemaVersion-currentManifestVersion = ManifestSchemaVersion 14+currentManifestVersion = ManifestSchemaVersion 15  -- | The oldest schema the ordinary decoder reads. Anything older records -- machine-specific absolute paths and must go through the explicit@@ -107,6 +110,7 @@ minimumManifestVersion SeedOwnerClaims = ManifestSchemaVersion 11 minimumManifestVersion CheckoutLocalOutputs = ManifestSchemaVersion 9 minimumManifestVersion CheckoutLocalCommands = ManifestSchemaVersion 10+minimumManifestVersion JudgementState = ManifestSchemaVersion 15  -- | Whether a manifest's schema can express everything a capability needs. -- A decoded schema-6 manifest answers 'False' for@@ -130,7 +134,8 @@       blueprint = Nothing,       seeds = Map.empty,       checkoutOutputs = Map.empty,-      blueprintMigrations = []+      blueprintMigrations = [],+      judgements = []     }  -- | Record an applied-blueprint provenance on a manifest, replacing any@@ -150,7 +155,8 @@       blueprint = Just ab,       seeds = m ^. #seeds,       checkoutOutputs = m ^. #checkoutOutputs,-      blueprintMigrations = m ^. #blueprintMigrations+      blueprintMigrations = m ^. #blueprintMigrations,+      judgements = m ^. #judgements     }  -- | Insert or replace one exact blueprint migration receipt. Replacement is@@ -174,7 +180,8 @@       blueprint = manifest ^. #blueprint,       seeds = manifest ^. #seeds,       checkoutOutputs = manifest ^. #checkoutOutputs,-      blueprintMigrations = upsert (manifest ^. #blueprintMigrations)+      blueprintMigrations = upsert (manifest ^. #blueprintMigrations),+      judgements = manifest ^. #judgements     }   where     sameEdge existing =@@ -235,6 +242,7 @@       ]         ++ ["checkoutOutputs" .= Map.map checkoutToJSON (m ^. #checkoutOutputs) | m ^. #version >= ManifestSchemaVersion 9]         ++ ["seeds" .= Map.map (seedToJSON (m ^. #version)) (m ^. #seeds) | m ^. #version >= ManifestSchemaVersion 8]+        ++ ["judgements" .= map activeJudgementToJSON (m ^. #judgements) | manifestSupports JudgementState m]         ++ maybe [] (\r -> ["recipe" .= r]) (m ^. #recipe)         ++ maybe [] (\b -> ["blueprint" .= b]) (m ^. #blueprint) @@ -257,6 +265,9 @@         <*> o Aeson..:? "recipe"         <*> o Aeson..:? "blueprint"         <*> o Aeson..:? "blueprintMigrations" Aeson..!= []+        <*> (if v >= minimumManifestVersion JudgementState then mapM parseActiveJudgement =<< o .: "judgements" else pure [])+    let judgementNames = map (^. #definition . #name) (manifest ^. #judgements)+    unless (Set.size (Set.fromList judgementNames) == length judgementNames) $ fail "manifest records the same judgement name more than once"     unless (manifestSupports ConditionalRemovalPolicies manifest || not (hasConditionalRemoval manifest || hasRemovalPolicy manifest)) $ fail "conditional removal policies require manifest schema 14"     case validateManifestInvariants manifest of       Left err -> fail (T.unpack (renderManifestInvariantError err))@@ -937,6 +948,7 @@     <> ["removal commands cannot declare checkout setup" | applied <- manifest ^. #modules, removal <- maybe [] pure (applied ^. #removal), any ((== CheckoutCommandLifecycle) . (^. #lifecycle)) (removal ^. #commands)]     <> ["checkout commands require manifest schema 10" | manifest ^. #version < ManifestSchemaVersion 10, any (not . null . (^. #checkoutCommands)) (manifest ^. #applications)]     <> ["checkout outputs require manifest schema 9" | manifest ^. #version < ManifestSchemaVersion 9, not (Map.null (manifest ^. #checkoutOutputs))]+    <> ["judgements require manifest schema 15" | not (manifestSupports JudgementState manifest), not (null (manifest ^. #judgements))]     <> ["seed receipts require manifest schema 8" | manifest ^. #version < ManifestSchemaVersion 8, not (Map.null (manifest ^. #seeds))]  checkoutToJSON :: CheckoutOutputRecord -> Aeson.Value@@ -989,3 +1001,49 @@       unless (command ^. #occurrence >= 0 && not (T.null (T.strip (command ^. #command)))) (fail "invalid checkout command occurrence or empty shell text")       forM_ (command ^. #workDir) $ \path ->         unless (path == ".") $ either (fail . T.unpack) pure (validateCanonicalProjectPath (T.pack path))++activeJudgementToJSON :: ActiveJudgement -> Aeson.Value+activeJudgementToJSON j =+  Aeson.object+    [ "source" .= case j ^. #source of+        BundledJudgement -> Aeson.object ["kind" .= ("bundled" :: Text)]+        ArtifactJudgement origin -> Aeson.object ["kind" .= ("artifact" :: Text), "origin" .= origin],+      "definition" .= definitionToJSON (j ^. #definition),+      "digest" .= (j ^. #digest . #unSHA256),+      "policy" .= policyToJSON (j ^. #policy),+      "owners" .= map ownerToJSON (j ^. #owners)+    ]+  where+    ownerToJSON = \case+      DirectAdoption -> Aeson.object ["kind" .= ("direct" :: Text)]+      ApplicationOwner a -> Aeson.object ["kind" .= ("application" :: Text), "applicationId" .= (a ^. #unApplicationId)]++-- | A recorded snapshot whose digest no longer matches its definition was+-- edited by hand; refuse it rather than check against unreviewed policy.+parseActiveJudgement :: Aeson.Value -> Aeson.Parser ActiveJudgement+parseActiveJudgement = Aeson.withObject "ActiveJudgement" $ \o -> do+  source <-+    o .: "source"+      >>= Aeson.withObject+        "JudgementSource"+        ( \s ->+            (s .: "kind" :: Aeson.Parser Text) >>= \case+              "bundled" -> pure BundledJudgement+              "artifact" -> ArtifactJudgement <$> s .: "origin"+              other -> fail ("unknown judgement source kind: " <> T.unpack other)+        )+  definition <- parseDefinition =<< o .: "definition"+  recorded <- o .: "digest"+  let actual = definitionDigest definition+  unless (T.toLower recorded == actual ^. #unSHA256) $+    fail ("judgement '" <> T.unpack (definition ^. #name) <> "' does not match its recorded digest; re-apply it with 'seihou run " <> T.unpack (definition ^. #name) <> "'")+  policy <- parsePolicy definition =<< o .: "policy"+  owners <- mapM parseOwner =<< o .: "owners"+  when (null owners) $ fail ("judgement '" <> T.unpack (definition ^. #name) <> "' has no owners; remove it from the manifest or re-apply it")+  pure (ActiveJudgement source definition actual policy owners)+  where+    parseOwner = Aeson.withObject "JudgementOwner" $ \w ->+      (w .: "kind" :: Aeson.Parser Text) >>= \case+        "direct" -> pure DirectAdoption+        "application" -> ApplicationOwner . ApplicationId <$> w .: "applicationId"+        other -> fail ("unknown judgement owner kind: " <> T.unpack other)
src/Seihou/Manifest/Upgrade.hs view
@@ -64,6 +64,7 @@   | AddUnknownRecipeChain   | MakeSeedOwnershipExplicit   | AddEmptyCheckoutCommands+  | AddEmptyJudgements   deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)  -- | Whether a step may be staged automatically.@@ -128,7 +129,8 @@     step 10 MakeSeedOwnershipExplicit "explicit seed instance ownership",     step 11 AddUnknownRecipeInputs "recipe inputs",     step 12 AddUnknownRecipeChain "recipe inheritance provenance",-    step 13 IntroduceConditionalRemoval "conditional removal policies and recorded root retirement"+    step 13 IntroduceConditionalRemoval "conditional removal policies and recorded root retirement",+    step 14 AddEmptyJudgements "active judgements"   ]   where     step from stepAction =@@ -145,6 +147,7 @@ upgradeStepKind AddEmptyCheckoutOutputs = LosslessUpgrade upgradeStepKind AddEmptyCheckoutCommands = LosslessUpgrade upgradeStepKind MakeSeedOwnershipExplicit = LosslessUpgrade+upgradeStepKind AddEmptyJudgements = LosslessUpgrade  -- | The contiguous steps from @source@ to @target@. Planning from a version -- to itself is an empty path.@@ -219,6 +222,12 @@       Aeson.Object fields -> Right (Aeson.Object (KeyMap.insert "applications" (addInputs (fromMaybe (Aeson.Array mempty) (KeyMap.lookup "applications" fields))) fields))       _ -> Right document     StampVersion -> Right document+    AddEmptyJudgements -> case document of+      Aeson.Object fields -> case KeyMap.lookup "judgements" fields of+        Nothing -> Right (Aeson.Object (KeyMap.insert "judgements" (Aeson.toJSON ([] :: [Aeson.Value])) fields))+        Just (Aeson.Array xs) | null xs -> Right document+        _ -> Left (MalformedDocument actual "schema 14 cannot contain judgements")+      _ -> Left (MalformedDocument actual "manifest must be an object")     MakeSeedOwnershipExplicit -> first (MalformedDocument actual) (makeSeedOwnershipExplicit document)     AddEmptyCheckoutCommands -> case document of       Aeson.Object fields -> do
test/Main.hs view
@@ -18,9 +18,12 @@ import Seihou.Core.EntailmentSpec qualified as EntailmentSpec import Seihou.Core.ExprSpec qualified as ExprSpec import Seihou.Core.InstallSpec qualified as InstallSpec+import Seihou.Core.JudgementArtifactSpec qualified as JudgementArtifactSpec+import Seihou.Core.JudgementSpec qualified as JudgementSpec import Seihou.Core.ListSpec qualified as ListSpec import Seihou.Core.MigrationSpec qualified as MigrationSpec import Seihou.Core.ModuleSpec qualified as ModuleSpec+import Seihou.Core.PathSpec qualified as PathSpec import Seihou.Core.RecipeSpec qualified as RecipeSpec import Seihou.Core.RegistryEmitSpec qualified as RegistryEmitSpec import Seihou.Core.RegistrySpec qualified as RegistrySpec@@ -92,9 +95,12 @@   entailmentTests <- EntailmentSpec.tests   exprTests <- ExprSpec.tests   installTests <- InstallSpec.tests+  judgementTests <- JudgementSpec.tests+  judgementArtifactTests <- JudgementArtifactSpec.tests   listTests <- ListSpec.tests   migrationTests <- MigrationSpec.tests   moduleTests <- ModuleSpec.tests+  pathTests <- PathSpec.tests   recipeTests <- RecipeSpec.tests   registryTests <- RegistrySpec.tests   registryEmitTests <- RegistryEmitSpec.tests@@ -144,4 +150,4 @@   confirmTests <- ConfirmSpec.tests   seedTransactionTests <- SeedTransaction.tests   seedEngineTests <- SeedEngine.tests-  defaultMain (testGroup "seihou-core" [seedTransactionTests, seedEngineTests, graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactIdentityTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, entailmentTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, checkoutEngineTests, checkoutTests, seedTests, schemaUpgradeTests, statusTests, variableTests, versionTests, templateTests, threeWayMergeTests, updateTransactionTests, planTests, previewTests, reconcileTests, sectionTests, validateTests, splitFlakeTests, dhallTextFlakeTests, typedDhallTextTests, conditionalTemplateTests, configTests, dhallEvalTests, migrationDecoderTests, configReaderTests, configWriterTests, baselineStoreTests, filesystemTests, loggerTests, manifestStoreTests, conflictTests, baselineTests, diffTests, executeTests, engineMigrateTests, removeTests, compositionTests, executionTests, integrationTests, generationTests, manifestTypesTests, manifestUpgradeTests, promptTests, confirmTests])+  defaultMain (testGroup "seihou-core" [seedTransactionTests, seedEngineTests, graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactIdentityTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, entailmentTests, exprTests, installTests, judgementTests, judgementArtifactTests, listTests, migrationTests, moduleTests, pathTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, checkoutEngineTests, checkoutTests, seedTests, schemaUpgradeTests, statusTests, variableTests, versionTests, templateTests, threeWayMergeTests, updateTransactionTests, planTests, previewTests, reconcileTests, sectionTests, validateTests, splitFlakeTests, dhallTextFlakeTests, typedDhallTextTests, conditionalTemplateTests, configTests, dhallEvalTests, migrationDecoderTests, configReaderTests, configWriterTests, baselineStoreTests, filesystemTests, loggerTests, manifestStoreTests, conflictTests, baselineTests, diffTests, executeTests, engineMigrateTests, removeTests, compositionTests, executionTests, integrationTests, generationTests, manifestTypesTests, manifestUpgradeTests, promptTests, confirmTests])
test/Seihou/Core/ArtifactIdentitySpec.hs view
@@ -3,7 +3,8 @@ import Control.Monad (forM_) import Data.Text (Text) import Data.Text qualified as T-import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl, normalizeOriginUrl)+import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl, normalizeOriginUrl, repositoryOriginKey, sameArtifactIdentity, sameRepositoryOrigin)+import Seihou.Core.Types (ArtifactOrigin (..)) import Test.Hspec import Test.Tasty import Test.Tasty.Hspec (testSpec)@@ -49,14 +50,193 @@         isMachineLocalOriginUrl url `shouldBe` False    describe "normalizeOriginUrl" $ do-    it "recognizes public GitHub SSH and HTTPS transports as the same repository" $-      map-        normalizeOriginUrl-        [ "git@github.com:shinzui/seihou-modules.git",-          "ssh://git@github.com/shinzui/seihou-modules.git/",-          "https://github.com/shinzui/seihou-modules/"+    it "keeps the cosmetic .git and trailing-slash spellings together" $+      map normalizeOriginUrl ["https://host/repo", "https://host/repo.git", "https://host/repo/", " https://host/repo.git/ "]+        `shouldBe` replicate 4 "https://host/repo"+    it "does not equate transports, so connection-specific keys stay specific" $+      normalizeOriginUrl "git@github.com:shinzui/seihou-modules.git" `shouldNotBe` normalizeOriginUrl "https://github.com/shinzui/seihou-modules"+    it "does not erase a separate .git path component" $+      normalizeOriginUrl "https://host/owner/repo/.git" `shouldNotBe` normalizeOriginUrl "https://host/owner/repo"++  describe "sameRepositoryOrigin" $ do+    forM_ equivalenceClasses $ \(label, members) ->+      describe label $+        forM_ [(a, b) | a <- members, b <- members] $ \(a, b) ->+          it (T.unpack a <> " ~ " <> T.unpack b) $ sameRepositoryOrigin a b `shouldBe` True++    forM_ distinctPairs $ \(a, b) ->+      it (T.unpack a <> " differs from " <> T.unpack b) $ do+        sameRepositoryOrigin a b `shouldBe` False+        sameRepositoryOrigin b a `shouldBe` False++    it "keeps every class apart from every other class" $+      and+        [ not (sameRepositoryOrigin a b)+        | (i, (_, left)) <- indexed,+          (j, (_, right)) <- indexed,+          i /= j,+          a <- left,+          b <- right         ]-        `shouldBe` replicate 3 "https://github.com/shinzui/seihou-modules"-    it "keeps different repositories and hosts distinct" $ do-      normalizeOriginUrl "git@github.com:other/seihou-modules.git" `shouldNotBe` normalizeOriginUrl "https://github.com/shinzui/seihou-modules"-      normalizeOriginUrl "git@git.example:shinzui/seihou-modules.git" `shouldNotBe` normalizeOriginUrl "https://git.example/shinzui/seihou-modules"+        `shouldBe` True++    it "is reflexive, symmetric and transitive over every sample" $ do+      let samples = concatMap snd equivalenceClasses <> concatMap (\(a, b) -> [a, b]) distinctPairs+      all (\a -> sameRepositoryOrigin a a) samples `shouldBe` True+      and [sameRepositoryOrigin a b == sameRepositoryOrigin b a | a <- samples, b <- samples] `shouldBe` True+      and+        [ sameRepositoryOrigin a c+        | a <- samples,+          b <- samples,+          sameRepositoryOrigin a b,+          c <- samples,+          sameRepositoryOrigin b c+        ]+        `shouldBe` True++    it "never lets malformed or hostile input become the recognised GitHub repository" $+      forM_ (concatMap (\(a, b) -> [a, b]) distinctPairs) $ \candidate ->+        if candidate `elem` reportedClass+          then pure ()+          else repositoryOriginKey candidate `shouldNotBe` repositoryOriginKey reportedHttps++  describe "sameArtifactIdentity" $ do+    it "treats the reported SSH and HTTPS records of one module as one artifact" $+      sameArtifactIdentity+        (RemoteOrigin "git@github.com:shinzui/seihou-modules.git" "git-init" Nothing)+        (RemoteOrigin reportedHttps "git-init" (Just "seihou-modules"))+        `shouldBe` True+    it "still requires the artifact name to agree" $+      sameArtifactIdentity+        (RemoteOrigin "git@github.com:shinzui/seihou-modules.git" "git-init" Nothing)+        (RemoteOrigin reportedHttps "haskell-base" Nothing)+        `shouldBe` False+    it "keeps project and local origins unchanged" $ do+      sameArtifactIdentity (ProjectOrigin "./.seihou/modules/a/") (ProjectOrigin ".seihou/modules/a") `shouldBe` True+      sameArtifactIdentity (LocalOrigin "a") (LocalOrigin "a") `shouldBe` True+      sameArtifactIdentity (LocalOrigin "a") (RemoteOrigin "a" "a" Nothing) `shouldBe` False+  where+    indexed = zip [0 :: Int ..] equivalenceClasses++reportedHttps :: Text+reportedHttps = "https://github.com/shinzui/seihou-modules.git"++reportedClass :: [Text]+reportedClass = maybe [] id (lookup "public GitHub transports of one repository" equivalenceClasses)++-- | Each list is one repository; every member must equal every other member+-- and no member of any other list.+equivalenceClasses :: [(String, [Text])]+equivalenceClasses =+  [ ( "public GitHub transports of one repository",+      [ "git@github.com:shinzui/seihou-modules.git",+        reportedHttps,+        "https://github.com/shinzui/seihou-modules",+        "https://github.com/shinzui/seihou-modules/",+        "https://github.com/shinzui/seihou-modules.git/",+        "  https://github.com/shinzui/seihou-modules  ",+        "HTTPS://GitHub.COM/shinzui/seihou-modules",+        "https://github.com:443/shinzui/seihou-modules",+        "https://token@github.com/shinzui/seihou-modules.git",+        "ssh://git@github.com/shinzui/seihou-modules.git",+        "ssh://git@github.com:22/shinzui/seihou-modules",+        "ssh://git@ssh.github.com:443/shinzui/seihou-modules.git",+        "git@GitHub.com:shinzui/seihou-modules/"+      ]+    ),+    ( "a generic HTTPS repository with cosmetic spellings",+      [ "https://git.example/team/repo",+        "https://git.example/team/repo.git",+        "https://GIT.example:443/team/repo/"+      ]+    ),+    ( "a generic SSH URL with an explicit default port",+      [ "ssh://alice@git.example/srv/repo.git",+        "ssh://alice@git.example:22/srv/repo"+      ]+    ),+    ( "a generic relative scp path",+      ["alice@git.example:repo.git", "alice@GIT.EXAMPLE:repo/"]+    ),+    ( "a git-protocol URL with its default port",+      ["git://git.example/repo.git", "git://git.example:9418/repo"]+    ),+    ( "a bracketed IPv6 authority",+      ["ssh://git@[2001:db8::1]:2222/repo.git", "ssh://git@[2001:DB8::1]:2222/repo"]+    ),+    ( "an opaque local path",+      ["/srv/repos/modules", "/srv/repos/modules.git/"]+    )+  ]++-- | Pairs that must never compare equal, in either direction.+distinctPairs :: [(Text, Text)]+distinctPairs =+  [ ("git@github.com:other/seihou-modules.git", reportedHttps),+    ("git@github.com:shinzui/other.git", reportedHttps),+    ("https://github.com/Shinzui/seihou-modules", reportedHttps),+    ("git@git.example:shinzui/seihou-modules.git", "https://git.example/shinzui/seihou-modules"),+    ("http://github.com/shinzui/seihou-modules", reportedHttps),+    ("git://github.com/shinzui/seihou-modules.git", reportedHttps),+    ("https://github.com.evil.example/shinzui/seihou-modules.git", reportedHttps),+    ("https://github.com@evil.example/shinzui/seihou-modules.git", reportedHttps),+    ("https://www.github.com/shinzui/seihou-modules", reportedHttps),+    ("https://github.com:8443/shinzui/seihou-modules", reportedHttps),+    ("https://github.com:/shinzui/seihou-modules", reportedHttps),+    ("https://github.com:0/shinzui/seihou-modules", reportedHttps),+    ("https://github.com:65536/shinzui/seihou-modules", reportedHttps),+    ("https://github.com:4x3/shinzui/seihou-modules", reportedHttps),+    ("ssh://github.com/shinzui/seihou-modules.git", reportedHttps),+    ("ssh://alice@github.com/shinzui/seihou-modules.git", reportedHttps),+    ("ssh://git@github.com:2222/shinzui/seihou-modules.git", reportedHttps),+    ("ssh://git@ssh.github.com/shinzui/seihou-modules.git", reportedHttps),+    ("ssh://git@ssh.github.com:22/shinzui/seihou-modules.git", reportedHttps),+    ("alice@github.com:shinzui/seihou-modules.git", reportedHttps),+    ("github.com:shinzui/seihou-modules.git", reportedHttps),+    ("git@github.com:/shinzui/seihou-modules.git", reportedHttps),+    ("git@github.com:~/shinzui/seihou-modules.git", reportedHttps),+    ("https://github.com/shinzui/seihou-modules/.git", reportedHttps),+    ("https://github.com/shinzui/seihou-modules/extra", reportedHttps),+    ("https://github.com//shinzui/seihou-modules", reportedHttps),+    ("https://github.com/shinzui//seihou-modules", reportedHttps),+    ("https://github.com/shinzui/seihou-modules?", reportedHttps),+    ("https://github.com/shinzui/seihou-modules#", reportedHttps),+    ("https://github.com/shinzui/seihou-modules?ref=main", reportedHttps),+    ("https://github.com/shinzui/seihou%2Dmodules", reportedHttps),+    ("https://github.com/shinzui/seihou modules", reportedHttps),+    ("https://github.com/shinzui/..", "https://github.com/shinzui/."),+    ("https://github.com/owner/.git", "https://github.com/owner"),+    ("https:///shinzui/seihou-modules", reportedHttps),+    ("https://github.com", reportedHttps),+    ("seihou-modules", reportedHttps),+    ("ftp://github.com/shinzui/seihou-modules", reportedHttps),+    ("file:///srv/shinzui/seihou-modules", reportedHttps),+    ("/srv/shinzui/seihou-modules", "ssh://git@github.com/srv/shinzui/seihou-modules"),+    ("C:/src/seihou-modules", "c:/src/seihou-modules.git"),+    ("https://example.com/repo?ref=topic.git", "https://example.com/repo?ref=topic"),+    ("https://example.com/repo?", "https://example.com/repo"),+    ("https://example.com/repo#", "https://example.com/repo"),+    ("https://example.com/Repo", "https://example.com/repo"),+    ("https://example.com/repo/.git", "https://example.com/repo"),+    ("https://example.com/a%2Fb", "https://example.com/a/b"),+    ("https://example.com/a/./b", "https://example.com/a/b"),+    ("https://example.com/a//b", "https://example.com/a/b"),+    ("https://example.com:8443/repo", "https://example.com/repo"),+    ("https://alice@example.com/repo", "https://example.com/repo"),+    ("ssh://alice@git.example/repo", "ssh://bob@git.example/repo"),+    ("ssh://alice@git.example/repo", "ssh://git.example/repo"),+    ("ssh://alice@git.example:2222/repo", "ssh://alice@git.example:2223/repo"),+    ("ssh://alice@git.example/repo", "https://git.example/repo"),+    ("http://example.com/repo", "https://example.com/repo"),+    ("http://example.com:443/repo", "https://example.com/repo"),+    ("alice@git.example:repo", "ssh://alice@git.example/repo"),+    ("alice@git.example:/srv/repo", "ssh://alice@git.example/srv/repo"),+    ("alice@git.example:/srv/repo", "alice@git.example:srv/repo"),+    ("alice@git.example:repo", "bob@git.example:repo"),+    ("alice@git.example:repo", "git.example:repo"),+    ("alice@git.example:repo", "https://git.example/repo"),+    ("ssh://git@[2001:db8::1]/repo", "ssh://git@[2001:db8::2]/repo"),+    ("ssh://git@[2001:db8::1/repo", "ssh://git@[2001:db8::1]/repo"),+    ("svn+ssh://git.example/repo", "ssh://git.example/repo"),+    ("unknown://github.com/shinzui/seihou-modules", reportedHttps)+  ]
+ test/Seihou/Core/JudgementArtifactSpec.hs view
@@ -0,0 +1,194 @@+-- | Judgements as ordinary artifacts: registry entries, registry validation+-- and synchronization, discovery, enumeration, and the ambiguity rule for a+-- @judgement.dhall@ that shares its directory with another definition file.+module Seihou.Core.JudgementArtifactSpec (tests) where++import Data.Generics.Labels ()+import Data.List (isInfixOf)+import Data.Text qualified as T+import Seihou.Core.Module (DiscoveredRunnable (..), RunnableKind (..), discoverAllRunnables, discoverRunnable)+import Seihou.Core.Registry+import Seihou.Core.Types (ModuleLoadError (..), ModuleName (..), Runnable (..))+import Seihou.Dhall.Eval (evalRegistryFromFile)+import Seihou.Prelude+import System.Directory (createDirectoryIfMissing, getCurrentDirectory)+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.Core.JudgementArtifact" spec++entry :: Text -> FilePath -> RegistryEntry+entry n p = RegistryEntry (ModuleName n) Nothing p Nothing []++registry :: [RegistryEntry] -> [RegistryEntry] -> Registry+registry mods judgements =+  Registry+    { repoName = "fixture",+      repoDescription = Nothing,+      modules = mods,+      recipes = [],+      blueprints = [],+      prompts = [],+      judgements = judgements+    }++-- | A minimal judgement definition importing the local schema.+writeJudgement :: FilePath -> Text -> Text -> IO ()+writeJudgement dir name version = do+  cwd <- getCurrentDirectory+  createDirectoryIfMissing True dir+  writeFile (dir </> "judgement.dhall") $+    "let S = "+      <> cwd+      <> "/../schema/package.dhall in S.Judgement::{ name = \""+      <> T.unpack name+      <> "\", version = Some \""+      <> T.unpack version+      <> "\", description = Some \"Guards data\", checks = [ S.Check::{ name = \"c\", question = \"q?\" } ] }"++writeModule :: FilePath -> Text -> IO ()+writeModule dir name = do+  cwd <- getCurrentDirectory+  createDirectoryIfMissing True dir+  writeFile (dir </> "module.dhall") ("let S = " <> cwd <> "/../schema/package.dhall in S.Module::{ name = \"" <> T.unpack name <> "\" }")++spec :: Spec+spec = do+  describe "registry judgements" $ do+    it "decodes a registry with a judgement entry" $+      withSystemTempDirectory "judgement-registry" $ \tmp -> do+        writeFile (tmp </> "seihou-registry.dhall") $+          "{ repoName = \"r\", repoDescription = None Text\n\+          \, modules = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }\n\+          \, judgements = [ { name = \"guard\", version = Some \"0.1.0\", path = \"judgements/guard\", description = Some \"Guards data\", tags = [ \"review\" ] } ]\n\+          \}"+        result <- evalRegistryFromFile (tmp </> "seihou-registry.dhall")+        case result of+          Left err -> expectationFailure (show err)+          Right reg -> do+            reg ^. #judgements `shouldBe` [RegistryEntry (ModuleName "guard") (Just "0.1.0") "judgements/guard" (Just "Guards data") ["review"]]+            reg ^. #prompts `shouldBe` []++    it "decodes a legacy registry without a judgements field as an empty list" $+      withSystemTempDirectory "judgement-registry-legacy" $ \tmp -> do+        writeFile (tmp </> "seihou-registry.dhall") $+          "{ repoName = \"r\", repoDescription = None Text\n\+          \, modules = [ { name = \"m\", version = None Text, path = \"m\", description = None Text, tags = [] : List Text } ]\n\+          \, recipes = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }\n\+          \, blueprints = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }\n\+          \, prompts = [] : List { name : Text, version : Optional Text, path : Text, description : Optional Text, tags : List Text }\n\+          \}"+        result <- evalRegistryFromFile (tmp </> "seihou-registry.dhall")+        fmap (^. #judgements) result `shouldBe` Right []++    it "round-trips judgement entries through renderRegistryDhall" $+      withSystemTempDirectory "judgement-registry-emit" $ \tmp -> do+        let reg = registry [entry "m" "m"] [RegistryEntry (ModuleName "guard") (Just "0.1.0") "guard" (Just "Guards") ["a"]]+        writeFile (tmp </> "seihou-registry.dhall") (T.unpack (renderRegistryDhall reg))+        evalRegistryFromFile (tmp </> "seihou-registry.dhall") `shouldReturn` Right reg++    it "accepts a well-formed judgement entry" $+      withSystemTempDirectory "judgement-validate-ok" $ \tmp -> do+        writeJudgement (tmp </> "guard") "guard" "0.1.0"+        validateRegistry tmp (registry [] [entry "guard" "guard"]) `shouldReturn` []++    it "reports a judgement entry whose judgement.dhall is missing" $+      withSystemTempDirectory "judgement-validate-missing" $ \tmp -> do+        errs <- validateRegistry tmp (registry [] [entry "ghost" "ghost"])+        errs `shouldBe` ["registry judgement entry 'ghost' points to missing judgement.dhall at ghost"]++    it "reports an invalid name and an unsafe path" $+      withSystemTempDirectory "judgement-validate-unsafe" $ \tmp -> do+        errs <- validateRegistry tmp (registry [] [entry "Bad_Name" "../escape"])+        errs `shouldSatisfy` elem "registry judgement name must match [a-z][a-z0-9-]*, got: Bad_Name"+        errs `shouldSatisfy` elem "registry judgement path must not contain '..': ../escape"++    it "reports a judgement whose name collides with a module" $+      withSystemTempDirectory "judgement-validate-collision" $ \tmp -> do+        writeModule (tmp </> "modules" </> "shared") "shared"+        writeJudgement (tmp </> "judgements" </> "shared") "shared" "0.1.0"+        errs <- validateRegistry tmp (registry [entry "shared" "modules/shared"] [entry "shared" "judgements/shared"])+        errs `shouldBe` ["name collision: 'shared' appears as both a module and a judgement"]++    it "reports an entry directory where judgement.dhall sits next to module.dhall" $+      withSystemTempDirectory "judgement-validate-ambiguous" $ \tmp -> do+        writeModule (tmp </> "mixed") "mixed"+        writeJudgement (tmp </> "mixed") "mixed" "0.1.0"+        errs <- validateRegistry tmp (registry [] [entry "mixed" "mixed"])+        errs `shouldSatisfy` any (T.isInfixOf "ambiguous artifact 'mixed'")+        errs `shouldSatisfy` any (T.isInfixOf "module.dhall, judgement.dhall")++    it "classifies judgement version drift and counts judgements" $+      withSystemTempDirectory "judgement-validate-full" $ \tmp -> do+        writeJudgement (tmp </> "guard") "guard" "0.2.0"+        let reg = registry [] [RegistryEntry (ModuleName "guard") (Just "0.1.0") "guard" Nothing []]+            lookups = [(JudgementEntry, ModuleName "guard", Just "0.2.0")]+            report = computeRegistrySync reg lookups+        map (^. #status) (report ^. #diffs) `shouldBe` [SyncStale "0.2.0"]+        map (^. #kind) (report ^. #diffs) `shouldBe` [JudgementEntry]+        fmap (^. #version) (report ^. #updated . #judgements) `shouldBe` [Just "0.2.0"]+        full <- validateRegistryFull tmp reg lookups+        full ^. #judgementCount `shouldBe` 1+        map formatValidationIssue (full ^. #issues)+          `shouldBe` ["judgements.guard: registry version 0.1.0 does not match judgement.dhall version 0.2.0"]++    it "recognises a single-judgement repository root" $+      withSystemTempDirectory "judgement-single-root" $ \tmp -> do+        writeJudgement tmp "guard" "0.1.0"+        contents <- discoverRepoContents evalRegistryFromFile tmp+        case contents of+          SingleJudgement root -> root `shouldBe` tmp+          other -> expectationFailure ("expected SingleJudgement, got " <> show other)++  describe "judgement discovery" $ do+    it "discovers a judgement directory by name" $+      withSystemTempDirectory "judgement-discover" $ \tmp -> do+        writeJudgement (tmp </> "guard") "guard" "0.1.0"+        result <- discoverRunnable [tmp] (ModuleName "guard")+        case result of+          Right (RunnableJudgement def dir) -> do+            def ^. #name `shouldBe` "guard"+            dir `shouldBe` tmp </> "guard"+          Right _ -> expectationFailure "expected a judgement"+          Left err -> expectationFailure (show err)++    it "rejects a judgement co-located with a module as AmbiguousArtifact" $+      withSystemTempDirectory "judgement-ambiguous" $ \tmp -> do+        writeModule (tmp </> "mixed") "mixed"+        writeJudgement (tmp </> "mixed") "mixed" "0.1.0"+        result <- discoverRunnable [tmp] (ModuleName "mixed")+        case result of+          Left (AmbiguousArtifact name dir files) -> do+            name `shouldBe` ModuleName "mixed"+            dir `shouldBe` tmp </> "mixed"+            files `shouldBe` ["module.dhall", "judgement.dhall"]+          Left err -> expectationFailure ("expected AmbiguousArtifact, got " <> show err)+          Right _ -> expectationFailure "expected AmbiguousArtifact, got a runnable"++    it "keeps module precedence for combinations without a judgement" $+      withSystemTempDirectory "judgement-no-ambiguity" $ \tmp -> do+        writeModule (tmp </> "plain") "plain"+        writeFile (tmp </> "plain" </> "prompt.dhall") "this is never read"+        result <- discoverRunnable [tmp] (ModuleName "plain")+        case result of+          Right (RunnableModule _ _) -> pure ()+          Right _ -> expectationFailure "expected the module"+          Left err -> expectationFailure (show err)++    it "enumerates judgements with their kind, and an ambiguous directory as an error" $+      withSystemTempDirectory "judgement-enumerate" $ \tmp -> do+        writeJudgement (tmp </> "guard") "guard" "0.1.0"+        writeModule (tmp </> "mixed") "mixed"+        writeJudgement (tmp </> "mixed") "mixed" "0.1.0"+        found <- discoverAllRunnables [tmp]+        let rows = [(r ^. #name, r ^. #kind, r ^. #isError, r ^. #description) | r <- found]+        rows `shouldSatisfy` elem ("guard", KindJudgement, False, Just "Guards data")+        case [r | r <- found, r ^. #name == "mixed"] of+          [r] -> do+            r ^. #kind `shouldBe` KindJudgement+            r ^. #isError `shouldBe` True+            fmap T.unpack (r ^. #error) `shouldSatisfy` maybe False ("judgement.dhall" `isInfixOf`)+          other -> expectationFailure ("expected one row for mixed, got " <> show (length other))
+ test/Seihou/Core/JudgementSpec.hs view
@@ -0,0 +1,332 @@+module Seihou.Core.JudgementSpec (tests) where++import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy qualified as LBS+import Data.Either (isLeft, isRight)+import Data.Generics.Labels ()+import Data.List.NonEmpty (NonEmpty (..))+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Time (UTCTime (..), fromGregorian)+import Seihou.Core.Judgement+import Seihou.Core.JudgementState (AdoptionChange (..), adoptWithOwner, policyWeakenings, pruneJudgementOwners, retireOwner)+import Seihou.Core.Types (ActiveJudgement (..), ApplicationId (..), JudgementOwner (..), JudgementSource (..), Manifest, ManifestSchemaVersion (..))+import Seihou.Dhall.Eval (evalJudgementFromFile, evalJudgementRefsFromFile)+import Seihou.Manifest.Judgement (definitionDigest)+import Seihou.Manifest.Types (emptyManifest, manifestFromJSON, manifestToJSON)+import Seihou.Prelude+import System.Directory (getCurrentDirectory)+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++category :: Text -> Severity -> FindingCategory+category n s = FindingCategory n s Nothing++mkCheck :: Text -> Answer -> Thresholds -> JudgementCheck+mkCheck n concern t =+  JudgementCheck+    { name = n,+      question = "Is the concern present?",+      concernWhen = concern,+      yesMeans = Nothing,+      noMeans = Nothing,+      categories = [category "credential" Critical, category "style" Low],+      enforcement = Enforced,+      blockingSeverity = High,+      review = t,+      gate = Thresholds 0.6 0.05,+      useGate = True+    }++strictBand, lenientBand :: Thresholds+strictBand = Thresholds 0.5 0.1+lenientBand = Thresholds 0.9 0.6++definition :: NonEmpty JudgementCheck -> JudgementDefinition+definition cs =+  JudgementDefinition+    { name = "demo",+      version = Just "1.0.0",+      description = Nothing,+      instructions = "Review the change.",+      evidence = defaultEvidenceDeclaration,+      limits = defaultCoverageLimits,+      deterministicChecks = [],+      checks = cs+    }++complete :: Coverage+complete = Coverage ["a.txt"] [] []++reviewAt :: Double -> ReviewObservation+reviewAt p = ReviewObservation p [] False++chunkWith :: Maybe GateObservation -> Maybe (Either Text ReviewObservation) -> ChunkObservation+chunkWith = ChunkObservation 0++policyOf :: JudgementCheck -> CheckPolicy+policyOf c = checkPolicyFor (defaultPolicy ApprovedRemote True (definition (c :| []))) c++statusOf :: Bool -> JudgementCheck -> ChunkObservation -> CheckStatus+statusOf gateOn c obs = decideCheck gateOn complete (policyOf c) (CheckObservation (c ^. #name) [obs] []) ^. #status++finding :: Text -> Severity -> Finding+finding cat sev = Finding cat sev (Location "a.txt" (Just 3)) "explained"++tests :: IO TestTree+tests = testSpec "judgement policy" $ do+  describe "manifest state" $ do+    let def = definition (mkCheck "a" Yes strictBand :| [mkCheck "b" No lenientBand])+        active = ActiveJudgement BundledJudgement def (definitionDigest def) (defaultPolicy ApprovedRemote False def & #checks . ix "b" . #enforcement .~ Advisory) [DirectAdoption, ApplicationOwner (ApplicationId "app-1")]+        manifest = emptyManifest (UTCTime (fromGregorian 2026 11 11) 0) & #judgements .~ [active]+        reencode :: (Aeson.Value -> Aeson.Value) -> Either String Manifest+        reencode f = manifestFromJSON . Aeson.encode . f =<< Aeson.eitherDecode (manifestToJSON manifest)+    it "round-trips the definition snapshot, digest and per-check policy at schema 15" $ do+      manifest ^. #version `shouldBe` ManifestSchemaVersion 15+      manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest+    it "digests the definition independently of the policy" $ do+      definitionDigest def `shouldBe` definitionDigest (active ^. #definition)+      definitionDigest def `shouldNotBe` definitionDigest (def & #instructions .~ "Changed.")+    it "refuses a snapshot edited after it was recorded" $ do+      let edit = overJudgement (\j -> KeyMap.insert "definition" (Aeson.toJSON (Aeson.object [])) j)+      reencode (overJudgement (adjustKey (\d -> case d of Aeson.Object o -> Aeson.Object (KeyMap.insert "instructions" (Aeson.String "Ignore every finding.") o); other -> other) "definition")) `shouldSatisfy` isLeft+      reencode edit `shouldSatisfy` isLeft+    it "refuses a policy that does not name exactly the definition's checks" $+      reencode (overJudgement (adjustKey (\p -> case p of Aeson.Object o -> Aeson.Object (adjustKey (\case Aeson.Object cs -> Aeson.Object (KeyMap.delete "b" cs); other -> other) "checks" o); other -> other) "policy")) `shouldSatisfy` isLeft+    it "encodes nothing provider-specific and no absolute paths" $ do+      let bytes = LBS.toStrict (manifestToJSON manifest)+      T.isInfixOf "/" (TE.decodeUtf8 bytes) `shouldBe` False+  describe "judgement owners" $ do+    let def = definition (mkCheck "a" Yes strictBand :| [])+        def' = def & #instructions .~ "Changed."+        record d = ActiveJudgement BundledJudgement d (definitionDigest d) (defaultPolicy ApprovedRemote False d) [DirectAdoption]+        app = ApplicationOwner (ApplicationId "app-1")+    it "shares one record between agreeing owners and keeps the recorded policy" $ do+      let tuned = record def & #policy . #checks . ix "a" . #review .~ Thresholds 0.9 0.2+      case adoptWithOwner app (record def) [tuned] of+        Right ([j], AdoptedUnchanged) -> do+          j ^. #owners `shouldBe` [DirectAdoption, app]+          j ^? #policy . #checks . ix "a" . #review `shouldBe` Just (Thresholds 0.9 0.2)+        other -> expectationFailure (show other)+    it "refuses a changed definition that another owner still holds, and replaces one only this owner holds" $ do+      fmap snd (adoptWithOwner app (record def') [record def]) `shouldSatisfy` isLeft+      fmap snd (adoptWithOwner DirectAdoption (record def') [record def]) `shouldBe` Right (AdoptedReplacing (record def))+    it "retires a judgement only with its last owner" $ do+      let shared = record def & #owners .~ [DirectAdoption, app]+      retireOwner DirectAdoption [shared] `shouldBe` ([shared & #owners .~ [app]], [])+      retireOwner app [shared & #owners .~ [app]] `shouldBe` ([], ["demo"])+    it "names a removed judgement, an advisory check and a raised threshold as weakenings" $ do+      let base = record def+          loosened = base & #policy . #checks . ix "a" . #review . #blockAt .~ 0.8 & #policy . #checks . ix "a" . #enforcement .~ Advisory+      policyWeakenings [base] [base] `shouldBe` []+      policyWeakenings [base] [] `shouldBe` ["judgement 'demo' is no longer active"]+      policyWeakenings [base] [loosened] `shouldBe` ["check 'a' of 'demo' is now advisory", "check 'a' of 'demo' blocks at 0.80 instead of 0.50"]+      policyWeakenings [loosened] [base] `shouldBe` []+    it "drops application owners whose application is no longer recorded" $ do+      let m = emptyManifest (UTCTime (fromGregorian 2026 11 11) 0) & #judgements .~ [record def & #owners .~ [app]]+      pruneJudgementOwners m `shouldBe` (m & #judgements .~ [], ["demo"])++  describe "judgement references" $ do+    let refsOf file body = withSystemTempDirectory "seihou-judgement-refs" $ \dir -> do+          cwd <- getCurrentDirectory+          writeFile (dir </> file) ("let S = " <> cwd <> "/../schema/package.dhall in " <> body)+          evalJudgementRefsFromFile (dir </> file)+    it "decodes a module's references with their source and condition" $+      refsOf "module.dhall" "S.Module::{ name = \"m\", judgements = [ S.JudgementRef::{ name = \"security-review\", when = Some \"Eq ci true\" }, S.JudgementRef::{ name = \"docs-review\", source = Some \"https://example.com/r.git\" } ] }"+        `shouldReturn` Right [JudgementRef "security-review" Nothing (Just "Eq ci true"), JudgementRef "docs-review" (Just "https://example.com/r.git") Nothing]+    it "reads none from a module that omits the field or predates it" $ do+      refsOf "module.dhall" "S.Module::{ name = \"m\" }" `shouldReturn` Right []+      refsOf "module.dhall" "{ name = \"legacy\" }" `shouldReturn` Right []+    it "decodes a recipe's references" $+      refsOf "recipe.dhall" "S.Recipe::{ name = \"r\", modules = [ S.Dependency::{ module = \"m\" } ], judgements = [ S.JudgementRef::{ name = \"security-review\" } ] }"+        `shouldReturn` Right [JudgementRef "security-review" Nothing Nothing]++  describe "decoding judgement.dhall" $ do+    let decode body = withSystemTempDirectory "seihou-judgement" $ \dir -> do+          cwd <- getCurrentDirectory+          let file = dir </> "judgement.dhall"+          writeFile file ("let S = " <> cwd <> "/../schema/package.dhall in " <> body)+          evalJudgementFromFile file+    it "fills every default from the schema for a one-check judgement" $ do+      r <- decode "S.Judgement::{ name = \"docs\", checks = [ S.Check::{ name = \"stale\", question = \"Is it stale?\" } ] }"+      case r of+        Left e -> expectationFailure (show e)+        Right d -> do+          d ^. #evidence `shouldBe` defaultEvidenceDeclaration+          d ^. #limits `shouldBe` defaultCoverageLimits+          d ^. #deterministicChecks `shouldBe` []+          let c = NE.head (d ^. #checks)+          (c ^. #concernWhen, c ^. #enforcement, c ^. #blockingSeverity, c ^. #useGate) `shouldBe` (Yes, Enforced, High, True)+          (c ^. #review, c ^. #gate) `shouldBe` (Thresholds 0.7 0.3, Thresholds 0.9 0.05)+          validateDefinition d `shouldBe` Right ()+    it "decodes concern direction, enforcement, categories, thresholds and deterministic checks" $ do+      r <-+        decode+          ( "S.Judgement::{ name = \"customer-data-review\", deterministicChecks = [ S.DeterministicCheck.SecretScan ], "+              <> "evidence = S.JudgementEvidence::{ scopes = [ S.EvidenceScope.StagedChanges ], include = [ \"docs/**\" ] }, "+              <> "checks = [ S.Check::{ name = \"undocumented-export\", question = \"Do the notes mention every export?\", "+              <> "concernWhen = S.Answer.No, enforcement = S.Enforcement.Advisory, blockAt = 0.8, passAt = 0.4, useGate = False, "+              <> "categories = [ S.FindingCategory::{ name = \"export\", severity = S.Severity.Critical } ] } ] }"+          )+      case r of+        Left e -> expectationFailure (show e)+        Right d -> do+          d ^. #deterministicChecks `shouldBe` [SecretScan]+          d ^. #evidence `shouldBe` EvidenceDeclaration [StagedChangesScope] ["docs/**"] []+          let c = NE.head (d ^. #checks)+          (c ^. #concernWhen, c ^. #enforcement, c ^. #review, c ^. #useGate) `shouldBe` (No, Advisory, Thresholds 0.8 0.4, False)+          c ^. #categories `shouldBe` [FindingCategory "export" Critical Nothing]+    it "rejects a judgement with no checks at decode time" $ do+      r <- decode "S.Judgement::{ name = \"empty\", checks = [] : List S.Check.Type }"+      r `shouldSatisfy` isLeft+    it "leaves an inverted threshold pair to validation, which names the check" $ do+      r <- decode "S.Judgement::{ name = \"x\", checks = [ S.Check::{ name = \"c\", question = \"q?\", blockAt = 0.2, passAt = 0.5 } ] }"+      fmap validateDefinition r `shouldSatisfy` \case+        Right (Left e) -> e ^. #code == "judgement_threshold_inverted"+        _ -> False++  let c = mkCheck "c" Yes strictBand+  describe "definition validation" $ do+    it "accepts a one-check definition" $+      validateDefinition (definition (c :| [])) `shouldSatisfy` isRight+    it "rejects duplicate check names" $+      validateDefinition (definition (c :| [c])) `shouldSatisfy` isLeft+    it "rejects thresholds outside 0 to 1 and inverted pairs" $ do+      validateDefinition (definition (mkCheck "c" Yes (Thresholds 1.2 0.1) :| [])) `shouldSatisfy` isLeft+      validateDefinition (definition (mkCheck "c" Yes (Thresholds 0.3 0.4) :| [])) `shouldSatisfy` isLeft+      validateDefinition (definition ((c & #gate .~ Thresholds 0.1 0.2) :| [])) `shouldSatisfy` isLeft+    it "accepts equal thresholds, which remove the needs-review band" $+      validateDefinition (definition (mkCheck "c" Yes (Thresholds 0.4 0.4) :| [])) `shouldSatisfy` isRight+    it "rejects duplicate categories and malformed names" $ do+      validateDefinition (definition ((c & #categories .~ [category "x" Low, category "x" High]) :| [])) `shouldSatisfy` isLeft+      validateDefinition (definition ((c & #name .~ "Bad Name") :| [])) `shouldSatisfy` isLeft+    it "rejects escaping path patterns" $+      validateDefinition (definition (c :| []) & #evidence . #exclude .~ ["../x"]) `shouldSatisfy` isLeft++  describe "concern direction" $ do+    it "is the identity when yes is the concern and the complement when no is" $ do+      concernProbability Yes 0.8 `shouldBe` 0.8+      concernProbability No 0.8 `shouldSatisfy` (\p -> abs (p - 0.2) < 1e-9)++  describe "review band" $ do+    it "passes at or below the pass threshold" $+      statusOf False c (chunkWith Nothing (Just (Right (reviewAt 0.1)))) `shouldBe` CheckPassed+    it "needs review strictly between the thresholds" $+      statusOf False c (chunkWith Nothing (Just (Right (reviewAt 0.3)))) `shouldBe` CheckNeedsReview+    it "blocks at or above the block threshold, even without a finding" $+      statusOf False c (chunkWith Nothing (Just (Right (reviewAt 0.5)))) `shouldBe` CheckBlocked+    it "blocks on a blocking located finding regardless of a low probability" $+      statusOf False c (chunkWith Nothing (Just (Right (ReviewObservation 0.01 [finding "credential" Critical] False)))) `shouldBe` CheckBlocked+    it "does not block on a finding below the blocking severity" $+      statusOf False c (chunkWith Nothing (Just (Right (ReviewObservation 0.01 [finding "style" Low] False)))) `shouldBe` CheckPassed+    it "needs review when the model asks for it, even at a low probability" $+      statusOf False c (chunkWith Nothing (Just (Right (ReviewObservation 0.0 [] True)))) `shouldBe` CheckNeedsReview+    it "fails on an out-of-range probability or a failed review" $ do+      statusOf False c (chunkWith Nothing (Just (Right (reviewAt 1.5)))) `shouldBe` CheckFailed+      statusOf False c (chunkWith Nothing (Just (Left "malformed reply"))) `shouldBe` CheckFailed+    it "cannot pass with incomplete coverage" $+      decideCheck False (Coverage [] [] [("big.bin", "binary")]) (policyOf c) (CheckObservation "c" [chunkWith Nothing (Just (Right (reviewAt 0.0)))] []) ^. #status+        `shouldBe` CheckFailed++  describe "two checks with different thresholds" $+    it "reach different outcomes from the same probability" $ do+      let a = mkCheck "a" Yes strictBand+          b = mkCheck "b" Yes lenientBand+          def = definition (a :| [b])+          obs = JudgementObservation complete [] [] [CheckObservation n [chunkWith Nothing (Just (Right (reviewAt 0.7)))] [] | n <- ["a", "b"]] False+          d = decideJudgement def (defaultPolicy ApprovedRemote False def) obs+      map (^. #status) (d ^. #checks) `shouldBe` [CheckBlocked, CheckNeedsReview]+      d ^. #status `shouldBe` Blocked++  describe "advisory checks" $+    it "report a block without failing the judgement" $ do+      let a = mkCheck "a" Yes strictBand & #enforcement .~ Advisory+          def = definition (a :| [])+          obs = JudgementObservation complete [] [] [CheckObservation "a" [chunkWith Nothing (Just (Right (reviewAt 0.9)))] []] False+          d = decideJudgement def (defaultPolicy ApprovedRemote False def) obs+      map (^. #status) (d ^. #checks) `shouldBe` [CheckBlocked]+      d ^. #status `shouldBe` Passed++  describe "gate" $ do+    it "clears a check at or below the gate pass threshold with no review" $ do+      let d = decideCheck True complete (policyOf c) (CheckObservation "c" [chunkWith (Just (GateAnswered 0.05)) Nothing] [])+      d ^. #status `shouldBe` CheckPassed+      map (^. #decidedBy) (d ^. #chunks) `shouldBe` [DecidedByGate]+    it "escalates above the gate pass threshold and lets the review decide" $ do+      statusOf True c (chunkWith (Just (GateAnswered 0.3)) (Just (Right (reviewAt 0.05)))) `shouldBe` CheckPassed+      statusOf True c (chunkWith (Just (GateAnswered 0.3)) (Just (Right (reviewAt 0.7)))) `shouldBe` CheckBlocked+    it "turns a clean review after a gate block into needs-review" $+      statusOf True c (chunkWith (Just (GateAnswered 0.9)) (Just (Right (reviewAt 0.0)))) `shouldBe` CheckNeedsReview+    it "fails an escalated check whose review never ran" $+      statusOf True c (chunkWith (Just (GateAnswered 0.3)) Nothing) `shouldBe` CheckFailed+    it "escalates visibly when the gate fails" $ do+      let d = decideCheck True complete (policyOf c) (CheckObservation "c" [chunkWith (Just (GateFailed "timeout")) (Just (Right (reviewAt 0.0)))] [])+      d ^. #status `shouldBe` CheckPassed+      d ^. #reasons `shouldContain` [GateUnavailable "timeout"]+    it "is ignored when the policy does not permit it or the check opts out" $ do+      statusOf False c (chunkWith (Just (GateAnswered 0.0)) Nothing) `shouldBe` CheckFailed+      statusOf True (c & #useGate .~ False) (chunkWith (Just (GateAnswered 0.0)) Nothing) `shouldBe` CheckFailed++  describe "a check whose concern is no" $+    it "blocks on a low probability of yes and passes on a high one, for gate and review" $ do+      let n = mkCheck "tests" No strictBand+          viaReview pYes = statusOf False n (chunkWith Nothing (Just (Right (reviewAt (concernProbability No pYes)))))+          viaGate pYes = statusOf True n (chunkWith (Just (GateAnswered (concernProbability No pYes))) Nothing)+      viaReview 0.1 `shouldBe` CheckBlocked+      viaReview 0.95 `shouldBe` CheckPassed+      viaGate 0.99 `shouldBe` CheckPassed+      viaGate 0.1 `shouldBe` CheckFailed++  describe "judgement combination" $ do+    let a = mkCheck "a" Yes strictBand+        b = mkCheck "b" Yes strictBand+        def = definition (a :| [b])+        pol = defaultPolicy ApprovedRemote False def+        obsFor pa pb = JudgementObservation complete [] [] [CheckObservation "a" [pa] [], CheckObservation "b" [pb] []] False+        ok = chunkWith Nothing (Just (Right (reviewAt 0.0)))+    it "orders block above needs-review above error" $ do+      decideJudgement def pol (obsFor (chunkWith Nothing (Just (Left "x"))) (chunkWith Nothing (Just (Right (reviewAt 0.3))))) ^. #status `shouldBe` NeedsReview+      decideJudgement def pol (obsFor (chunkWith Nothing (Just (Left "x"))) ok) ^. #status `shouldBe` Failed+      decideJudgement def pol (obsFor (chunkWith Nothing (Just (Right (reviewAt 0.3)))) (chunkWith Nothing (Just (Right (reviewAt 0.9))))) ^. #status `shouldBe` Blocked+      decideJudgement def pol (obsFor ok ok) ^. #status `shouldBe` Passed+    it "blocks on any deterministic finding regardless of probabilities" $ do+      let obs = obsFor ok ok & #deterministic .~ [DeterministicFinding SecretScan "generic-api-key" (Location "a.txt" (Just 1))]+      decideJudgement def pol obs ^. #status `shouldBe` Blocked+    it "reports NoChanges for empty evidence rather than a pass" $+      decideJudgement def pol (JudgementObservation (Coverage [] [] []) [] [] [] True) ^. #status `shouldBe` NoChanges+    it "fails a check that produced no observation" $+      decideJudgement def pol (JudgementObservation complete [] [] [CheckObservation "a" [ok] []] False) ^. #status `shouldBe` Failed+    it "errors on a transmission-safeguard refusal without claiming a finding" $ do+      let obs = JudgementObservation complete [] [] [CheckObservation "a" [] ["'a.txt' was not sent: the transmission safeguard flagged it"], CheckObservation "b" [ok] []] False+          d = decideJudgement def pol obs+      d ^. #status `shouldBe` Failed+      concatMap (^. #findings) (d ^. #checks) `shouldBe` []++  describe "output validation" $ do+    let idx = EvidenceIndex (Map.fromList [("a.txt", 10)])+        raw fs = RawReview 0.2 fs False+    it "accepts declared categories at real locations, taking severity from the author" $+      fmap (map (^. #severity) . (^. #findings)) (validateReview defaultCoverageLimits c idx (raw [RawFinding "credential" "a.txt" (Just 2) "x"]))+        `shouldBe` Right [Critical]+    it "rejects undeclared categories, invented files and out-of-range lines" $ do+      validateReview defaultCoverageLimits c idx (raw [RawFinding "other" "a.txt" Nothing "x"]) `shouldSatisfy` isLeft+      validateReview defaultCoverageLimits c idx (raw [RawFinding "credential" "b.txt" Nothing "x"]) `shouldSatisfy` isLeft+      validateReview defaultCoverageLimits c idx (raw [RawFinding "credential" "a.txt" (Just 11) "x"]) `shouldSatisfy` isLeft+    it "rejects a missing or out-of-range probability and oversized output" $ do+      validateReview defaultCoverageLimits c idx (RawReview (-0.1) [] False) `shouldSatisfy` isLeft+      validateReview (defaultCoverageLimits & #maxFindings .~ 1) c idx (raw (replicate 2 (RawFinding "style" "a.txt" Nothing "x"))) `shouldSatisfy` isLeft++-- | Apply a change to the single recorded judgement object in an encoded+-- manifest.+overJudgement :: (KeyMap.KeyMap Aeson.Value -> KeyMap.KeyMap Aeson.Value) -> Aeson.Value -> Aeson.Value+overJudgement f = \case+  Aeson.Object top -> Aeson.Object (adjustKey (\case Aeson.Array js -> Aeson.Array (fmap (\case Aeson.Object j -> Aeson.Object (f j); other -> other) js); other -> other) "judgements" top)+  other -> other++adjustKey :: (Aeson.Value -> Aeson.Value) -> KeyMap.Key -> KeyMap.KeyMap Aeson.Value -> KeyMap.KeyMap Aeson.Value+adjustKey f k m = maybe m (\v -> KeyMap.insert k (f v) m) (KeyMap.lookup k m)
test/Seihou/Core/ModuleSpec.hs view
@@ -189,6 +189,45 @@           Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)           Right _ -> expectationFailure "Expected validation failure" +    it "rejects a destination in Seihou's own state" $ do+      withSystemTempDirectory "seihou-test" $ \tmpDir -> do+        createDirectoryIfMissing True (tmpDir </> "files")+        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"+        let bad =+              goodModule+                & #steps+                  .~ [Step Template "README.md.tpl" ".seihou/manifest.json" Nothing Nothing Managed]+        result <- validateModule tmpDir bad+        case result of+          Left (ValidationError _ errs) ->+            hasError "step destination path targets Seihou's own state at .seihou/manifest.json" errs `shouldBe` True+          Left other -> expectationFailure ("Expected ValidationError, got: " <> show other)+          Right _ -> expectationFailure "Expected validation failure"++    it "accepts a destination in the module's own .seihou directory" $ do+      withSystemTempDirectory "seihou-test" $ \tmpDir -> do+        createDirectoryIfMissing True (tmpDir </> "files")+        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"+        let good =+              goodModule+                & #steps+                  .~ [Step Template "README.md.tpl" ".seihou/test-module/helper.txt" Nothing Nothing Managed]+        result <- validateModule tmpDir good+        either (\err -> expectationFailure ("Expected success, got: " <> show err)) (const (pure ())) result++    it "still loads a module with a legacy file directly under .seihou" $ do+      -- The own-directory rule is enforced at generation, not load, so an+      -- existing application of such a module can still be removed.+      withSystemTempDirectory "seihou-test" $ \tmpDir -> do+        createDirectoryIfMissing True (tmpDir </> "files")+        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"+        let legacy =+              goodModule+                & #steps+                  .~ [Step Template "README.md.tpl" ".seihou/test-module.conf" Nothing Nothing Managed]+        result <- validateModule tmpDir legacy+        either (\err -> expectationFailure ("Expected success, got: " <> show err)) (const (pure ())) result+     it "rejects absolute destination path" $ do       withSystemTempDirectory "seihou-test" $ \tmpDir -> do         createDirectoryIfMissing True (tmpDir </> "files")
+ test/Seihou/Core/PathSpec.hs view
@@ -0,0 +1,110 @@+module Seihou.Core.PathSpec (tests) where++import Control.Monad (forM_)+import Data.Maybe (isJust)+import Data.Text qualified as T+import Seihou.Core.Path+import Seihou.Prelude+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++isControl :: ProjectPathClass -> Bool+isControl (ControlPath _) = True+isControl _ = False++tests :: IO TestTree+tests = testSpec "project path classification" $ do+  describe "classifyProjectPath" $ do+    it "treats paths outside .seihou and .git as ordinary" $+      forM_ ["README.md", "docs/.seihou-notes", "src/.seihou/x", "seihou/manifest.json"] $ \path ->+        classifyProjectPath path `shouldBe` OrdinaryProjectPath++    it "treats everything under .git as Git control data" $+      forM_ [".git", ".git/config", ".GIT/HEAD", "./.git/index"] $ \path ->+        classifyProjectPath path `shouldBe` ControlPath "path targets Git control data"++    it "treats Seihou's reserved entries as control data, in any case and either separator"+      $ forM_+        [ ".seihou/manifest.json",+          ".seihou/manifest.json.tmp",+          ".seihou/Manifest.JSON",+          ".SEIHOU/manifest.json",+          ".seihou/baselines/abc",+          ".seihou/transactions/x/journal.json",+          ".seihou/transactions-quarantine/x",+          ".seihou/modules/demo/module.dhall",+          ".seihou/context",+          ".seihou/config.dhall",+          ".seihou/trace.jsonl",+          ".seihou/.migrate-signal",+          ".seihou\\baselines\\abc",+          ".seihou//baselines/abc",+          ".seihou/./manifest.json"+        ]+      $ \path -> classifyProjectPath path `shouldSatisfy` isControl++    it "names the reserved entry as the path spells it" $+      classifyProjectPath ".SEIHOU/Manifest.JSON"+        `shouldBe` ControlPath "path targets Seihou's own state at .SEIHOU/Manifest.JSON"++    it "reports other .seihou entries as module state areas" $ do+      classifyProjectPath ".seihou/docs-mori-bootstrap/bootstrap-docs.py"+        `shouldBe` ModuleStatePath "docs-mori-bootstrap" True+      classifyProjectPath ".seihou/gcp-kube-env.conf" `shouldBe` ModuleStatePath "gcp-kube-env.conf" False+      classifyProjectPath ".seihou/demo/" `shouldBe` ModuleStatePath "demo" False++    it "recognises the .seihou directory itself" $+      forM_ [".seihou", ".SEIHOU", ".seihou/", "./.seihou"] $ \path ->+        classifyProjectPath path `shouldBe` SeihouRootDirectory++    it "keeps the reserved list to names that are not valid module names, except legacy entries" $+      -- Legacy entries predate the rule; new entries must not look like module names.+      filter (T.all (\c -> (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')) reservedSeihouEntries+        `shouldBe` ["baselines", "transactions", "transactions-quarantine", "modules", "context"]++  describe "control predicates" $ do+    it "refuses the .seihou directory as a file but allows it as a directory" $ do+      isControlFileTarget ".seihou" `shouldBe` True+      isControlDirectory ".seihou" `shouldBe` False++    it "refuses control data as both files and directories" $+      forM_ [".git", ".seihou/baselines", ".seihou/manifest.json"] $ \path -> do+        isControlFileTarget path `shouldBe` True+        isControlDirectory path `shouldBe` True++    it "allows ordinary and module-state paths" $+      forM_ ["src/x", ".seihou/demo", ".seihou/demo/helper.txt"] $ \path -> do+        isControlFileTarget path `shouldBe` False+        isControlDirectory path `shouldBe` False+        controlPathReason FileTarget path `shouldBe` Nothing++  describe "ownStateDirectoryViolation" $ do+    let violation = ownStateDirectoryViolation "demo"++    it "accepts ordinary paths and files inside the module's own directory" $+      forM_ [".seihou/demo/a/b.txt", ".seihou/demo/helper.txt", "src/x"] $ \path ->+        violation FileTarget path `shouldBe` Nothing++    it "refuses other .seihou file targets, naming the allowed directory" $ do+      forM_ [".seihou/demo", ".seihou", ".seihou/other/x", ".Seihou/demo/x", ".seihou/Demo/x", ".seihou/demo.conf"] $ \path ->+        violation FileTarget path `shouldSatisfy` isJust+      violation FileTarget ".seihou/other/x"+        `shouldBe` Just ".seihou/other/x is outside this module's state directory; a module may write under .seihou/ only inside .seihou/demo/"++    it "refuses Seihou's own state with the control reason" $+      violation FileTarget ".seihou/manifest.json"+        `shouldBe` Just "path targets Seihou's own state at .seihou/manifest.json"++    it "gives a module named after a reserved entry no state directory" $+      ownStateDirectoryViolation "modules" FileTarget ".seihou/modules/x"+        `shouldBe` Just "path targets Seihou's own state at .seihou/modules"++    it "accepts the .seihou directory and the module's own directory as directory operands" $ do+      violation DirectoryOperand ".seihou" `shouldBe` Nothing+      violation DirectoryOperand ".seihou/demo" `shouldBe` Nothing+      violation DirectoryOperand ".seihou/demo/sub" `shouldBe` Nothing++    it "refuses other directories under .seihou" $ do+      violation DirectoryOperand ".seihou/other" `shouldSatisfy` isJust+      violation DirectoryOperand ".seihou/baselines" `shouldSatisfy` isJust
test/Seihou/Core/RegistryEmitSpec.hs view
@@ -47,7 +47,8 @@                     }                 ],               blueprints = [],-              prompts = []+              prompts = [],+              judgements = []             }     roundTrip reg @@ -67,7 +68,8 @@                 ],               recipes = [],               blueprints = [],-              prompts = []+              prompts = [],+              judgements = []             }     roundTrip reg @@ -87,7 +89,8 @@                 ],               recipes = [],               blueprints = [],-              prompts = []+              prompts = [],+              judgements = []             }     roundTrip reg @@ -107,7 +110,8 @@                 ],               recipes = [],               blueprints = [],-              prompts = []+              prompts = [],+              judgements = []             }     roundTrip reg @@ -119,7 +123,8 @@               modules = [],               recipes = [],               blueprints = [],-              prompts = []+              prompts = [],+              judgements = []             }     roundTrip reg @@ -170,7 +175,8 @@                       description = Just "Prompt entry",                       tags = ["agent"]                     }-                ]+                ],+              judgements = []             }     roundTrip reg 
test/Seihou/Core/RegistrySpec.hs view
@@ -184,7 +184,8 @@                   modules = [RegistryEntry (ModuleName "mod-a") Nothing "mod-a" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         errs `shouldBe` []@@ -200,7 +201,8 @@                   modules = [RegistryEntry (ModuleName "Bad_Name") Nothing "Bad_Name" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         length errs `shouldSatisfy` (> 0)@@ -215,7 +217,8 @@                   modules = [RegistryEntry (ModuleName "missing") Nothing "nonexistent" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         length errs `shouldSatisfy` (> 0)@@ -230,7 +233,8 @@                   modules = [RegistryEntry (ModuleName "bad-path") Nothing "../escape" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("must not contain" `isInfixOf`) (map show errs) `shouldBe` True@@ -247,7 +251,8 @@                   modules = [RegistryEntry (ModuleName "mod-a") (Just "1.0.0") "mod-a" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }             lookups = [(ModuleEntry, ModuleName "mod-a", Just "1.0.0")]         report <- validateRegistryFull tmpDir reg lookups@@ -268,7 +273,8 @@                   modules = [RegistryEntry (ModuleName "mod-a") Nothing "mod-a" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }             lookups = [(ModuleEntry, ModuleName "mod-a", Just "1.0.0")]         report <- validateRegistryFull tmpDir reg lookups@@ -287,7 +293,8 @@                   modules = [RegistryEntry (ModuleName "mod-a") (Just "1.0.0") "mod-a" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }             lookups = [(ModuleEntry, ModuleName "mod-a", Just "2.0.0")]         report <- validateRegistryFull tmpDir reg lookups@@ -306,7 +313,8 @@                   modules = [RegistryEntry (ModuleName "Bad_Name") Nothing "Bad_Name" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }             lookups = [(ModuleEntry, ModuleName "Bad_Name", Nothing)]         report <- validateRegistryFull tmpDir reg lookups@@ -322,7 +330,8 @@                   modules = [RegistryEntry (ModuleName "escape") Nothing "../escape" Nothing []],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         report <- validateRegistryFull tmpDir reg []         let structurals = [msg | StructuralError msg <- report ^. #issues]@@ -345,7 +354,8 @@                     ],                   recipes = [],                   blueprints = [],-                  prompts = []+                  prompts = [],+                  judgements = []                 }             lookups =               [ (ModuleEntry, ModuleName "good", Just "1.0.0"),@@ -452,7 +462,8 @@                   modules = [],                   recipes = [],                   blueprints = [RegistryEntry (ModuleName "Bad_Bp") Nothing "Bad_Bp" Nothing []],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("blueprint name must match" `isInfixOf`) (map show errs) `shouldBe` True@@ -466,7 +477,8 @@                   modules = [],                   recipes = [],                   blueprints = [RegistryEntry (ModuleName "ghost") Nothing "ghost" Nothing []],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("missing blueprint.dhall" `isInfixOf`) (map show errs) `shouldBe` True@@ -480,7 +492,8 @@                   modules = [],                   recipes = [],                   blueprints = [RegistryEntry (ModuleName "escape") Nothing "../escape" Nothing []],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("blueprint path must not contain" `isInfixOf`) (map show errs) `shouldBe` True@@ -505,7 +518,8 @@                     [ RegistryEntry (ModuleName "duped") Nothing "shared-bp1" Nothing [],                       RegistryEntry (ModuleName "other") Nothing "shared-bp2" Nothing []                     ],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("appears as both a module and a blueprint" `isInfixOf`) (map show errs)@@ -528,7 +542,8 @@                   modules = [RegistryEntry (ModuleName "tripled") Nothing "trip-m" Nothing []],                   recipes = [RegistryEntry (ModuleName "tripled") Nothing "trip-r" Nothing []],                   blueprints = [RegistryEntry (ModuleName "tripled") Nothing "trip-b" Nothing []],-                  prompts = []+                  prompts = [],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         let messages = map show errs@@ -546,7 +561,8 @@                 blueprints =                   [ RegistryEntry (ModuleName "bp-stale") (Just "0.1.0") "blueprints/bp-stale" Nothing []                   ],-                prompts = []+                prompts = [],+                judgements = []               }           lookups = [(BlueprintEntry, ModuleName "bp-stale", Just "0.2.0")]           SyncReport diffs updated = computeRegistrySync reg lookups@@ -575,7 +591,8 @@                     [ RegistryEntry (ModuleName "bp-a") (Just "1.0.0") "bp-a" Nothing [],                       RegistryEntry (ModuleName "bp-b") (Just "1.0.0") "bp-b" Nothing []                     ],-                  prompts = []+                  prompts = [],+                  judgements = []                 }             lookups =               [ (BlueprintEntry, ModuleName "bp-a", Just "1.0.0"),@@ -597,7 +614,8 @@                   modules = [],                   recipes = [],                   blueprints = [],-                  prompts = [RegistryEntry (ModuleName "Bad_Prompt") Nothing "Bad_Prompt" Nothing []]+                  prompts = [RegistryEntry (ModuleName "Bad_Prompt") Nothing "Bad_Prompt" Nothing []],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("prompt name must match" `isInfixOf`) (map show errs) `shouldBe` True@@ -611,7 +629,8 @@                   modules = [],                   recipes = [],                   blueprints = [],-                  prompts = [RegistryEntry (ModuleName "ghost") Nothing "ghost" Nothing []]+                  prompts = [RegistryEntry (ModuleName "ghost") Nothing "ghost" Nothing []],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("missing prompt.dhall" `isInfixOf`) (map show errs) `shouldBe` True@@ -625,7 +644,8 @@                   modules = [],                   recipes = [],                   blueprints = [],-                  prompts = [RegistryEntry (ModuleName "escape") Nothing "../escape" Nothing []]+                  prompts = [RegistryEntry (ModuleName "escape") Nothing "../escape" Nothing []],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         any ("prompt path must not contain" `isInfixOf`) (map show errs) `shouldBe` True@@ -655,7 +675,8 @@                     [ RegistryEntry (ModuleName "as-module") Nothing "shared-prompt1" Nothing [],                       RegistryEntry (ModuleName "as-recipe") Nothing "shared-prompt2" Nothing [],                       RegistryEntry (ModuleName "as-blueprint") Nothing "shared-prompt3" Nothing []-                    ]+                    ],+                  judgements = []                 }         errs <- validateRegistry tmpDir reg         let messages = map show errs@@ -673,7 +694,8 @@                 blueprints = [],                 prompts =                   [ RegistryEntry (ModuleName "prompt-stale") (Just "0.1.0") "prompts/prompt-stale" Nothing []-                  ]+                  ],+                judgements = []               }           lookups = [(PromptEntry, ModuleName "prompt-stale", Just "0.2.0")]           SyncReport diffs updated = computeRegistrySync reg lookups@@ -702,7 +724,8 @@                   prompts =                     [ RegistryEntry (ModuleName "prompt-a") (Just "1.0.0") "prompt-a" Nothing [],                       RegistryEntry (ModuleName "prompt-b") (Just "1.0.0") "prompt-b" Nothing []-                    ]+                    ],+                  judgements = []                 }             lookups =               [ (PromptEntry, ModuleName "prompt-a", Just "1.0.0"),
test/Seihou/Core/RegistrySyncSpec.hs view
@@ -162,5 +162,6 @@       modules = mods,       recipes = recs,       blueprints = [],-      prompts = []+      prompts = [],+      judgements = []     }
test/Seihou/Dhall/MigrationDecoderSpec.hs view
@@ -21,6 +21,32 @@ spec :: Spec spec = do   describe "moduleDecoder migrations field" $ do+    it "decodes the extended union through evalModuleFromFile" $ do+      let extended = T.replace " >" " | RemoveLine : { path : Text, line : Text } >" migrationOpType+          removal = "(" <> extended <> ").RemoveLine { path = \".gitignore\", line = \".envrc\" }"+      withModuleDhall (oneMigration removal) $ \result ->+        case result of+          Right m -> map (^. #ops) (m ^. #migrations) `shouldBe` [[RemoveLine ".gitignore" ".envrc"]]+          Left err -> expectationFailure (show err)+    it "decodes all older constructors in the extended union" $ do+      let cases = [(moveFileOp, MoveFile "old/Path.hs" "new/Path.hs"), (moveDirOp, MoveDir "app" "src"), (deleteFileOp, DeleteFile "Setup.hs"), (deleteDirOp, DeleteDir "obsolete"), (runCommandNoWorkDir, RunCommand "echo hi" Nothing)]+      mapM_+        ( \(old, expectedOp) -> do+            let extended = T.replace " >" " | RemoveLine : { path : Text, line : Text } >" (oneMigration old)+            withModuleDhall extended $ \result -> case result of+              Right m -> map (^. #ops) (m ^. #migrations) `shouldBe` [[expectedOp]]+              Left err -> expectationFailure (show err)+        )+        cases+    it "decodes an empty extended migrations list" $+      withModuleDhall (T.replace " >" " | RemoveLine : { path : Text, line : Text } >" emptyMigrations) $ \result ->+        case result of+          Right m -> m ^. #migrations `shouldBe` []+          Left err -> expectationFailure (show err)+    it "refuses an unsupported migration constructor" $+      withModuleDhall (oneMigration ("(" <> T.replace " >" " | Unsupported : Text >" migrationOpType <> ").Unsupported \"x\"")) $ \result ->+        result `shouldSatisfy` either (const True) (const False)+     it "decodes a module with no migrations field as []" $       withModuleDhall noMigrationsField $ \result ->         case result of
test/Seihou/Engine/DiffSpec.hs view
@@ -59,7 +59,8 @@           blueprint = base ^. #blueprint,           seeds = base ^. #seeds,           checkoutOutputs = base ^. #checkoutOutputs,-          blueprintMigrations = base ^. #blueprintMigrations+          blueprintMigrations = base ^. #blueprintMigrations,+          judgements = base ^. #judgements         }  spec :: Spec
test/Seihou/Engine/MigrateSpec.hs view
@@ -23,6 +23,8 @@     MigrationOpInstance (..),     classifyMigration,     executeMigration,+    removeExactLine,+    validateSeedMigration,   ) import Seihou.Manifest.Hash (hashContent) import Seihou.Manifest.Types (emptyManifest)@@ -105,6 +107,54 @@  spec :: Spec spec = do+  describe "removeExactLine" $ do+    it "preserves CRLF, LF, whitespace variants and the final unterminated line" $+      removeExactLine ".envrc" ".envrc\r\n.envrc.local\r\n .envrc\n/.envrc\n.envrc\n# comment" `shouldBe` ".envrc.local\r\n .envrc\n/.envrc\n# comment"+    it "removes duplicate matches and an unterminated match" $+      removeExactLine ".envrc" ".envrc\n.envrc" `shouldBe` ""+    it "preserves a nonmatching final carriage return" $+      removeExactLine ".envrc" ".envrc\r" `shouldBe` ".envrc\r"+    it "is idempotent" $+      removeExactLine ".envrc" (removeExactLine ".envrc" "x\n.envrc\n") `shouldBe` "x\n"++  describe "RemoveLine execution" $ do+    it "preserves unrelated edits without force and retains manifest file metadata" $ do+      let manifest = mkManifest [(".gitignore", ".envrc\noriginal\n")]+          fs = mkFS [(".gitignore", ".envrc\noriginal\nuser edit\n")]+          plan = runClassify fs manifest (chain1 "1.0.0" "2.0.0" [RemoveLine ".gitignore" ".envrc"])+          (result, finalFS) = runExecute fs manifest plan False+      Map.lookup ".gitignore" (finalFS ^. #files) `shouldBe` Just "original\nuser edit\n"+      fmap (^. #files) result `shouldBe` Right (manifest ^. #files)+    it "does not create a missing target" $ do+      let manifest = mkManifest []+          fs = mkFS []+          plan = runClassify fs manifest (chain1 "1.0.0" "2.0.0" [RemoveLine ".gitignore" ".envrc"])+          (_, finalFS) = runExecute fs manifest plan False+      finalFS `shouldBe` fs+    it "rejects a symlink target without editing its referent" $ do+      let manifest = mkManifest []+          fs = (mkFS [("real", ".envrc\n")]) & #links .~ Map.singleton ".gitignore" "real"+          plan = runClassify fs manifest (chain1 "1.0.0" "2.0.0" [RemoveLine ".gitignore" ".envrc"])+          (result, finalFS) = runExecute fs manifest plan False+      result `shouldSatisfy` either (const True) (const False)+      finalFS `shouldBe` fs+    it "rejects protected seed targets and their parent paths" $ do+      mapM_ (\path -> validateSeedMigration (Set.singleton "owned/seed") [RemoveLine path ".envrc"] `shouldSatisfy` either (const True) (const False)) ["owned/seed", "owned"]+    it "rejects unsafe paths before reading any target" $ do+      mapM_ (\path -> runClassifyResult (mkFS []) (mkManifest []) (chain1 "1.0.0" "2.0.0" [RemoveLine path ".envrc"]) `shouldSatisfy` either (const True) (const False)) ["../outside", "/absolute", ".git/config", ".seihou/manifest.json"]+    it "rejects directories and paths through symbolic-link parents" $ do+      let fs = (mkFS [("real/ignore", ".envrc\n")]) & #dirs .~ Set.singleton "directory" & #links .~ Map.singleton "linked" "real"+      mapM_+        ( \path -> do+            let plan = runClassify fs (mkManifest []) (chain1 "1.0.0" "2.0.0" [RemoveLine path ".envrc"])+                (result, finalFS) = runExecute fs (mkManifest []) plan False+            result `shouldSatisfy` either (const True) (const False)+            finalFS `shouldBe` fs+        )+        ["directory", "linked/ignore"]+    it "rejects empty and multiline removal arguments" $ do+      let manifest = mkManifest []+      mapM_ (\line -> runClassifyResult (mkFS []) manifest (chain1 "1.0.0" "2.0.0" [RemoveLine ".gitignore" line]) `shouldSatisfy` either (const True) (const False)) ["", "a\nb", "a\rb"]   describe "classifyMigration" $ do     it "marks a move-file safe when disk hash matches manifest" $ do       let manifest = mkManifest [("app/Main.hs", "module Main where")]@@ -140,6 +190,24 @@           c = chain1 "1.0.0" "2.0.0" [MoveFile "README.md" "../outside"]           result = runClassifyResult fs manifest c       result `shouldBe` Left (MigrationUnsafePath "move-file destination" "../outside" "path must not contain '..' segment: ../outside")++    it "rejects a migration path in Seihou's own state" $ do+      let manifest = mkManifest []+          fs = mkFS []+          c = chain1 "1.0.0" "2.0.0" [DeleteDir ".seihou/baselines"]+          result = runClassifyResult fs manifest c+      result `shouldBe` Left (MigrationUnsafePath "delete-dir path" ".seihou/baselines" "path targets Seihou's own state at .seihou/baselines")++    it "rejects a migration that deletes the .seihou directory itself" $ do+      let c = chain1 "1.0.0" "2.0.0" [DeleteDir ".seihou"]+      runClassifyResult (mkFS []) (mkManifest []) c `shouldSatisfy` either (const True) (const False)++    it "allows moving a legacy .seihou file into the module's own directory" $ do+      let manifest = mkManifest [(".seihou/gcp-kube-env.conf", "x")]+          fs = mkFS [(".seihou/gcp-kube-env.conf", "x")]+          c = chain1 "1.0.0" "2.0.0" [MoveFile ".seihou/gcp-kube-env.conf" ".seihou/gcp-kube-env/env.conf"]+          plan = runClassify fs manifest c+      (plan ^. #ops) `shouldBe` [MoveFileInst ".seihou/gcp-kube-env.conf" ".seihou/gcp-kube-env/env.conf" MFSafe]      it "rejects a run-command workDir with a parent directory segment" $ do       let manifest = mkManifest []
test/Seihou/Engine/PlanSpec.hs view
@@ -240,6 +240,26 @@             T.isInfixOf "must be relative" (errs !! 0) `shouldBe` True           Right _ -> expectationFailure "Expected Left" +    it "compiles a write into the module's own .seihou directory with its parents" $ do+      withFixture [("helper.txt.tpl", "content")] $ \baseDir -> do+        let modul = stateModule [Step Template "helper.txt.tpl" ".seihou/demo/x" Nothing Nothing Managed]+        result <- compilePlan baseDir modul Map.empty+        result+          `shouldBe` Right [CreateDirOp ".seihou", CreateDirOp ".seihou/demo", WriteFileOp ".seihou/demo/x" "content" Template]++    it "rejects a rendered destination in another .seihou directory" $ do+      withFixture [("helper.txt.tpl", "content")] $ \baseDir -> do+        let modul = stateModule [Step Template "helper.txt.tpl" ".seihou/{{name}}/x" Nothing Nothing Managed]+        result <- compilePlan baseDir modul (Map.fromList [("name", VText "other")])+        result+          `shouldBe` Left ["step destination .seihou/other/x is outside this module's state directory; a module may write under .seihou/ only inside .seihou/demo/"]++    it "rejects a rendered destination in Seihou's own state" $ do+      withFixture [("helper.txt.tpl", "content")] $ \baseDir -> do+        let modul = stateModule [Step Copy "helper.txt.tpl" ".seihou/{{name}}" Nothing Nothing Managed]+        result <- compilePlan baseDir modul (Map.fromList [("name", VText "manifest.json")])+        result `shouldBe` Left ["step destination path targets Seihou's own state at .seihou/manifest.json"]+     it "allows dots inside a rendered destination filename" $ do       withFixture [("README.md.tpl", "content")] $ \baseDir -> do         let modul =@@ -1001,3 +1021,20 @@             length errs `shouldSatisfy` (>= 1)             T.isInfixOf "missing" (errs !! 0) `shouldBe` True           Right _ -> expectationFailure "Expected Left"++-- | A module named @demo@ with the given steps and no variables.+stateModule :: [Step] -> Module+stateModule steps =+  Module+    { name = "demo",+      version = Nothing,+      description = Nothing,+      vars = [],+      exports = [],+      prompts = [],+      steps = steps,+      commands = [],+      dependencies = [],+      removal = Nothing,+      migrations = []+    }
test/Seihou/Engine/ReconcileSpec.hs view
@@ -1,6 +1,7 @@ module Seihou.Engine.ReconcileSpec (tests) where  import Control.Lens ((^.))+import Control.Monad (forM_) import Data.Functor.Identity (Identity (..)) import Data.Generics.Labels () import Data.Map.Strict qualified as Map@@ -22,6 +23,14 @@  spec :: Spec spec = do+  describe "exact reconciliation drift" $ do+    it "names changed observations and output even when summary counts are equal" $ do+      let desired = DesiredFile "README.md" "new" "owner" Template Set.empty False+          state = PlannedFileState "new" "new" (hashContent "new") True+          planned = ReconciliationPlan Set.empty (Map.singleton "README.md" (FileUpdate desired state (ObservedFile True (Just (hashContent "old"))) Nothing)) Set.empty+          actual = ReconciliationPlan Set.empty (Map.singleton "README.md" (FileUpdate desired (PlannedFileState "new" "new with user edit" (hashContent "new with user edit") True) (ObservedFile True (Just (hashContent "old with user edit"))) Nothing)) Set.empty+      reconciliationSummary planned `shouldBe` reconciliationSummary actual+      reconciliationDifferences planned actual `shouldBe` Map.singleton "README.md" ["observed content", "planned output"]   describe "materialization" $ do     it "folds four ordered patches into one desired file" $ do       let operations =@@ -246,6 +255,43 @@               cleanMerge       result `shouldSatisfy` isInvalidPath +    it "rejects Seihou's own state, the .seihou directory as a file, and Git data" $+      forM_ [".seihou/baselines/x", ".SEIHOU/manifest.json", ".seihou", ".git/config"] $ \path ->+        planWith Map.empty Map.empty Map.empty empty [appA] [WriteFileOp path "bad" Template] (owners path [appA]) cleanMerge+          `shouldSatisfy` isInvalidPath++    it "rejects a directory operand inside Seihou's own state" $+      planWith Map.empty Map.empty Map.empty empty [appA] [CreateDirOp ".seihou/baselines"] Map.empty cleanMerge+        `shouldSatisfy` isInvalidPath++    it "plans a write into a module's own .seihou directory with its parent directories" $ do+      let path = ".seihou/demo/helper.txt"+          operations = [CreateDirOp ".seihou", CreateDirOp ".seihou/demo", WriteFileOp path "top\nmiddle v1\nbottom\n" Template]+      case planWith Map.empty Map.empty Map.empty empty [appA] operations (owners path [appA]) cleanMerge of+        Right reconciliation -> Map.member path (reconciliation ^. #files) `shouldBe` True+        Left err -> expectationFailure (show err)++    it "merges a user edit in a module's own .seihou directory like any managed file" $ do+      let path = ".seihou/demo/helper.txt"+          base = "top\nmiddle v1\nbottom\n"+          ref = baselineRefForContent base+          manifest = withFile path (record base (Just ref) [appA]) empty+          result =+            planWith+              (Map.singleton path "top edited by user\nmiddle v1\nbottom\n")+              Map.empty+              (Map.singleton ref base)+              manifest+              [appA]+              [CreateDirOp ".seihou", CreateDirOp ".seihou/demo", WriteFileOp path "top\nmiddle v2\nbottom\n" Template]+              (owners path [appA])+              (\_ _ _ -> MergeClean "top edited by user\nmiddle v2\nbottom\n")+      case result of+        Right reconciliation -> case (reconciliation ^. #files) Map.! path of+          FileAutoMerge _ state _ _ -> (state ^. #appliedContent) `shouldBe` "top edited by user\nmiddle v2\nbottom\n"+          other -> expectationFailure ("expected automatic merge, got " <> show other)+        Left err -> expectationFailure (show err)+   describe "classification and resolution" $ do     it "preserves a user-only edit without advancing its applied hash" $ do       let base = "base\n"@@ -369,7 +415,8 @@       blueprint = manifest ^. #blueprint,       seeds = manifest ^. #seeds,       checkoutOutputs = manifest ^. #checkoutOutputs,-      blueprintMigrations = manifest ^. #blueprintMigrations+      blueprintMigrations = manifest ^. #blueprintMigrations,+      judgements = manifest ^. #judgements     }  record :: Text -> Maybe BaselineRef -> [ApplicationId] -> FileRecord
test/Seihou/Engine/RemoveSpec.hs view
@@ -248,7 +248,8 @@                 blueprint = base ^. #blueprint,                 seeds = base ^. #seeds,                 checkoutOutputs = base ^. #checkoutOutputs,-                blueprintMigrations = base ^. #blueprintMigrations+                blueprintMigrations = base ^. #blueprintMigrations,+                judgements = base ^. #judgements               }           fs = mkFS [("mine.txt", "mine"), ("other.txt", "other")]           plan = RemovalPlan {targetModule = modName, files = [RemovalSafe "mine.txt"]}@@ -337,6 +338,20 @@           )       Map.member "../outside" (fs ^. #files) `shouldBe` True +    it "rejects a removal step destination in Seihou's own state" $ do+      let removal = Removal [RemovalStep RemoveFileAction ".seihou/baselines/x" Nothing Nothing] []+          manifest = mkManifestWithRemoval removal []+          fs = mkFS [(".seihou/baselines/x", "keep")]+          result = runBuildOps fs manifest modName removal+      result+        `shouldBe` Left+          ( RemovalUnsafePath+              "remove-file destination"+              ".seihou/baselines/x"+              "path targets Seihou's own state at .seihou/baselines"+          )+      Map.member ".seihou/baselines/x" (fs ^. #files) `shouldBe` True+     it "rejects a removal command workDir with a parent directory segment" $ do       let removal = Removal [] [Command "echo unsafe" (Just "../outside") Nothing ApplicationCommand]           manifest = mkManifestWithRemoval removal []@@ -433,7 +448,8 @@                 blueprint = base ^. #blueprint,                 seeds = base ^. #seeds,                 checkoutOutputs = base ^. #checkoutOutputs,-                blueprintMigrations = base ^. #blueprintMigrations+                blueprintMigrations = base ^. #blueprintMigrations,+                judgements = base ^. #judgements               }           fs = mkFS [("mine.txt", "mine"), ("other.txt", "other")]           plan = ExecutedRemovalPlan modName [DeleteFileOp "mine.txt" RFSafe]
test/Seihou/Engine/UpdateTransactionSpec.hs view
@@ -302,6 +302,57 @@         readProject projectRoot "file.txt" `shouldReturn` "user\n"         Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False +  describe "module state under .seihou" $ do+    it "merges and commits a file in a module's own .seihou directory" $+      withSystemTempDirectory "seihou-update-module-state" $ \projectRoot -> do+        let path = ".seihou/demo/helper.txt"+            base = "top\nmiddle v1\nbottom\n"+            userEdit = "top edited by user\nmiddle v1\nbottom\n"+            generated = "top\nmiddle v2\nbottom\n"+            merged = "top edited by user\nmiddle v2\nbottom\n"+            oldRecord = fileRecord base (Just (baselineRefForContent base)) [appA]+            manifest = manifestWithFiles (Map.singleton path oldRecord)+            desired = desiredFile path generated [appA]+            plan =+              ReconciliationPlan+                (Set.singleton appA)+                (Map.singleton path (FileAutoMerge desired (plannedState generated merged True) (observed userEdit) (Just oldRecord)))+                (Set.fromList [".seihou", ".seihou/demo"])+        writeProject projectRoot path userEdit+        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Set.singleton path)+        candidate <- expectRight =<< applyReconciliation transaction plan manifest+        readProject projectRoot path `shouldReturn` merged+        (((candidate ^. #files) Map.! path) ^. #hash) `shouldBe` hashContent merged+        completeUpdateTransaction transaction `shouldReturn` Right ()+        readProject projectRoot path `shouldReturn` merged++    it "refuses Seihou's own state as a transaction target" $+      withSystemTempDirectory "seihou-update-control-target" $ \projectRoot -> do+        result <- beginUpdateTransaction projectRoot (Set.singleton ".seihou/manifest.json")+        case result of+          Left (InvalidTransactionPath path reason) -> do+            path `shouldBe` ".seihou/manifest.json"+            reason `shouldBe` "path targets Seihou's own state at .seihou/manifest.json"+          Left other -> expectationFailure ("expected an invalid path, got " <> show other)+          Right _ -> expectationFailure "expected .seihou/manifest.json to be refused"+        Directory.doesDirectoryExist (projectRoot </> ".seihou" </> "transactions") `shouldReturn` False++    it "refuses a required directory inside Seihou's own state before writing" $+      withSystemTempDirectory "seihou-update-control-directory" $ \projectRoot -> do+        writeProject projectRoot "file.txt" "old\n"+        let desired = desiredFile "file.txt" "new\n" [appA]+            plan =+              ReconciliationPlan+                (Set.singleton appA)+                (Map.singleton "file.txt" (FileUpdate desired (plannedState "new\n" "new\n" True) (observed "old\n") Nothing))+                (Set.singleton ".seihou/baselines")+        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Set.singleton "file.txt")+        result <- applyReconciliation transaction plan (manifestWithFiles Map.empty)+        case result of+          Left (InvalidTransactionPath ".seihou/baselines" _) -> pure ()+          other -> expectationFailure ("expected .seihou/baselines to be refused, got " <> show other)+        readProject projectRoot "file.txt" `shouldReturn` "old\n"+   describe "partial update of a co-owned path" $ do     -- A targeted update plans only the applications it selected, so for a     -- co-owned path the reconciliation result describes a subset of the@@ -471,7 +522,8 @@                   blueprint = candidate ^. #blueprint,                   seeds = candidate ^. #seeds,                   checkoutOutputs = candidate ^. #checkoutOutputs,-                  blueprintMigrations = candidate ^. #blueprintMigrations+                  blueprintMigrations = candidate ^. #blueprintMigrations,+                  judgements = candidate ^. #judgements                 }         setUpdateTransactionExpectedManifest transaction finalManifest `shouldReturn` Right ()         Directory.createDirectoryIfMissing True (projectRoot </> ".seihou")@@ -693,7 +745,8 @@           blueprint = manifest ^. #blueprint,           seeds = manifest ^. #seeds,           checkoutOutputs = manifest ^. #checkoutOutputs,-          blueprintMigrations = manifest ^. #blueprintMigrations+          blueprintMigrations = manifest ^. #blueprintMigrations,+          judgements = manifest ^. #judgements         }  fileRecord :: Text -> Maybe BaselineRef -> [ApplicationId] -> FileRecord
test/Seihou/Engine/ValidateSpec.hs view
@@ -140,6 +140,28 @@         report <- buildReport False tmpDir badModule         hasFailedCheck "Safe step destinations" (report ^. #checks) `shouldBe` True +    it "reports a destination outside the module's own .seihou directory" $ do+      withSystemTempDirectory "seihou-validate" $ \tmpDir -> do+        createDirectoryIfMissing True (tmpDir </> "files")+        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"+        let legacy =+              goodModule+                & #name .~ "gcp-kube-env"+                & #steps .~ [Step Template "README.md.tpl" ".seihou/gcp-kube-env.conf" Nothing Nothing Managed]+        report <- buildReport False tmpDir legacy+        reportHasErrors report `shouldBe` True+        [c ^. #details | c <- report ^. #checks, c ^. #label == "Module state destinations"]+          `shouldBe` [["step destination .seihou/gcp-kube-env.conf is outside this module's state directory; a module may write under .seihou/ only inside .seihou/gcp-kube-env/"]]++    it "passes the module state check for the module's own .seihou directory" $ do+      withSystemTempDirectory "seihou-validate" $ \tmpDir -> do+        createDirectoryIfMissing True (tmpDir </> "files")+        writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"+        let good = goodModule & #steps .~ [Step Template "README.md.tpl" ".seihou/test-module/helper.txt" Nothing Nothing Managed]+        report <- buildReport False tmpDir good+        hasPassedCheck "Module state destinations" (report ^. #checks) `shouldBe` True+        reportHasErrors report `shouldBe` False+     it "detects missing module version" $ do       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do         report <- buildReport False tmpDir badModule
test/Seihou/Integration/ExecutionSpec.hs view
@@ -70,7 +70,8 @@           blueprint = base ^. #blueprint,           seeds = base ^. #seeds,           checkoutOutputs = base ^. #checkoutOutputs,-          blueprintMigrations = base ^. #blueprintMigrations+          blueprintMigrations = base ^. #blueprintMigrations,+          judgements = base ^. #judgements         }  -- | Extract planned files from operations for computeDiff.
test/Seihou/Manifest/TypesSpec.hs view
@@ -50,7 +50,7 @@ -- | Helper to set modules on a Manifest without ambiguous record update. withManifestModules :: [AppliedModule] -> Manifest -> Manifest withManifestModules mods m =-  Manifest (m ^. #version) (m ^. #genAt) mods (m ^. #vars) (m ^. #files) (m ^. #seeds) (m ^. #checkoutOutputs) (m ^. #applications) (m ^. #recipe) (m ^. #blueprint) (m ^. #blueprintMigrations)+  Manifest (m ^. #version) (m ^. #genAt) mods (m ^. #vars) (m ^. #files) (m ^. #seeds) (m ^. #checkoutOutputs) (m ^. #applications) (m ^. #recipe) (m ^. #blueprint) (m ^. #blueprintMigrations) (m ^. #judgements)  -- | Every string that appears anywhere inside a value keyed @origin@ or -- @targetOrigin@, at any depth.@@ -445,7 +445,8 @@                 blueprint = Nothing,                 seeds = Map.empty,                 checkoutOutputs = Map.empty,-                blueprintMigrations = []+                blueprintMigrations = [],+                judgements = []               }       manifestFromJSON (manifestToJSON m) `shouldBe` Right m @@ -485,7 +486,8 @@                 blueprint = Nothing,                 seeds = Map.empty,                 checkoutOutputs = Map.empty,-                blueprintMigrations = []+                blueprintMigrations = [],+                judgements = []               }       manifestFromJSON (manifestToJSON m) `shouldBe` Right m @@ -744,6 +746,22 @@         manifest2         `shouldBe` False +    -- A receipt recorded while the blueprint was installed over SSH is the+    -- same edge once it is installed over HTTPS, and the other way round:+    -- completed work stays done and no second receipt is appended.+    it "treats equivalent public GitHub transports as one receipt identity, both ways" $ do+      let ssh = RemoteOrigin "git@github.com:acme/payments.git" "payments" Nothing+          https = RemoteOrigin "https://github.com/acme/payments.git" "payments" Nothing+          edgeFrom origin at = AppliedBlueprintMigration (ModuleName "payments") origin (Just "0.5.0") "1.0.0" "2.0.0" MigrationApplied at Nothing+      forM_ [(ssh, https), (https, ssh)] $ \(recorded, installed) -> do+        let original = edgeFrom recorded fixedTime+            manifest1 = writeAppliedBlueprintMigration original (emptyManifest fixedTime)+            rerun = edgeFrom installed fixedTime2+        hasAppliedBlueprintMigration installed "payments" "1.0.0" "2.0.0" manifest1 `shouldBe` True+        (writeAppliedBlueprintMigration rerun manifest1 ^. #blueprintMigrations) `shouldBe` [rerun]+        hasAppliedBlueprintMigration (RemoteOrigin "git@github.com:other/payments.git" "payments" Nothing) "payments" "1.0.0" "2.0.0" manifest1 `shouldBe` False+        hasAppliedBlueprintMigration (RemoteOrigin "https://github.com/acme/payments.git" "billing" Nothing) "payments" "1.0.0" "2.0.0" manifest1 `shouldBe` False+     -- The upsert key deliberately ignores the outcome, so a re-run replaces     -- the earlier record. The read side does not ignore it: an edge that     -- reported itself inapplicable has not been applied.@@ -926,7 +944,7 @@   describe "version checking" $ do     it "rejects manifests with version higher than current" $ do       let base = emptyManifest fixedTime-          m = Manifest {version = ManifestSchemaVersion 99, genAt = base ^. #genAt, modules = base ^. #modules, vars = base ^. #vars, files = base ^. #files, applications = base ^. #applications, recipe = Nothing, blueprint = Nothing, seeds = Map.empty, checkoutOutputs = Map.empty, blueprintMigrations = []}+          m = Manifest {version = ManifestSchemaVersion 99, genAt = base ^. #genAt, modules = base ^. #modules, vars = base ^. #vars, files = base ^. #files, applications = base ^. #applications, recipe = Nothing, blueprint = Nothing, seeds = Map.empty, checkoutOutputs = Map.empty, blueprintMigrations = [], judgements = []}           result = manifestFromJSON (manifestToJSON m)       case result of         Left err -> err `shouldContain` "newer version"
test/Seihou/Manifest/UpgradeSpec.hs view
@@ -63,6 +63,12 @@  spec :: Spec spec = do+  it "adds an empty judgement list at schema 15 and refuses a forged one" $ do+    let document = Aeson.object ["version" Aeson..= (14 :: Int), "producerField" Aeson..= True]+        expected = Aeson.object ["version" Aeson..= (15 :: Int), "producerField" Aeson..= True, "judgements" Aeson..= ([] :: [Aeson.Value])]+    fmap snd (upgradeDocumentLosslessly (v 15) document) `shouldBe` Right expected+    let forged = Aeson.object ["version" Aeson..= (14 :: Int), "judgements" Aeson..= [Aeson.object []]]+    upgradeDocumentLosslessly (v 15) forged `shouldSatisfy` either (const True) (const False)   it "upgrades unconditional schema-13 removal without changing producer fields" $ do     let document = Aeson.object ["version" Aeson..= (13 :: Int), "producerField" Aeson..= True, "modules" Aeson..= [Aeson.object ["removal" Aeson..= Aeson.object ["steps" Aeson..= [Aeson.object ["action" Aeson..= ("remove-file" :: String), "dest" Aeson..= ("package.json" :: String)]]]]]]     case upgradeDocumentLosslessly (v 14) document of