okf-core 0.11.0.0 → 0.12.0.0
raw patch · 13 files changed
+2003/−2 lines, 13 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
+ Okf.Profile.Sweep: AcceptanceCoreInvalid :: !FilePath -> ![BundleValidationError] -> ![LogStaleness] -> BaselineFault
+ Okf.Profile.Sweep: AcceptanceObservableOutcome :: Outcome
+ Okf.Profile.Sweep: AcceptanceProfileDeviations :: !FilePath -> ![Text] -> BaselineFault
+ Okf.Profile.Sweep: AdmitValue :: !Text -> RelaxationKind
+ Okf.Profile.Sweep: AllKinds :: KindSet
+ Okf.Profile.Sweep: AllowLocal :: RelaxationKind
+ Okf.Profile.Sweep: AllowSelf :: RelaxationKind
+ Okf.Profile.Sweep: CaseExpectation :: !Text -> !Int -> CaseExpectation
+ Okf.Profile.Sweep: CaseExpectationUnmet :: !Text -> !CaseExpectation -> ![Text] -> BaselineFault
+ Okf.Profile.Sweep: ClearVocabulary :: RelaxationKind
+ Okf.Profile.Sweep: Contradiction :: Outcome
+ Okf.Profile.Sweep: Covered :: !Text -> !Text -> Outcome
+ Okf.Profile.Sweep: DefaultKinds :: KindSet
+ Okf.Profile.Sweep: Deferred :: !Text -> ExceptionClass
+ Okf.Profile.Sweep: Delete :: RelaxationKind
+ Okf.Profile.Sweep: DescriptorInvalidOutcome :: Outcome
+ Okf.Profile.Sweep: DropCardinality :: RelaxationKind
+ Okf.Profile.Sweep: DropCondition :: RelaxationKind
+ Okf.Profile.Sweep: DropExternalUriPattern :: RelaxationKind
+ Okf.Profile.Sweep: DropFormat :: RelaxationKind
+ Okf.Profile.Sweep: DropHandleIdentity :: RelaxationKind
+ Okf.Profile.Sweep: DropListShape :: RelaxationKind
+ Okf.Profile.Sweep: DropNested :: !NestedContainer -> RelaxationKind
+ Okf.Profile.Sweep: DropPath :: RelaxationKind
+ Okf.Profile.Sweep: DropReference :: RelaxationKind
+ Okf.Profile.Sweep: DropTypeAttribute :: !TypeAttribute -> RelaxationKind
+ Okf.Profile.Sweep: DropUniqueBy :: RelaxationKind
+ Okf.Profile.Sweep: DropUniqueKey :: RelaxationKind
+ Okf.Profile.Sweep: ElementFields :: NestedContainer
+ Okf.Profile.Sweep: ExceptionOutcome :: !SweepException -> Outcome
+ Okf.Profile.Sweep: ExpectationsError :: !FilePath -> !Text -> SuiteError
+ Okf.Profile.Sweep: IdPrefix :: TypeAttribute
+ Okf.Profile.Sweep: Inert :: !Text -> Outcome
+ Okf.Profile.Sweep: LegacyKinds :: KindSet
+ Okf.Profile.Sweep: ObjectFields :: NestedContainer
+ Okf.Profile.Sweep: Observation :: ![ProfileDefinitionError] -> ![(FilePath, [Text])] -> ![Text] -> ![Text] -> !Bool -> Observation
+ Okf.Profile.Sweep: OptionalList :: PresenceList
+ Okf.Profile.Sweep: OverlappingRule :: !Text -> ExceptionClass
+ Okf.Profile.Sweep: PathPattern :: TypeAttribute
+ Okf.Profile.Sweep: Presence :: RelaxationKind
+ Okf.Profile.Sweep: ProfileScope :: RuleScope
+ Okf.Profile.Sweep: Protected :: Outcome
+ Okf.Profile.Sweep: RecommendedList :: PresenceList
+ Okf.Profile.Sweep: RejectionCase :: !Text -> !CaseExpectation -> !SuiteBundle -> RejectionCase
+ Okf.Profile.Sweep: Relaxation :: !RelaxationTarget -> !RelaxationKind -> Relaxation
+ Okf.Profile.Sweep: RequireSchemaSection :: TypeAttribute
+ Okf.Profile.Sweep: RequiredList :: PresenceList
+ Okf.Profile.Sweep: ResourceScheme :: TypeAttribute
+ Okf.Profile.Sweep: RuleLocation :: !RuleScope -> !PresenceList -> !Text -> !Maybe (NestedContainer, PresenceList, Text) -> RuleLocation
+ Okf.Profile.Sweep: RuleTarget :: !RuleLocation -> RelaxationTarget
+ Okf.Profile.Sweep: SchemaColumns :: TypeAttribute
+ Okf.Profile.Sweep: SharedFamily :: !Text -> ExceptionClass
+ Okf.Profile.Sweep: Subsumed :: ![Text] -> Outcome
+ Okf.Profile.Sweep: Suite :: ![SuiteBundle] -> ![RejectionCase] -> Suite
+ Okf.Profile.Sweep: SuiteBundle :: !FilePath -> ![Concept] -> !BundleInventory -> !VersionDeclaration -> ![LogFile] -> SuiteBundle
+ Okf.Profile.Sweep: SuiteBundleError :: !FilePath -> !BundleError -> SuiteError
+ Okf.Profile.Sweep: SweepException :: !Text -> !ExceptionClass -> !Text -> SweepException
+ Okf.Profile.Sweep: SweepResult :: !Relaxation -> !Observation -> !Outcome -> SweepResult
+ Okf.Profile.Sweep: TypeScope :: !Text -> RuleScope
+ Okf.Profile.Sweep: TypeTarget :: !Text -> RelaxationTarget
+ Okf.Profile.Sweep: Unprotected :: Outcome
+ Okf.Profile.Sweep: [acceptanceDeviations] :: Observation -> ![(FilePath, [Text])]
+ Okf.Profile.Sweep: [acceptance] :: Suite -> ![SuiteBundle]
+ Okf.Profile.Sweep: [behaviourUnchanged] :: Observation -> !Bool
+ Okf.Profile.Sweep: [bundle] :: RejectionCase -> !SuiteBundle
+ Okf.Profile.Sweep: [cases] :: Suite -> ![RejectionCase]
+ Okf.Profile.Sweep: [concepts] :: SuiteBundle -> ![Concept]
+ Okf.Profile.Sweep: [count] :: CaseExpectation -> !Int
+ Okf.Profile.Sweep: [declaration] :: SuiteBundle -> !VersionDeclaration
+ Okf.Profile.Sweep: [definitionErrors] :: Observation -> ![ProfileDefinitionError]
+ Okf.Profile.Sweep: [exceptionClass] :: SweepException -> !ExceptionClass
+ Okf.Profile.Sweep: [expectation] :: RejectionCase -> !CaseExpectation
+ Okf.Profile.Sweep: [facetWitnesses] :: Observation -> ![Text]
+ Okf.Profile.Sweep: [field] :: CaseExpectation -> !Text
+ Okf.Profile.Sweep: [inventory] :: SuiteBundle -> !BundleInventory
+ Okf.Profile.Sweep: [kind] :: Relaxation -> !RelaxationKind
+ Okf.Profile.Sweep: [list] :: RuleLocation -> !PresenceList
+ Okf.Profile.Sweep: [logs] :: SuiteBundle -> ![LogFile]
+ Okf.Profile.Sweep: [name] :: RejectionCase -> !Text
+ Okf.Profile.Sweep: [nested] :: RuleLocation -> !Maybe (NestedContainer, PresenceList, Text)
+ Okf.Profile.Sweep: [observation] :: SweepResult -> !Observation
+ Okf.Profile.Sweep: [outcome] :: SweepResult -> !Outcome
+ Okf.Profile.Sweep: [path] :: SuiteBundle -> !FilePath
+ Okf.Profile.Sweep: [rationale] :: SweepException -> !Text
+ Okf.Profile.Sweep: [relaxation] :: SweepResult -> !Relaxation
+ Okf.Profile.Sweep: [scope] :: RuleLocation -> !RuleScope
+ Okf.Profile.Sweep: [target] :: Relaxation -> !RelaxationTarget
+ Okf.Profile.Sweep: [witnesses] :: Observation -> ![Text]
+ Okf.Profile.Sweep: applyRelaxation :: Relaxation -> ProfileSpec -> ProfileSpec
+ Okf.Profile.Sweep: behaviourKey :: CompiledProfile -> BehaviourKey
+ Okf.Profile.Sweep: checkBaseline :: Render -> CompiledProfile -> Suite -> Either (NonEmpty BaselineFault) PreparedSuite
+ Okf.Profile.Sweep: compositeUnit :: ProfileSpec -> Relaxation -> Maybe Relaxation
+ Okf.Profile.Sweep: data BaselineFault
+ Okf.Profile.Sweep: data BehaviourKey
+ Okf.Profile.Sweep: data CaseExpectation
+ Okf.Profile.Sweep: data ExceptionClass
+ Okf.Profile.Sweep: data KindSet
+ Okf.Profile.Sweep: data NestedContainer
+ Okf.Profile.Sweep: data Observation
+ Okf.Profile.Sweep: data Outcome
+ Okf.Profile.Sweep: data PreparedSuite
+ Okf.Profile.Sweep: data PresenceList
+ Okf.Profile.Sweep: data RejectionCase
+ Okf.Profile.Sweep: data Relaxation
+ Okf.Profile.Sweep: data RelaxationKind
+ Okf.Profile.Sweep: data RelaxationTarget
+ Okf.Profile.Sweep: data RuleLocation
+ Okf.Profile.Sweep: data RuleScope
+ Okf.Profile.Sweep: data Suite
+ Okf.Profile.Sweep: data SuiteBundle
+ Okf.Profile.Sweep: data SuiteError
+ Okf.Profile.Sweep: data SweepException
+ Okf.Profile.Sweep: data SweepResult
+ Okf.Profile.Sweep: data TypeAttribute
+ Okf.Profile.Sweep: enumerateRelaxations :: KindSet -> ProfileSpec -> [Relaxation]
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.BaselineFault
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.BehaviourKey
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.CaseExpectation
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.ExceptionClass
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.Facet
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.KindSet
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.NestedContainer
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.Observation
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.Outcome
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.PresenceList
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.Relaxation
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.RelaxationKind
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.RelaxationTarget
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.RuleKey
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.RuleLocation
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.RuleScope
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.SuiteError
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.SweepException
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.SweepResult
+ Okf.Profile.Sweep: instance GHC.Classes.Eq Okf.Profile.Sweep.TypeAttribute
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.KindSet
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.NestedContainer
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.PresenceList
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.Relaxation
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.RelaxationKind
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.RelaxationTarget
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.RuleLocation
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.RuleScope
+ Okf.Profile.Sweep: instance GHC.Classes.Ord Okf.Profile.Sweep.TypeAttribute
+ Okf.Profile.Sweep: instance GHC.Internal.Enum.Bounded Okf.Profile.Sweep.PresenceList
+ Okf.Profile.Sweep: instance GHC.Internal.Enum.Enum Okf.Profile.Sweep.PresenceList
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.BaselineFault
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.BehaviourKey
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.CaseExpectation
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.ExceptionClass
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.KindSet
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.NestedContainer
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.Observation
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.Outcome
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.PreparedSuite
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.PresenceList
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.RejectionCase
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.Relaxation
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.RelaxationKind
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.RelaxationTarget
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.RuleFacts
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.RuleKey
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.RuleLocation
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.RuleScope
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.Suite
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.SuiteBundle
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.SuiteError
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.SweepException
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.SweepResult
+ Okf.Profile.Sweep: instance GHC.Internal.Generics.Generic Okf.Profile.Sweep.TypeAttribute
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.BaselineFault
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.BehaviourKey
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.CaseExpectation
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.ExceptionClass
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.Facet
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.KindSet
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.NestedContainer
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.Observation
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.Outcome
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.PresenceList
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.Relaxation
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.RelaxationKind
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.RelaxationTarget
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.RuleKey
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.RuleLocation
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.RuleScope
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.SuiteError
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.SweepException
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.SweepResult
+ Okf.Profile.Sweep: instance GHC.Internal.Show.Show Okf.Profile.Sweep.TypeAttribute
+ Okf.Profile.Sweep: legacyKindName :: RelaxationKind -> Maybe Text
+ Okf.Profile.Sweep: legacyOutcome :: Observation -> Text
+ Okf.Profile.Sweep: loadExceptions :: FilePath -> IO (Either Text [SweepException])
+ Okf.Profile.Sweep: loadSuite :: [FilePath] -> FilePath -> IO (Either SuiteError Suite)
+ Okf.Profile.Sweep: observeRelaxation :: PreparedSuite -> Relaxation -> Observation
+ Okf.Profile.Sweep: outcomeIsFailing :: Outcome -> Bool
+ Okf.Profile.Sweep: outcomeName :: Outcome -> Text
+ Okf.Profile.Sweep: parseExceptions :: ByteString -> Either Text [SweepException]
+ Okf.Profile.Sweep: parseExpectations :: ByteString -> Either Text [(Text, CaseExpectation)]
+ Okf.Profile.Sweep: parseKindSet :: Text -> Maybe KindSet
+ Okf.Profile.Sweep: planRelaxations :: KindSet -> PreparedSuite -> [Relaxation]
+ Okf.Profile.Sweep: preparedProfile :: PreparedSuite -> CompiledProfile
+ Okf.Profile.Sweep: preparedSuite :: PreparedSuite -> Suite
+ Okf.Profile.Sweep: primaryWitness :: Observation -> Maybe Text
+ Okf.Profile.Sweep: relaxationField :: Relaxation -> Text
+ Okf.Profile.Sweep: relaxationId :: Relaxation -> Text
+ Okf.Profile.Sweep: relaxationKindName :: RelaxationKind -> Text
+ Okf.Profile.Sweep: renderKindSet :: KindSet -> Text
+ Okf.Profile.Sweep: renderRuleLocation :: RuleLocation -> Text
+ Okf.Profile.Sweep: staleExceptions :: KindSet -> Bool -> [SweepException] -> [(Text, Outcome)] -> [SweepException]
+ Okf.Profile.Sweep: sweepProfile :: [SweepException] -> PreparedSuite -> [Relaxation] -> [SweepResult]
+ Okf.Profile.Sweep: type Render = CompiledProfile -> [Concept] -> ProfileViolation -> Text
+ Okf.Profile.Sweep: withBehaviourKey :: (CompiledProfile -> BehaviourKey) -> PreparedSuite -> PreparedSuite
Files
- CHANGELOG.md +13/−0
- okf-core.cabal +3/−1
- src/Okf/Profile/Sweep.hs +1432/−0
- test/Main.hs +363/−1
- test/fixtures/profile-discovery/dist-newstyle/valid-build-output.dhall +2/−0
- test/fixtures/sweep/accept/log.md +4/−0
- test/fixtures/sweep/accept/notes/first.md +11/−0
- test/fixtures/sweep/cases/expected.json +4/−0
- test/fixtures/sweep/cases/note-bad-status/notes/bad-status.md +11/−0
- test/fixtures/sweep/cases/note-missing-title/notes/missing-title.md +10/−0
- test/fixtures/sweep/classification.dhall +76/−0
- test/fixtures/sweep/closed.dhall +35/−0
- test/fixtures/sweep/profile.dhall +39/−0
CHANGELOG.md view
@@ -7,6 +7,19 @@ ## [Unreleased] +## [0.12.0.0] - 2026-10-28++### Added++- `Okf.Profile.Sweep`: enumerate and apply relaxations of an authored+ `ProfileSpec`, load a sweep suite (acceptance bundles and `expected.json`+ cases in file order), check its baseline, and observe each relaxation's+ witnesses with the okf-profiles catalog's legacy predicate. `sweepProfile`+ classifies each relaxation by evidence and proof: `behaviourKey` proves+ inertness with documented erasures, composite policy units and+ `admitValue` relaxations cover what one relaxation alone cannot show, and+ `parseExceptions` reads a per-suite exception manifest.+ ## [0.11.0.0] - 2026-10-28 ### Added
okf-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: okf-core-version: 0.11.0.0+version: 0.12.0.0 synopsis: Read, validate, index, and traverse Open Knowledge Format bundles @@ -27,6 +27,7 @@ extra-source-files: dhall/**/*.dhall test/fixtures/**/*.dhall+ test/fixtures/**/*.json test/fixtures/**/*.md test/fixtures/**/*.py test/fixtures/**/*.sql@@ -73,6 +74,7 @@ Okf.Profile.Discovery Okf.Profile.Documentation Okf.Profile.Registry+ Okf.Profile.Sweep Okf.Query Okf.Trust Okf.Validation
+ src/Okf/Profile/Sweep.hs view
@@ -0,0 +1,1432 @@+-- | Load-bearing sweeps of a profile against its own test suite.+--+-- A profile catalog tests each descriptor with acceptance bundles that must pass+-- and rejection cases that must each fail for one reason. A /sweep/ asks the+-- converse question: weaken one rule one way (a /relaxation/), and does some+-- rejection case notice? A rule no case notices is not load-bearing, and the+-- suite has a gap.+--+-- The sweep relaxes the decoded 'ProfileSpec' in memory and recompiles it, per+-- [ADR 5](docs/adr/5-compile-profile-rules-before-validation.md): a relaxation+-- changes what the author wrote, and compilation decides what it means. Core+-- validation and log staleness do not depend on the profile, so every bundle is+-- loaded and checked once ('loadSuite', 'checkBaseline') and each relaxation+-- re-runs only the pure profile check ('observeRelaxation').+--+-- A case /meets its expectation/ under the predicate of the okf-profiles+-- catalog's @scripts\/profile_fixture_cases.py@: run without strict authoring, it+-- produces exactly the expected number of @profile: @ deviation lines and at+-- least one of them contains the expected field name. The predicate compares+-- rendered lines, and rendering belongs to the CLI, so the caller supplies the+-- renderer as a 'Render'.+module Okf.Profile.Sweep+ ( -- * Rule identity+ RuleScope (..),+ PresenceList (..),+ NestedContainer (..),+ RuleLocation (..),+ renderRuleLocation,++ -- * Relaxations+ RelaxationKind (..),+ TypeAttribute (..),+ KindSet (..),+ renderKindSet,+ parseKindSet,+ RelaxationTarget (..),+ Relaxation (..),+ relaxationId,+ relaxationField,+ relaxationKindName,+ legacyKindName,+ enumerateRelaxations,+ applyRelaxation,++ -- * Suites+ CaseExpectation (..),+ SuiteBundle (..),+ RejectionCase (..),+ Suite (..),+ SuiteError (..),+ loadSuite,+ parseExpectations,++ -- * Evaluation+ Render,+ BaselineFault (..),+ PreparedSuite,+ preparedSuite,+ preparedProfile,+ checkBaseline,+ planRelaxations,+ Observation (..),+ observeRelaxation,+ primaryWitness,+ legacyOutcome,+ compositeUnit,++ -- * Classification+ BehaviourKey,+ behaviourKey,+ withBehaviourKey,+ ExceptionClass (..),+ SweepException (..),+ loadExceptions,+ parseExceptions,+ Outcome (..),+ outcomeName,+ outcomeIsFailing,+ SweepResult (..),+ sweepProfile,+ staleExceptions,+ )+where++import Control.Exception (IOException, try)+import Data.Aeson qualified as Aeson+import Data.Aeson.Decoding.ByteString.Lazy (lbsToTokens)+import Data.Aeson.Decoding.Tokens (TkArray (..), TkRecord (..), Tokens (..))+import Data.Aeson.Key (Key)+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy qualified as LazyBytes+import Data.Foldable (toList)+import Data.List qualified as List+import Data.Map.Lazy qualified as LazyMap+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, listToMaybe, mapMaybe)+import Data.Scientific (toBoundedInteger)+import Data.Text qualified as Text+import Okf.Bundle+ ( BundleError,+ BundleInventory,+ Concept,+ LogFile,+ conceptIdOf,+ conceptType,+ walkBundle,+ walkBundleInventory,+ walkLogs,+ )+import Okf.ConceptId (ConceptId)+import Okf.Index (OkfVersion, VersionDeclaration, readBundleVersion)+import Okf.Prelude hiding (List, Object)+import Okf.Profile+ ( Cardinality (..),+ CompiledProfile,+ EffectiveConceptConstraints,+ EffectiveFieldRule,+ FieldCondition,+ FieldFormat (DocumentHandle),+ FieldPath (..),+ FieldPathSegment (..),+ FieldRequirement,+ FieldRule,+ HandleReferenceRule,+ PathReferenceRule,+ ProfileDefinitionError,+ ProfileSpec,+ ProfileViolation (..),+ TypeRule,+ compileProfile,+ compiledProfileBaseConstraints,+ compiledProfileBaseRules,+ compiledProfileConstraintsForType,+ compiledProfileRequiredBundleVersion,+ compiledProfileRulesForType,+ compiledProfileSpec,+ compiledProfileTypeNames,+ fieldRuleAllowedValues,+ fieldRuleCardinality,+ fieldRuleDescription,+ fieldRuleElementAllowsUnknownFields,+ fieldRuleElementConstraints,+ fieldRuleElementFields,+ fieldRuleFormat,+ fieldRuleObjectAllowsUnknownFields,+ fieldRuleObjectConstraints,+ fieldRuleObjectFields,+ fieldRulePath,+ fieldRulePresenceClauses,+ fieldRuleReference,+ fieldRuleUniqueBy,+ presenceClauseCondition,+ presenceClauseRequirement,+ validateProfileVersion,+ validateProfileWith,+ )+import Okf.Validation+ ( BundleValidationError,+ LogStaleness,+ ValidationProfile (..),+ logStaleness,+ validateBundle,+ validateBundleLogs,+ )+import System.FilePath ((</>))++-- | Where a rule is declared: profile-wide, or under one concept type.+data RuleScope = ProfileScope | TypeScope !Text+ deriving stock (Generic, Eq, Ord, Show)++-- | Which presence list of a 'Okf.Profile.FrontmatterRules' or+-- 'Okf.Profile.NestedRules' holds the rule.+data PresenceList = RequiredList | RecommendedList | OptionalList+ deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)++-- | The two nested record shapes a top-level rule can declare.+data NestedContainer = ElementFields | ObjectFields+ deriving stock (Generic, Eq, Ord, Show)++-- | One authored rule. 'Okf.Profile.compileProfile' rejects a field named twice+-- in one list at one scope, so scope, list and field identify a top-level rule,+-- and the optional nested part identifies a member of its element or object+-- record. Nested rules cannot nest further, so there are at most two levels.+data RuleLocation = RuleLocation+ { scope :: !RuleScope,+ list :: !PresenceList,+ field :: !Text,+ nested :: !(Maybe (NestedContainer, PresenceList, Text))+ }+ deriving stock (Generic, Eq, Ord, Show)++-- | @profile/required/status@, or+-- @type:Objective/required/keyResults/elementFields/required/id@.+renderRuleLocation :: RuleLocation -> Text+renderRuleLocation RuleLocation {scope, list = presenceList, field = fieldName, nested} =+ Text.intercalate "/" $+ [renderScope scope, renderPresenceList presenceList, fieldName]+ <> maybe [] (\(container, innerList, innerField) -> [renderContainer container, renderPresenceList innerList, innerField]) nested++renderScope :: RuleScope -> Text+renderScope = \case+ ProfileScope -> "profile"+ TypeScope typeName -> "type:" <> typeName++renderPresenceList :: PresenceList -> Text+renderPresenceList = \case+ RequiredList -> "required"+ RecommendedList -> "recommended"+ OptionalList -> "optional"++renderContainer :: NestedContainer -> Text+renderContainer = \case+ ElementFields -> "elementFields"+ ObjectFields -> "objectFields"++-- | A type-scope attribute that is not a frontmatter rule.+data TypeAttribute = PathPattern | ResourceScheme | RequireSchemaSection | SchemaColumns | IdPrefix+ deriving stock (Generic, Eq, Ord, Show)++-- | One way to weaken one rule. The first seven after 'Presence' are the kinds+-- the okf-profiles Python sweep generates ('legacyKindName').+data RelaxationKind+ = -- | remove the rule from its list+ Delete+ | -- | move a @required@ rule to @optional@ at the same scope, dropping only its+ -- @when@ gate, which governs presence alone+ Presence+ | -- | @allowedValues@ to @[]@, which means unconstrained+ ClearVocabulary+ | -- | append one value to a non-empty @allowedValues@+ AdmitValue !Text+ | DropFormat+ | DropUniqueBy+ | -- | @allowSelf@ from 'False' to 'True' inside a handle @reference@+ AllowSelf+ | -- | @allowLocal@ from 'False' to 'True' inside a handle @reference@+ AllowLocal+ | DropExternalUriPattern+ | DropCardinality+ | DropCondition+ | DropPath+ | DropReference+ | DropNested !NestedContainer+ | DropTypeAttribute !TypeAttribute+ | -- | composite unit: a list rule's cardinality to 'Any' and its+ -- @elementFields@ dropped together+ DropListShape+ | -- | composite unit: a type's @idPrefix@ and the @DocumentHandle@ format of+ -- its @idField@ rule dropped together+ DropHandleIdentity+ | -- | composite unit: a list rule's @uniqueBy@ dropped and its key member+ -- demoted to @optional@ together, which the descriptor cannot express one+ -- at a time+ DropUniqueKey+ deriving stock (Generic, Eq, Ord, Show)++-- | Which kinds a sweep enumerates. 'LegacyKinds' reproduces the Python sweep;+-- 'DefaultKinds' adds presence demotion; 'AllKinds' adds every other kind.+data KindSet = LegacyKinds | DefaultKinds | AllKinds+ deriving stock (Generic, Eq, Ord, Show)++renderKindSet :: KindSet -> Text+renderKindSet = \case+ LegacyKinds -> "legacy"+ DefaultKinds -> "default"+ AllKinds -> "all"++parseKindSet :: Text -> Maybe KindSet+parseKindSet = \case+ "legacy" -> Just LegacyKinds+ "default" -> Just DefaultKinds+ "all" -> Just AllKinds+ _ -> Nothing++-- | What a relaxation changes: one authored rule, or one type's attributes.+data RelaxationTarget = RuleTarget !RuleLocation | TypeTarget !Text+ deriving stock (Generic, Eq, Ord, Show)++data Relaxation = Relaxation+ { target :: !RelaxationTarget,+ kind :: !RelaxationKind+ }+ deriving stock (Generic, Eq, Ord, Show)++-- | The target followed by @#@ and the kind, such as+-- @type:Launch/required/launchRef#allowLocal@. Stable: exception manifests name+-- relaxations by it.+relaxationId :: Relaxation -> Text+relaxationId Relaxation {target, kind} = renderTarget target <> "#" <> relaxationKindName kind++renderTarget :: RelaxationTarget -> Text+renderTarget = \case+ RuleTarget location -> renderRuleLocation location+ TypeTarget typeName -> "type:" <> typeName++-- | The innermost field name the relaxation weakens, or the empty text for a+-- type attribute. This is the name the legacy case order prefers.+relaxationField :: Relaxation -> Text+relaxationField Relaxation {target} = case target of+ RuleTarget RuleLocation {field = fieldName, nested} ->+ maybe fieldName (\(_, _, innerField) -> innerField) nested+ TypeTarget _ -> ""++relaxationKindName :: RelaxationKind -> Text+relaxationKindName = \case+ Delete -> "delete"+ Presence -> "presence"+ ClearVocabulary -> "allowedValues"+ AdmitValue value -> "admitValue:" <> value+ DropFormat -> "format"+ DropUniqueBy -> "uniqueBy"+ AllowSelf -> "allowSelf"+ AllowLocal -> "allowLocal"+ DropExternalUriPattern -> "externalUriPattern"+ DropCardinality -> "cardinality"+ DropCondition -> "condition"+ DropPath -> "path"+ DropReference -> "reference"+ DropNested container -> renderContainer container+ DropTypeAttribute attribute -> case attribute of+ PathPattern -> "pathPattern"+ ResourceScheme -> "resourceScheme"+ RequireSchemaSection -> "requireSchemaSection"+ SchemaColumns -> "schemaColumns"+ IdPrefix -> "idPrefix"+ DropListShape -> "listShape"+ DropHandleIdentity -> "handleIdentity"+ DropUniqueKey -> "uniqueKey"++-- | The kind string the Python sweep prints, when the kind is one it generates.+legacyKindName :: RelaxationKind -> Maybe Text+legacyKindName kind = case kind of+ Delete -> Just (relaxationKindName kind)+ ClearVocabulary -> Just (relaxationKindName kind)+ DropFormat -> Just (relaxationKindName kind)+ DropUniqueBy -> Just (relaxationKindName kind)+ AllowSelf -> Just (relaxationKindName kind)+ AllowLocal -> Just (relaxationKindName kind)+ DropExternalUriPattern -> Just (relaxationKindName kind)+ _ -> Nothing++-- | Every applicable relaxation of the authored descriptor, in declaration+-- order: profile-wide rules, then each type's rules followed by its attributes;+-- within one scope the @required@, @recommended@ and @optional@ lists in turn;+-- and within one rule its own relaxations before those of its nested members.+--+-- The legacy kinds follow the Python sweep's applicability: @allowedValues@+-- only when non-empty, @format@ and @uniqueBy@ only when set, @allowSelf@ and+-- @allowLocal@ only when 'False' inside a handle @reference@ (never a @path@),+-- @externalUriPattern@ only when set, and @delete@ always. Unlike the Python+-- sweep, a rule that is the sole element of its list can be deleted.+enumerateRelaxations :: KindSet -> ProfileSpec -> [Relaxation]+enumerateRelaxations kinds spec =+ scopeRelaxations kinds ProfileScope (spec ^. #frontmatter . #required) (spec ^. #frontmatter . #recommended) (spec ^. #frontmatter . #optional)+ <> concat+ [ scopeRelaxations kinds (TypeScope (rule ^. #type_)) (rule ^. #frontmatter . #required) (rule ^. #frontmatter . #recommended) (rule ^. #frontmatter . #optional)+ <> typeRelaxations kinds rule+ | rule <- spec ^. #types+ ]++scopeRelaxations :: KindSet -> RuleScope -> [FieldRule] -> [FieldRule] -> [FieldRule] -> [Relaxation]+scopeRelaxations kinds scope required recommended optional =+ concat+ [ fieldRuleRelaxations kinds (RuleLocation scope presenceList (rule ^. #field) Nothing) rule+ | (presenceList, rules) <- [(RequiredList, required), (RecommendedList, recommended), (OptionalList, optional)],+ rule <- rules+ ]++fieldRuleRelaxations :: KindSet -> RuleLocation -> FieldRule -> [Relaxation]+fieldRuleRelaxations kinds location rule =+ map (Relaxation (RuleTarget location)) (ruleKinds kinds (location ^. #list) facts)+ <> nestedRelaxations ElementFields (rule ^. #elementFields)+ <> nestedRelaxations ObjectFields (rule ^. #objectFields)+ where+ facts =+ RuleFacts+ { allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = rule ^. #format,+ reference = rule ^. #reference,+ path = rule ^. #path,+ condition = rule ^. #when,+ uniqueBy = rule ^. #uniqueBy,+ hasElementFields = isJust (rule ^. #elementFields),+ hasObjectFields = isJust (rule ^. #objectFields)+ }+ nestedRelaxations container = \case+ Nothing -> []+ Just nestedRules ->+ concat+ [ map+ (Relaxation (RuleTarget (location & #nested ?~ (container, presenceList, member ^. #field))))+ ( ruleKinds+ kinds+ presenceList+ RuleFacts+ { allowedValues = member ^. #allowedValues,+ cardinality = member ^. #cardinality,+ format = member ^. #format,+ reference = member ^. #reference,+ path = member ^. #path,+ condition = member ^. #when,+ uniqueBy = Nothing,+ hasElementFields = False,+ hasObjectFields = False+ }+ )+ | (presenceList, members) <-+ [ (RequiredList, nestedRules ^. #required),+ (RecommendedList, nestedRules ^. #recommended),+ (OptionalList, nestedRules ^. #optional)+ ],+ member <- members+ ]++-- | What enumeration needs to know about one rule, top-level or nested.+data RuleFacts = RuleFacts+ { allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe FieldFormat),+ reference :: !(Maybe HandleReferenceRule),+ path :: !(Maybe PathReferenceRule),+ condition :: !(Maybe FieldCondition),+ uniqueBy :: !(Maybe Text),+ hasElementFields :: !Bool,+ hasObjectFields :: !Bool+ }+ deriving stock (Generic)++ruleKinds :: KindSet -> PresenceList -> RuleFacts -> [RelaxationKind]+ruleKinds kinds presenceList facts =+ catMaybes+ [ Just Delete,+ Presence <$ guard' (kinds /= LegacyKinds && presenceList == RequiredList),+ ClearVocabulary <$ guard' (not (null (facts ^. #allowedValues))),+ DropFormat <$ facts ^. #format,+ DropUniqueBy <$ facts ^. #uniqueBy,+ AllowSelf <$ guard' (any (not . (^. #allowSelf)) (facts ^. #reference)),+ AllowLocal <$ guard' (any (not . (^. #allowLocal)) (facts ^. #reference)),+ DropExternalUriPattern <$ (facts ^. #reference >>= (^. #externalUriPattern))+ ]+ <> if kinds /= AllKinds+ then []+ else+ catMaybes+ [ DropCardinality <$ guard' (facts ^. #cardinality /= Any),+ DropCondition <$ facts ^. #condition,+ DropPath <$ facts ^. #path,+ DropReference <$ facts ^. #reference,+ DropNested ElementFields <$ guard' (facts ^. #hasElementFields),+ DropNested ObjectFields <$ guard' (facts ^. #hasObjectFields)+ ]++typeRelaxations :: KindSet -> TypeRule -> [Relaxation]+typeRelaxations kinds rule+ | kinds /= AllKinds = []+ | otherwise =+ map (Relaxation (TypeTarget (rule ^. #type_)) . DropTypeAttribute) . catMaybes $+ [ PathPattern <$ rule ^. #pathPattern,+ ResourceScheme <$ rule ^. #resourceScheme,+ RequireSchemaSection <$ guard' (rule ^. #requireSchemaSection),+ SchemaColumns <$ guard' (not (null (rule ^. #schemaColumns))),+ IdPrefix <$ rule ^. #idPrefix+ ]++guard' :: Bool -> Maybe ()+guard' condition = if condition then Just () else Nothing++-- | Perform one relaxation on the authored descriptor. A relaxation whose target+-- does not exist leaves the descriptor unchanged.+applyRelaxation :: Relaxation -> ProfileSpec -> ProfileSpec+applyRelaxation Relaxation {target, kind} spec = case target of+ TypeTarget typeName+ | kind == DropHandleIdentity ->+ spec+ & #types+ . traverse+ . filtered ((== typeName) . (^. #type_))+ %~ \rule ->+ rule+ & #idPrefix+ .~ Nothing+ & #frontmatter+ %~ dropHandleFormat+ | otherwise ->+ spec & #types . traverse . filtered ((== typeName) . (^. #type_)) %~ relaxTypeRule kind+ RuleTarget location@RuleLocation {scope} -> case scope of+ ProfileScope -> spec & #frontmatter %~ relaxScope location kind+ TypeScope typeName ->+ spec & #types . traverse . filtered ((== typeName) . (^. #type_)) . #frontmatter %~ relaxScope location kind+ where+ dropHandleFormat rules = case spec ^. #idField of+ Nothing -> rules+ Just idField ->+ foldr+ (\presenceList -> presenceListOf presenceList . traverse . filtered (named idField) . #format %~ dropHandle)+ rules+ [RequiredList, RecommendedList, OptionalList]+ dropHandle = \case+ Just (DocumentHandle _) -> Nothing+ other -> other++relaxScope ::+ ( HasField' "required" rules [FieldRule],+ HasField' "recommended" rules [FieldRule],+ HasField' "optional" rules [FieldRule]+ ) =>+ RuleLocation ->+ RelaxationKind ->+ rules ->+ rules+relaxScope RuleLocation {list = presenceList, field = fieldName, nested} kind rules = case nested of+ Nothing -> relaxInList presenceList fieldName kind relaxFieldRule rules+ Just (container, innerList, innerField)+ | kind == DropUniqueKey ->+ rules+ & presenceListOf presenceList+ . traverse+ . filtered (named fieldName)+ %~ \rule ->+ rule+ & #uniqueBy+ .~ Nothing+ & containerOf container+ . _Just+ %~ relaxInList innerList innerField Presence relaxCommon+ | otherwise ->+ rules+ & presenceListOf presenceList+ . traverse+ . filtered (named fieldName)+ . containerOf container+ . _Just+ %~ relaxInList innerList innerField kind relaxCommon+ where+ containerOf = \case+ ElementFields -> #elementFields+ ObjectFields -> #objectFields++relaxInList ::+ ( HasField' "required" rules [rule],+ HasField' "recommended" rules [rule],+ HasField' "optional" rules [rule],+ HasField' "field" rule Text,+ HasField' "when" rule (Maybe FieldCondition)+ ) =>+ PresenceList ->+ Text ->+ RelaxationKind ->+ (RelaxationKind -> rule -> rule) ->+ rules ->+ rules+relaxInList presenceList fieldName kind relaxOne rules = case kind of+ Delete -> rules & presenceListOf presenceList %~ filter (not . named fieldName)+ Presence+ | presenceList == RequiredList ->+ let demoted = [rule & field' @"when" .~ Nothing | rule <- rules ^. presenceListOf presenceList, named fieldName rule]+ in rules+ & presenceListOf presenceList+ %~ filter (not . named fieldName)+ & presenceListOf OptionalList+ %~ (<> demoted)+ | otherwise -> rules+ _ -> rules & presenceListOf presenceList . traverse . filtered (named fieldName) %~ relaxOne kind++presenceListOf ::+ ( HasField' "required" rules [rule],+ HasField' "recommended" rules [rule],+ HasField' "optional" rules [rule]+ ) =>+ PresenceList ->+ Lens' rules [rule]+presenceListOf = \case+ RequiredList -> field' @"required"+ RecommendedList -> field' @"recommended"+ OptionalList -> field' @"optional"++named :: (HasField' "field" rule Text) => Text -> rule -> Bool+named fieldName rule = rule ^. field' @"field" == fieldName++relaxFieldRule :: RelaxationKind -> FieldRule -> FieldRule+relaxFieldRule kind rule = case kind of+ DropUniqueBy -> rule & #uniqueBy .~ Nothing+ DropNested ElementFields -> rule & #elementFields .~ Nothing+ DropNested ObjectFields -> rule & #objectFields .~ Nothing+ DropListShape -> rule & #cardinality .~ Any & #elementFields .~ Nothing+ _ -> relaxCommon kind rule++-- | The relaxations a top-level 'FieldRule' and a nested+-- 'Okf.Profile.NestedFieldRule' share.+relaxCommon ::+ ( HasField' "allowedValues" rule [Text],+ HasField' "cardinality" rule Cardinality,+ HasField' "format" rule (Maybe FieldFormat),+ HasField' "reference" rule (Maybe HandleReferenceRule),+ HasField' "path" rule (Maybe PathReferenceRule),+ HasField' "when" rule (Maybe FieldCondition)+ ) =>+ RelaxationKind ->+ rule ->+ rule+relaxCommon kind rule = case kind of+ ClearVocabulary -> rule & field' @"allowedValues" .~ []+ AdmitValue value -> rule & field' @"allowedValues" %~ (<> [value])+ DropFormat -> rule & field' @"format" .~ Nothing+ AllowSelf -> rule & field' @"reference" . _Just . #allowSelf .~ True+ AllowLocal -> rule & field' @"reference" . _Just . #allowLocal .~ True+ DropExternalUriPattern -> rule & field' @"reference" . _Just . #externalUriPattern .~ Nothing+ DropCardinality -> rule & field' @"cardinality" .~ Any+ DropCondition -> rule & field' @"when" .~ Nothing+ DropPath -> rule & field' @"path" .~ Nothing+ DropReference -> rule & field' @"reference" .~ Nothing+ _ -> rule++relaxTypeRule :: RelaxationKind -> TypeRule -> TypeRule+relaxTypeRule kind rule = case kind of+ DropTypeAttribute PathPattern -> rule & #pathPattern .~ Nothing+ DropTypeAttribute ResourceScheme -> rule & #resourceScheme .~ Nothing+ DropTypeAttribute RequireSchemaSection -> rule & #requireSchemaSection .~ False+ DropTypeAttribute SchemaColumns -> rule & #schemaColumns .~ []+ DropTypeAttribute IdPrefix -> rule & #idPrefix .~ Nothing+ _ -> rule++-- | What one rejection case must produce: @count@ deviation lines, at least one+-- of which contains @field@.+data CaseExpectation = CaseExpectation+ { field :: !Text,+ count :: !Int+ }+ deriving stock (Generic, Eq, Show)++-- | One bundle, loaded once: its concepts, file inventory, version declaration+-- and logs.+data SuiteBundle = SuiteBundle+ { path :: !FilePath,+ concepts :: ![Concept],+ inventory :: !BundleInventory,+ declaration :: !VersionDeclaration,+ logs :: ![LogFile]+ }+ deriving stock (Generic)++data RejectionCase = RejectionCase+ { name :: !Text,+ expectation :: !CaseExpectation,+ bundle :: !SuiteBundle+ }+ deriving stock (Generic)++-- | The acceptance bundles and the rejection cases, the cases in the order+-- @expected.json@ lists them.+data Suite = Suite+ { acceptance :: ![SuiteBundle],+ cases :: ![RejectionCase]+ }+ deriving stock (Generic)++data SuiteError+ = -- | a bundle could not be walked or its version declaration read+ SuiteBundleError !FilePath !BundleError+ | -- | @expected.json@ is missing or malformed (path, reason)+ ExpectationsError !FilePath !Text+ deriving stock (Generic, Eq, Show)++-- | Load the acceptance bundles and the cases directory. The cases are the+-- subdirectories @expected.json@ names, in the order it names them; a+-- subdirectory it does not name is not a case.+loadSuite :: [FilePath] -> FilePath -> IO (Either SuiteError Suite)+loadSuite acceptPaths casesDir = do+ let expectedPath = casesDir </> "expected.json"+ read' <- try (LazyBytes.readFile expectedPath)+ case read' of+ Left (readError :: IOException) -> pure (Left (ExpectationsError expectedPath (Text.pack (show readError))))+ Right bytes -> case parseExpectations bytes of+ Left reason -> pure (Left (ExpectationsError expectedPath reason))+ Right expectations -> do+ accepted <- traverse loadSuiteBundle acceptPaths+ rejected <-+ for expectations $ \(caseName, expectation) ->+ fmap (RejectionCase caseName expectation) <$> loadSuiteBundle (casesDir </> Text.unpack caseName)+ pure (Suite <$> sequence accepted <*> sequence rejected)++loadSuiteBundle :: FilePath -> IO (Either SuiteError SuiteBundle)+loadSuiteBundle bundlePath = do+ concepts <- walkBundle bundlePath+ inventory <- walkBundleInventory bundlePath+ declaration <- readBundleVersion bundlePath+ logs <- walkLogs bundlePath+ pure . first (SuiteBundleError bundlePath) $+ SuiteBundle bundlePath <$> concepts <*> inventory <*> declaration <*> logs++-- | Read @expected.json@: an object mapping each case name either to the field+-- name its diagnostic must contain, or to @{"field": …, "diagnostics": N}@.+--+-- Aeson's object type does not keep key order, and the legacy witness is the+-- first case that notices, so the order is read from the token stream and the+-- values from the decoded object. A repeated key keeps its first position.+parseExpectations :: LazyBytes.ByteString -> Either Text [(Text, CaseExpectation)]+parseExpectations bytes = do+ value <- first Text.pack (Aeson.eitherDecode bytes)+ entries <- case value of+ Aeson.Object entries -> Right entries+ _ -> Left "expected a JSON object mapping case names to expectations"+ order <- first Text.pack (topLevelKeys (lbsToTokens bytes))+ for (List.nub order) $ \key ->+ let caseName = Key.toText key+ in case KeyMap.lookup key entries of+ Just (String fieldName) -> Right (caseName, CaseExpectation fieldName 1)+ Just (Aeson.Object spec)+ | Just (String fieldName) <- KeyMap.lookup "field" spec,+ Just (Number diagnostics) <- KeyMap.lookup "diagnostics" spec,+ Just count <- toBoundedInteger diagnostics,+ count >= 0 ->+ Right (caseName, CaseExpectation fieldName count)+ _ ->+ Left+ ( "case "+ <> caseName+ <> ": expected a field name or {\"field\": …, \"diagnostics\": N}"+ )++topLevelKeys :: Tokens k String -> Either String [Key]+topLevelKeys = \case+ TkRecordOpen record -> keysOf record+ TkErr err -> Left err+ _ -> Left "expected a JSON object"+ where+ keysOf = \case+ TkPair key value -> (key :) <$> (skipValue value >>= keysOf)+ TkRecordEnd _ -> Right []+ TkRecordErr err -> Left err++-- | Skip one JSON value in a token stream, returning what follows it.+skipValue :: Tokens k String -> Either String k+skipValue = \case+ TkLit _ rest -> Right rest+ TkText _ rest -> Right rest+ TkNumber _ rest -> Right rest+ TkArrayOpen items -> skipItems items+ TkRecordOpen pairs -> skipPairs pairs+ TkErr err -> Left err+ where+ skipItems = \case+ TkItem value -> skipValue value >>= skipItems+ TkArrayEnd rest -> Right rest+ TkArrayErr err -> Left err+ skipPairs = \case+ TkPair _ value -> skipValue value >>= skipPairs+ TkRecordEnd rest -> Right rest+ TkRecordErr err -> Left err++-- | How the CLI renders one deviation, given the compiled profile it came from+-- and the bundle's concepts.+type Render = CompiledProfile -> [Concept] -> ProfileViolation -> Text++-- | Why a sweep cannot start: the unrelaxed profile and suite disagree.+data BaselineFault+ = -- | an acceptance bundle fails core validation or has stale logs, which no+ -- relaxation can change+ AcceptanceCoreInvalid !FilePath ![BundleValidationError] ![LogStaleness]+ | -- | an acceptance bundle deviates from the unrelaxed profile (rendered lines)+ AcceptanceProfileDeviations !FilePath ![Text]+ | -- | a case does not meet its expectation under the unrelaxed profile+ -- (case, expectation, rendered deviation lines)+ CaseExpectationUnmet !Text !CaseExpectation ![Text]+ deriving stock (Generic, Eq, Show)++-- | A suite whose baseline holds, with what every relaxation is compared to.+data PreparedSuite = PreparedSuite+ { suite :: !Suite,+ render :: !Render,+ baseline :: !CompiledProfile,+ -- | each case's violations under the unrelaxed profile, in case order+ baselineViolations :: ![[ProfileViolation]],+ keyOf :: !(CompiledProfile -> BehaviourKey),+ baselineKey :: !BehaviourKey+ }+ deriving stock (Generic)++preparedSuite :: PreparedSuite -> Suite+preparedSuite prepared = prepared ^. #suite++preparedProfile :: PreparedSuite -> CompiledProfile+preparedProfile prepared = prepared ^. #baseline++-- | Replace the behaviour key a prepared suite proves inertness with. For tests+-- of the inertness contradiction only: a key that erases too much makes a+-- relaxation that some case notices look inert.+withBehaviourKey :: (CompiledProfile -> BehaviourKey) -> PreparedSuite -> PreparedSuite+withBehaviourKey keyFunction prepared =+ prepared & #keyOf .~ keyFunction & #baselineKey .~ keyFunction (prepared ^. #baseline)++-- | Check the unrelaxed profile against the suite: every acceptance bundle+-- passes as @okf validate --strict --profile-enforce --log-enforce@ decides, and+-- every case meets its expectation. Reports every fault, not only the first.+checkBaseline :: Render -> CompiledProfile -> Suite -> Either (NonEmpty BaselineFault) PreparedSuite+checkBaseline renderViolation compiled suite =+ case acceptanceFaults <> caseFaults of+ firstFault : moreFaults -> Left (firstFault :| moreFaults)+ [] ->+ Right+ PreparedSuite+ { suite,+ render = renderViolation,+ baseline = compiled,+ baselineViolations = caseViolations,+ keyOf = behaviourKey,+ baselineKey = behaviourKey compiled+ }+ where+ acceptanceFaults = concatMap acceptanceFault (suite ^. #acceptance)+ acceptanceFault bundle'+ | not (null coreErrors && null staleness) = [AcceptanceCoreInvalid (bundle' ^. #path) coreErrors staleness]+ | not (null violations) = [AcceptanceProfileDeviations (bundle' ^. #path) (deviationLines renderViolation compiled bundle' violations)]+ | otherwise = []+ where+ coreErrors =+ validateBundle StrictAuthoring (bundle' ^. #declaration) (bundle' ^. #inventory) (bundle' ^. #concepts)+ <> validateBundleLogs (bundle' ^. #logs)+ staleness = logStaleness (bundle' ^. #concepts) (bundle' ^. #logs)+ violations = profileViolations StrictAuthoring compiled bundle'+ caseViolations = [profileViolations PermissiveConformance compiled (rejection ^. #bundle) | rejection <- suite ^. #cases]+ caseFaults =+ [ CaseExpectationUnmet (rejection ^. #name) (rejection ^. #expectation) lines'+ | (rejection, violations) <- zip (suite ^. #cases) caseViolations,+ let lines' = deviationLines renderViolation compiled (rejection ^. #bundle) violations,+ not (meetsExpectation (rejection ^. #expectation) lines')+ ]++-- | The relaxations a sweep evaluates, in order. Outside 'LegacyKinds', an+-- @allowedValues@ relaxation that makes the descriptor invalid (a @when@+-- condition needs the vocabulary) is followed by one @admitValue@ relaxation+-- per distinct value that some case's baseline 'ValueNotInVocabulary' violation+-- reports at that rule, so that the vocabulary can still be shown load-bearing.+planRelaxations :: KindSet -> PreparedSuite -> [Relaxation]+planRelaxations kinds prepared =+ concatMap expand (enumerateRelaxations kinds spec)+ where+ spec = compiledProfileSpec (prepared ^. #baseline)+ expand relaxation@Relaxation {target, kind}+ | kinds /= LegacyKinds,+ kind == ClearVocabulary,+ Left _ <- compileProfile (applyRelaxation relaxation spec) =+ relaxation : [Relaxation target (AdmitValue value) | value <- admissibleValues target]+ | otherwise = [relaxation]+ admissibleValues target =+ List.nub+ [ value+ | (rejection, violations) <- zip (prepared ^. #suite . #cases) (prepared ^. #baselineViolations),+ ValueNotInVocabulary conceptId path _ (String value) <- violations,+ inScope target (conceptTypes rejection) conceptId,+ pathNames path == targetPath target+ ]++-- | What one relaxation did to the suite.+data Observation = Observation+ { -- | non-empty when the relaxed descriptor no longer compiles+ definitionErrors :: ![ProfileDefinitionError],+ -- | acceptance bundles that deviate from the relaxed profile, with their+ -- rendered lines+ acceptanceDeviations :: ![(FilePath, [Text])],+ -- | cases that no longer meet their expectation, in legacy order: cases+ -- expecting the relaxed field first, then the rest in @expected.json@ order+ witnesses :: ![Text],+ -- | cases in which a baseline violation of the relaxation's own kind at the+ -- relaxed rule disappears, whether or not the case still fails otherwise+ facetWitnesses :: ![Text],+ -- | the relaxed profile compiles to the baseline's 'BehaviourKey'+ behaviourUnchanged :: !Bool+ }+ deriving stock (Generic, Eq, Show)++-- | The witness the Python sweep would name: the first in legacy order.+primaryWitness :: Observation -> Maybe Text+primaryWitness observation = listToMaybe (observation ^. #witnesses)++-- | The Python sweep's verdict, with its precedence: a descriptor that does not+-- compile or an acceptance deviation is @acceptance-fails@, then the primary+-- witness, then @UNPROTECTED@.+legacyOutcome :: Observation -> Text+legacyOutcome observation+ | not (null (observation ^. #definitionErrors)) = "acceptance-fails"+ | not (null (observation ^. #acceptanceDeviations)) = "acceptance-fails"+ | Just witness <- primaryWitness observation = "fixture " <> witness+ | otherwise = "UNPROTECTED"++-- | Apply one relaxation, recompile, and evaluate every acceptance bundle and+-- every case. All witnesses are collected, not only the first.+observeRelaxation :: PreparedSuite -> Relaxation -> Observation+observeRelaxation prepared relaxation =+ case compileProfile (applyRelaxation relaxation (compiledProfileSpec (prepared ^. #baseline))) of+ Left errors -> Observation (toList errors) [] [] [] False+ Right compiled ->+ let evaluated =+ [ (rejection, before, after)+ | (rejection, before) <- zip (prepared ^. #suite . #cases) (prepared ^. #baselineViolations),+ let after = profileViolations PermissiveConformance compiled (rejection ^. #bundle)+ ]+ noticed =+ [ (rejection ^. #name, rejection ^. #expectation . #field)+ | (rejection, _, after) <- evaluated,+ not (meetsExpectation (rejection ^. #expectation) (deviationLines renderViolation compiled (rejection ^. #bundle) after))+ ]+ in Observation+ { definitionErrors = [],+ acceptanceDeviations =+ [ (bundle' ^. #path, deviationLines renderViolation compiled bundle' violations)+ | bundle' <- prepared ^. #suite . #acceptance,+ let violations = profileViolations StrictAuthoring compiled bundle',+ not (null violations)+ ],+ witnesses = map fst (legacyOrder (relaxationField relaxation) noticed),+ facetWitnesses =+ [ rejection ^. #name+ | (rejection, before, after) <- evaluated,+ any (\violation -> isFacet (conceptTypes rejection) violation && violation `notElem` after) before+ ],+ behaviourUnchanged = (prepared ^. #keyOf) compiled == prepared ^. #baselineKey+ }+ where+ renderViolation = prepared ^. #render+ facets = kindFacets (relaxation ^. #kind)+ isFacet typesById violation =+ or+ [ facet `elem` facets && inScope (relaxation ^. #target) typesById conceptId && facetPathMatches facet names (targetPath (relaxation ^. #target))+ | (conceptId, facet, names) <- violationFacets violation+ ]++-- | Order (case, expected field) pairs as the Python sweep does: cases+-- expecting the given field first, the rest after, each group in+-- @expected.json@ order (a stable sort).+legacyOrder :: Text -> [(Text, Text)] -> [(Text, Text)]+legacyOrder fieldName = List.sortOn ((/= fieldName) . snd)++conceptTypes :: RejectionCase -> Map ConceptId Text+conceptTypes rejection = Map.fromList [(conceptIdOf concept, conceptType concept) | concept <- rejection ^. #bundle . #concepts]++inScope :: RelaxationTarget -> Map ConceptId Text -> ConceptId -> Bool+inScope target typesById conceptId = case target of+ RuleTarget RuleLocation {scope = ProfileScope} -> True+ RuleTarget RuleLocation {scope = TypeScope typeName} -> Map.lookup conceptId typesById == Just typeName+ TypeTarget typeName -> Map.lookup conceptId typesById == Just typeName++-- | The field names a relaxation's rule governs, outermost first; empty for a+-- type attribute.+targetPath :: RelaxationTarget -> [Text]+targetPath = \case+ RuleTarget RuleLocation {field = fieldName, nested} -> fieldName : maybe [] (\(_, _, innerField) -> [innerField]) nested+ TypeTarget _ -> []++pathNames :: FieldPath -> [Text]+pathNames (FieldPath segments) = [name' | FieldName name' <- toList segments]++-- | Which kind of check a violation reports, so a relaxation can be credited+-- with the disappearance of exactly the checks it weakens.+data Facet+ = MissingFacet+ | VocabularyFacet+ | FormatFacet+ | UniquenessFacet+ | LocalFacet+ | SelfFacet+ | PatternFacet+ | CardinalityFacet+ | ReferenceFacet+ | PathFacet+ | PathPatternFacet+ | ResourceFacet+ | SchemaSectionFacet+ | SchemaColumnsFacet+ | DocumentIdFacet+ deriving stock (Eq, Show)++violationFacets :: ProfileViolation -> [(ConceptId, Facet, [Text])]+violationFacets = \case+ MissingProfileField conceptId key _ -> [(conceptId, MissingFacet, [key])]+ MissingNestedProfileField conceptId path _ -> [(conceptId, MissingFacet, pathNames path)]+ ValueNotInVocabulary conceptId path _ _ -> [(conceptId, VocabularyFacet, pathNames path)]+ ValueFormatMismatch conceptId path _ _ -> [(conceptId, FormatFacet, pathNames path)]+ DuplicateNestedFieldValue conceptId path _ _ -> [(conceptId, UniquenessFacet, pathNames path)]+ CardinalityMismatch conceptId path _ _ -> [(conceptId, CardinalityFacet, pathNames path)]+ LocalDocumentReferenceNotAllowed conceptId path _ -> reference conceptId path [LocalFacet]+ SelfDocumentReference conceptId path _ -> reference conceptId path [SelfFacet, PathFacet]+ ExternalReferencePatternMismatch conceptId path _ _ -> reference conceptId path [PatternFacet]+ DanglingHandleReference conceptId path _ -> reference conceptId path []+ ReferenceHandlePrefixMismatch conceptId path _ _ -> reference conceptId path []+ MalformedDocumentReference conceptId path _ -> reference conceptId path [PathFacet]+ ExternalReferenceSchemeNotAllowed conceptId path _ _ -> reference conceptId path [PathFacet]+ MalformedPathReference conceptId path _ -> [(conceptId, PathFacet, pathNames path)]+ PathEscapesBundle conceptId path _ -> [(conceptId, PathFacet, pathNames path)]+ DanglingPathReference conceptId path _ -> [(conceptId, PathFacet, pathNames path)]+ PathPatternMismatch conceptId _ _ -> [(conceptId, PathPatternFacet, [])]+ MissingResource conceptId _ _ -> [(conceptId, ResourceFacet, [])]+ ResourceSchemeMismatch conceptId _ _ -> [(conceptId, ResourceFacet, [])]+ MissingSchemaSection conceptId _ -> [(conceptId, SchemaSectionFacet, [])]+ SchemaColumnsMismatch conceptId _ _ _ -> [(conceptId, SchemaColumnsFacet, [])]+ MissingDocumentId conceptId _ _ -> [(conceptId, DocumentIdFacet, [])]+ MalformedDocumentId conceptId _ _ -> [(conceptId, DocumentIdFacet, [])]+ _ -> []+ where+ reference conceptId path extra = [(conceptId, facet, pathNames path) | facet <- ReferenceFacet : extra]++kindFacets :: RelaxationKind -> [Facet]+kindFacets = \case+ Delete -> [MissingFacet]+ Presence -> [MissingFacet]+ ClearVocabulary -> [VocabularyFacet]+ AdmitValue _ -> [VocabularyFacet]+ DropFormat -> [FormatFacet]+ DropUniqueBy -> [UniquenessFacet]+ AllowSelf -> [SelfFacet]+ AllowLocal -> [LocalFacet]+ DropExternalUriPattern -> [PatternFacet]+ DropCardinality -> [CardinalityFacet]+ DropCondition -> [MissingFacet]+ DropPath -> [PathFacet]+ DropReference -> [ReferenceFacet]+ DropNested _ -> []+ DropTypeAttribute attribute -> case attribute of+ PathPattern -> [PathPatternFacet]+ ResourceScheme -> [ResourceFacet]+ RequireSchemaSection -> [SchemaSectionFacet]+ SchemaColumns -> [SchemaColumnsFacet]+ IdPrefix -> [DocumentIdFacet]+ DropListShape -> [CardinalityFacet]+ DropHandleIdentity -> [DocumentIdFacet, FormatFacet]+ DropUniqueKey -> [MissingFacet, UniquenessFacet]++-- | A facet violation belongs to a rule when its field names are the rule's;+-- a uniqueness violation names the duplicated member below the list rule, and a+-- type attribute's violations name no field at all.+facetPathMatches :: Facet -> [Text] -> [Text] -> Bool+facetPathMatches facet names rulePath = case facet of+ UniquenessFacet -> rulePath `List.isPrefixOf` names+ _ | null rulePath -> True+ _ -> names == rulePath++profileViolations :: ValidationProfile -> CompiledProfile -> SuiteBundle -> [ProfileViolation]+profileViolations mode compiled bundle' =+ validateProfileVersion (bundle' ^. #declaration) compiled+ <> validateProfileWith (bundle' ^. #inventory) mode compiled (bundle' ^. #concepts)++-- | The deviation lines @okf validate@ prints, as the catalog's harness reads+-- them: each violation is printed behind @profile: @, and a line counts when it+-- starts with that prefix and is not the advisory summary.+deviationLines :: Render -> CompiledProfile -> SuiteBundle -> [ProfileViolation] -> [Text]+deviationLines renderViolation compiled bundle' =+ filter isDeviationLine . concatMap (Text.lines . ("profile: " <>) . renderViolation compiled (bundle' ^. #concepts))+ where+ isDeviationLine line =+ "profile: " `Text.isPrefixOf` line && not ("advisory deviation(s)" `Text.isInfixOf` line)++meetsExpectation :: CaseExpectation -> [Text] -> Bool+meetsExpectation CaseExpectation {field = fieldName, count} lines' =+ length lines' == count && any (fieldName `Text.isInfixOf`) lines'++-- | The relaxation that weakens a whole composite policy unit of which this+-- relaxation weakens one member, per the okf-profiles catalog's ADR-9: a+-- member's effect can be masked by the unit's other members, so a member no+-- case notices is covered when some case notices the unit.+--+-- The units are a handle @reference@ (members @allowLocal@, @allowSelf@ and+-- @externalUriPattern@), a list rule's shape (its 'List' cardinality and its+-- @elementFields@), and a type's handle identity (its @idPrefix@ and the+-- @DocumentHandle@ format of its @idField@ rule). A fourth is okf's own: a+-- list rule's @uniqueBy@ and its key member's presence, because+-- 'Okf.Profile.compileProfile' rejects a @uniqueBy@ key that is not+-- unconditionally required, so deleting or demoting the key alone never+-- compiles.+compositeUnit :: ProfileSpec -> Relaxation -> Maybe Relaxation+compositeUnit spec Relaxation {target, kind} = case (target, kind) of+ (RuleTarget _, AllowLocal) -> Just (Relaxation target DropReference)+ (RuleTarget _, AllowSelf) -> Just (Relaxation target DropReference)+ (RuleTarget _, DropExternalUriPattern) -> Just (Relaxation target DropReference)+ (RuleTarget location@RuleLocation {nested = Nothing}, DropCardinality)+ | Just rule <- topLevelRule spec location,+ rule ^. #cardinality == List,+ isJust (rule ^. #elementFields) ->+ Just (Relaxation target DropListShape)+ (RuleTarget RuleLocation {nested = Nothing}, DropNested ElementFields) -> Just (Relaxation target DropListShape)+ (RuleTarget location@RuleLocation {scope = TypeScope typeName, field = fieldName, nested = Nothing}, DropFormat)+ | spec ^. #idField == Just fieldName,+ Just rule <- topLevelRule spec location,+ Just (DocumentHandle _) <- rule ^. #format,+ typeHasIdPrefix typeName ->+ Just (Relaxation (TypeTarget typeName) DropHandleIdentity)+ (TypeTarget typeName, DropTypeAttribute IdPrefix) -> Just (Relaxation (TypeTarget typeName) DropHandleIdentity)+ (RuleTarget location@RuleLocation {nested = Just (ElementFields, RequiredList, key)}, memberKind)+ | memberKind == Delete || memberKind == Presence,+ Just rule <- topLevelRule spec location,+ rule ^. #uniqueBy == Just key ->+ Just (Relaxation target DropUniqueKey)+ _ -> Nothing+ where+ typeHasIdPrefix typeName = any (\rule -> rule ^. #type_ == typeName && isJust (rule ^. #idPrefix)) (spec ^. #types)++topLevelRule :: ProfileSpec -> RuleLocation -> Maybe FieldRule+topLevelRule spec RuleLocation {scope, list = presenceList, field = fieldName} =+ listToMaybe (filter (named fieldName) (concatMap (^. presenceListOf presenceList) scopes))+ where+ scopes = case scope of+ ProfileScope -> [spec ^. #frontmatter]+ TypeScope typeName -> [rule ^. #frontmatter | rule <- spec ^. #types, rule ^. #type_ == typeName]++-- | What a compiled profile makes the validator do, with what it provably never+-- consults erased, so that two compiled profiles with equal keys produce the+-- same violations on every bundle. Built from the public inspection accessors.+--+-- The erasures, each with the function that proves it:+--+-- * Descriptions and guidance. The validator never reads them; the CLI's+-- renderer reads a field's description only for a missing-field violation,+-- which only a rule with a presence clause produces, so a description is kept+-- exactly when the effective rule has one.+-- * A rule that constrains nothing (no presence clause, no vocabulary, 'Any'+-- cardinality, no format, nested shape, reference, path or uniqueness) in an+-- open record: 'validateProfileWith' checks a present key only against its+-- rule, and reports an undeclared key only when the record is closed.+-- * @allowSelf@ in a handle reference whose effective @allowLocal@ is 'False':+-- @validateReferenceText@ rejects every local handle before it reads+-- @allowSelf@.+-- * @externalUriPattern@ when @externalUriSchemes@ is empty:+-- @validateReferenceText@ reads the pattern only for a URI whose scheme is+-- allowed.+data BehaviourKey = BehaviourKey+ { allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ requiredBundleVersion :: !(Maybe OkfVersion),+ typeAttributes :: ![(Text, Maybe Text, Maybe Text, Bool, [Text], Maybe Text)],+ baseRules :: !(Map Text RuleKey),+ baseConstraints :: !EffectiveConceptConstraints,+ typeRules :: ![(Text, Map Text RuleKey, EffectiveConceptConstraints)]+ }+ deriving stock (Generic, Eq, Show)++data RuleKey = RuleKey+ { presence :: ![(FieldRequirement, Maybe FieldCondition)],+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe FieldFormat),+ elementFields :: !(Maybe (Map Text RuleKey)),+ objectFields :: !(Maybe (Map Text RuleKey)),+ elementOpen :: !(Maybe Bool),+ objectOpen :: !(Maybe Bool),+ reference :: !(Maybe HandleReferenceRule),+ path :: !(Maybe PathReferenceRule),+ uniqueBy :: !(Maybe Text),+ elementConstraints :: !EffectiveConceptConstraints,+ objectConstraints :: !EffectiveConceptConstraints+ }+ deriving stock (Generic, Eq, Show)++behaviourKey :: CompiledProfile -> BehaviourKey+behaviourKey compiled =+ BehaviourKey+ { allowUnknownTypes = spec ^. #allowUnknownTypes,+ allowUnknownFields = open,+ idField = spec ^. #idField,+ requiredBundleVersion = compiledProfileRequiredBundleVersion compiled,+ typeAttributes =+ [ (rule ^. #type_, rule ^. #pathPattern, rule ^. #resourceScheme, rule ^. #requireSchemaSection, rule ^. #schemaColumns, rule ^. #idPrefix)+ | rule <- spec ^. #types+ ],+ baseRules = rulesKey open (compiledProfileBaseRules compiled),+ baseConstraints = compiledProfileBaseConstraints compiled,+ typeRules =+ [ (typeName, rulesKey open (compiledProfileRulesForType compiled typeName), compiledProfileConstraintsForType compiled typeName)+ | typeName <- compiledProfileTypeNames compiled+ ]+ }+ where+ spec = compiledProfileSpec compiled+ open = spec ^. #allowUnknownFields++rulesKey :: Bool -> Map Text EffectiveFieldRule -> Map Text RuleKey+rulesKey open = Map.mapMaybe $ \rule ->+ let key = ruleKey rule+ in if open && constrainsNothing key then Nothing else Just key++ruleKey :: EffectiveFieldRule -> RuleKey+ruleKey rule =+ RuleKey+ { presence,+ description = if null presence then Nothing else fieldRuleDescription rule,+ allowedValues = fieldRuleAllowedValues rule,+ cardinality = fieldRuleCardinality rule,+ format = fieldRuleFormat rule,+ elementFields = rulesKey (elementOpen == Just True) <$> fieldRuleElementFields rule,+ objectFields = rulesKey (objectOpen == Just True) <$> fieldRuleObjectFields rule,+ elementOpen,+ objectOpen,+ reference = eraseUnread <$> fieldRuleReference rule,+ path = fieldRulePath rule,+ uniqueBy = fieldRuleUniqueBy rule,+ elementConstraints = fieldRuleElementConstraints rule,+ objectConstraints = fieldRuleObjectConstraints rule+ }+ where+ presence = [(presenceClauseRequirement clause, presenceClauseCondition clause) | clause <- fieldRulePresenceClauses rule]+ elementOpen = fieldRuleElementAllowsUnknownFields rule+ objectOpen = fieldRuleObjectAllowsUnknownFields rule+ eraseUnread policy =+ policy+ & (if policy ^. #allowLocal then id else #allowSelf .~ False)+ & (if null (policy ^. #externalUriSchemes) then #externalUriPattern .~ Nothing else id)++constrainsNothing :: RuleKey -> Bool+constrainsNothing key =+ null (key ^. #presence)+ && null (key ^. #allowedValues)+ && key ^. #cardinality == Any+ && isNothing (key ^. #format)+ && isNothing (key ^. #elementFields)+ && isNothing (key ^. #objectFields)+ && isNothing (key ^. #reference)+ && isNothing (key ^. #path)+ && isNothing (key ^. #uniqueBy)++-- | Why okf cannot decide a relaxation itself, as documented beside the cases.+data ExceptionClass+ = -- | the rule is spliced from a shared family tested in its home profile+ SharedFamily !Text+ | -- | another rule or type attribute governs the same value+ OverlappingRule !Text+ | -- | a known gap, deferred to the named request (a @mori://@ URI)+ Deferred !Text+ deriving stock (Generic, Eq, Show)++data SweepException = SweepException+ { relaxation :: !Text,+ exceptionClass :: !ExceptionClass,+ rationale :: !Text+ }+ deriving stock (Generic, Eq, Show)++loadExceptions :: FilePath -> IO (Either Text [SweepException])+loadExceptions manifestPath = do+ read' <- try (LazyBytes.readFile manifestPath)+ pure $ case read' of+ Left (readError :: IOException) -> Left (Text.pack (show readError))+ Right bytes -> parseExceptions bytes++-- | Read an exception manifest:+-- @{"version": 1, "exceptions": [{"relaxation": ID, "class": C, …, "rationale": R}]}@.+-- The class is closed: @shared-family@ needs @home@, @overlapping-rule@ needs+-- @overlapsWith@, and @deferred@ needs @request@, a @mori://@ URI.+parseExceptions :: LazyBytes.ByteString -> Either Text [SweepException]+parseExceptions bytes = do+ value <- first Text.pack (Aeson.eitherDecode bytes)+ manifest <- case value of+ Aeson.Object manifest -> Right manifest+ _ -> Left "expected a JSON object with version and exceptions"+ case KeyMap.lookup "version" manifest of+ Just (Number 1) -> Right ()+ _ -> Left "unsupported manifest version; expected \"version\": 1"+ entries <- case KeyMap.lookup "exceptions" manifest of+ Just (Array entries) -> Right (toList entries)+ _ -> Left "expected an \"exceptions\" array"+ for (zip [0 :: Int ..] entries) $ \(entryIndex, entry) ->+ first (\reason -> "exceptions[" <> Text.pack (show entryIndex) <> "]: " <> reason) $ case entry of+ Aeson.Object fields -> do+ relaxation <- text fields "relaxation"+ rationale <- text fields "rationale"+ exceptionClass <-+ text fields "class" >>= \case+ "shared-family" -> SharedFamily <$> text fields "home"+ "overlapping-rule" -> OverlappingRule <$> text fields "overlapsWith"+ "deferred" -> do+ request <- text fields "request"+ if "mori://" `Text.isPrefixOf` request then Right (Deferred request) else Left "request must be a mori:// URI"+ other -> Left ("unknown class " <> other <> "; expected shared-family, overlapping-rule or deferred")+ Right (SweepException relaxation exceptionClass rationale)+ _ -> Left "expected an object"+ where+ text fields key = case KeyMap.lookup key fields of+ Just (String value) | not (Text.null (Text.strip value)) -> Right value+ _ -> Left ("missing or empty \"" <> Key.toText key <> "\"")++-- | How one relaxation is classified. The first matching rule decides:+--+-- 1. 'Protected': some case noticed it, and the descriptor still compiles and+-- every acceptance bundle still passes.+-- 2. 'Subsumed': a @delete@ that fails a descriptor or acceptance check, while+-- every other relaxation of the same rule is non-failing.+-- 3. 'Covered' by an @admitValue@: an @allowedValues@ relaxation the+-- descriptor cannot express, while admitting one reported value is noticed.+-- 4. 'DescriptorInvalidOutcome' or 'AcceptanceObservableOutcome'. A+-- descriptor the relaxation cannot express is first 'Covered' when its+-- composite unit is protected (rule 6), or an 'ExceptionOutcome' when the+-- manifest documents it (rule 7).+-- 5. 'Inert': the relaxed profile has the baseline's 'BehaviourKey'.+-- 6. 'Covered' by a composite unit ('compositeUnit') that is protected.+-- 7. 'ExceptionOutcome': no witness, and the manifest documents it.+-- 8. 'Unprotected'.+--+-- 'Contradiction' is okf's own bug: a relaxation with an unchanged behaviour+-- key that some case nevertheless noticed.+data Outcome+ = Protected+ | Subsumed ![Text]+ | -- | the covering relaxation and its witness+ Covered !Text !Text+ | DescriptorInvalidOutcome+ | AcceptanceObservableOutcome+ | Inert !Text+ | ExceptionOutcome !SweepException+ | Unprotected+ | Contradiction+ deriving stock (Generic, Eq, Show)++outcomeName :: Outcome -> Text+outcomeName = \case+ Protected -> "protected"+ Subsumed _ -> "subsumed"+ Covered _ _ -> "covered"+ DescriptorInvalidOutcome -> "descriptor-invalid"+ AcceptanceObservableOutcome -> "acceptance-observable"+ Inert _ -> "inert"+ ExceptionOutcome _ -> "exception"+ Unprotected -> "unprotected"+ Contradiction -> "contradiction"++outcomeIsFailing :: Outcome -> Bool+outcomeIsFailing = \case+ DescriptorInvalidOutcome -> True+ AcceptanceObservableOutcome -> True+ Unprotected -> True+ Contradiction -> True+ _ -> False++data SweepResult = SweepResult+ { relaxation :: !Relaxation,+ observation :: !Observation,+ outcome :: !Outcome+ }+ deriving stock (Generic, Eq, Show)++-- | Evaluate and classify the given relaxations, normally 'planRelaxations' or+-- a filtered part of it, one rule location (or type) at a time. The list is+-- lazy per group: consuming the first group does not force later groups, and a+-- group's results, which may depend on each other, are yielded in the order+-- given. Composite units evaluated to classify a member are reported only+-- through 'Covered'.+sweepProfile :: [SweepException] -> PreparedSuite -> [Relaxation] -> [SweepResult]+sweepProfile exceptions prepared =+ concatMap evaluateGroup . List.groupBy (\left right -> left ^. #target == right ^. #target)+ where+ spec = compiledProfileSpec (prepared ^. #baseline)+ manifest = Map.fromList [(entry ^. #relaxation, entry) | entry <- exceptions]+ -- Lazy maps on purpose: a delete's outcome reads its siblings' outcomes+ -- from the same map, and a unit is observed only if a member asks for it.+ evaluateGroup group =+ let observed = LazyMap.fromList [(relaxation, observeRelaxation prepared relaxation) | relaxation <- group]+ units = LazyMap.fromList [(unit, observeRelaxation prepared unit) | Just unit <- map (compositeUnit spec) group]+ outcomes = LazyMap.mapWithKey classify observed+ classify relaxation observation+ | observation ^. #behaviourUnchanged, not (null (observation ^. #witnesses)) = Contradiction+ | protects observation = Protected+ | relaxation ^. #kind == Delete,+ failsCheck observation,+ all (nonFailing . (outcomes LazyMap.!)) siblings =+ Subsumed (map relaxationId siblings)+ | relaxation ^. #kind == ClearVocabulary,+ not (null (observation ^. #definitionErrors)),+ (admitted, witness) : _ <-+ [ (sibling, witness)+ | sibling@Relaxation {kind = AdmitValue _} <- siblings,+ Protected <- [outcomes LazyMap.! sibling],+ Just witness <- [primaryWitness (observed LazyMap.! sibling)]+ ] =+ Covered (relaxationId admitted) witness+ | not (null (observation ^. #definitionErrors)),+ Just covered <- unitCover relaxation =+ covered+ | not (null (observation ^. #definitionErrors)),+ Just entry <- Map.lookup (relaxationId relaxation) manifest =+ ExceptionOutcome entry+ | not (null (observation ^. #definitionErrors)) = DescriptorInvalidOutcome+ | not (null (observation ^. #acceptanceDeviations)) = AcceptanceObservableOutcome+ | observation ^. #behaviourUnchanged = Inert (inertReason relaxation)+ | Just covered <- unitCover relaxation = covered+ | Just entry <- Map.lookup (relaxationId relaxation) manifest = ExceptionOutcome entry+ | otherwise = Unprotected+ where+ siblings = filter (/= relaxation) group+ unitCover relaxation = do+ unit <- compositeUnit spec relaxation+ unitObservation <- LazyMap.lookup unit units+ witness <- primaryWitness unitObservation+ if protects unitObservation then Just (Covered (relaxationId unit) witness) else Nothing+ in [SweepResult relaxation (observed LazyMap.! relaxation) (outcomes LazyMap.! relaxation) | relaxation <- group]+ protects observation =+ not (null (observation ^. #witnesses))+ && null (observation ^. #definitionErrors)+ && null (observation ^. #acceptanceDeviations)+ failsCheck observation =+ not (null (observation ^. #definitionErrors)) || not (null (observation ^. #acceptanceDeviations))+ nonFailing = \case+ Protected -> True+ Inert _ -> True+ Covered _ _ -> True+ ExceptionOutcome _ -> True+ _ -> False++inertReason :: Relaxation -> Text+inertReason Relaxation {kind} = case kind of+ AllowSelf -> "allowSelf is never read while allowLocal is False"+ DropExternalUriPattern -> "externalUriPattern is never read while externalUriSchemes is empty"+ _ -> "the relaxed profile compiles to the same behaviour key as the baseline"++-- | Manifest entries that no longer describe a gap: the entry's relaxation was+-- evaluated and classified as something other than an exception, or it names a+-- relaxation this run's kinds would enumerate and none was. With @onlySome@+-- (an @--only@ run) an entry for a relaxation that was not swept is not stale.+staleExceptions :: KindSet -> Bool -> [SweepException] -> [(Text, Outcome)] -> [SweepException]+staleExceptions kinds onlySome exceptions results =+ filter stale exceptions+ where+ outcomes = Map.fromList results+ stale entry = case Map.lookup (entry ^. #relaxation) outcomes of+ Just (ExceptionOutcome _) -> False+ Just _ -> True+ Nothing -> not onlySome && enumeratedBy (snd (Text.breakOnEnd "#" (entry ^. #relaxation)))+ enumeratedBy kindName+ | kindName `elem` legacyNames = True+ | kindName == "presence" || "admitValue:" `Text.isPrefixOf` kindName = kinds /= LegacyKinds+ | otherwise = kinds == AllKinds+ legacyNames = mapMaybe legacyKindName [Delete, ClearVocabulary, DropFormat, DropUniqueBy, AllowSelf, AllowLocal, DropExternalUriPattern]
test/Main.hs view
@@ -10,7 +10,7 @@ import Data.List qualified as List import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map-import Data.Maybe (catMaybes, mapMaybe, maybeToList)+import Data.Maybe (catMaybes, listToMaybe, mapMaybe, maybeToList) import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO@@ -41,6 +41,7 @@ import Okf.Profile.Discovery qualified as ProfileDiscovery import Okf.Profile.Documentation import Okf.Profile.Registry+import Okf.Profile.Sweep qualified as Sweep import Okf.Query import Okf.Trust import Okf.Validation@@ -164,6 +165,17 @@ testIO "loadProfileFile decodes the postgresql fixture" testLoadProfileFixture, testIO "loadProfileFile decodes record-completed document ID rules" testLoadDocumentIdProfileFixture, testIO "loadProfileFile exposes nested reference and uniqueness declarations" testLoadNestedReferenceProfileFixture,+ testIO "sweep enumerates relaxations in declaration order" testSweepEnumeration,+ testIO "sweep applies each relaxation kind to the authored descriptor" testSweepApplyRelaxation,+ test "sweep reads expected.json in file order" testSweepExpectationOrder,+ testIO "sweep finds witnesses and unprotected relaxations on a two-case suite" testSweepWitnesses,+ testIO "sweep baseline reports a case that does not meet its expectation" testSweepBaselineFault,+ testIO "sweep classifies an open profile's relaxations by evidence and proof" testSweepClassification,+ testIO "sweep classifies closed-profile deletions as subsumed or acceptance-observable" testSweepClosedProfile,+ testIO "sweep reports an inert relaxation some case notices as a contradiction" testSweepContradiction,+ testIO "sweep applies manifest exceptions and reports stale ones" testSweepExceptions,+ test "sweep rejects malformed exception manifests" testSweepMalformedExceptions,+ testIO "behaviourKey erases only what validation never consults" testSweepBehaviourKeyErasures, testIO "loadProfileFile accepts the pre-type-frontmatter described schema" testLoadDescribedProfileFixture, testIO "loadProfileFile accepts the frozen EP-1 type-aware schema" testLoadTypeAwareCompatibilityFixture, testIO "loadProfileFile accepts the frozen EP-2 vocabulary schema" testLoadVocabularyCompatibilityFixture,@@ -8577,3 +8589,353 @@ assertEqual (violationsFor True "stray") (unknowns coexist) assertEqual 1 (length [() | ConstraintPresenceMissing {} <- coexist]) assertEqual 1 (length [() | TimestampOrderViolation {} <- coexist])++-- Load-bearing sweeps ---------------------------------------------------------++-- | A renderer that needs nothing from the CLI: 'show' names the field, which is+-- all the legacy predicate looks for.+showRender :: Sweep.Render+showRender _ _ violation = Text.pack (show violation)++loadSweepFixture :: IO (ProfileSpec, CompiledProfile, Sweep.Suite)+loadSweepFixture = do+ profilePath <- fixtureFilePath "sweep/profile.dhall"+ acceptPath <- fixturePath "sweep/accept"+ casesPath <- fixturePath "sweep/cases"+ spec <- either (fail . Text.unpack) pure =<< loadProfileFile profilePath+ compiled <- either (fail . show) pure (compileProfile spec)+ suite <- either (fail . show) pure =<< Sweep.loadSuite [acceptPath] casesPath+ pure (spec, compiled, suite)++testSweepEnumeration :: IO (Either Text ())+testSweepEnumeration = do+ (spec, _, _) <- loadSweepFixture+ nestedPath <- fixtureFilePath "profiles/nested-references-and-uniqueness.dhall"+ nestedSpec <- either (fail . Text.unpack) pure =<< loadProfileFile nestedPath+ pure $ do+ assertEqual+ [ "profile/required/type#delete",+ "profile/required/title#delete",+ "type:Note/required/status#delete",+ "type:Note/required/status#allowedValues",+ "type:Note/optional/summary#delete"+ ]+ (map Sweep.relaxationId (Sweep.enumerateRelaxations Sweep.LegacyKinds spec))+ assertEqual+ [ "profile/required/type#delete",+ "profile/required/type#presence",+ "profile/required/title#delete",+ "profile/required/title#presence",+ "type:Note/required/status#delete",+ "type:Note/required/status#presence",+ "type:Note/required/status#allowedValues",+ "type:Note/optional/summary#delete"+ ]+ (map Sweep.relaxationId (Sweep.enumerateRelaxations Sweep.DefaultKinds spec))+ assertEqual+ [ "profile/required/type#delete",+ "profile/required/requestId#delete",+ "profile/required/requestId#format",+ "profile/required/dependencies#delete",+ "profile/required/dependencies/elementFields/required/ref#delete",+ "profile/required/dependencies/elementFields/required/ref#allowSelf",+ "profile/required/dependencies/elementFields/required/ref#allowLocal",+ "profile/required/dependencies/elementFields/required/ref#externalUriPattern",+ "profile/required/acceptanceCriteria#delete",+ "profile/required/acceptanceCriteria#uniqueBy",+ "profile/required/acceptanceCriteria/elementFields/required/id#delete",+ "profile/required/acceptanceCriteria/elementFields/required/id#format",+ "profile/required/acceptanceCriteria/elementFields/required/text#delete"+ ]+ (map Sweep.relaxationId (Sweep.enumerateRelaxations Sweep.LegacyKinds nestedSpec))+ assertEqual+ (Just "id")+ (listToMaybe [Sweep.relaxationField r | r <- Sweep.enumerateRelaxations Sweep.LegacyKinds nestedSpec, Sweep.relaxationId r == "profile/required/acceptanceCriteria/elementFields/required/id#format"])++testSweepApplyRelaxation :: IO (Either Text ())+testSweepApplyRelaxation = do+ (spec, _, _) <- loadSweepFixture+ nestedPath <- fixtureFilePath "profiles/nested-references-and-uniqueness.dhall"+ nestedSpec <- either (fail . Text.unpack) pure =<< loadProfileFile nestedPath+ let status = Sweep.RuleTarget (Sweep.RuleLocation (Sweep.TypeScope "Note") Sweep.RequiredList "status" Nothing)+ note relaxed = case relaxed ^. #types of+ [rule] -> Right (rule ^. #frontmatter)+ other -> Left ("expected one type rule, got " <> Text.pack (show (length other)))+ relax target kind = Sweep.applyRelaxation (Sweep.Relaxation target kind)+ fieldsOf = map (^. #field)+ ref = Sweep.RuleTarget (Sweep.RuleLocation Sweep.ProfileScope Sweep.RequiredList "dependencies" (Just (Sweep.ElementFields, Sweep.RequiredList, "ref")))+ nestedRef relaxed = do+ dependencies <- lookupRawRule "dependencies" (relaxed ^. #frontmatter . #required)+ case dependencies ^. #elementFields of+ Just nestedRules | [member] <- nestedRules ^. #required -> Right member+ _ -> Left "expected one nested rule under dependencies"+ pure $ do+ deleted <- note (relax status Sweep.Delete spec)+ assertEqual [] (fieldsOf (deleted ^. #required))+ demoted <- note (relax status Sweep.Presence spec)+ assertEqual [] (fieldsOf (demoted ^. #required))+ assertEqual ["summary", "status"] (fieldsOf (demoted ^. #optional))+ cleared <- note (relax status Sweep.ClearVocabulary spec)+ assertEqual [[]] (map (^. #allowedValues) (cleared ^. #required))+ admitted <- note (relax status (Sweep.AdmitValue "wip") spec)+ assertEqual [["draft", "final", "wip"]] (map (^. #allowedValues) (admitted ^. #required))+ anyShape <- note (relax status Sweep.DropCardinality spec)+ assertEqual [Any] (map (^. #cardinality) (anyShape ^. #required))+ assertEqual spec (relax (Sweep.RuleTarget (Sweep.RuleLocation (Sweep.TypeScope "Missing") Sweep.RequiredList "status" Nothing)) Sweep.Delete spec)+ selfAllowed <- nestedRef (relax ref Sweep.AllowSelf nestedSpec)+ assertEqual (Just True) ((^. #allowSelf) <$> selfAllowed ^. #reference)+ localAllowed <- nestedRef (relax ref Sweep.AllowLocal nestedSpec)+ assertEqual (Just True) ((^. #allowLocal) <$> localAllowed ^. #reference)+ unpatterned <- nestedRef (relax ref Sweep.DropExternalUriPattern nestedSpec)+ assertEqual (Just Nothing) ((^. #externalUriPattern) <$> unpatterned ^. #reference)+ unreferenced <- nestedRef (relax ref Sweep.DropReference nestedSpec)+ assertEqual Nothing (unreferenced ^. #reference)+ let criteria = Sweep.RuleTarget (Sweep.RuleLocation Sweep.ProfileScope Sweep.RequiredList "acceptanceCriteria" Nothing)+ notUnique <- lookupRawRule "acceptanceCriteria" (relax criteria Sweep.DropUniqueBy nestedSpec ^. #frontmatter . #required)+ assertEqual Nothing (notUnique ^. #uniqueBy)+ flat <- lookupRawRule "acceptanceCriteria" (relax criteria (Sweep.DropNested Sweep.ElementFields) nestedSpec ^. #frontmatter . #required)+ assertBool "elementFields dropped" (isNothing (flat ^. #elementFields))++testSweepExpectationOrder :: Either Text ()+testSweepExpectationOrder = do+ parsed <-+ Sweep.parseExpectations+ "{\"zeta\": \"status\", \"alpha\": {\"field\": \"docId\", \"diagnostics\": 2}, \"mid\": \"title\"}"+ assertEqual+ [ ("zeta", Sweep.CaseExpectation "status" 1),+ ("alpha", Sweep.CaseExpectation "docId" 2),+ ("mid", Sweep.CaseExpectation "title" 1)+ ]+ parsed+ assertBool "a non-object is rejected" (either (const True) (const False) (Sweep.parseExpectations "[]"))+ assertBool "a malformed entry is rejected" (either (const True) (const False) (Sweep.parseExpectations "{\"a\": 3}"))++testSweepWitnesses :: IO (Either Text ())+testSweepWitnesses = do+ (_, compiled, suite) <- loadSweepFixture+ pure $ do+ assertEqual ["note-missing-title", "note-bad-status"] (map (^. #name) (suite ^. #cases))+ prepared <- first (Text.pack . show) (Sweep.checkBaseline showRender compiled suite)+ let results = Sweep.sweepProfile [] prepared (Sweep.planRelaxations Sweep.LegacyKinds prepared)+ outcomes = [(Sweep.relaxationId (r ^. #relaxation), Sweep.legacyOutcome (r ^. #observation)) | r <- results]+ assertEqual+ [ ("profile/required/type#delete", "UNPROTECTED"),+ ("profile/required/title#delete", "fixture note-missing-title"),+ ("type:Note/required/status#delete", "fixture note-bad-status"),+ ("type:Note/required/status#allowedValues", "fixture note-bad-status"),+ ("type:Note/optional/summary#delete", "UNPROTECTED")+ ]+ outcomes++testSweepBaselineFault :: IO (Either Text ())+testSweepBaselineFault = do+ (_, compiled, suite) <- loadSweepFixture+ let misdirected =+ suite+ & #cases+ . traverse+ . filtered ((== "note-bad-status") . (^. #name))+ . #expectation+ . #field+ .~ "title"+ pure $ case Sweep.checkBaseline showRender compiled misdirected of+ Left (Sweep.CaseExpectationUnmet "note-bad-status" (Sweep.CaseExpectation "title" 1) [_] :| []) -> Right ()+ Left faults -> Left ("unexpected faults: " <> Text.pack (show faults))+ Right _ -> Left "a case whose expected field is wrong must fail the baseline"++lookupRawRule :: Text -> [FieldRule] -> Either Text FieldRule+lookupRawRule key rules =+ case [rule | rule <- rules, rule ^. #field == key] of+ [rule] -> Right rule+ _ -> Left ("expected one raw rule for " <> key)++sweepLogFile :: LogFile+sweepLogFile = LogFile "log.md" (parseLog "# Bundle Update Log\n\n## 2026-10-28\n* **Add**: Created the sweep fixture.\n")++sweepDocument :: [(Text, Text)] -> Text+sweepDocument fields =+ "---\n" <> Text.unlines [key <> ": " <> value | (key, value) <- fields] <> "---\n\n# Sweep fixture\n\nA concept.\n"++-- | A bundle held in memory, with one fresh log so strict acceptance passes.+memoryBundle :: FilePath -> [(Text, [(Text, Text)])] -> Either Text Sweep.SuiteBundle+memoryBundle bundlePath documents = do+ concepts <- for documents $ \(rawId, fields) -> do+ conceptId <- parseTestConceptId rawId+ document <- firstShow (parseDocument (sweepDocument fields))+ pure (conceptFromDocument conceptId document)+ pure (Sweep.SuiteBundle bundlePath concepts (bundleInventoryOfConcepts concepts) VersionUndeclared [sweepLogFile])++memoryCase :: Text -> Text -> [(Text, Text)] -> Either Text Sweep.RejectionCase+memoryCase caseName expected fields =+ Sweep.RejectionCase caseName (Sweep.CaseExpectation expected 1) <$> memoryBundle (Text.unpack caseName) [("tasks/" <> caseName, fields)]++setSweepField :: Text -> Text -> [(Text, Text)] -> [(Text, Text)]+setSweepField key value fields = [(k, v) | (k, v) <- fields, k /= key] <> [(key, value)]++baseTask :: [(Text, Text)]+baseTask =+ [ ("type", "Task"),+ ("title", "A task"),+ ("description", "A task in the sweep fixture."),+ ("timestamp", "\"2026-10-01T00:00:00Z\""),+ ("docId", "TASK-1"),+ ("status", "open")+ ]++loadCompiledFixture :: FilePath -> IO (ProfileSpec, CompiledProfile)+loadCompiledFixture name = do+ path <- fixtureFilePath name+ spec <- either (fail . Text.unpack) pure =<< loadProfileFile path+ compiled <- either (fail . show) pure (compileProfile spec)+ pure (spec, compiled)++-- | The classification fixture's suite: one accepted task and three cases, each+-- breaking exactly one rule.+classificationSuite :: Either Text Sweep.Suite+classificationSuite = do+ accept <- memoryBundle "accept" [("tasks/first", baseTask <> [("parent", "https://example.com/tasks/1")])]+ cases <-+ sequence+ [ memoryCase "task-bad-status" "status" (setSweepField "status" "wip" baseTask),+ memoryCase "task-local-parent" "parent" (baseTask <> [("parent", "TASK-9")]),+ memoryCase "task-missing-closedby" "closedBy" (setSweepField "status" "done" baseTask)+ ]+ pure (Sweep.Suite [accept] cases)++sweepOutcomes :: [Sweep.SweepException] -> Sweep.KindSet -> Sweep.PreparedSuite -> [(Text, Sweep.SweepResult)]+sweepOutcomes exceptions kinds prepared =+ [(Sweep.relaxationId (result ^. #relaxation), result) | result <- Sweep.sweepProfile exceptions prepared (Sweep.planRelaxations kinds prepared)]++expectOutcome :: [(Text, Sweep.SweepResult)] -> Text -> Sweep.Outcome -> Either Text ()+expectOutcome results relaxation expected =+ case lookup relaxation results of+ Nothing -> Left ("no result for " <> relaxation <> " among " <> Text.pack (show (map fst results)))+ Just result+ | result ^. #outcome == expected -> Right ()+ | otherwise -> Left (relaxation <> ": expected " <> Text.pack (show expected) <> ", got " <> Text.pack (show (result ^. #outcome)) <> " from " <> Text.pack (show (result ^. #observation)))++testSweepClassification :: IO (Either Text ())+testSweepClassification = do+ (_, compiled) <- loadCompiledFixture "sweep/classification.dhall"+ pure $ do+ suite <- classificationSuite+ prepared <- first (Text.pack . show) (Sweep.checkBaseline showRender compiled suite)+ let results = sweepOutcomes [] Sweep.DefaultKinds prepared+ expect = expectOutcome results+ -- A vocabulary a condition depends on cannot be cleared, so admitting the+ -- value a case reports stands in for it.+ expect "type:Task/required/status#admitValue:wip" Sweep.Protected+ expect "type:Task/required/status#allowedValues" (Sweep.Covered "type:Task/required/status#admitValue:wip" "task-bad-status")+ expect "type:Task/required/status#delete" Sweep.DescriptorInvalidOutcome+ expect "type:Task/required/status#presence" Sweep.Unprotected+ expect "type:Task/required/closedBy#delete" Sweep.Protected+ expect "type:Task/required/closedBy#presence" Sweep.Protected+ -- allowLocal turns the local-handle rejection into a dangling-handle one on+ -- the same field, which the legacy predicate cannot tell apart.+ expect "type:Task/optional/parent#allowLocal" (Sweep.Covered "type:Task/optional/parent#reference" "task-local-parent")+ assertEqual (Just ["task-local-parent"]) ((^. #observation . #facetWitnesses) <$> lookup "type:Task/optional/parent#allowLocal" results)+ expect "type:Task/optional/parent#allowSelf" (Sweep.Inert "allowSelf is never read while allowLocal is False")+ expect "type:Task/optional/note#delete" (Sweep.Inert "the relaxed profile compiles to the same behaviour key as the baseline")+ expect "type:Task/optional/priority#allowedValues" Sweep.Unprotected+ assertBool "a composite unit is not reported as its own result" (isNothing (lookup "type:Task/optional/parent#reference" results))+ assertBool "no protected result lacks a witness" (and [not (null (r ^. #observation . #witnesses)) | (_, r) <- results, r ^. #outcome == Sweep.Protected])++testSweepClosedProfile :: IO (Either Text ())+testSweepClosedProfile = do+ (_, compiled) <- loadCompiledFixture "sweep/closed.dhall"+ pure $ do+ let item extra =+ [("type", "Item"), ("title", "An item"), ("description", "An item."), ("timestamp", "\"2026-10-01T00:00:00Z\"")] <> extra+ accept <- memoryBundle "accept" [("items/first", item [("size", "s"), ("colour", "red")])]+ badSize <- memoryCase "item-bad-size" "size" (item [("size", "xl")])+ prepared <- first (Text.pack . show) (Sweep.checkBaseline showRender compiled (Sweep.Suite [accept] [badSize]))+ let results = sweepOutcomes [] Sweep.LegacyKinds prepared+ expect = expectOutcome results+ expect "profile/optional/size#allowedValues" Sweep.Protected+ expect "profile/optional/size#delete" (Sweep.Subsumed ["profile/optional/size#allowedValues"])+ expect "profile/optional/colour#allowedValues" Sweep.Unprotected+ expect "profile/optional/colour#delete" Sweep.AcceptanceObservableOutcome+ assertEqual (Just "acceptance-fails") (Sweep.legacyOutcome . (^. #observation) <$> lookup "profile/optional/size#delete" results)++testSweepContradiction :: IO (Either Text ())+testSweepContradiction = do+ (_, compiled) <- loadCompiledFixture "sweep/classification.dhall"+ pure $ do+ suite <- classificationSuite+ prepared <- first (Text.pack . show) (Sweep.checkBaseline showRender compiled suite)+ -- A key that erases everything claims every relaxation is inert.+ let erasesEverything = Sweep.withBehaviourKey (const (Sweep.behaviourKey compiled)) prepared+ results = sweepOutcomes [] Sweep.DefaultKinds erasesEverything+ expectOutcome results "type:Task/required/closedBy#delete" Sweep.Contradiction++testSweepExceptions :: IO (Either Text ())+testSweepExceptions = do+ (_, compiled) <- loadCompiledFixture "sweep/classification.dhall"+ pure $ do+ suite <- classificationSuite+ prepared <- first (Text.pack . show) (Sweep.checkBaseline showRender compiled suite)+ let shared = Sweep.SweepException "type:Task/optional/priority#allowedValues" (Sweep.SharedFamily "tasks") "Tested in the tasks family."+ inexpressible = Sweep.SweepException "type:Task/required/status#delete" (Sweep.OverlappingRule "type:Task/required/closedBy") "closedBy's condition needs status."+ notAGap = Sweep.SweepException "type:Task/required/closedBy#delete" (Sweep.Deferred "mori://shinzui/okf/okf/improvement-requests/concepts/IR-12") "Protected after all."+ absent = Sweep.SweepException "type:Task/optional/ghost#delete" (Sweep.SharedFamily "tasks") "No such rule."+ exceptions = [shared, inexpressible, notAGap, absent]+ results = sweepOutcomes exceptions Sweep.DefaultKinds prepared+ outcomes = [(identifier, result ^. #outcome) | (identifier, result) <- results]+ expectOutcome results "type:Task/optional/priority#allowedValues" (Sweep.ExceptionOutcome shared)+ expectOutcome results "type:Task/required/status#delete" (Sweep.ExceptionOutcome inexpressible)+ expectOutcome results "type:Task/required/closedBy#delete" Sweep.Protected+ assertEqual [notAGap, absent] (Sweep.staleExceptions Sweep.DefaultKinds False exceptions outcomes)+ assertEqual [notAGap] (Sweep.staleExceptions Sweep.DefaultKinds True exceptions outcomes)+ -- A presence exception names a kind a legacy run never enumerates.+ let presence = Sweep.SweepException "type:Task/required/ghost#presence" (Sweep.SharedFamily "tasks") "Default kinds only."+ assertEqual [] (Sweep.staleExceptions Sweep.LegacyKinds False [presence] outcomes)++testSweepMalformedExceptions :: Either Text ()+testSweepMalformedExceptions = do+ let rejected bytes = either (const True) (const False) (Sweep.parseExceptions bytes)+ entry fields = "{\"version\": 1, \"exceptions\": [{" <> fields <> "}]}"+ parsed <- Sweep.parseExceptions (entry "\"relaxation\": \"profile/required/x#delete\", \"class\": \"shared-family\", \"home\": \"okfV02\", \"rationale\": \"Spliced.\"")+ assertEqual [Sweep.SweepException "profile/required/x#delete" (Sweep.SharedFamily "okfV02") "Spliced."] parsed+ assertBool "version 2 is rejected" (rejected "{\"version\": 2, \"exceptions\": []}")+ assertBool "an unknown class is rejected" (rejected (entry "\"relaxation\": \"r\", \"class\": \"vibes\", \"rationale\": \"x\""))+ assertBool "shared-family needs home" (rejected (entry "\"relaxation\": \"r\", \"class\": \"shared-family\", \"rationale\": \"x\""))+ assertBool "overlapping-rule needs overlapsWith" (rejected (entry "\"relaxation\": \"r\", \"class\": \"overlapping-rule\", \"rationale\": \"x\""))+ assertBool "deferred needs a mori:// request" (rejected (entry "\"relaxation\": \"r\", \"class\": \"deferred\", \"request\": \"IR-3\", \"rationale\": \"x\""))+ assertBool "an empty rationale is rejected" (rejected (entry "\"relaxation\": \"r\", \"class\": \"shared-family\", \"home\": \"h\", \"rationale\": \" \""))++-- | Each erasure must leave the key equal and, on concepts that exercise the+-- erased member, the violations identical; a description on a rule with a+-- presence clause must not be erased.+testSweepBehaviourKeyErasures :: IO (Either Text ())+testSweepBehaviourKeyErasures = do+ (spec, compiled) <- loadCompiledFixture "sweep/classification.dhall"+ pure $ do+ probes <-+ traverse+ (\(rawId, fields) -> conceptFromDocument <$> parseTestConceptId rawId <*> firstShow (parseDocument (sweepDocument fields)))+ [ ("tasks/a", baseTask <> [("note", "anything"), ("parent", "TASK-1")]),+ ("tasks/b", setSweepField "docId" "TASK-2" baseTask <> [("parent", "https://example.com/tasks/1")]),+ ("tasks/c", setSweepField "docId" "TASK-3" baseTask <> [("parent", "TASK-2"), ("note", "[1, 2]")]),+ ("tasks/d", [(k, v) | (k, v) <- setSweepField "docId" "TASK-4" baseTask, k /= "title"])+ ]+ let onType f = spec & #types . traverse . #frontmatter %~ f+ onRule name f = onType (\rules -> rules & #optional . traverse . filtered ((== name) . (^. #field)) %~ f)+ withReference f = onRule "parent" (\rule -> rule & #reference . _Just %~ f)+ same label relaxed = do+ relaxedCompiled <- first (\errors -> label <> ": " <> Text.pack (show errors)) (compileProfile relaxed)+ assertBool (label <> ": behaviour key differs") (Sweep.behaviourKey relaxedCompiled == Sweep.behaviourKey compiled)+ for_ [PermissiveConformance, StrictAuthoring] $ \mode ->+ assertEqual (validateProfile mode compiled probes) (validateProfile mode relaxedCompiled probes)+ unpatterned = withReference (\policy -> policy & #externalUriSchemes .~ [] & #externalUriPattern .~ Nothing)+ patterned = withReference (\policy -> policy & #externalUriSchemes .~ [] & #externalUriPattern ?~ "https://example[.]com/.*")+ same "unconstrained optional rule in an open record" (onType (#optional %~ filter ((/= "note") . (^. #field))))+ same "description on a rule without a presence clause" (onRule "note" (#description .~ Nothing))+ same "allowSelf while allowLocal is False" (withReference (#allowSelf .~ True))+ unpatternedCompiled <- first (Text.pack . show) (compileProfile unpatterned)+ patternedCompiled <- first (Text.pack . show) (compileProfile patterned)+ assertBool "externalUriPattern without schemes is erased" (Sweep.behaviourKey unpatternedCompiled == Sweep.behaviourKey patternedCompiled)+ for_ [PermissiveConformance, StrictAuthoring] $ \mode ->+ assertEqual (validateProfile mode unpatternedCompiled probes) (validateProfile mode patternedCompiled probes)+ let describedTitle = spec & #frontmatter . #required . traverse . filtered ((== "title") . (^. #field)) . #description ?~ "Shown when title is missing."+ describedCompiled <- first (Text.pack . show) (compileProfile describedTitle)+ assertBool "a required rule's description is kept" (Sweep.behaviourKey describedCompiled /= Sweep.behaviourKey compiled)
+ test/fixtures/profile-discovery/dist-newstyle/valid-build-output.dhall view
@@ -0,0 +1,2 @@+-- Build-output directories are excluded even when they contain valid profiles.+../../profiles/decisions.dhall
+ test/fixtures/sweep/accept/log.md view
@@ -0,0 +1,4 @@+# Bundle Update Log++## 2026-10-28+* **Add**: Created the sweep fixture.
+ test/fixtures/sweep/accept/notes/first.md view
@@ -0,0 +1,11 @@+---+type: Note+title: First+description: A note in the sweep fixture.+timestamp: "2026-10-01T00:00:00Z"+status: final+---++# First++A note.
+ test/fixtures/sweep/cases/expected.json view
@@ -0,0 +1,4 @@+{+ "note-missing-title": "title",+ "note-bad-status": "status"+}
+ test/fixtures/sweep/cases/note-bad-status/notes/bad-status.md view
@@ -0,0 +1,11 @@+---+type: Note+title: Bad status+description: A note in the sweep fixture.+timestamp: "2026-10-01T00:00:00Z"+status: wip+---++# Bad status++A note.
+ test/fixtures/sweep/cases/note-missing-title/notes/missing-title.md view
@@ -0,0 +1,10 @@+---+type: Note+description: A note in the sweep fixture.+timestamp: "2026-10-01T00:00:00Z"+status: draft+---++# x++A note.
+ test/fixtures/sweep/classification.dhall view
@@ -0,0 +1,76 @@+-- Exercises every classification rule of okf profile sweep that an open+-- profile can reach: a vocabulary a condition depends on (admitValue), a+-- local-only reference whose allowLocal is masked (covered by reference), an+-- allowSelf that is never read (inert), an unconstrained optional field+-- (inert), and an untested vocabulary (unprotected or a manifest exception).+let okf = ../../../dhall/package.dhall++let Profile = okf.defaults.Profile++let TypeRule = okf.defaults.TypeRule++let FieldRule = okf.defaults.FieldRule++let FrontmatterDefaults = okf.defaults.FrontmatterRules++let HandleReferenceRule = okf.defaults.HandleReferenceRule++let Cardinality = okf.Cardinality++let FieldFormat = okf.FieldFormat++let field = okf.mk.FieldRule++in Profile::{+ , name = "sweep-classification"+ , allowUnknownTypes = False+ , idField = Some "docId"+ , frontmatter = FrontmatterDefaults::{+ , required = [ field.plain "type", field.plain "title" ]+ }+ , types =+ [ TypeRule::{+ , type = "Task"+ , idPrefix = Some "TASK"+ , frontmatter = FrontmatterDefaults::{+ , required =+ [ FieldRule::{+ , field = "docId"+ , cardinality = Cardinality.Scalar+ , format = Some (FieldFormat.DocumentHandle "TASK")+ }+ , FieldRule::{+ , field = "status"+ , allowedValues = [ "open", "done" ]+ , cardinality = Cardinality.Scalar+ }+ , FieldRule::{+ , field = "closedBy"+ , cardinality = Cardinality.Scalar+ , when = Some { field = "status", hasValue = [ "done" ] }+ }+ ]+ , optional =+ [ FieldRule::{+ , field = "parent"+ , cardinality = Cardinality.Scalar+ , reference = Some HandleReferenceRule::{+ , localPrefix = "TASK"+ , externalUriSchemes = [ "https" ]+ , allowLocal = False+ }+ }+ , FieldRule::{+ , field = "note"+ , description = Some "Free text; checked by nothing."+ }+ , FieldRule::{+ , field = "priority"+ , allowedValues = [ "low", "high" ]+ , cardinality = Cardinality.Scalar+ }+ ]+ }+ }+ ]+ }
+ test/fixtures/sweep/closed.dhall view
@@ -0,0 +1,35 @@+-- A closed profile: deleting a declaration makes an accepted key unknown, which+-- the acceptance bundle notices for the wrong reason.+let okf = ../../../dhall/package.dhall++let Profile = okf.defaults.Profile++let FieldRule = okf.defaults.FieldRule++let FrontmatterDefaults = okf.defaults.FrontmatterRules++let Cardinality = okf.Cardinality++let field = okf.mk.FieldRule++in Profile::{+ , name = "sweep-closed"+ , allowUnknownFields = False+ , frontmatter = FrontmatterDefaults::{+ , required = [ field.plain "type", field.plain "title" ]+ , optional =+ [ field.plain "description"+ , field.plain "timestamp"+ , FieldRule::{+ , field = "size"+ , allowedValues = [ "s", "m" ]+ , cardinality = Cardinality.Scalar+ }+ , FieldRule::{+ , field = "colour"+ , allowedValues = [ "red", "blue" ]+ , cardinality = Cardinality.Scalar+ }+ ]+ }+ }
+ test/fixtures/sweep/profile.dhall view
@@ -0,0 +1,39 @@+-- The smallest suite a load-bearing sweep can disagree with: two cases that+-- each notice some relaxations, and rules (type, summary) that no case tests.+let okf = ../../../dhall/package.dhall++let Profile = okf.defaults.Profile++let TypeRule = okf.defaults.TypeRule++let FieldRule = okf.defaults.FieldRule++let FrontmatterDefaults = okf.defaults.FrontmatterRules++let Cardinality = okf.Cardinality++let field = okf.mk.FieldRule++in Profile::{+ , name = "sweep"+ , description = Some "A two-case suite for okf profile sweep."+ , allowUnknownTypes = False+ , frontmatter = FrontmatterDefaults::{+ , required = [ field.plain "type", field.plain "title" ]+ }+ , types =+ [ TypeRule::{+ , type = "Note"+ , frontmatter = FrontmatterDefaults::{+ , required =+ [ FieldRule::{+ , field = "status"+ , allowedValues = [ "draft", "final" ]+ , cardinality = Cardinality.Scalar+ }+ ]+ , optional = [ field.plain "summary" ]+ }+ }+ ]+ }