okf-core 0.4.0.0 → 0.5.0.0
raw patch · 72 files changed
+14770/−5539 lines, 72 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
- Okf.Profile: instance GHC.Classes.Eq Okf.Profile.CompiledCondition
- Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.CompiledCondition
- Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.CompiledCondition
+ Okf.Actor: HumanActor :: !Text -> Actor
+ Okf.Actor: ProcessActor :: !Text -> Actor
+ Okf.Actor: ProducerActor :: !Text -> !Text -> Actor
+ Okf.Actor: UnclassifiedActor :: !Text -> Actor
+ Okf.Actor: data Actor
+ Okf.Actor: instance GHC.Classes.Eq Okf.Actor.Actor
+ Okf.Actor: instance GHC.Classes.Ord Okf.Actor.Actor
+ Okf.Actor: instance GHC.Internal.Generics.Generic Okf.Actor.Actor
+ Okf.Actor: instance GHC.Internal.Show.Show Okf.Actor.Actor
+ Okf.Actor: isHumanActor :: Actor -> Bool
+ Okf.Actor: parseActor :: Text -> Actor
+ Okf.Actor: renderActor :: Actor -> Text
+ Okf.Bundle: bundleInventoryMember :: FilePath -> BundleInventory -> Bool
+ Okf.Bundle: bundleInventoryOfConcepts :: [Concept] -> BundleInventory
+ Okf.Bundle: conceptAttester :: Concept -> Maybe Attester
+ Okf.Bundle: conceptComputation :: Concept -> Maybe Text
+ Okf.Bundle: conceptComputationSources :: Concept -> [ComputationSource]
+ Okf.Bundle: conceptExecutor :: Concept -> Maybe Executor
+ Okf.Bundle: conceptGenerated :: Concept -> Maybe Generated
+ Okf.Bundle: conceptParameters :: Concept -> [Parameter]
+ Okf.Bundle: conceptRuntime :: Concept -> Maybe Text
+ Okf.Bundle: conceptSources :: Concept -> [Source]
+ Okf.Bundle: conceptStaleAfter :: Concept -> Maybe Text
+ Okf.Bundle: conceptStatus :: Concept -> Status
+ Okf.Bundle: conceptUsageWindow :: Concept -> Maybe UsageWindow
+ Okf.Bundle: conceptVerified :: Concept -> [Verification]
+ Okf.Bundle: data BundleInventory
+ Okf.Bundle: instance GHC.Classes.Eq Okf.Bundle.BundleInventory
+ Okf.Bundle: instance GHC.Internal.Generics.Generic Okf.Bundle.BundleInventory
+ Okf.Bundle: instance GHC.Internal.Show.Show Okf.Bundle.BundleInventory
+ Okf.Bundle: walkBundleInventory :: FilePath -> IO (Either BundleError BundleInventory)
+ Okf.Document: Attester :: Maybe Text -> Attester
+ Okf.Document: ComputationFile :: !Text -> ComputationSource
+ Okf.Document: ComputationInline :: !Text -> ComputationSource
+ Okf.Document: Deprecated :: Status
+ Okf.Document: Draft :: Status
+ Okf.Document: Executor :: !Maybe Text -> ![Text] -> Executor
+ Okf.Document: Generated :: !Actor -> !Maybe Text -> Generated
+ Okf.Document: Parameter :: !Text -> !Maybe Text -> !Maybe Bool -> Parameter
+ Okf.Document: Source :: !Maybe Text -> !Text -> !Maybe Text -> !Maybe Actor -> !Maybe Integer -> !Maybe Text -> !Maybe UsageWindow -> Source
+ Okf.Document: Stable :: Status
+ Okf.Document: UnknownStatus :: !Text -> Status
+ Okf.Document: UsageWindow :: !Maybe Text -> !Maybe Text -> UsageWindow
+ Okf.Document: Verification :: !Actor -> !Maybe Text -> Verification
+ Okf.Document: [attesterResource] :: Attester -> Maybe Text
+ Okf.Document: [executorReceipt] :: Executor -> ![Text]
+ Okf.Document: [executorResource] :: Executor -> !Maybe Text
+ Okf.Document: [generatedAt] :: Generated -> !Maybe Text
+ Okf.Document: [generatedBy] :: Generated -> !Actor
+ Okf.Document: [parameterName] :: Parameter -> !Text
+ Okf.Document: [parameterRequired] :: Parameter -> !Maybe Bool
+ Okf.Document: [parameterType] :: Parameter -> !Maybe Text
+ Okf.Document: [sourceAuthor] :: Source -> !Maybe Actor
+ Okf.Document: [sourceId] :: Source -> !Maybe Text
+ Okf.Document: [sourceLastModified] :: Source -> !Maybe Text
+ Okf.Document: [sourceResource] :: Source -> !Text
+ Okf.Document: [sourceTitle] :: Source -> !Maybe Text
+ Okf.Document: [sourceUsageCount] :: Source -> !Maybe Integer
+ Okf.Document: [sourceUsageWindow] :: Source -> !Maybe UsageWindow
+ Okf.Document: [usageWindowFrom] :: UsageWindow -> !Maybe Text
+ Okf.Document: [usageWindowTo] :: UsageWindow -> !Maybe Text
+ Okf.Document: [verificationAt] :: Verification -> !Maybe Text
+ Okf.Document: [verificationBy] :: Verification -> !Actor
+ Okf.Document: attestedComputationType :: Text
+ Okf.Document: data ComputationSource
+ Okf.Document: data Executor
+ Okf.Document: data Generated
+ Okf.Document: data Parameter
+ Okf.Document: data Source
+ Okf.Document: data Status
+ Okf.Document: data UsageWindow
+ Okf.Document: data Verification
+ Okf.Document: effectiveUsageWindow :: Maybe UsageWindow -> Source -> Maybe UsageWindow
+ Okf.Document: fieldsIntroducedInV02 :: [Text]
+ Okf.Document: fieldsSupersededInV02 :: [Text]
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.Attester
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.ComputationSource
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.Executor
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.Generated
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.Parameter
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.Source
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.Status
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.UsageWindow
+ Okf.Document: instance GHC.Classes.Eq Okf.Document.Verification
+ Okf.Document: instance GHC.Classes.Ord Okf.Document.Status
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.Attester
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.ComputationSource
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.Executor
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.Generated
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.Parameter
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.Source
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.Status
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.UsageWindow
+ Okf.Document: instance GHC.Internal.Generics.Generic Okf.Document.Verification
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.Attester
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.ComputationSource
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.Executor
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.Generated
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.Parameter
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.Source
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.Status
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.UsageWindow
+ Okf.Document: instance GHC.Internal.Show.Show Okf.Document.Verification
+ Okf.Document: newtype Attester
+ Okf.Document: readAttester :: Frontmatter -> Maybe Attester
+ Okf.Document: readComputation :: Frontmatter -> Maybe Text
+ Okf.Document: readComputationSources :: OKFDocument -> [ComputationSource]
+ Okf.Document: readExecutor :: Frontmatter -> Maybe Executor
+ Okf.Document: readGenerated :: Frontmatter -> Maybe Generated
+ Okf.Document: readParameters :: Frontmatter -> [Parameter]
+ Okf.Document: readRuntime :: Frontmatter -> Maybe Text
+ Okf.Document: readSources :: Frontmatter -> [Source]
+ Okf.Document: readStaleAfter :: Frontmatter -> Maybe Text
+ Okf.Document: readStatus :: Frontmatter -> Status
+ Okf.Document: readUsageWindow :: Frontmatter -> Maybe UsageWindow
+ Okf.Document: readVerified :: Frontmatter -> [Verification]
+ Okf.Document: renderStatus :: Status -> Text
+ Okf.Document: setGenerated :: Generated -> Frontmatter -> Frontmatter
+ Okf.Document: setSources :: [Source] -> Frontmatter -> Frontmatter
+ Okf.Document: setStaleAfter :: Text -> Frontmatter -> Frontmatter
+ Okf.Document: setStatus :: Status -> Frontmatter -> Frontmatter
+ Okf.Document: setUsageWindow :: UsageWindow -> Frontmatter -> Frontmatter
+ Okf.Document: setVerified :: [Verification] -> Frontmatter -> Frontmatter
+ Okf.Index: OkfVersion :: !Int -> !Int -> OkfVersion
+ Okf.Index: VersionDeclared :: !OkfVersion -> VersionDeclaration
+ Okf.Index: VersionUndeclared :: VersionDeclaration
+ Okf.Index: VersionUnparseable :: !Text -> VersionDeclaration
+ Okf.Index: [okfVersionMajor] :: OkfVersion -> !Int
+ Okf.Index: [okfVersionMinor] :: OkfVersion -> !Int
+ Okf.Index: data OkfVersion
+ Okf.Index: data VersionDeclaration
+ Okf.Index: instance GHC.Classes.Eq Okf.Index.OkfVersion
+ Okf.Index: instance GHC.Classes.Eq Okf.Index.VersionDeclaration
+ Okf.Index: instance GHC.Classes.Ord Okf.Index.OkfVersion
+ Okf.Index: instance GHC.Internal.Generics.Generic Okf.Index.OkfVersion
+ Okf.Index: instance GHC.Internal.Generics.Generic Okf.Index.VersionDeclaration
+ Okf.Index: instance GHC.Internal.Show.Show Okf.Index.OkfVersion
+ Okf.Index: instance GHC.Internal.Show.Show Okf.Index.VersionDeclaration
+ Okf.Index: parseOkfVersion :: Text -> Maybe OkfVersion
+ Okf.Index: readBundleVersion :: FilePath -> IO (Either BundleError VersionDeclaration)
+ Okf.Index: renderBundleIndexesWith :: Maybe OkfVersion -> FilePath -> IO (Either BundleError [(FilePath, Text)])
+ Okf.Index: renderOkfVersion :: OkfVersion -> Text
+ Okf.Index: renderRootIndex :: Maybe OkfVersion -> [FilePath] -> [FilePath] -> [Concept] -> Text
+ Okf.Index: supportedOkfVersion :: OkfVersion
+ Okf.Index: writeBundleIndexesWith :: Maybe OkfVersion -> FilePath -> IO (Either BundleError ())
+ Okf.Markdown: FootnoteLabels :: ![Text] -> ![Text] -> FootnoteLabels
+ Okf.Markdown: [footnoteDefinitions] :: FootnoteLabels -> ![Text]
+ Okf.Markdown: [footnoteReferences] :: FootnoteLabels -> ![Text]
+ Okf.Markdown: computationBlocks :: Text -> [Text]
+ Okf.Markdown: data FootnoteLabels
+ Okf.Markdown: extractFootnoteLabels :: Text -> FootnoteLabels
+ Okf.Markdown: footnoteLabelsUsed :: FootnoteLabels -> [Text]
+ Okf.Markdown: instance GHC.Classes.Eq Okf.Markdown.CodeRegion
+ Okf.Markdown: instance GHC.Classes.Eq Okf.Markdown.FootnoteLabels
+ Okf.Markdown: instance GHC.Internal.Generics.Generic Okf.Markdown.CodeRegion
+ Okf.Markdown: instance GHC.Internal.Generics.Generic Okf.Markdown.FootnoteLabels
+ Okf.Markdown: instance GHC.Internal.Show.Show Okf.Markdown.CodeRegion
+ Okf.Markdown: instance GHC.Internal.Show.Show Okf.Markdown.FootnoteLabels
+ Okf.Markdown: markdownOptions :: [CMarkOption]
+ Okf.Path: BundlePath :: !FilePath -> PathReference
+ Okf.Path: DanglingInBundle :: !FilePath -> PathResolution
+ Okf.Path: EscapesBundle :: PathReference
+ Okf.Path: ExternalUrl :: !Text -> PathReference
+ Okf.Path: MalformedPath :: PathReference
+ Okf.Path: ResolvedExternal :: !Text -> PathResolution
+ Okf.Path: ResolvedInBundle :: !FilePath -> PathResolution
+ Okf.Path: UnresolvableEscape :: PathResolution
+ Okf.Path: UnresolvableMalformed :: PathResolution
+ Okf.Path: classifyPathReference :: ConceptId -> Text -> PathReference
+ Okf.Path: collapseBundlePath :: FilePath -> Maybe FilePath
+ Okf.Path: data PathReference
+ Okf.Path: data PathResolution
+ Okf.Path: instance GHC.Classes.Eq Okf.Path.PathReference
+ Okf.Path: instance GHC.Classes.Eq Okf.Path.PathResolution
+ Okf.Path: instance GHC.Classes.Ord Okf.Path.PathReference
+ Okf.Path: instance GHC.Classes.Ord Okf.Path.PathResolution
+ Okf.Path: instance GHC.Internal.Generics.Generic Okf.Path.PathReference
+ Okf.Path: instance GHC.Internal.Generics.Generic Okf.Path.PathResolution
+ Okf.Path: instance GHC.Internal.Show.Show Okf.Path.PathReference
+ Okf.Path: instance GHC.Internal.Show.Show Okf.Path.PathResolution
+ Okf.Path: resolvePathReference :: (FilePath -> Bool) -> ConceptId -> Text -> PathResolution
+ Okf.Profile: Actor :: FieldFormat
+ Okf.Profile: Boolean :: FieldFormat
+ Okf.Profile: DanglingPathReference :: ConceptId -> FieldPath -> Text -> ProfileViolation
+ Okf.Profile: FieldSupersededInOkfVersion :: Maybe Text -> FieldPath -> Text -> Text -> ProfileDefinitionError
+ Okf.Profile: FormatRequiresOkfVersion :: Maybe Text -> FieldPath -> FieldFormat -> Text -> Text -> ProfileDefinitionError
+ Okf.Profile: HumanActor :: FieldFormat
+ Okf.Profile: Integer :: FieldFormat
+ Okf.Profile: InvalidProfileOkfVersion :: Text -> ProfileDefinitionError
+ Okf.Profile: InvalidRequiredBundleVersion :: Text -> ProfileDefinitionError
+ Okf.Profile: MalformedPathReference :: ConceptId -> FieldPath -> Value -> ProfileViolation
+ Okf.Profile: NonNegativeInteger :: FieldFormat
+ Okf.Profile: Object :: Cardinality
+ Okf.Profile: ObjectFieldsRequireObjectShape :: Maybe Text -> FieldPath -> Cardinality -> ProfileDefinitionError
+ Okf.Profile: PathEscapesBundle :: ConceptId -> FieldPath -> Text -> ProfileViolation
+ Okf.Profile: PathReferenceRule :: ![Text] -> !Bool -> PathReferenceRule
+ Okf.Profile: PathReferenceWithHandleReference :: Maybe Text -> FieldPath -> ProfileDefinitionError
+ Okf.Profile: ProfileOkfVersionNotUnderstood :: Text -> ProfileDefinitionError
+ Okf.Profile: RecommendedField :: FieldRequirement
+ Okf.Profile: RequiredBundleVersionUnmet :: Text -> Maybe Text -> ProfileViolation
+ Okf.Profile: RequiredField :: FieldRequirement
+ Okf.Profile: [objectFields] :: FieldRule -> !Maybe NestedRules
+ Okf.Profile: [path] :: NestedFieldRule -> !Maybe PathReferenceRule
+ Okf.Profile: [requireBundleVersion] :: ProfileSpec -> !Maybe Text
+ Okf.Profile: compiledProfileBaseRules :: CompiledProfile -> Map Text EffectiveFieldRule
+ Okf.Profile: compiledProfileRequiredBundleVersion :: CompiledProfile -> Maybe OkfVersion
+ Okf.Profile: compiledProfileRulesForType :: CompiledProfile -> Text -> Map Text EffectiveFieldRule
+ Okf.Profile: compiledProfileTypeNames :: CompiledProfile -> [Text]
+ Okf.Profile: data EffectiveFieldRule
+ Okf.Profile: data FieldRequirement
+ Okf.Profile: data PathReferenceRule
+ Okf.Profile: data PresenceClause
+ Okf.Profile: fieldRuleAllowedValues :: EffectiveFieldRule -> [Text]
+ Okf.Profile: fieldRuleCardinality :: EffectiveFieldRule -> Cardinality
+ Okf.Profile: fieldRuleDescription :: EffectiveFieldRule -> Maybe Text
+ Okf.Profile: fieldRuleElementFields :: EffectiveFieldRule -> Maybe (Map Text EffectiveFieldRule)
+ Okf.Profile: fieldRuleFormat :: EffectiveFieldRule -> Maybe FieldFormat
+ Okf.Profile: fieldRuleObjectFields :: EffectiveFieldRule -> Maybe (Map Text EffectiveFieldRule)
+ Okf.Profile: fieldRulePath :: EffectiveFieldRule -> Maybe PathReferenceRule
+ Okf.Profile: fieldRulePresenceClauses :: EffectiveFieldRule -> [PresenceClause]
+ Okf.Profile: fieldRuleReference :: EffectiveFieldRule -> Maybe HandleReferenceRule
+ Okf.Profile: instance Data.Aeson.Types.ToJSON.ToJSON Okf.Profile.PathReferenceRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PathReferenceRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreActorProfileFieldRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreActorProfileFrontmatterRules
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreActorProfileNestedFieldRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreActorProfileNestedRules
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreActorProfileSpec
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreActorProfileTypeRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreBundleVersionProfileSpec
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreObjectProfileFieldRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreObjectProfileFrontmatterRules
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreObjectProfileNestedFieldRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreObjectProfileNestedRules
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreObjectProfileSpec
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreObjectProfileTypeRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PrePathProfileFieldRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PrePathProfileFrontmatterRules
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PrePathProfileNestedFieldRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PrePathProfileNestedRules
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PrePathProfileSpec
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PrePathProfileTypeRule
+ Okf.Profile: instance Dhall.Marshal.Decode.FromDhall Okf.Profile.PreV02FieldFormat
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PathReferenceRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PathTargetPresence
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreActorProfileFieldRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreActorProfileFrontmatterRules
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreActorProfileNestedFieldRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreActorProfileNestedRules
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreActorProfileSpec
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreActorProfileTypeRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreBundleVersionProfileSpec
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreObjectProfileFieldRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreObjectProfileFrontmatterRules
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreObjectProfileNestedFieldRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreObjectProfileNestedRules
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreObjectProfileSpec
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreObjectProfileTypeRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PrePathProfileFieldRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PrePathProfileFrontmatterRules
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PrePathProfileNestedFieldRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PrePathProfileNestedRules
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PrePathProfileSpec
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PrePathProfileTypeRule
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.PreV02FieldFormat
+ Okf.Profile: instance GHC.Classes.Ord Okf.Profile.PathReferenceRule
+ Okf.Profile: instance GHC.Classes.Ord Okf.Profile.PreV02FieldFormat
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.FieldRequirement
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PathReferenceRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PathTargetPresence
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreActorProfileFieldRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreActorProfileFrontmatterRules
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreActorProfileNestedFieldRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreActorProfileNestedRules
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreActorProfileSpec
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreActorProfileTypeRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreBundleVersionProfileSpec
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreObjectProfileFieldRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreObjectProfileFrontmatterRules
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreObjectProfileNestedFieldRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreObjectProfileNestedRules
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreObjectProfileSpec
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreObjectProfileTypeRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PrePathProfileFieldRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PrePathProfileFrontmatterRules
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PrePathProfileNestedFieldRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PrePathProfileNestedRules
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PrePathProfileSpec
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PrePathProfileTypeRule
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.PreV02FieldFormat
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PathReferenceRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PathTargetPresence
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreActorProfileFieldRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreActorProfileFrontmatterRules
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreActorProfileNestedFieldRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreActorProfileNestedRules
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreActorProfileSpec
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreActorProfileTypeRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreBundleVersionProfileSpec
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreObjectProfileFieldRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreObjectProfileFrontmatterRules
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreObjectProfileNestedFieldRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreObjectProfileNestedRules
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreObjectProfileSpec
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreObjectProfileTypeRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PrePathProfileFieldRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PrePathProfileFrontmatterRules
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PrePathProfileNestedFieldRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PrePathProfileNestedRules
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PrePathProfileSpec
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PrePathProfileTypeRule
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.PreV02FieldFormat
+ Okf.Profile: presenceClauseCondition :: PresenceClause -> Maybe FieldCondition
+ Okf.Profile: presenceClauseRequirement :: PresenceClause -> FieldRequirement
+ Okf.Profile: renderCardinalityName :: Cardinality -> Text
+ Okf.Profile: renderFieldFormatName :: FieldFormat -> Text
+ Okf.Profile: validateProfileVersion :: VersionDeclaration -> CompiledProfile -> [ProfileViolation]
+ Okf.Profile: validateProfileWith :: BundleInventory -> ValidationProfile -> CompiledProfile -> [Concept] -> [ProfileViolation]
+ Okf.Profile.Documentation: DocumentationOptions :: !Text -> !Text -> !Maybe Text -> !Maybe Generated -> DocumentationOptions
+ Okf.Profile.Documentation: InvalidRootConceptId :: !Text -> !ConceptIdError -> DocumentationError
+ Okf.Profile.Documentation: InvalidTypeDirectory :: !Text -> !ConceptIdError -> DocumentationError
+ Okf.Profile.Documentation: [generated] :: DocumentationOptions -> !Maybe Generated
+ Okf.Profile.Documentation: [rootConceptId] :: DocumentationOptions -> !Text
+ Okf.Profile.Documentation: [timestamp] :: DocumentationOptions -> !Maybe Text
+ Okf.Profile.Documentation: [typeDirectory] :: DocumentationOptions -> !Text
+ Okf.Profile.Documentation: data DocumentationError
+ Okf.Profile.Documentation: data DocumentationOptions
+ Okf.Profile.Documentation: defaultDocumentationActor :: Actor
+ Okf.Profile.Documentation: defaultDocumentationOptions :: DocumentationOptions
+ Okf.Profile.Documentation: instance GHC.Classes.Eq Okf.Profile.Documentation.DocumentationError
+ Okf.Profile.Documentation: instance GHC.Classes.Eq Okf.Profile.Documentation.DocumentationOptions
+ Okf.Profile.Documentation: instance GHC.Classes.Eq Okf.Profile.Documentation.PresenceClass
+ Okf.Profile.Documentation: instance GHC.Internal.Generics.Generic Okf.Profile.Documentation.DocumentationError
+ Okf.Profile.Documentation: instance GHC.Internal.Generics.Generic Okf.Profile.Documentation.DocumentationOptions
+ Okf.Profile.Documentation: instance GHC.Internal.Show.Show Okf.Profile.Documentation.DocumentationError
+ Okf.Profile.Documentation: instance GHC.Internal.Show.Show Okf.Profile.Documentation.DocumentationOptions
+ Okf.Profile.Documentation: instance GHC.Internal.Show.Show Okf.Profile.Documentation.PresenceClass
+ Okf.Profile.Documentation: profileConceptType :: Text
+ Okf.Profile.Documentation: profileDocumentationSlug :: Text -> Text
+ Okf.Profile.Documentation: profileTypeConceptType :: Text
+ Okf.Profile.Documentation: renderProfileDocumentation :: DocumentationOptions -> CompiledProfile -> Either DocumentationError [Concept]
+ Okf.Trust: Fresh :: Staleness
+ Okf.Trust: HumanReviewed :: TrustTier
+ Okf.Trust: MachineConfirmed :: TrustTier
+ Okf.Trust: NoStaleAfter :: Staleness
+ Okf.Trust: Stale :: !Day -> Staleness
+ Okf.Trust: StaleAfterUnparseable :: !Text -> Staleness
+ Okf.Trust: Unverified :: TrustTier
+ Okf.Trust: data Staleness
+ Okf.Trust: data TrustTier
+ Okf.Trust: instance GHC.Classes.Eq Okf.Trust.Staleness
+ Okf.Trust: instance GHC.Classes.Eq Okf.Trust.TrustTier
+ Okf.Trust: instance GHC.Classes.Ord Okf.Trust.TrustTier
+ Okf.Trust: instance GHC.Internal.Generics.Generic Okf.Trust.Staleness
+ Okf.Trust: instance GHC.Internal.Generics.Generic Okf.Trust.TrustTier
+ Okf.Trust: instance GHC.Internal.Show.Show Okf.Trust.Staleness
+ Okf.Trust: instance GHC.Internal.Show.Show Okf.Trust.TrustTier
+ Okf.Trust: latestVerification :: [Verification] -> Maybe Text
+ Okf.Trust: renderStaleness :: Staleness -> Text
+ Okf.Trust: renderTrustTier :: TrustTier -> Text
+ Okf.Trust: staleness :: Day -> Maybe Text -> Staleness
+ Okf.Trust: trustTier :: [Verification] -> TrustTier
+ Okf.Validation: AttestedComputationHasBothComputations :: ValidationError
+ Okf.Validation: AttestedComputationHasManyBlocks :: Int -> ValidationError
+ Okf.Validation: AttestedComputationHasNoComputation :: ValidationError
+ Okf.Validation: AttestedComputationMissingRuntime :: ValidationError
+ Okf.Validation: BundleVersionNotUnderstood :: Text -> BundleValidationError
+ Okf.Validation: BundleVersionUnparseable :: Text -> BundleValidationError
+ Okf.Validation: DanglingFrontmatterPath :: ConceptId -> Text -> FilePath -> Maybe FilePath -> BundleValidationError
+ Okf.Validation: DuplicateSourceId :: Text -> ValidationError
+ Okf.Validation: FootnoteLabelNotInSources :: Text -> ValidationError
+ Okf.Validation: GeneratedMustHaveActor :: ValidationError
+ Okf.Validation: LegacyFieldInDeclaredV2 :: Text -> ValidationError
+ Okf.Validation: MissingGeneratedField :: ValidationError
+ Okf.Validation: SourceIdNotCited :: Text -> ValidationError
+ Okf.Validation: SourceMissingResource :: Int -> ValidationError
+ Okf.Validation: VersionGate :: !VersionDeclaration -> !Maybe OkfVersion -> !Maybe Text -> VersionGate
+ Okf.Validation: [gateDeclaration] :: VersionGate -> !VersionDeclaration
+ Okf.Validation: [gateEffective] :: VersionGate -> !Maybe OkfVersion
+ Okf.Validation: [gateNotUnderstood] :: VersionGate -> !Maybe Text
+ Okf.Validation: data VersionGate
+ Okf.Validation: gateDeclaresAtLeast :: OkfVersion -> VersionGate -> Bool
+ Okf.Validation: instance GHC.Classes.Eq Okf.Validation.VersionGate
+ Okf.Validation: instance GHC.Internal.Generics.Generic Okf.Validation.VersionGate
+ Okf.Validation: instance GHC.Internal.Show.Show Okf.Validation.VersionGate
+ Okf.Validation: versionGate :: VersionDeclaration -> VersionGate
- Okf.Index: renderIndex :: [FilePath] -> [Concept] -> Text
+ Okf.Index: renderIndex :: [FilePath] -> [FilePath] -> [Concept] -> Text
- Okf.Profile: FieldRule :: !Text -> !Maybe Text -> ![Text] -> !Cardinality -> !Maybe FieldFormat -> !Maybe NestedRules -> !Maybe HandleReferenceRule -> !Maybe FieldCondition -> FieldRule
+ Okf.Profile: FieldRule :: !Text -> !Maybe Text -> ![Text] -> !Cardinality -> !Maybe FieldFormat -> !Maybe NestedRules -> !Maybe NestedRules -> !Maybe HandleReferenceRule -> !Maybe PathReferenceRule -> !Maybe FieldCondition -> FieldRule
- Okf.Profile: NestedFieldRule :: !Text -> !Maybe Text -> ![Text] -> !Cardinality -> !Maybe FieldFormat -> !Maybe FieldCondition -> NestedFieldRule
+ Okf.Profile: NestedFieldRule :: !Text -> !Maybe Text -> ![Text] -> !Cardinality -> !Maybe FieldFormat -> !Maybe PathReferenceRule -> !Maybe FieldCondition -> NestedFieldRule
- Okf.Profile: ProfileSpec :: !Text -> !Maybe Text -> !Text -> !FrontmatterRules -> !Bool -> !Bool -> !Maybe Text -> ![TypeRule] -> ProfileSpec
+ Okf.Profile: ProfileSpec :: !Text -> !Maybe Text -> !Text -> !FrontmatterRules -> !Bool -> !Bool -> !Maybe Text -> !Maybe Text -> ![TypeRule] -> ProfileSpec
- Okf.Profile: [allowSelf] :: HandleReferenceRule -> !Bool
+ Okf.Profile: [allowSelf] :: PathReferenceRule -> !Bool
- Okf.Profile: [externalUriSchemes] :: HandleReferenceRule -> ![Text]
+ Okf.Profile: [externalUriSchemes] :: PathReferenceRule -> ![Text]
- Okf.Validation: validateBundle :: ValidationProfile -> [Concept] -> [BundleValidationError]
+ Okf.Validation: validateBundle :: ValidationProfile -> VersionDeclaration -> BundleInventory -> [Concept] -> [BundleValidationError]
Files
- CHANGELOG.md +238/−0
- dhall/FieldFormat.dhall +13/−1
- dhall/FieldRule.dhall +19/−0
- dhall/NestedFieldRule.dhall +16/−3
- dhall/PathReferenceRule.dhall +20/−0
- dhall/Profile.dhall +8/−0
- dhall/defaults/FieldRule.dhall +4/−0
- dhall/defaults/NestedFieldRule.dhall +3/−0
- dhall/defaults/PathReferenceRule.dhall +13/−0
- dhall/defaults/Profile.dhall +1/−0
- dhall/mk/FieldRule.dhall +32/−0
- dhall/mk/NestedFieldRule.dhall +25/−0
- dhall/package.dhall +2/−0
- okf-core.cabal +12/−1
- src/Okf/Actor.hs +68/−0
- src/Okf/Bundle.hs +212/−4
- src/Okf/Document.hs +697/−8
- src/Okf/Graph.hs +20/−25
- src/Okf/Index.hs +239/−17
- src/Okf/Log.hs +2/−1
- src/Okf/Markdown.hs +255/−0
- src/Okf/Path.hs +141/−0
- src/Okf/Profile.hs +3903/−2480
- src/Okf/Profile/Documentation.hs +586/−0
- src/Okf/Trust.hs +120/−0
- src/Okf/Validation.hs +548/−15
- test/Main.hs +6269/−2976
- test/fixtures/attested-computation/computations/both-computations.md +27/−0
- test/fixtures/attested-computation/computations/churn.md +31/−0
- test/fixtures/attested-computation/computations/margin.md +23/−0
- test/fixtures/attested-computation/computations/no-computation.md +17/−0
- test/fixtures/attested-computation/computations/revenue.md +35/−0
- test/fixtures/attested-computation/computations/two-blocks.md +35/−0
- test/fixtures/attested-computation/index.md +13/−0
- test/fixtures/attested-computation/metrics/revenue.md +19/−0
- test/fixtures/attested-computation/references/attesters/revenue.py +16/−0
- test/fixtures/attested-computation/references/queries/revenue.sql +3/−0
- test/fixtures/attested-computation/references/skills/run-on-bq.md +22/−0
- test/fixtures/dangling-frontmatter-path/computations/index.md +3/−0
- test/fixtures/dangling-frontmatter-path/computations/spec-spelling.md +26/−0
- test/fixtures/dangling-frontmatter-path/dangling.md +14/−0
- test/fixtures/dangling-frontmatter-path/external.md +13/−0
- test/fixtures/dangling-frontmatter-path/index.md +10/−0
- test/fixtures/dangling-frontmatter-path/non-markdown.md +15/−0
- test/fixtures/dangling-frontmatter-path/references/attesters/revenue.py +1/−0
- test/fixtures/profiles/attested-computation-house.dhall +131/−0
- test/fixtures/profiles/cardinality.dhall +1/−0
- test/fixtures/profiles/closed-fields.dhall +1/−0
- test/fixtures/profiles/conditional-fields-ep2.dhall +13/−1
- test/fixtures/profiles/decisions.dhall +1/−0
- test/fixtures/profiles/document-references-ep3.dhall +13/−1
- test/fixtures/profiles/document-references-invalid.dhall +1/−0
- test/fixtures/profiles/document-references.dhall +1/−0
- test/fixtures/profiles/formats-ep4.dhall +13/−1
- test/fixtures/profiles/formats-mp8-ep2.dhall +182/−0
- test/fixtures/profiles/formats.dhall +1/−0
- test/fixtures/profiles/nested-reviews-ep1.dhall +13/−1
- test/fixtures/profiles/nested-reviews.dhall +1/−0
- test/fixtures/profiles/object-fields-mp8-ep1.dhall +172/−0
- test/fixtures/profiles/path-references-mp8-ep3.dhall +198/−0
- test/fixtures/profiles/postgresql.dhall +1/−0
- test/fixtures/profiles/pre-bundle-version.dhall +189/−0
- test/fixtures/profiles/type-frontmatter.dhall +1/−0
- test/fixtures/v01-legacy-bundle/index.md +10/−0
- test/fixtures/v01-legacy-bundle/log.md +4/−0
- test/fixtures/v01-legacy-bundle/tables/index.md +3/−0
- test/fixtures/v01-legacy-bundle/tables/orders.md +15/−0
- test/fixtures/valid-bundle/datasets/sales.md +3/−1
- test/fixtures/valid-bundle/index.md +4/−0
- test/fixtures/valid-bundle/references/source-system.md +3/−1
- test/fixtures/valid-bundle/tables/customers.md +3/−1
- test/fixtures/valid-bundle/tables/orders.md +3/−1
CHANGELOG.md view
@@ -7,6 +7,244 @@ ## [Unreleased] +## [0.5.0.0] - 2026-08-01++### Added++- **OKF v0.2 core semantics.** okf now tracks version 0.2 of the Open Knowledge+ Format. Every family below is optional; `type` remains the only key a concept+ must have, and no bundle is ever rejected for omitting one.+- `Okf.Actor`: the specification §7 actor convention, with `parseActor` (total,+ never fails), `renderActor` (its exact inverse), and `isHumanActor`, which is+ the single test separating the machine-confirmed trust tier from+ human-reviewed. Four fields carry an actor: `generated.by`, `verified[].by`,+ and `sources[].author`.+- The trust family (§5.2): `Generated`/`readGenerated`/`setGenerated` for who or+ what produced a concept's content and when, and+ `Verification`/`readVerified`/`setVerified` for independent confirmation.+ `readVerified` accepts a bare mapping as a one-element list, as §5.2 requires.+- The lifecycle family (§5.4, §5.5): `Status`/`readStatus`/`renderStatus`/+ `setStatus`, where an absent key means `stable`, and `readStaleAfter`/+ `setStaleAfter`.+- The provenance family (§5.1): `Source`/`readSources`/`setSources` with the+ credibility signals `author`, `usage_count`, and `last_modified`, plus+ `UsageWindow`/`readUsageWindow`/`setUsageWindow` and `effectiveUsageWindow`,+ which resolves an entry's own window against the document-scope one.+ `usage_count` is read only from a YAML integer; a numeric string is not+ coerced.+- `Okf.Trust`: `trustTier`, `renderTrustTier`, `latestVerification`,+ `staleness`, and `renderStaleness`. Everything here is derived on read and+ never stored, and no function in `okf-core` reads the clock — `staleness`+ takes the current day as an argument. See+ `docs/adr/8-derived-not-stored-trust-and-credibility.md`.+- `Okf.Bundle.Concept` projects six more typed fields from frontmatter —+ `generated`, `verified`, `status`, `staleAfter`, `sources`, `usageWindow` —+ with matching accessors. Every projection restates frontmatter; none is a+ derivation.+- The bundle-root version declaration of §12: `OkfVersion`,+ `VersionDeclaration`, `readBundleVersion`, `parseOkfVersion`,+ `renderOkfVersion`, `supportedOkfVersion`, and `renderRootIndex` in+ `Okf.Index`. `Okf.Validation.versionGate` is the one place that decides what a+ declared version implies, and `gateDeclaresAtLeast` is the only question a+ check asks of it. An unknown minor within a known major is read as the highest+ known version; an unknown major is read permissively. Neither is ever a refusal.+ See `docs/adr/10-okf-version-declaration-and-best-effort-reading.md`.+- `Okf.Markdown`, with one shared `markdownOptions` list, `extractFootnoteLabels`,+ and `footnoteLabelsUsed`. All three `commonmarkToNode` call sites now route+ through it and Markdown footnotes are enabled. See+ `docs/adr/9-one-markdown-parse-configuration-and-source-scanned-authoring-checks.md`.+- `okf-core/test/fixtures/v01-legacy-bundle`, a deliberately unmigrated v0.1+ bundle that keeps the legacy fallback under test. Do not migrate it.+- **Object rules on profile field rules.** `FieldRule` gains+ `objectFields :: Maybe NestedRules`, describing the record that *is* a key's+ value, as distinct from `elementFields`, which describes the record inside each+ element of a list. Both take the same `NestedRules` value and their members are+ checked identically; only the reported `FieldPath` differs (`generated.by`+ against `reviews[0].kind`). Declaring both accepts either spelling, which is how+ a profile describes the OKF v0.2 `verified` key. `NestedFieldRule` gains+ neither member, so the descriptor stays depth-bounded.+ `EffectiveFieldRule` gains the `fieldRuleObjectFields` accessor, and generated+ profile documentation gains an `- Object fields:` bullet. Declaring+ `objectFields` and no explicit cardinality refines the rule to the new+ compiled-only `Cardinality` constructor `Object`; pairing it with an explicit+ `Scalar` or `List` is the new definition error+ `ObjectFieldsRequireObjectShape`. See+ `docs/adr/11-growing-the-profile-descriptor-language.md`.+- `okf-core/test/fixtures/profiles/object-fields-mp8-ep1.dhall`, the frozen+ descriptor generation from immediately before `objectFields` existed. Do not+ edit it.+- **The OKF v0.2 `Attested Computation` concept type (§10).** A concept of that+ exact type carries a sanctioned way to compute a value, so a consumer can+ confirm a number came from running the blessed computation rather than from an+ agent improvising its own query. `Okf.Document` reads the five contract keys —+ `runtime`, `parameters`, `computation`, `executor`, `attester` — with+ `Parameter`, `Executor`, `Attester`, and `attestedComputationType`, the exact+ case-sensitive type string. All five joined `coreFrontmatterFieldOrder` and+ `fieldsIntroducedInV02`, between `status` and `generated`, which is §10.2's own+ worked-example order, so serialization round-trips a §10.2 concept unchanged.+ `Okf.Bundle` projects the lot onto `Concept`.+- The body half of §10.3: `Okf.Markdown.computationBlocks` finds the code blocks+ under a `# Computation` heading, bounded at the next heading of the same or+ shallower level, and accepts both the fenced spelling §10.3's prose names and+ the indented one §10.2's own example writes. `Okf.Document.ComputationSource`+ and `readComputationSources` restate what a document offers — file before+ inline — and `Okf.Bundle.conceptComputationSources` projects it. Every one of+ these is type-agnostic and never reports; scoping to the type is+ `Okf.Validation`'s job.+- Four strict-mode `ValidationError` constructors for that type and no other:+ `AttestedComputationMissingRuntime` for §10.2's one REQUIRED field, and+ `AttestedComputationHasNoComputation`, `AttestedComputationHasBothComputations`,+ and `AttestedComputationHasManyBlocks Int` for the three ways §10.3's+ exactly-one rule breaks. Strict-only, per+ `docs/adr/7-okf-v0-1-legacy-fallback-policy.md`: §11's conformance list reaches+ none of them and separately forbids rejecting a bundle for an unknown `type`,+ so "REQUIRED for this type" binds the producer and does not license a consumer+ to refuse.+- **Path-valued frontmatter fields are resolved against the bundle** (§6.2), a+ gap that predates attested computations — nothing in okf had ever looked at a+ path sitting in a frontmatter value. `Okf.Path` gains existence checking, and+ `Okf.Validation` reports `DanglingFrontmatterPath` under `StrictAuthoring` for+ `resource`, `computation`, `executor.resource`, and `attester.resource`. A+ relative path that would have resolved from the bundle root is told the+ leading-slash spelling to write instead. `sources[].resource` is deliberately+ excluded: §5.1 sanctions a scope descriptor there, so path-checking it would+ report correct bundles as broken. See+ `docs/adr/12-frontmatter-path-resolution.md`.+- `Okf.Profile.validateProfileWith` takes a `BundleInventory`, so a profile+ `path` rule resolves against every file in the bundle rather than only `.md`+ concepts — which is what §6.3's own `references/attesters/revenue.py` example+ needs. `validateProfile` is defined in terms of it and keeps its exact meaning:+ a caller that supplies no inventory has not looked, and okf reports nothing it+ did not check. See `docs/adr/13-the-references-convention-and-non-markdown-files.md`.+- `Okf.Index` lists a directory's non-Markdown files under a `# Files` heading,+ so a `references/attesters/` directory holding only `.py` files no longer+ generates a one-byte `index.md`.+- `okf-core/test/fixtures/attested-computation/`, a bundle carrying one complete+ contract, one of each §10.3 failure, one `Metric` that must never be mistaken+ for a computation, and one concept that is core-clean and deviates only from a+ house profile. `okf-core/test/fixtures/profiles/attested-computation-house.dhall`+ is that house profile, and is the descriptor `docs/user/profiles.md` documents.+- `Okf.Profile.Documentation.defaultDocumentationActor`, the actor+ `defaultDocumentationOptions` names as the producer of a generated+ documentation bundle: `ProcessActor "okf-profile-document"`. Deliberately+ version-free, so generated output stays byte-identical across okf releases.+- A profile may require its bundle to declare an OKF version.+ `ProfileSpec.requireBundleVersion` holds a minimum as `Just "0.2"`, and the new+ `Okf.Profile.validateProfileVersion` reports an undeclared, older, or+ unparseable declaration as `RequiredBundleVersionUnmet`. Specification §12+ makes the declaration a MAY, so `validateBundle` still never asks for one; this+ is the house-convention half, inert until a profile author writes the field.+ `compiledProfileRequiredBundleVersion` reads the parsed minimum off a compiled+ profile. See+ `docs/adr/10-okf-version-declaration-and-best-effort-reading.md`.+- `validateProfileVersion` is a new entry point rather than a parameter on+ `validateProfile` or `validateProfileWith`, whose signatures are unchanged: the+ check consults no concepts, and adding a parameter would break every caller.+- The generated profile documentation bundle gains a `Required bundle version`+ bullet in its `## Settings` list, so a profile setting cannot be a silent hole+ in its own documentation. Regenerating a committed documentation bundle+ produces that one added line.++### Changed++- **Breaking for exhaustive consumers, from the attested computation work.**+ Four changes, listed together because a consumer moving its okf pin meets them+ as one surface. `Okf.Validation.validateBundle` takes a required+ `BundleInventory` before its list of concepts — required rather than defaulted,+ so no caller can pass an empty inventory and have every path report as+ dangling. `BundleValidationError` gains `DanglingFrontmatterPath ConceptId Text+ FilePath (Maybe FilePath)`. `ValidationError` gains the four+ `AttestedComputation*` constructors listed above. And `Okf.Index.renderIndex`+ takes two further parameters, for the non-Markdown files in a directory.+ The arity change on `validateBundle` is the one that breaks a caller outright;+ the constructor additions break only an exhaustive `case`. Mori+ (`mori://shinzui/mori`) matches `ProfileViolation` and not `ValidationError` or+ `BundleValidationError` as of 2026-08-01, so the constructors do not reach it,+ but its call to `validateBundle` does — check before moving the pin rather than+ assuming, since that is a position on a date and not a guarantee.+ `Okf.Profile.validateProfile` is unchanged in signature and meaning.+- **Breaking for exhaustive consumers.** `Cardinality` gains a fourth+ constructor, `Object`, and `ProfileDefinitionError` gains+ `ObjectFieldsRequireObjectShape`. The `Cardinality` addition is the wider of+ the two: that type appears in `ConflictingCardinality`,+ `ElementFieldsRequireList`, `ConditionFieldNotScalar`, and+ `CardinalityMismatch`, so a consumer that renders a cardinality needs a new+ case even if it declares no object rules. `Object` is deliberately unreachable+ from Dhall — the published union in `okf-core/dhall/Cardinality.dhall` keeps+ exactly its three alternatives — so no pinned descriptor changes type. No+ `ProfileViolation` constructor was added.+- Adding `objectFields` to the closed Dhall `FieldRule` record is a schema+ event. The complete optional-presence generation is frozen before it and+ upgrades with `objectFields = None`, so descriptors written with record+ completion or the `mk` constructors are unaffected; a descriptor that annotates+ itself against okf's current schema by relative path must add the field, since+ Dhall rejects the annotation before any fallback decoder runs.+- A missing member of a nested record now carries the member's `description`+ prose in parentheses, as a missing top-level key already did. This applies to+ list elements as well as object members, and emits nothing where a member+ declares no description.+- **Breaking for exhaustive consumers.** `validateBundle` takes a+ `VersionDeclaration` between the profile and the concepts:+ `ValidationProfile -> VersionDeclaration -> [Concept] -> [BundleValidationError]`.+ Passing `VersionUndeclared` reproduces the previous behaviour exactly.+ `ValidationError` gains `MissingGeneratedField`, `GeneratedMustHaveActor`,+ `SourceMissingResource`, `DuplicateSourceId`, `FootnoteLabelNotInSources`,+ `SourceIdNotCited`, and `LegacyFieldInDeclaredV2`; `BundleValidationError`+ gains `BundleVersionUnparseable` and `BundleVersionNotUnderstood`. Every new+ diagnostic is `StrictAuthoring` only.+- **Strict validation asks for `generated` rather than `timestamp`**, falling+ back to a legacy `timestamp` when `generated` is absent, so nothing that+ passed before fails now. The message for a concept with neither changes from+ `missing recommended field: timestamp` to+ `missing generated field (or legacy timestamp)`. The fallback is silent in an+ undeclared bundle and reported in one that declares `okf_version: "0.2"`; see+ `docs/adr/7-okf-v0-1-legacy-fallback-policy.md`.+- **`logStaleness` reads `generated.at` first**, falling back to `timestamp`. A+ v0.2 concept with no `timestamp` at all is staleness-checked for the first+ time.+- `coreFrontmatterFieldOrder` gains all six v0.2 concept keys, so re-serializing+ a document orders them deterministically and a closed profile+ (`allowUnknownFields = False`) permits them without redeclaring. `timestamp`+ moved to the end of that order, which reorders it once in any document a+ producer re-serializes.+- Markdown bodies parse with footnotes enabled. This also fixes a bug: a+ single-token footnote definition such as `[^src]: doc.md` previously parsed as+ a link reference definition, producing a phantom dangling link.+- `setTimestamp`, `OkfCommon`'s `commonTimestamp`, and reading a v0.1+ `timestamp` are all retained. Writing v0.1 on purpose stays supported.+- `Okf.Profile.Documentation.DocumentationOptions` gains a `generated ::+ Maybe Generated` field carrying the OKF v0.2 `generated` family written on+ every generated document, and `defaultDocumentationOptions` now supplies one.+ `Nothing` omits the key. Generation still reads no clock: `generatedAt` is+ caller-supplied and absent by default.+- **Breaking for record-literal callers.** A consumer that constructs+ `DocumentationOptions` as a record literal rather than by overriding+ `defaultDocumentationOptions` must add the new field. The module Haddock has+ always directed callers to start from `defaultDocumentationOptions` for+ exactly this reason; a call site that does so is unaffected.+- **Breaking for exhaustive matchers and record-literal callers**, from+ `requireBundleVersion`. `ProfileSpec` gains a field, so a consumer building one+ as a record literal must add it. `ProfileViolation` gains+ `RequiredBundleVersionUnmet` — the first violation that carries no `ConceptId`,+ so a consumer grouping violations by concept needs a case for it — and+ `ProfileDefinitionError` gains `InvalidRequiredBundleVersion`. Handle all three+ before moving an okf pin. `okf profile show --json` gains a+ `requireBundleVersion` key for the same reason.+- The published Dhall schema `okf-core/dhall/Profile.dhall` gains+ `requireBundleVersion : Optional Text`, defaulted to `None Text` in+ `okf-core/dhall/defaults/Profile.dhall`. A descriptor written as+ `Profile::{ … }` is unaffected; one written as a bare record literal annotated+ `: Profile` must add the member, and one pinned at an earlier release keeps+ decoding through a new frozen generation+ (`okf-core/test/fixtures/profiles/pre-bundle-version.dhall`).++### Fixed++- The sdist ships `test/fixtures/**/*.sql`. The `attested-computation` fixture+ points a `computation` field at a `.sql` file, so without it `cabal test` on+ the released tarball reported that path as dangling — a failure only someone+ building from Hackage with tests enabled would ever have seen.+ ## [0.4.0.0] - 2026-07-30 ### Added
dhall/FieldFormat.dhall view
@@ -1,7 +1,19 @@---| Named textual formats available to profile field rules.+--| Named value formats available to profile field rules.+--+-- The first five constrain text. `Actor` and `HumanActor` constrain text against+-- the OKF v0.2 actor convention (specification §7): `<producer>/<version>`,+-- `human:<id>`, or `process:<id>`, with `HumanActor` accepting only the second.+-- `Integer`, `NonNegativeInteger`, and `Boolean` constrain a value that is not+-- text at all, and declaring one of them refines an unspecified cardinality to+-- `Scalar`. < Rfc3339Utc | Date | Uri | UriWithScheme : Text | DocumentHandle : Text+| Actor+| HumanActor+| Integer+| NonNegativeInteger+| Boolean >
dhall/FieldRule.dhall view
@@ -8,6 +8,21 @@ -- `allowedValues = []` leaves textual values unconstrained. -- `cardinality = Cardinality.Any` preserves the legacy scalar-or-list presence -- behavior.+--+-- `elementFields` and `objectFields` are a pair and describe two different+-- shapes. `elementFields` describes the record inside *each element of a list*,+-- so `reviews: [{kind: human}, …]` is constrained with `elementFields`.+-- `objectFields` describes the record that *is* the value, so+-- `generated: {by: …, at: …}` is constrained with `objectFields`. Declaring both+-- means either spelling is accepted and both are checked against the same member+-- rules, which is how a profile describes the OKF v0.2 `verified` key: the+-- specification permits it as a list of mappings or as one bare mapping, and+-- requires a consumer to treat the bare mapping as a one-element list.+--+-- Declaring `objectFields` with no explicit cardinality means the key must be a+-- mapping. Declaring it alongside `cardinality = Cardinality.Scalar` or+-- `Cardinality.List` is a profile definition error, because a mapping is+-- neither. let Cardinality = ./Cardinality.dhall let FieldFormat = ./FieldFormat.dhall@@ -18,12 +33,16 @@ let HandleReferenceRule = ./HandleReferenceRule.dhall +let PathReferenceRule = ./PathReferenceRule.dhall+ in { field : Text , description : Optional Text , allowedValues : List Text , cardinality : Cardinality , format : Optional FieldFormat , elementFields : Optional NestedRules+ , objectFields : Optional NestedRules , reference : Optional HandleReferenceRule+ , path : Optional PathReferenceRule , when : Optional FieldCondition }
dhall/NestedFieldRule.dhall view
@@ -1,17 +1,30 @@---| Canonical schema for one field inside a list element record.+--| Canonical schema for one field inside a list element record, or inside the+-- mapping that is an object-valued field. ----- This deliberately omits `elementFields`, so profile schemas are bounded to--- one list of flat records rather than recursively nested objects.+-- This deliberately omits `elementFields` and `objectFields`, so profile schemas+-- are bounded to one level of flat records rather than recursively nested+-- objects.+--+-- It does carry `path`, because `sources[].resource` — the motivating+-- path-valued field of OKF v0.2 specification §6.2 — lives inside a list element+-- record and is unreachable from a top-level rule. It deliberately does not+-- carry `reference`: no v0.2 field names a `PREFIX-N` document handle inside a+-- nested record, and adding an unused member to a published record is a+-- compatibility event bought for nothing. It is a cheap additive change for+-- whoever has a motivating case. let Cardinality = ./Cardinality.dhall let FieldFormat = ./FieldFormat.dhall let FieldCondition = ./FieldCondition.dhall +let PathReferenceRule = ./PathReferenceRule.dhall+ in { field : Text , description : Optional Text , allowedValues : List Text , cardinality : Cardinality , format : Optional FieldFormat+ , path : Optional PathReferenceRule , when : Optional FieldCondition }
+ dhall/PathReferenceRule.dhall view
@@ -0,0 +1,20 @@+--| Policy for a field whose value names a path or URI per OKF v0.2+-- specification §6.2: an absolute URL, a bundle-relative path beginning with+-- `/`, or an ordinary relative path. A relative path resolves against the+-- directory of the concept carrying it, exactly as a Markdown link in that+-- concept's body would.+--+-- Deliberately distinct from `HandleReferenceRule`, which resolves a `PREFIX-N`+-- document handle against the bundle's document-ID index. A path resolves+-- against the bundle's concept tree instead, and declaring both policies on one+-- key is a profile definition error: a value cannot be read as both.+--+-- `externalUriSchemes` lists the URL schemes the profile permits; an empty list+-- means no absolute URL is permitted and the value must be a path. okf resolves+-- only paths, and only to concepts: a path naming a `.md` file must name one+-- that exists in the bundle, and a path naming any other file is accepted+-- without a check, because profile validation never touches the filesystem.+-- `allowSelf` permits a path that resolves to the concept carrying it.+{ externalUriSchemes : List Text+, allowSelf : Bool+}
dhall/Profile.dhall view
@@ -16,6 +16,13 @@ -- read or adopt it. Like every description in this schema it is documentary only. -- `allowUnknownFields = False` closes top-level frontmatter to core OKF keys, -- the configured `idField`, and the effective profile/type field rules.+--+-- `requireBundleVersion = Some "0.2"` means the bundle's root `index.md` must+-- declare `okf_version` at that version or later; `None Text` demands nothing and+-- is the default. This is a house convention, not a rule of the format:+-- specification §12 makes the declaration a MAY, so okf itself never asks for one.+-- It is distinct from `okfVersion` above, which says which version's rules this+-- profile writes rather than what it demands of a bundle. let TypeRule = ./TypeRule.dhall let FrontmatterRules = ./FrontmatterRules.dhall@@ -27,5 +34,6 @@ , allowUnknownTypes : Bool , allowUnknownFields : Bool , idField : Optional Text+ , requireBundleVersion : Optional Text , types : List TypeRule }
dhall/defaults/FieldRule.dhall view
@@ -11,6 +11,8 @@ let HandleReferenceRule = ../HandleReferenceRule.dhall +let PathReferenceRule = ../PathReferenceRule.dhall+ in { Type = FieldRuleType , default = { description = None Text@@ -18,7 +20,9 @@ , cardinality = Cardinality.Any , format = None FieldFormat , elementFields = None NestedRules+ , objectFields = None NestedRules , reference = None HandleReferenceRule+ , path = None PathReferenceRule , when = None FieldCondition } }
dhall/defaults/NestedFieldRule.dhall view
@@ -7,12 +7,15 @@ let FieldCondition = ../FieldCondition.dhall +let PathReferenceRule = ../PathReferenceRule.dhall+ in { Type = NestedFieldRuleType , default = { description = None Text , allowedValues = [] : List Text , cardinality = Cardinality.Any , format = None FieldFormat+ , path = None PathReferenceRule , when = None FieldCondition } }
+ dhall/defaults/PathReferenceRule.dhall view
@@ -0,0 +1,13 @@+--| Record-completion defaults for a path-valued field policy.+--+-- The defaults are the strictest coherent policy: no absolute URL is permitted,+-- so the value must be a path, and a path resolving to the concept carrying it+-- is reported. An author widens from there.+let PathReferenceRule = ../PathReferenceRule.dhall++in { Type = PathReferenceRule+ , default =+ { externalUriSchemes = [] : List Text+ , allowSelf = False+ }+ }
dhall/defaults/Profile.dhall view
@@ -13,6 +13,7 @@ , allowUnknownTypes = True , allowUnknownFields = True , idField = None Text+ , requireBundleVersion = None Text , types = [] : List TypeRule } }
dhall/mk/FieldRule.dhall view
@@ -24,6 +24,8 @@ let HandleReferenceRule = ../defaults/HandleReferenceRule.dhall +let PathReferenceRule = ../defaults/PathReferenceRule.dhall+ in { plain = \(field : Text) -> FieldRule::{ field } , documented = \(field : Text) ->@@ -51,6 +53,16 @@ \(field : Text) -> \(prefix : Text) -> FieldRule::{ field, format = Some (FieldFormat.DocumentHandle prefix) }+ , actor = \(field : Text) -> FieldRule::{ field, format = Some FieldFormat.Actor }+ , humanActor =+ \(field : Text) -> FieldRule::{ field, format = Some FieldFormat.HumanActor }+ , integer =+ \(field : Text) -> FieldRule::{ field, format = Some FieldFormat.Integer }+ , nonNegativeInteger =+ \(field : Text) ->+ FieldRule::{ field, format = Some FieldFormat.NonNegativeInteger }+ , boolean =+ \(field : Text) -> FieldRule::{ field, format = Some FieldFormat.Boolean } , recordList = \(field : Text) -> \(elementFields : NestedRules) ->@@ -59,6 +71,18 @@ , cardinality = Cardinality.List , elementFields = Some elementFields }+ , record =+ \(field : Text) ->+ \(objectFields : NestedRules) ->+ FieldRule::{ field, objectFields = Some objectFields }+ , recordOrList =+ \(field : Text) ->+ \(fields : NestedRules) ->+ FieldRule::{+ , field+ , objectFields = Some fields+ , elementFields = Some fields+ } , conditional = \(rule : FieldRule.Type) -> \(condition : FieldCondition) ->@@ -78,5 +102,13 @@ , field , reference = Some HandleReferenceRule::{ localPrefix, externalUriSchemes }+ }+ , bundlePath = \(field : Text) -> FieldRule::{ field, path = Some PathReferenceRule::{=} }+ , localOrExternalPath =+ \(field : Text) ->+ \(externalUriSchemes : List Text) ->+ FieldRule::{+ , field+ , path = Some PathReferenceRule::{ externalUriSchemes } } }
dhall/mk/NestedFieldRule.dhall view
@@ -7,6 +7,8 @@ let FieldCondition = ../FieldCondition.dhall +let PathReferenceRule = ../defaults/PathReferenceRule.dhall+ in { plain = \(field : Text) -> NestedFieldRule::{ field } , documented = \(field : Text) ->@@ -34,8 +36,31 @@ \(field : Text) -> \(prefix : Text) -> NestedFieldRule::{ field, format = Some (FieldFormat.DocumentHandle prefix) }+ , actor =+ \(field : Text) -> NestedFieldRule::{ field, format = Some FieldFormat.Actor }+ , humanActor =+ \(field : Text) ->+ NestedFieldRule::{ field, format = Some FieldFormat.HumanActor }+ , integer =+ \(field : Text) -> NestedFieldRule::{ field, format = Some FieldFormat.Integer }+ , nonNegativeInteger =+ \(field : Text) ->+ NestedFieldRule::{ field, format = Some FieldFormat.NonNegativeInteger }+ , boolean =+ \(field : Text) ->+ NestedFieldRule::{ field, format = Some FieldFormat.Boolean } , conditional = \(rule : NestedFieldRule.Type) -> \(condition : FieldCondition) -> rule with when = Some condition+ , bundlePath =+ \(field : Text) ->+ NestedFieldRule::{ field, path = Some PathReferenceRule::{=} }+ , localOrExternalPath =+ \(field : Text) ->+ \(externalUriSchemes : List Text) ->+ NestedFieldRule::{+ , field+ , path = Some PathReferenceRule::{ externalUriSchemes }+ } }
dhall/package.dhall view
@@ -19,6 +19,7 @@ , FieldRule = ./FieldRule.dhall , FieldCondition = ./FieldCondition.dhall , HandleReferenceRule = ./HandleReferenceRule.dhall+, PathReferenceRule = ./PathReferenceRule.dhall , NestedRules = ./NestedRules.dhall , NestedFieldRule = ./NestedFieldRule.dhall , Cardinality = ./Cardinality.dhall@@ -29,6 +30,7 @@ , FrontmatterRules = ./defaults/FrontmatterRules.dhall , FieldRule = ./defaults/FieldRule.dhall , HandleReferenceRule = ./defaults/HandleReferenceRule.dhall+ , PathReferenceRule = ./defaults/PathReferenceRule.dhall , NestedRules = ./defaults/NestedRules.dhall , NestedFieldRule = ./defaults/NestedFieldRule.dhall }
okf-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: okf-core-version: 0.4.0.0+version: 0.5.0.0 synopsis: Read, validate, index, and traverse Open Knowledge Format bundles @@ -22,10 +22,15 @@ -- The canonical profile schema, plus the fixtures okf-core-test reads. The -- fixture descriptors import the schema through ../../../dhall, so both trees -- must ship for `cabal test` to work from the sdist.+-- The .py and .sql files are not a mistake: the dangling-frontmatter-path and+-- attested-computation fixtures need non-Markdown files in the bundle, because+-- resolving a path to one is the whole point of the checks they exercise. extra-source-files: dhall/**/*.dhall test/fixtures/**/*.dhall test/fixtures/**/*.md+ test/fixtures/**/*.py+ test/fixtures/**/*.sql common common-options ghc-options:@@ -45,6 +50,7 @@ import: common-options hs-source-dirs: src exposed-modules:+ Okf.Actor Okf.Bundle Okf.ConceptId Okf.Discovery@@ -52,9 +58,13 @@ Okf.Graph Okf.Index Okf.Log+ Okf.Markdown+ Okf.Path Okf.Prelude Okf.Profile+ Okf.Profile.Documentation Okf.Profile.Registry+ Okf.Trust Okf.Validation build-depends:@@ -84,6 +94,7 @@ build-depends: , aeson , base >=4.20 && <5+ , containers >=0.6 && <0.8 , dhall >=1.41 && <1.43 , directory , filepath
+ src/Okf/Actor.hs view
@@ -0,0 +1,68 @@+-- | The OKF v0.2 actor convention (specification §7).+--+-- Fields that record an identity — @generated.by@, @verified[].by@, and+-- @sources[].author@ — all carry an /actor/: a short string naming who or what+-- acted. The specification defines exactly three shapes, and consumers that+-- classify trust (§5.3) key off the @human:@ prefix, so the prefix test lives+-- here once rather than being re-derived by each reader.+module Okf.Actor+ ( Actor (..),+ parseActor,+ renderActor,+ isHumanActor,+ )+where++import Data.Text qualified as Text+import Okf.Prelude++-- | An actor as defined by OKF v0.2 specification §7.+--+-- 'parseActor' is total: text matching none of the three shapes becomes+-- 'UnclassifiedActor' rather than a parse failure, because §11 forbids+-- rejecting a document for a malformed optional field. Reporting an+-- unclassified actor is a validation decision, not a parsing one.+data Actor+ = -- | @human:\<id\>@ per specification §7, carrying the id.+ HumanActor !Text+ | -- | @process:\<id\>@ per specification §7, carrying the id.+ ProcessActor !Text+ | -- | @\<producer\>/\<version\>@ per specification §7, carrying producer then version.+ ProducerActor !Text !Text+ | -- | Text matching none of the three shapes, preserved verbatim.+ UnclassifiedActor !Text+ deriving stock (Generic, Eq, Ord, Show)++-- | Classify an actor string. Never fails.+--+-- The @human:@ and @process:@ prefixes are matched before the @\/@ split and+-- matching is case-sensitive, because §7 writes them in lower case and §5.3+-- makes the @human:@ test the sole discriminator between trust tiers. A value+-- such as @Human:ahormati@ is therefore 'UnclassifiedActor'.+--+-- @'renderActor' . 'parseActor'@ is the identity on every input.+parseActor :: Text -> Actor+parseActor raw+ | Just actorId <- Text.stripPrefix "human:" raw, not (Text.null actorId) = HumanActor actorId+ | Just actorId <- Text.stripPrefix "process:" raw, not (Text.null actorId) = ProcessActor actorId+ | (producer, versionWithSlash) <- Text.breakOn "/" raw,+ Just version <- Text.stripPrefix "/" versionWithSlash,+ not (Text.null producer),+ not (Text.null version) =+ ProducerActor producer version+ | otherwise = UnclassifiedActor raw++-- | Render an actor back to the text a producer wrote. Inverse of 'parseActor'.+renderActor :: Actor -> Text+renderActor = \case+ HumanActor actorId -> "human:" <> actorId+ ProcessActor actorId -> "process:" <> actorId+ ProducerActor producer version -> producer <> "/" <> version+ UnclassifiedActor raw -> raw++-- | Whether the actor is a person. Specification §5.3 makes this the sole+-- discriminator between the machine-confirmed and human-reviewed trust tiers.+isHumanActor :: Actor -> Bool+isHumanActor = \case+ HumanActor _ -> True+ _ -> False
src/Okf/Bundle.hs view
@@ -1,17 +1,33 @@ -- | Bundle-level discovery for OKF concept documents. module Okf.Bundle ( BundleError (..),+ BundleInventory, Concept, LogFile (..),+ bundleInventoryMember,+ bundleInventoryOfConcepts,+ walkBundleInventory, conceptFromDocument,+ conceptAttester,+ conceptComputation,+ conceptComputationSources, conceptDescription, conceptDocument,+ conceptExecutor,+ conceptGenerated, conceptIdOf,+ conceptParameters, conceptResource,+ conceptRuntime, conceptSourcePath,+ conceptSources,+ conceptStaleAfter,+ conceptStatus, conceptTags,+ conceptUsageWindow, conceptTitle, conceptType,+ conceptVerified, findConcept, findConceptsByDocumentId, isReservedMarkdownFile,@@ -25,6 +41,8 @@ import Control.Exception (IOException, try) import Data.Aeson.KeyMap qualified as KeyMap import Data.List qualified as List+import Data.Set (Set)+import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Okf.ConceptId@@ -49,7 +67,19 @@ title :: !(Maybe Text), description :: !(Maybe Text), resource :: !(Maybe Text),- tags :: ![Text]+ tags :: ![Text],+ generated :: !(Maybe Generated),+ verified :: ![Verification],+ status :: !Status,+ staleAfter :: !(Maybe Text),+ sources :: ![Source],+ usageWindow :: !(Maybe UsageWindow),+ runtime :: !(Maybe Text),+ parameters :: ![Parameter],+ computation :: !(Maybe Text),+ executor :: !(Maybe Executor),+ attester :: !(Maybe Attester),+ computationSources :: ![ComputationSource] } deriving stock (Generic, Eq, Show) @@ -60,6 +90,18 @@ | BundleIoError FilePath Text deriving stock (Generic, Eq, Show) +-- | Every regular file in a bundle, as bundle-relative paths, whether or not okf+-- can parse it. Concepts are the @.md@ subset; a @references\/@ script, a CSV, or+-- an image is here and nowhere else.+--+-- This exists so that a path-valued frontmatter field can be resolved without+-- giving validation a filesystem handle. Validation is offline by design+-- (@docs\/adr\/5-compile-profile-rules-before-validation.md@): it receives parsed+-- values and decides. Reading the inventory once during the walk, where okf is+-- already doing IO, keeps it that way.+newtype BundleInventory = BundleInventory (Set FilePath)+ deriving stock (Generic, Eq, Show)+ -- | A parsed @log.md@ reserved file discovered in a bundle. data LogFile = LogFile { logSourcePath :: !FilePath,@@ -87,6 +129,38 @@ results <- mapM (readLog root) paths pure (List.sortOn logSourcePath <$> sequenceA results) +-- | Record every regular file in a bundle, including the ones okf cannot parse.+--+-- A second, independent traversal rather than a widening of 'walkBundle', whose+-- @[Concept]@ result every caller in both packages depends on. Walking twice is+-- mildly wasteful and is the right trade for a change that cannot alter what+-- 'walkBundle' returns.+--+-- Reserved files (@index.md@, @log.md@) are recorded here even though they are+-- not concepts: they are files, and a path-valued field naming one names+-- something that exists.+walkBundleInventory :: FilePath -> IO (Either BundleError BundleInventory)+walkBundleInventory root = do+ discovered <- discoverAllFiles root ""+ pure (BundleInventory . Set.fromList <$> discovered)++-- | Whether the bundle contains a file at the given bundle-relative path. The+-- path is normalised the same way the inventory's entries are, so a caller may+-- pass 'Okf.Path.collapseBundlePath' output directly.+bundleInventoryMember :: FilePath -> BundleInventory -> Bool+bundleInventoryMember path (BundleInventory paths) =+ Set.member (FilePath.normalise path) paths++-- | The inventory an in-memory bundle can honestly report: the concepts' own+-- source paths and nothing else.+--+-- For producers that assemble concepts without a directory to walk. Such a+-- bundle resolves concept-to-concept paths correctly and simply cannot know+-- about a non-Markdown file, because there is no filesystem holding one.+bundleInventoryOfConcepts :: [Concept] -> BundleInventory+bundleInventoryOfConcepts concepts =+ BundleInventory (Set.fromList (FilePath.normalise . conceptSourcePath <$> concepts))+ -- | Find a concept by identifier in an already walked bundle. findConcept :: ConceptId -> [Concept] -> Maybe Concept findConcept conceptId =@@ -137,6 +211,91 @@ conceptTags :: Concept -> [Text] conceptTags Concept {tags} = tags +-- | The OKF v0.2 @generated@ family projected from frontmatter, or 'Nothing'+-- when the concept carries none (or carries one without the @by@ actor that+-- specification §5.2 requires within it).+conceptGenerated :: Concept -> Maybe Generated+conceptGenerated Concept {generated} = generated++-- | The OKF v0.2 @verified@ family projected from frontmatter, empty when the+-- concept carries none. A bare @{ by, at }@ mapping projects as one element,+-- per the specification §5.2 MUST.+--+-- Note what is /not/ here: the trust tier §5.3 derives from this list is a+-- function of it, not a field beside it. See+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@ and use+-- @Okf.Trust.trustTier . conceptVerified@.+conceptVerified :: Concept -> [Verification]+conceptVerified Concept {verified} = verified++-- | The OKF v0.2 @status@ lifecycle field, 'Stable' when the concept carries+-- none (specification §5.4).+conceptStatus :: Concept -> Status+conceptStatus Concept {status} = status++-- | The OKF v0.2 @stale_after@ date read verbatim (specification §5.5).+-- Interpreting it against a calendar day is 'Okf.Trust.staleness''s job.+conceptStaleAfter :: Concept -> Maybe Text+conceptStaleAfter Concept {staleAfter} = staleAfter++-- | The OKF v0.2 @sources@ provenance entries projected from frontmatter,+-- empty when the concept carries none (specification §5.1). Entries lacking the+-- required @resource@ are absent here; 'Okf.Validation.validateDocument'+-- reports them.+conceptSources :: Concept -> [Source]+conceptSources Concept {sources} = sources++-- | The document-scope @usage_window@ that frames every entry's @usage_count@+-- (specification §5.1). Resolve a given entry's effective window with+-- @Okf.Document.effectiveUsageWindow (conceptUsageWindow concept)@, which+-- honours a per-entry override.+conceptUsageWindow :: Concept -> Maybe UsageWindow+conceptUsageWindow Concept {usageWindow} = usageWindow++-- | The @runtime@ that says how an attested computation is run, and so what its+-- @parameters@ mean (specification §10.2).+--+-- Present on any concept that declares it, not only on one whose @type@ is+-- @Attested Computation@: a projection restates what frontmatter says and+-- nothing more. Only 'Okf.Validation.validateDocument' knows that the field is+-- REQUIRED for that one type.+conceptRuntime :: Concept -> Maybe Text+conceptRuntime Concept {runtime} = runtime++-- | The typed, named holes an agent may fill (specification §10.2), empty when+-- the concept declares none. An entry with no @name@ is absent here.+conceptParameters :: Concept -> [Parameter]+conceptParameters Concept {parameters} = parameters++-- | The §6.2 path to a file holding the computation, when the concept names one+-- instead of carrying an inline body fence (specification §10.2, §10.3).+--+-- Verbatim, and not resolved against the bundle. Resolving a path-valued+-- frontmatter field is 'Okf.Validation.validateBundle''s job, which has the+-- 'BundleInventory' this projection does not.+conceptComputation :: Concept -> Maybe Text+conceptComputation Concept {computation} = computation++-- | How the computation is run and what evidence a run must return+-- (specification §10.2). okf never runs it; §10.5 places the run and its receipt+-- outside the bundle.+conceptExecutor :: Concept -> Maybe Executor+conceptExecutor Concept {executor} = executor++-- | The deterministic, no-LLM check that inspects a receipt (specification+-- §10.2). okf never runs it and never computes a verdict.+conceptAttester :: Concept -> Maybe Attester+conceptAttester Concept {attester} = attester++-- | Every computation the concept offers (specification §10.3), file before+-- inline. Empty when it offers none.+--+-- Unlike 'conceptComputation', which restates the frontmatter key verbatim, this+-- also sees the body. §10.3's rule that exactly one of the two forms must be+-- present is 'Okf.Validation.validateDocument''s to report.+conceptComputationSources :: Concept -> [ComputationSource]+conceptComputationSources Concept {computationSources} = computationSources+ -- | Reserved Markdown filenames are not normal concept documents. isReservedMarkdownFile :: FilePath -> Bool isReservedMarkdownFile path =@@ -171,6 +330,31 @@ ) pure (concat <$> sequenceA discovered) +-- | Every regular file under the root, bundle-relative and normalised. Skips+-- directories, recursing into them rather than recording them, because a+-- path-valued field names a file.+discoverAllFiles :: FilePath -> FilePath -> IO (Either BundleError [FilePath])+discoverAllFiles root relativeDir = do+ let absoluteDir = root </> relativeDir+ displayDir = if null relativeDir then root else relativeDir+ listed <- tryBundleIo displayDir (listDirectory absoluteDir)+ case listed of+ Left bundleError -> pure (Left bundleError)+ Right entries -> do+ discovered <-+ for+ (List.sort entries)+ ( \entry -> do+ let relativePath = relativeDir </> entry+ absolutePath = root </> relativePath+ isDirectory <- tryBundleIo relativePath (doesDirectoryExist absolutePath)+ case isDirectory of+ Left bundleError -> pure (Left bundleError)+ Right True -> discoverAllFiles root relativePath+ Right False -> pure (Right [FilePath.normalise relativePath])+ )+ pure (concat <$> sequenceA discovered)+ discoverLogFiles :: FilePath -> FilePath -> IO (Either BundleError [FilePath]) discoverLogFiles root relativeDir = do let absoluteDir = root </> relativeDir@@ -249,8 +433,20 @@ ) -- | Build a 'Concept' from its identity and document. The typed projection--- fields (@type_@, @title@, @description@, @resource@, @tags@) are derived from--- the document's frontmatter, so they can never disagree with it. The source+-- fields (@type_@, @title@, @description@, @resource@, @tags@, @generated@,+-- @verified@, @status@, @staleAfter@, @sources@, @usageWindow@, @runtime@,+-- @parameters@, @computation@, @executor@, @attester@) are derived+-- from the document's frontmatter, so they can never disagree with it.+-- A projection may only restate what frontmatter says; it may never store a+-- derivation frontmatter does not carry.+--+-- @computationSources@ is the one field derived from the whole document rather+-- than from frontmatter alone, because specification §10.3 makes the+-- @computation@ key and the body's @# Computation@ section alternatives to each+-- other. Restating what the /document/ says, both halves together, is still+-- restating rather than deriving.+--+-- The source -- path is derived from the concept ID. Use this when assembling concepts in -- memory (for 'writeBundle' or 'Okf.Validation.validateBundle'). conceptFromDocument :: ConceptId -> OKFDocument -> Concept@@ -268,7 +464,19 @@ title = optionalTextField "title" (frontmatter document), description = optionalTextField "description" (frontmatter document), resource = optionalTextField "resource" (frontmatter document),- tags = tagsField (frontmatter document)+ tags = tagsField (frontmatter document),+ generated = readGenerated (frontmatter document),+ verified = readVerified (frontmatter document),+ status = readStatus (frontmatter document),+ staleAfter = readStaleAfter (frontmatter document),+ sources = readSources (frontmatter document),+ usageWindow = readUsageWindow (frontmatter document),+ runtime = readRuntime (frontmatter document),+ parameters = readParameters (frontmatter document),+ computation = readComputation (frontmatter document),+ executor = readExecutor (frontmatter document),+ attester = readAttester (frontmatter document),+ computationSources = readComputationSources document } textField :: Text -> Frontmatter -> Text
src/Okf/Document.hs view
@@ -7,9 +7,43 @@ frontmatterLookup, frontmatterKeys, coreFrontmatterFields,+ fieldsIntroducedInV02,+ fieldsSupersededInV02, parseDocument, serializeDocument, + -- * OKF v0.2 trust family+ Generated (..),+ readGenerated,+ Verification (..),+ readVerified,++ -- * OKF v0.2 lifecycle family+ Status (..),+ readStatus,+ renderStatus,+ readStaleAfter,++ -- * OKF v0.2 provenance family+ Source (..),+ UsageWindow (..),+ readSources,+ readUsageWindow,+ effectiveUsageWindow,++ -- * OKF v0.2 attested computation family+ Parameter (..),+ Executor (..),+ Attester (..),+ attestedComputationType,+ readRuntime,+ readParameters,+ readComputation,+ readExecutor,+ readAttester,+ ComputationSource (..),+ readComputationSources,+ -- * Frontmatter authoring frontmatterFromFields, setField,@@ -20,15 +54,23 @@ setTitle, setDescription, setTimestamp,+ setGenerated,+ setVerified,+ setStatus,+ setStaleAfter,+ setSources,+ setUsageWindow, setResource, setTags, ) where +import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as AesonKey import Data.Aeson.KeyMap qualified as KeyMap import Data.Attoparsec.ByteString qualified as Attoparsec import Data.ByteString qualified as ByteString+import Data.Foldable (toList) import Data.Frontmatter qualified as Frontmatter import Data.List qualified as List import Data.Ord (comparing)@@ -39,6 +81,8 @@ import Data.Vector qualified as Vector import Data.Yaml qualified as Yaml import Data.Yaml.Pretty qualified as YamlPretty+import Okf.Actor (Actor, parseActor, renderActor)+import Okf.Markdown (computationBlocks) import Okf.Prelude hiding (setField) -- | YAML frontmatter fields. OKF allows producer-defined extension keys, so@@ -83,6 +127,470 @@ coreFrontmatterFields :: Set Text coreFrontmatterFields = Set.fromList coreFrontmatterFieldOrder +-- | The OKF v0.2 @generated@ family (specification §5.2): who or what produced+-- the concept's current content, and when.+--+-- @generatedAt@ stays 'Text' rather than a parsed time value for two reasons.+-- §5.2 does not mark @at@ required within the mapping, and okf's convention is+-- to keep frontmatter values exactly as the producer wrote them so+-- serialization round-trips. Checking the value against ISO 8601 belongs to the+-- profile layer, which already has an @Rfc3339Utc@ format for it.+data Generated = Generated+ { generatedBy :: !Actor,+ generatedAt :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++-- | Read the @generated@ family from frontmatter.+--+-- Returns 'Nothing' when the key is absent, when its value is not a YAML+-- mapping, or when the mapping has no textual @by@ — §5.2 makes @by@ REQUIRED+-- within @generated@, so a mapping without one is not a 'Generated'. This never+-- fails: a malformed value is simply not read, because §11 forbids rejecting a+-- document for a malformed optional field. Reporting it is+-- 'Okf.Validation.validateDocument''s job.+readGenerated :: Frontmatter -> Maybe Generated+readGenerated frontmatterValue =+ case frontmatterLookup "generated" frontmatterValue of+ Just (Object generatedFields) -> do+ by <- objectText "by" generatedFields+ pure (Generated (parseActor by) (objectText "at" generatedFields))+ _ -> Nothing++-- | One entry of the OKF v0.2 @verified@ family (specification §5.2): who or+-- what independently confirmed the content, and when.+--+-- Deliberately distinct from 'Generated'. §5.2: "who /wrote/ a concept need not+-- be who /confirmed/ it", and the two are independent — "content can change+-- without re-confirmation, and facts can be re-confirmed without regeneration."+--+-- @verificationAt@ stays 'Text' for the same two reasons as 'generatedAt': §5.2+-- does not mark @at@ required within an entry, and okf preserves frontmatter+-- values as the producer wrote them so serialization round-trips.+data Verification = Verification+ { verificationBy :: !Actor,+ verificationAt :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++-- | Read the OKF v0.2 @verified@ family from frontmatter.+--+-- Handles the two shapes §5.2 permits. A YAML list of mappings yields one+-- 'Verification' per element. A __bare mapping__ yields a one-element list:+-- "Consumers MUST treat a bare mapping as a one-element list", restated in §11's+-- conformance list. Anything else, including an absent key, yields @[]@.+--+-- An entry with no textual @by@ is skipped rather than yielding a partial+-- 'Verification', mirroring 'readGenerated'. An empty result is therefore+-- indistinguishable from an absent key, which is correct: §5.3 keys the+-- unverified tier off the absence of usable verification.+readVerified :: Frontmatter -> [Verification]+readVerified frontmatterValue =+ case frontmatterLookup "verified" frontmatterValue of+ Just (Array entries) -> foldMap (toList . verificationFromValue) entries+ Just bareMapping -> toList (verificationFromValue bareMapping)+ Nothing -> []+ where+ verificationFromValue = \case+ Object entryFields -> do+ by <- objectText "by" entryFields+ pure (Verification (parseActor by) (objectText "at" entryFields))+ _ -> Nothing++-- | The OKF v0.2 @status@ lifecycle field (specification §5.4).+--+-- 'UnknownStatus' carries a value outside the three the specification names,+-- verbatim. §11 forbids rejecting a concept for an unexpected optional value,+-- and preserving the text is what lets 'renderStatus' reproduce exactly what+-- the producer wrote.+data Status+ = -- | @draft@: not yet reviewed; possibly incomplete.+ Draft+ | -- | @stable@: ready for consumption. Also the value an absent key means.+ Stable+ | -- | @deprecated@: kept for links and history; no longer current.+ Deprecated+ | -- | A value outside the three named in §5.4, preserved as written.+ UnknownStatus !Text+ deriving stock (Generic, Eq, Ord, Show)++-- | Read the OKF v0.2 @status@ field (specification §5.4).+--+-- An absent key, or a value that is not text, yields 'Stable': §5.4 states+-- "Absent @status@ ⇒ @stable@". Matching is case-sensitive, consistent with the+-- actor convention of §7 — the specification writes all three values in lower+-- case, and a case-insensitive match would quietly accept a value it should+-- surface as unknown.+readStatus :: Frontmatter -> Status+readStatus frontmatterValue =+ case frontmatterLookup "status" frontmatterValue of+ Just (String "draft") -> Draft+ Just (String "stable") -> Stable+ Just (String "deprecated") -> Deprecated+ Just (String other) -> UnknownStatus other+ _ -> Stable++-- | Render a status back to the text a producer wrote. Inverse of 'readStatus'+-- on every value except an absent key, which reads as 'Stable'.+renderStatus :: Status -> Text+renderStatus = \case+ Draft -> "draft"+ Stable -> "stable"+ Deprecated -> "deprecated"+ UnknownStatus other -> other++-- | Read the OKF v0.2 @stale_after@ field (specification §5.5) verbatim.+--+-- Deliberately unparsed. §5.5 specifies an absolute @YYYY-MM-DD@ date, but+-- parsing here would either lose a malformed value on serialization or force+-- this total reader to fail. Interpreting the date is 'Okf.Trust.staleness''s+-- job, where a comparison is actually needed.+readStaleAfter :: Frontmatter -> Maybe Text+readStaleAfter frontmatterValue =+ case frontmatterLookup "stale_after" frontmatterValue of+ Just (String value) -> Just value+ _ -> Nothing++-- | The date range over which a @usage_count@ was counted (specification §5.1).+--+-- Written once as a sibling of @sources@ to frame every entry's count; a single+-- entry MAY carry its own to override the shared one. Both bounds stay 'Text'+-- and are not parsed into a @Day@, consistent with every other date in the v0.2+-- families: okf preserves the producer's text so serialization round-trips, and+-- format checking belongs to the profile layer's @Date@ format.+data UsageWindow = UsageWindow+ { usageWindowFrom :: !(Maybe Text),+ usageWindowTo :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++-- | One entry of the OKF v0.2 @sources@ family (specification §5.1): a piece of+-- material this concept was derived from, with the optional credibility signals+-- a consumer uses to judge it.+--+-- §5.1 records objective signals rather than a score, because a score "is+-- subjective, unportable across consumers, and goes stale". Nothing here+-- computes a verdict; see+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.+data Source = Source+ { -- | Optional stable key used to attribute individual claims. §5.1: SHOULD+ -- be present when the body cites the source.+ sourceId :: !(Maybe Text),+ -- | REQUIRED within an entry. Either a concrete artifact a consumer can+ -- follow (absolute URL, bundle-relative path, @references\/@ path) __or a+ -- population or scope descriptor it cannot__, such as+ -- @all queries in BigQuery project X@. Never treat this as a path.+ sourceResource :: !Text,+ -- | Optional human-readable label.+ sourceTitle :: !(Maybe Text),+ -- | Credibility signal: who or what produced the source, in the §7 actor+ -- convention. An authority signal.+ sourceAuthor :: !(Maybe Actor),+ -- | Credibility signal: how often the resource was exercised over the+ -- effective 'UsageWindow'. An adoption and liveness signal. §5.1 warns it+ -- is coarse — comparable at the alive-versus-dead and order-of-magnitude+ -- level, not as a precise ranking — so do not sort or score by it.+ sourceUsageCount :: !(Maybe Integer),+ -- | Credibility signal: when the source itself last changed. A recency+ -- signal, distinct from @generated.at@ (§5.2), which records when the+ -- /concept/ was written.+ sourceLastModified :: !(Maybe Text),+ -- | An entry-local window overriding the document-scope one. Resolve with+ -- 'effectiveUsageWindow' rather than reading this directly.+ sourceUsageWindow :: !(Maybe UsageWindow)+ }+ deriving stock (Generic, Eq, Show)++-- | Read the OKF v0.2 @sources@ family from frontmatter (specification §5.1).+--+-- An entry without a usable @resource@ is skipped, because §5.1 makes it+-- REQUIRED within an entry and a 'Source' without one would be meaningless.+-- Reporting the skipped entry is 'Okf.Validation.validateDocument''s job; this+-- reader stays total so §11's prohibition on rejecting a document is never at+-- risk.+--+-- @usage_count@ is read only from a YAML integer. A numeric string such as+-- @"5000"@ yields 'Nothing': coercing it would make the field's type+-- unpredictable for downstream consumers and would hide a producer mistake.+readSources :: Frontmatter -> [Source]+readSources frontmatterValue =+ case frontmatterLookup "sources" frontmatterValue of+ Just (Array entries) -> foldMap (toList . sourceFromValue) entries+ _ -> []+ where+ sourceFromValue = \case+ Object entryFields -> do+ resource <- objectText "resource" entryFields+ pure+ Source+ { sourceId = objectText "id" entryFields,+ sourceResource = resource,+ sourceTitle = objectText "title" entryFields,+ sourceAuthor = parseActor <$> objectText "author" entryFields,+ sourceUsageCount = objectInteger "usage_count" entryFields,+ sourceLastModified = objectText "last_modified" entryFields,+ sourceUsageWindow = usageWindowFromValue =<< KeyMap.lookup (AesonKey.fromText "usage_window") entryFields+ }+ _ -> Nothing++-- | Read the document-scope @usage_window@, a sibling of @sources@ rather than+-- a member of it (specification §5.1).+readUsageWindow :: Frontmatter -> Maybe UsageWindow+readUsageWindow frontmatterValue =+ usageWindowFromValue =<< frontmatterLookup "usage_window" frontmatterValue++-- | Resolve which window frames a source's @usage_count@, per §5.1: the entry's+-- own window wins when present, otherwise the document-scope one applies.+--+-- This is a named function rather than an inlined fallback because it is the+-- one piece of provenance logic a consumer is most likely to get wrong, and+-- every reader of a @usage_count@ must agree on it.+effectiveUsageWindow :: Maybe UsageWindow -> Source -> Maybe UsageWindow+effectiveUsageWindow documentWindow Source {sourceUsageWindow} =+ sourceUsageWindow <|> documentWindow++usageWindowFromValue :: Value -> Maybe UsageWindow+usageWindowFromValue = \case+ Object windowFields ->+ Just (UsageWindow (objectText "from" windowFields) (objectText "to" windowFields))+ _ -> Nothing++-- | The one @type@ value that carries the OKF v0.2 computation contract+-- (specification §10.1).+--+-- Matched as an exact, case-sensitive string. §4.1 says type values are "not+-- registered centrally" and consumers "MUST tolerate unknown types gracefully",+-- so okf keeps no taxonomy of types; but §10.1 names this one explicitly and+-- §10.5 calls @type: Attested Computation@ "a frontmatter signal", so matching+-- that single literal follows the specification rather than inventing a+-- registry. A document saying @attested computation@ gets no contract handling,+-- which is the tolerance §4.1 asks for.+attestedComputationType :: Text+attestedComputationType = "Attested Computation"++-- | One typed, named hole an agent may fill when running an attested+-- computation (specification §10.2).+--+-- Binding semantics follow the concept's @runtime@: the same entry is a SQL+-- bind variable under @bigquery@, a var under @dbt@, and a function argument+-- under @python@. That is why @runtime@ is the field §10.2 marks REQUIRED —+-- without it a parameter has no meaning.+--+-- @parameterType@ and @parameterRequired@ are optional even though §10.2 writes+-- every entry as @{ name, type, required }@: that describes the shape rather+-- than marking the members REQUIRED, and §11 forbids rejecting a document for a+-- malformed optional field. @parameterName@ is not optional, because an entry+-- naming no hole is not a parameter at all.+data Parameter = Parameter+ { parameterName :: !Text,+ parameterType :: !(Maybe Text),+ parameterRequired :: !(Maybe Bool)+ }+ deriving stock (Generic, Eq, Show)++-- | How an attested computation is run (specification §10.2).+--+-- @executorResource@ names run instructions or code that a runner — an agent,+-- or deterministic consumer code — follows. @executorReceipt@ declares the+-- fields a run must return: the evidence the attester inspects, such as a+-- BigQuery @job_id@ and the SQL the job actually executed.+--+-- okf never runs an executor and never sees a receipt. §10.5 marks the+-- execute-and-attest workflow informative and places its runtime artifacts+-- outside the bundle entirely; this record only says where the instructions+-- live.+data Executor = Executor+ { executorResource :: !(Maybe Text),+ executorReceipt :: ![Text]+ }+ deriving stock (Generic, Eq, Show)++-- | The deterministic check on a run's receipt (specification §10.2).+--+-- @attesterResource@ names code — explicitly with no language model in it —+-- that takes a receipt and returns a verdict, meant to run consumer-side. okf+-- never runs it and never computes a verdict; see+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@ on what okf+-- declines to derive.+--+-- A newtype over one field rather than a bare 'Maybe' 'Text', because §12 lists+-- "the attester ABI, portability, and sandboxing" among the items deferred to a+-- future OKF revision. The record will grow; a named type means it grows+-- without changing every call site.+newtype Attester = Attester {attesterResource :: Maybe Text}+ deriving stock (Generic, Eq, Show)++-- | Read the @runtime@ field (specification §10.2): the system that would+-- execute the computation, such as @bigquery@, @postgres@, @dbt@, @python@, or+-- @Looker@.+--+-- Kept verbatim and never matched against a list of known runtimes. §10.2 gives+-- those five as examples rather than as an enumeration, and a closed set here+-- would reject a correct document naming a runtime okf has not heard of.+--+-- Like every v0.2 reader this never fails: a non-textual value is simply not+-- read. Reporting a missing @runtime@ on an 'attestedComputationType' concept is+-- 'Okf.Validation.validateDocument''s job.+readRuntime :: Frontmatter -> Maybe Text+readRuntime frontmatterValue =+ case frontmatterLookup "runtime" frontmatterValue of+ Just (String value) -> Just value+ _ -> Nothing++-- | Read the @parameters@ list (specification §10.2).+--+-- An entry with no textual @name@ is skipped, exactly as 'readSources' skips an+-- entry with no @resource@: a hole with no name cannot be filled. An absent key,+-- or a value that is not a list, yields @[]@.+--+-- @required@ is read only from a YAML boolean. The string @"true"@ yields+-- 'Nothing', for the same reason 'objectInteger' refuses a numeric string:+-- coercing it would make the field's type unpredictable and hide a producer+-- mistake.+readParameters :: Frontmatter -> [Parameter]+readParameters frontmatterValue =+ case frontmatterLookup "parameters" frontmatterValue of+ Just (Array entries) -> foldMap (toList . parameterFromValue) entries+ _ -> []+ where+ parameterFromValue = \case+ Object entryFields -> do+ name <- objectText "name" entryFields+ pure+ Parameter+ { parameterName = name,+ parameterType = objectText "type" entryFields,+ parameterRequired = objectBool "required" entryFields+ }+ _ -> Nothing++-- | Read the @computation@ field (specification §10.2): a §6.2 path to a file+-- holding the computation, used instead of an inline body fence.+--+-- Deliberately returns the raw text and does __not__ resolve the path. Resolving+-- a path-valued frontmatter field against the bundle is+-- 'Okf.Validation.validateBundle''s job, which has the bundle inventory this+-- reader does not; keeping the reader dumb is also what preserves the+-- round-trip property, since a resolved path is not the text the producer wrote.+--+-- §10.3 makes this key and the body's @# Computation@ fence mutually exclusive.+-- Checking that is body inspection and is not done here.+readComputation :: Frontmatter -> Maybe Text+readComputation frontmatterValue =+ case frontmatterLookup "computation" frontmatterValue of+ Just (String value) -> Just value+ _ -> Nothing++-- | Read the @executor@ mapping (specification §10.2).+--+-- Returns 'Nothing' when the key is absent or its value is not a mapping. Unlike+-- 'readGenerated' no member is mandatory, because §10.2 marks none of them+-- REQUIRED: an @executor@ carrying only a @receipt@ still says something a+-- consumer can use.+--+-- A @receipt@ written as a bare string rather than a list is read as a+-- one-element list, mirroring how §5.2's @verified@ tolerates a bare mapping+-- where a list is expected. A non-textual list element is dropped.+readExecutor :: Frontmatter -> Maybe Executor+readExecutor frontmatterValue =+ case frontmatterLookup "executor" frontmatterValue of+ Just (Object executorFields) ->+ Just+ Executor+ { executorResource = objectText "resource" executorFields,+ executorReceipt = objectTextList "receipt" executorFields+ }+ _ -> Nothing++-- | Read the @attester@ mapping (specification §10.2).+--+-- Returns 'Nothing' when the key is absent or its value is not a mapping. An+-- @attester@ mapping with no @resource@ still reads as an 'Attester' carrying+-- 'Nothing': §10.2 marks no member REQUIRED, and the distinction between "no+-- attester declared" and "an attester declared badly" is one a diagnostic can+-- make only if the reader keeps it.+readAttester :: Frontmatter -> Maybe Attester+readAttester frontmatterValue =+ case frontmatterLookup "attester" frontmatterValue of+ Just (Object attesterFields) -> Just (Attester (objectText "resource" attesterFields))+ _ -> Nothing++-- | Where an attested computation's computation actually lives (specification+-- §10.3).+data ComputationSource+ = -- | A code block in the body's @# Computation@ section, carrying its literal+ -- contents.+ ComputationInline !Text+ | -- | The §6.2 path in the @computation@ frontmatter key, verbatim and not+ -- resolved against the bundle.+ ComputationFile !Text+ deriving stock (Generic, Eq, Show)++-- | Every computation the document offers, file before inline.+--+-- §10.3 requires exactly one — "Inline: a single fenced code block in the body+-- under @# Computation@" or "File: set @computation@ to a path (§6.2) and omit+-- the body fence" — and this reader deliberately does not enforce that. Like+-- every reader in this module it restates what the document says and never+-- fails. A list with none, or with two, is what+-- 'Okf.Validation.validateDocument' reports.+--+-- This is the first reader here that takes an 'OKFDocument' rather than a+-- 'Frontmatter', because §10.3 is the first rule in OKF that spans both halves+-- of a document: the frontmatter key and the body section are alternatives to+-- each other, so neither half can answer the question alone.+--+-- Type-agnostic, matching every other projection here: a @# Computation@+-- section on a @Metric@ is still a fact about that document. Scoping a report to+-- 'attestedComputationType' is "Okf.Validation"'s job.+readComputationSources :: OKFDocument -> [ComputationSource]+readComputationSources OKFDocument {frontmatter = documentFrontmatter, body = documentBody} =+ foldMap (pure . ComputationFile) (readComputation documentFrontmatter)+ <> map ComputationInline (computationBlocks documentBody)++-- | Read an integral member. Only a YAML integer qualifies: a numeric string+-- and a fractional number both yield 'Nothing', because aeson's @Integer@+-- decoder rejects each. Coercing either would make the field's type+-- unpredictable for downstream consumers and would hide a producer mistake.+objectInteger :: Text -> KeyMap.KeyMap Value -> Maybe Integer+objectInteger key members =+ case KeyMap.lookup (AesonKey.fromText key) members of+ Just value@(Number _) ->+ case Aeson.fromJSON value of+ Aeson.Success parsed -> Just parsed+ Aeson.Error _ -> Nothing+ _ -> Nothing++objectText :: Text -> KeyMap.KeyMap Value -> Maybe Text+objectText key members =+ case KeyMap.lookup (AesonKey.fromText key) members of+ Just (String value) -> Just value+ _ -> Nothing++-- | Read a boolean member. Only a YAML boolean qualifies; the string @"true"@+-- yields 'Nothing', for the same reason 'objectInteger' refuses a numeric+-- string.+objectBool :: Text -> KeyMap.KeyMap Value -> Maybe Bool+objectBool key members =+ case KeyMap.lookup (AesonKey.fromText key) members of+ Just (Bool value) -> Just value+ _ -> Nothing++-- | Read a member that is a list of strings, tolerating a bare string as a+-- one-element list. Non-textual elements are dropped rather than failing, and an+-- absent or otherwise-shaped value yields @[]@ — the same shape 'tagsField' in+-- @Okf.Bundle@ gives @tags@.+objectTextList :: Text -> KeyMap.KeyMap Value -> [Text]+objectTextList key members =+ case KeyMap.lookup (AesonKey.fromText key) members of+ Just (Array values) -> foldMap textValue (toList values)+ Just (String value) -> [value]+ _ -> []+ where+ textValue = \case+ String value -> [value]+ _ -> []+ -- | Build frontmatter from a list of @(key, value)@ pairs. Later duplicate -- keys overwrite earlier ones. frontmatterFromFields :: [(Text, Value)] -> Frontmatter@@ -134,10 +642,96 @@ setDescription :: Text -> Frontmatter -> Frontmatter setDescription value = setField "description" (String value) --- | Set the @timestamp@ field.+-- | Set the OKF v0.1 @timestamp@ field.+--+-- OKF v0.2 supersedes @timestamp@ with @generated.at@ (specification §13.1);+-- 'setGenerated' writes the v0.2 form. This is kept for producers deliberately+-- writing v0.1 bundles, which okf continues to read and write. See+-- @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@. setTimestamp :: Text -> Frontmatter -> Frontmatter setTimestamp value = setField "timestamp" (String value) +-- | Set the OKF v0.2 @generated@ field as a YAML mapping with @by@ and, when+-- present, @at@ (specification §5.2). This is the single place that knows+-- @generated@ is a mapping of an actor and a datetime.+setGenerated :: Generated -> Frontmatter -> Frontmatter+setGenerated Generated {generatedBy, generatedAt} =+ setField "generated" (actorMapping generatedBy generatedAt)++-- | Set the OKF v0.2 @verified@ field (specification §5.2).+--+-- Always writes a YAML list, even for one entry. §5.2 permits the bare-mapping+-- form on input and 'readVerified' honours that MUST, but writing it would be+-- pointlessly ambiguous when the list is the specification's primary form.+setVerified :: [Verification] -> Frontmatter -> Frontmatter+setVerified verifications =+ setField "verified" (Array (Vector.fromList (entryValue <$> verifications)))+ where+ entryValue Verification {verificationBy, verificationAt} =+ actorMapping verificationBy verificationAt++-- | Set the OKF v0.2 @status@ field (specification §5.4).+setStatus :: Status -> Frontmatter -> Frontmatter+setStatus status = setField "status" (String (renderStatus status))++-- | Set the OKF v0.2 @stale_after@ field (specification §5.5). The value is an+-- absolute @YYYY-MM-DD@ date; it is written as given and not validated here.+setStaleAfter :: Text -> Frontmatter -> Frontmatter+setStaleAfter value = setField "stale_after" (String value)++-- | Set the OKF v0.2 @sources@ field as a YAML list of mappings (§5.1).+--+-- Every optional key that is 'Nothing' is omitted rather than written as an+-- explicit null, so a round-trip through 'readSources' is lossless and a+-- generated document carries no noise. This is the single place that knows the+-- shape of a source entry.+setSources :: [Source] -> Frontmatter -> Frontmatter+setSources sources =+ setField "sources" (Array (Vector.fromList (sourceValue <$> sources)))+ where+ sourceValue source =+ Object+ ( KeyMap.fromList+ ( concat+ [ [(AesonKey.fromText "id", String value) | Just value <- [sourceId source]],+ [(AesonKey.fromText "resource", String (sourceResource source))],+ [(AesonKey.fromText "title", String value) | Just value <- [sourceTitle source]],+ [(AesonKey.fromText "author", String (renderActor value)) | Just value <- [sourceAuthor source]],+ [(AesonKey.fromText "usage_count", Number (fromInteger value)) | Just value <- [sourceUsageCount source]],+ [(AesonKey.fromText "last_modified", String value) | Just value <- [sourceLastModified source]],+ [(AesonKey.fromText "usage_window", usageWindowValue value) | Just value <- [sourceUsageWindow source]]+ ]+ )+ )++-- | Set the document-scope @usage_window@ that frames every @usage_count@+-- (specification §5.1).+setUsageWindow :: UsageWindow -> Frontmatter -> Frontmatter+setUsageWindow window = setField "usage_window" (usageWindowValue window)++usageWindowValue :: UsageWindow -> Value+usageWindowValue UsageWindow {usageWindowFrom, usageWindowTo} =+ Object+ ( KeyMap.fromList+ ( concat+ [ [(AesonKey.fromText "from", String value) | Just value <- [usageWindowFrom]],+ [(AesonKey.fromText "to", String value) | Just value <- [usageWindowTo]]+ ]+ )+ )++-- | A @{ by, at }@ YAML mapping, omitting @at@ when absent. Shared by the+-- @generated@ and @verified@ families, which specification §5.2 gives the same+-- shape.+actorMapping :: Actor -> Maybe Text -> Value+actorMapping actor occurredAt =+ Object+ ( KeyMap.fromList+ ( (AesonKey.fromText "by", String (renderActor actor))+ : [(AesonKey.fromText "at", String atValue) | Just atValue <- [occurredAt]]+ )+ )+ -- | Set the @resource@ field. setResource :: Text -> Frontmatter -> Frontmatter setResource value = setField "resource" (String value)@@ -157,9 +751,9 @@ else Right (OKFDocument emptyFrontmatter input) -- | Serialize to a normalized YAML-frontmatter Markdown document. Frontmatter--- keys are emitted in a deterministic order (the six common OKF fields first —--- @type, title, description, timestamp, resource, tags@ — then every other key--- in ascending alphabetical order) so regenerating a bundle yields minimal diffs.+-- keys are emitted in a deterministic order ('coreFrontmatterFieldOrder' first,+-- in that fixed order, then every other key in ascending alphabetical order) so+-- regenerating a bundle yields minimal diffs. serializeDocument :: OKFDocument -> Text serializeDocument OKFDocument {frontmatter, body} = Text.unlines ["---", renderedYaml, "---", ""] <> ensureTrailingNewline body@@ -175,9 +769,9 @@ where config = YamlPretty.setConfCompare (comparing okfKeyRank) YamlPretty.defConfig --- | Sort key for deterministic frontmatter ordering: the six common OKF fields--- come first in their fixed order; every other key sorts after them--- alphabetically by its text form.+-- | Sort key for deterministic frontmatter ordering: the core OKF fields come+-- first in their fixed 'coreFrontmatterFieldOrder'; every other key sorts after+-- them alphabetically by its text form. okfKeyRank :: Text -> (Int, Text) okfKeyRank keyText = case lookup keyText commonRanks of@@ -186,8 +780,103 @@ where commonRanks = zip coreFrontmatterFieldOrder [0 ..] +-- | The deterministic OKF concept-level key order: identity first, then the+-- v0.2 lifecycle field (§5.4), then the v0.2 attested computation contract+-- (§10.2), then the v0.2 trust families (§5.2, §5.5), then the v0.2 provenance+-- family (§5.1), then the v0.1 @timestamp@ superseded by @generated.at@ (§13.1).+--+-- The five computation keys sit between @status@ and @generated@ because that is+-- where §10.2's own worked example puts them, so a concept copied out of the+-- specification and regenerated by okf comes back in the order its author wrote.+-- They are in this list at all for the reason+-- [ADR 7](docs/adr/7-okf-v0-1-legacy-fallback-policy.md) gives for the six v0.2+-- concept keys before them: the list "exists to name the keys the format itself+-- defines", §13.2 makes these five format-defined, and leaving them out would+-- make a closed profile (@allowUnknownFields = False@) reject a conformant+-- Attested Computation until its author redeclared five keys they did not+-- choose — "a tax that grows with every specification revision". That they are+-- meaningful for exactly one @type@, unlike every other key here, does not+-- change the answer: closure governs /unknown/ keys, and a key §13.2 names is+-- not unknown. A profile that wants to reject @runtime@ on a @Metric@ says so+-- with a @TypeRule@, which is the layer that knows about types.+--+-- @okf_version@ is deliberately absent: it is an index-level key that appears+-- only in a bundle-root @index.md@ (§12), never on a concept. coreFrontmatterFieldOrder :: [Text]-coreFrontmatterFieldOrder = ["type", "title", "description", "timestamp", "resource", "tags"]+coreFrontmatterFieldOrder =+ [ "type",+ "title",+ "description",+ "resource",+ "tags",+ "status",+ "runtime",+ "parameters",+ "computation",+ "executor",+ "attester",+ "generated",+ "verified",+ "stale_after",+ "sources",+ "usage_window",+ "timestamp"+ ]++-- | Concept-level frontmatter keys that OKF v0.2 introduced (specification+-- §13.2), as reference data.+--+-- __Deliberately not used as a compile-time profile check.__ It is tempting to+-- reject a profile that declares @okfVersion = "0.1"@ and names one of these,+-- and that check was written and then removed. A profile key /name/ does not+-- imply the OKF core key of that name: per+-- @docs\/adr\/1-profile-declared-document-ids.md@, constraining keys the core+-- format does not own is what profiles are /for/, and @status@, @sources@, and+-- @verified@ are ordinary words that teams were already using as house+-- conventions before v0.2 claimed them. Rejecting+-- @field.documented "status" "One of: proposed, accepted, superseded."@ for+-- naming an ADR lifecycle would be a false positive on a pinned descriptor okf+-- cannot see. See @docs\/adr\/11-growing-the-profile-descriptor-language.md@ on+-- retroactive definition errors.+--+-- 'fieldsSupersededInV02' is checked, because it is the asymmetric case: it+-- fires only when the profile has declared v0.2 or later, which is an opt-in to+-- v0.2 semantics under which the key unambiguously means the core one.+--+-- Deliberately kept beside 'coreFrontmatterFieldOrder' and deliberately not+-- merged into it. That list answers "which keys does okf own", which is a+-- different question with different consumers — serialization order, and the set+-- of keys a closed profile always permits, per+-- [ADR 7](docs/adr/7-okf-v0-1-legacy-fallback-policy.md). Merging the two would+-- couple a version question to a permission question.+--+-- A plain @[Text]@ rather than a map to 'Okf.Index.OkfVersion' because+-- @Okf.Index@ imports this module; pairing a key with a version happens in+-- @Okf.Profile@, which imports both.+fieldsIntroducedInV02 :: [Text]+fieldsIntroducedInV02 =+ [ "status",+ "generated",+ "verified",+ "stale_after",+ "sources",+ "usage_window",+ -- §13.2's second bullet: "New concept type `Attested Computation` and its+ -- computation keys `runtime`, `parameters`, `computation`, `executor`,+ -- `attester` (§10)."+ "runtime",+ "parameters",+ "computation",+ "executor",+ "attester"+ ]++-- | Concept-level frontmatter keys OKF v0.2 superseded (specification §13.1).+-- @timestamp@ is superseded by @generated.at@. okf still /reads/ it, per+-- [ADR 7](docs/adr/7-okf-v0-1-legacy-fallback-policy.md); a profile that+-- /demands/ it while declaring v0.2 is asking authors to write a retired key.+fieldsSupersededInV02 :: [Text]+fieldsSupersededInV02 = ["timestamp"] parseFrontmatterDocument :: ByteString.ByteString -> Either DocumentParseError OKFDocument parseFrontmatterDocument inputBytes =
src/Okf/Graph.hs view
@@ -11,7 +11,6 @@ where import CMarkGFM qualified-import Control.Monad (foldM) import Data.Aeson (ToJSON (..), object, (.=)) import Data.List qualified as List import Data.Map.Strict qualified as Map@@ -20,8 +19,9 @@ import Okf.Bundle import Okf.ConceptId import Okf.Document (body)+import Okf.Markdown (markdownOptions)+import Okf.Path (PathReference (..), classifyPathReference) import Okf.Prelude hiding ((.=))-import System.FilePath ((</>)) import System.FilePath qualified as FilePath -- | A graph node for one concept.@@ -135,28 +135,33 @@ extractMarkdownLinks :: Text -> [Text] extractMarkdownLinks markdown =- walk (CMarkGFM.commonmarkToNode [] [] markdown)+ walk (CMarkGFM.commonmarkToNode markdownOptions [] markdown) where walk (CMarkGFM.Node _ nodeType childNodes) = case nodeType of CMarkGFM.LINK url _title -> [url] _ -> foldMap walk childNodes +-- | Resolve one Markdown link destination to the concept it names, if any.+--+-- The §6.2 grammar lives in 'Okf.Path' and is shared with the profile layer, but+-- this function deliberately differs from it in one place, and the difference is+-- the point of keeping 'isExternalUrl' here. A body link is a heuristic over+-- prose, so only the three schemes a Markdown author plausibly writes are+-- treated as external, and anything that resolves to no concept is dropped in+-- silence — OKF v0.2 §6.1 says a broken body link may be knowledge not yet+-- written. A path-valued /field/ is read the other way round: every scheme is+-- recognized, and one the profile did not permit is reported rather than+-- ignored. resolveLink :: Concept -> Text -> [ConceptId] resolveLink concept rawUrl | isExternalUrl rawUrl = []- | FilePath.takeExtension cleanPath /= ".md" = []- | otherwise = maybe [] (either (const []) pure . conceptIdFromFilePath) bundleRelativePath- where- cleanPath = Text.unpack (stripUrlSuffix rawUrl)- sourceDirectory = FilePath.takeDirectory (conceptIdToFilePath (conceptIdOf concept))- bundleRelativePath- | "/" `Text.isPrefixOf` rawUrl = collapseBundlePath (dropWhile (== '/') cleanPath)- | otherwise = collapseBundlePath (sourceDirectory </> cleanPath)--stripUrlSuffix :: Text -> Text-stripUrlSuffix =- Text.takeWhile (\char -> char /= '#' && char /= '?')+ | otherwise =+ case classifyPathReference (conceptIdOf concept) rawUrl of+ BundlePath resolved+ | FilePath.takeExtension resolved == ".md" ->+ either (const []) pure (conceptIdFromFilePath resolved)+ _ -> [] isExternalUrl :: Text -> Bool isExternalUrl rawUrl =@@ -164,13 +169,3 @@ in "http://" `Text.isPrefixOf` lower || "https://" `Text.isPrefixOf` lower || "mailto:" `Text.isPrefixOf` lower--collapseBundlePath :: FilePath -> Maybe FilePath-collapseBundlePath =- fmap FilePath.joinPath . foldM step [] . FilePath.splitDirectories- where- step [] "." = Just []- step acc "." = Just acc- step [] ".." = Nothing- step acc ".." = Just (init acc)- step acc segment = Just (acc <> [segment])
src/Okf/Index.hs view
@@ -1,30 +1,158 @@--- | Deterministic Markdown index rendering for OKF bundle directories.+-- | Deterministic Markdown index rendering for OKF bundle directories, and the+-- bundle-root version declaration of specification §12. module Okf.Index ( renderBundleIndexes,+ renderBundleIndexesWith, renderIndex,+ renderRootIndex, writeBundleIndexes,+ writeBundleIndexesWith,++ -- * OKF version declaration (specification §12)+ OkfVersion (..),+ VersionDeclaration (..),+ readBundleVersion,+ parseOkfVersion,+ renderOkfVersion,+ supportedOkfVersion, ) where +import Control.Exception (IOException, try)+import Data.Aeson.Text qualified as Aeson.Text+import Data.Char qualified as Char import Data.List qualified as List import Data.Map.Strict qualified as Map import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO+import Data.Text.Lazy qualified as Text.Lazy import Okf.Bundle+import Okf.Document (OKFDocument (..), frontmatterLookup, parseDocument) import Okf.Prelude import System.Directory ( doesDirectoryExist,+ doesFileExist, listDirectory, ) import System.FilePath ((</>)) import System.FilePath qualified as FilePath+import System.IO.Error (ioeGetErrorString)+import Text.Read (readMaybe) --- | Render an @index.md@ for one bundle directory from its immediate concepts--- and subdirectory names.-renderIndex :: [FilePath] -> [Concept] -> Text-renderIndex subdirectories concepts =- Text.intercalate "\n" (filter (not . Text.null) [subdirectorySection, conceptSections]) <> "\n"+-- | An OKF format version, written @\<major\>.\<minor\>@ (specification §12).+--+-- The derived 'Ord' compares major before minor, which is what §12's+-- "a minor version bump introduces backward-compatible additions" needs: a+-- consumer may read any declaration at or below the highest version it+-- understands within the same major.+data OkfVersion = OkfVersion+ { okfVersionMajor :: !Int,+ okfVersionMinor :: !Int+ }+ deriving stock (Generic, Eq, Ord, Show)++-- | What a bundle's root @index.md@ says about the version it targets.+--+-- All three cases are readable. §12 makes the declaration a MAY, so an absent+-- one is the ordinary case rather than a defect, and a malformed one is worth a+-- strict-authoring diagnostic but never a refusal.+data VersionDeclaration+ = VersionDeclared !OkfVersion+ | VersionUndeclared+ | -- | The key is present but its value is not @\<major\>.\<minor\>@. Carries+ -- the value as the author wrote it, so a diagnostic can quote it back.+ VersionUnparseable !Text+ deriving stock (Generic, Eq, Show)++-- | The highest OKF version this library understands. Specification §12's+-- best-effort rule is applied against this value: see+-- @Okf.Validation.versionGate@, which is the one place that decides what a+-- declared version implies.+supportedOkfVersion :: OkfVersion+supportedOkfVersion = OkfVersion {okfVersionMajor = 0, okfVersionMinor = 2}++-- | Render a version back to its @\<major\>.\<minor\>@ text form.+renderOkfVersion :: OkfVersion -> Text+renderOkfVersion OkfVersion {okfVersionMajor, okfVersionMinor} =+ Text.pack (show okfVersionMajor) <> "." <> Text.pack (show okfVersionMinor)++-- | Parse a @\<major\>.\<minor\>@ version. Exactly two dot-separated runs of+-- ASCII digits; anything else is unparseable.+parseOkfVersion :: Text -> Maybe OkfVersion+parseOkfVersion rawVersion =+ case Text.splitOn "." (Text.strip rawVersion) of+ [majorText, minorText] ->+ OkfVersion <$> digits majorText <*> digits minorText+ _ -> Nothing where+ digits component+ | Text.null component = Nothing+ | Text.all Char.isDigit component = readMaybe (Text.unpack component)+ | otherwise = Nothing++-- | Read the version a bundle declares in its root @index.md@ frontmatter.+--+-- §8 permits frontmatter in exactly one @index.md@, the bundle root's, and §12+-- permits exactly one key in it. This reads that key and nothing else; it does+-- not turn @index.md@ into a concept, which stays reserved by+-- 'Okf.Bundle.isReservedMarkdownFile'.+--+-- A missing root @index.md@, one with no frontmatter, one whose frontmatter+-- omits the key, and one whose frontmatter does not parse all yield+-- 'VersionUndeclared'. Only a present key with an unreadable value yields+-- 'VersionUnparseable'. The 'Left' is reserved for genuine IO failure.+readBundleVersion :: FilePath -> IO (Either BundleError VersionDeclaration)+readBundleVersion root = do+ let indexPath = root </> "index.md"+ exists <- doesFileExist indexPath+ if not exists+ then pure (Right VersionUndeclared)+ else do+ loaded <- try (Text.IO.readFile indexPath)+ pure $ case loaded of+ Left (exception :: IOException) ->+ Left (BundleIoError "index.md" (Text.pack (ioeGetErrorString exception)))+ Right content ->+ Right $ case parseDocument content of+ Left _ -> VersionUndeclared+ Right OKFDocument {frontmatter} ->+ case frontmatterLookup "okf_version" frontmatter of+ Nothing -> VersionUndeclared+ Just value -> declarationFromValue value++-- | Read the declared value, accepting both the quoted string form the+-- specification writes (@okf_version: "0.2"@) and the bare YAML number a+-- careless author writes (@okf_version: 0.2@). A bundle should not become+-- unreadable over a missing pair of quotes.+declarationFromValue :: Value -> VersionDeclaration+declarationFromValue value =+ case parseOkfVersion rendered of+ Just version -> VersionDeclared version+ Nothing -> VersionUnparseable rendered+ where+ rendered = case value of+ String text -> text+ -- Everything else is quoted back through JSON so the diagnostic can show+ -- what was written. A YAML number reaches here as @0.2@, which parses.+ other -> Text.Lazy.toStrict (Aeson.Text.encodeToLazyText other)++-- | Render an @index.md@ for one bundle directory from its immediate concepts,+-- subdirectory names, and non-concept files.+--+-- Specification §8 says an index "enumerates the directory's contents to support+-- progressive disclosure". A directory holding only+-- @references\/attesters\/revenue.py@ has contents, and before the files+-- parameter existed its generated index was a single newline — which is exactly+-- the directory shape §6.3's @references\/@ convention encourages. See+-- @docs\/adr\/13-the-references-convention-and-non-markdown-files.md@.+--+-- The files given are those that are not concepts and not reserved: in practice+-- every regular file whose extension is not @.md@. Dotfiles are excluded by the+-- caller, so a stray @.DS_Store@ never reaches a committed index.+renderIndex :: [FilePath] -> [FilePath] -> [Concept] -> Text+renderIndex subdirectories files concepts =+ Text.intercalate "\n" (filter (not . Text.null) [subdirectorySection, fileSection, conceptSections]) <> "\n"+ where sortedSubdirectories = List.sort subdirectories subdirectorySection | null sortedSubdirectories = ""@@ -35,6 +163,11 @@ : (directoryBullet <$> sortedSubdirectories) ) + sortedFiles = List.sort files+ fileSection+ | null sortedFiles = ""+ | otherwise = Text.unlines ("# Files" : "" : (fileBullet <$> sortedFiles))+ groupedConcepts = Map.toAscList (foldr addConcept Map.empty concepts) conceptSections = Text.intercalate "\n" (sectionForType <$> groupedConcepts)@@ -51,10 +184,41 @@ : (conceptBullet <$> List.sortOn conceptSourcePath concepts) ) +-- | Render the bundle-root @index.md@, which is the one index permitted to+-- carry frontmatter (specification §8) and the one place a bundle declares the+-- version it targets (§12).+--+-- With no version this is exactly 'renderIndex', so a bundle that declares+-- nothing keeps a frontmatter-free root index. The value is quoted because §12+-- writes it quoted and because an unquoted @0.2@ is a YAML float whose text+-- form no serializer guarantees to preserve.+renderRootIndex :: Maybe OkfVersion -> [FilePath] -> [FilePath] -> [Concept] -> Text+renderRootIndex version = renderRootIndexText (renderOkfVersion <$> version)++-- | 'renderRootIndex' over the declaration's raw text rather than a parsed+-- version, so that a declaration okf cannot parse survives regeneration+-- verbatim instead of being deleted.+renderRootIndexText :: Maybe Text -> [FilePath] -> [FilePath] -> [Concept] -> Text+renderRootIndexText Nothing subdirectories files concepts =+ renderIndex subdirectories files concepts+renderRootIndexText (Just versionText) subdirectories files concepts =+ Text.unlines ["---", "okf_version: \"" <> escaped versionText <> "\"", "---", ""]+ <> renderIndex subdirectories files concepts+ where+ -- The only two characters that can end a double-quoted YAML scalar early.+ -- A parsed version can contain neither; a preserved raw one might.+ escaped = Text.replace "\"" "\\\"" . Text.replace "\\" "\\\\"+ directoryBullet :: FilePath -> Text directoryBullet directory = "- [" <> Text.pack directory <> "/](" <> Text.pack directory <> "/index.md)" +-- | A file has no frontmatter, so there is no title to prefer and no description+-- to append: the link text is the name.+fileBullet :: FilePath -> Text+fileBullet file =+ "- [" <> Text.pack file <> "](" <> Text.pack file <> ")"+ conceptBullet :: Concept -> Text conceptBullet concept = "- ["@@ -64,26 +228,56 @@ <> ")" <> maybe "" (" - " <>) (conceptDescription concept) --- | Write deterministic @index.md@ files for every directory in a bundle.+-- | Write deterministic @index.md@ files for every directory in a bundle,+-- preserving any version declaration the root index already carries. writeBundleIndexes :: FilePath -> IO (Either BundleError ())-writeBundleIndexes root = do- rendered <- renderBundleIndexes root+writeBundleIndexes = writeBundleIndexesWith Nothing++-- | 'writeBundleIndexes' with an explicit version declaration for the bundle+-- root. 'Just' overrides whatever the root index carries; 'Nothing' preserves+-- it.+writeBundleIndexesWith :: Maybe OkfVersion -> FilePath -> IO (Either BundleError ())+writeBundleIndexesWith override root = do+ rendered <- renderBundleIndexesWith override root case rendered of Left bundleError -> pure (Left bundleError) Right indexes -> do mapM_ (\(relativePath, content) -> Text.IO.writeFile (root </> relativePath) content) indexes pure (Right ()) --- | Render every @index.md@ file that would be written for a bundle.+-- | Render every @index.md@ file that would be written for a bundle,+-- preserving any version declaration the root index already carries. renderBundleIndexes :: FilePath -> IO (Either BundleError [(FilePath, Text)])-renderBundleIndexes root = do+renderBundleIndexes = renderBundleIndexesWith Nothing++-- | 'renderBundleIndexes' with an explicit version declaration for the bundle+-- root. 'Just' overrides whatever the root index carries; 'Nothing' preserves+-- it.+--+-- Preserving is not a nicety. Index generation rewrites every directory's+-- @index.md@, root included, so without reading the existing declaration first+-- a single @okf index --write@ would silently delete the bundle's §12 version+-- declaration.+renderBundleIndexesWith :: Maybe OkfVersion -> FilePath -> IO (Either BundleError [(FilePath, Text)])+renderBundleIndexesWith override root = do walked <- walkBundle root- case walked of+ declared <- readBundleVersion root+ case (,) <$> walked <*> declared of Left bundleError -> pure (Left bundleError)- Right concepts -> do+ Right (concepts, declaration) -> do+ let rootVersion = (renderOkfVersion <$> override) <|> declaredText declaration directories <- indexDirectories root concepts- indexes <- mapM (renderDirectoryIndex root concepts) directories+ indexes <- mapM (renderDirectoryIndex rootVersion root concepts) directories pure (Right indexes)+ where+ declaredText = \case+ VersionDeclared version -> Just (renderOkfVersion version)+ -- An unparseable declaration is preserved as written and left for+ -- validation to report. Rewriting it to a version okf invented would+ -- destroy the author's text; dropping it would be exactly the data loss+ -- this function exists to prevent.+ VersionUnparseable rawVersion -> Just rawVersion+ VersionUndeclared -> Nothing indexDirectories :: FilePath -> [Concept] -> IO [FilePath] indexDirectories root concepts = do@@ -106,15 +300,21 @@ ) entries -renderDirectoryIndex :: FilePath -> [Concept] -> FilePath -> IO (FilePath, Text)-renderDirectoryIndex root concepts relativeDir = do+-- | Render one directory's index. The bundle root reaches this twice, once as+-- @\"\"@ and once as @\".\"@, and both normalise to the same @index.md@; only+-- the root carries the version declaration.+renderDirectoryIndex :: Maybe Text -> FilePath -> [Concept] -> FilePath -> IO (FilePath, Text)+renderDirectoryIndex rootVersion root concepts relativeDir = do subdirectories <- immediateSubdirectories root relativeDir+ files <- immediateFiles root relativeDir let immediateConcepts = List.filter (\concept -> FilePath.normalise (FilePath.takeDirectory (conceptSourcePath concept)) == FilePath.normalise relativeDir) concepts indexPath = relativeDir </> "index.md"- pure (FilePath.normalise indexPath, renderIndex subdirectories immediateConcepts)+ isBundleRoot = FilePath.normalise indexPath == "index.md"+ renderFor = if isBundleRoot then renderRootIndexText rootVersion else renderIndex+ pure (FilePath.normalise indexPath, renderFor subdirectories files immediateConcepts) immediateSubdirectories :: FilePath -> FilePath -> IO [FilePath] immediateSubdirectories root relativeDir = do@@ -124,5 +324,27 @@ ( \entry -> do isDirectory <- doesDirectoryExist (root </> relativeDir </> entry) pure [entry | isDirectory]+ )+ entries++-- | The directory's immediate non-concept files, for the @# Files@ section.+--+-- Every @.md@ file is excluded rather than only the concepts: a concept has its+-- own typed section and @index.md@ and @log.md@ are reserved, so listing any of+-- the three here would duplicate or clutter. A name beginning with @.@ is+-- skipped so a stray @.DS_Store@ never lands in a committed index.+immediateFiles :: FilePath -> FilePath -> IO [FilePath]+immediateFiles root relativeDir = do+ entries <- List.sort <$> listDirectory (root </> relativeDir)+ fmap concat $+ mapM+ ( \entry -> do+ isDirectory <- doesDirectoryExist (root </> relativeDir </> entry)+ pure+ [ entry+ | not isDirectory,+ FilePath.takeExtension entry /= ".md",+ not ("." `List.isPrefixOf` entry)+ ] ) entries
src/Okf/Log.hs view
@@ -17,6 +17,7 @@ import Data.Char qualified as Char import Data.Text qualified as Text import Data.Time (Day, defaultTimeLocale, parseTimeM)+import Okf.Markdown (markdownOptions) import Okf.Prelude -- | One parsed @log.md@ file.@@ -58,7 +59,7 @@ finish (foldl' step emptyBuild topLevelNodes) where topLevelNodes =- case CMarkGFM.commonmarkToNode [] [] markdown of+ case CMarkGFM.commonmarkToNode markdownOptions [] markdown of CMarkGFM.Node _ _ documentChildren -> documentChildren -- | Render a log deterministically with a trailing newline.
+ src/Okf/Markdown.hs view
@@ -0,0 +1,255 @@+-- | The CommonMark configuration okf parses every body with, footnote label+-- extraction for OKF v0.2 per-claim attribution, and the @# Computation@ body+-- section of an OKF v0.2 attested computation.+module Okf.Markdown+ ( markdownOptions,+ FootnoteLabels (..),+ extractFootnoteLabels,+ footnoteLabelsUsed,+ computationBlocks,+ )+where++import CMarkGFM qualified+import Control.Monad (guard)+import Data.ByteString (ByteString)+import Data.ByteString qualified as ByteString+import Data.ByteString.Char8 qualified as ByteString.Char8+import Data.List qualified as List+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Data.Word (Word8)+import Okf.Prelude++-- | The CommonMark options okf parses every body with.+--+-- Footnotes are enabled because specification §5.1 attributes a claim with a+-- markdown footnote whose label is a @sources[].id@, so footnote syntax must+-- parse as a footnote rather than as prose. Leaving them off is not neutral: a+-- single-token definition such as @[^src]: doc@ is otherwise read as a+-- CommonMark /link reference definition/ with destination @doc@, which turns its+-- citation into a phantom link that "Okf.Graph" extracts and validation then+-- reports as dangling.+--+-- The cost, accepted deliberately, is that cmark-gfm deletes a footnote+-- definition nothing cites, so Markdown links inside such a definition no longer+-- reach the concept graph.+--+-- Extensions stay per call site. They are not uniform: @schemaSectionColumns@ in+-- "Okf.Profile" needs @extTable@ to read a GitHub-flavored table, and the other+-- call sites read neither tables nor any other extension construct.+markdownOptions :: [CMarkGFM.CMarkOption]+markdownOptions = [CMarkGFM.optFootnotes]++-- | The literal contents of every code block in the first @# Computation@+-- section of a body, in document order.+--+-- A /section/ runs from a heading whose text is @computation@, trimmed and+-- compared case-insensitively, to the next heading at the same or a shallower+-- level, or to the end of the document. CommonMark makes every heading and block+-- a sibling, so the boundary is drawn here rather than read off the tree.+-- @schemaSectionColumns@ in "Okf.Profile" is the neighbouring inspector and does+-- /not/ bound its section, which is tolerable for asking whether a schema table+-- exists and is not tolerable here: specification §10.3 counts computations, and+-- a fenced block under a later @# Notes@ heading is not a second one.+--+-- Both spellings of a code block count. Specification §10.3 says "a single+-- fenced code block" and §10.2's own worked example writes an indented one;+-- cmark-gfm reports both as @CODE_BLOCK@ and the tolerant reading is the only+-- one that does not report the specification's own example as broken.+--+-- Unlike 'extractFootnoteLabels' this reads the parse tree rather than the+-- source text, and that is deliberate rather than an oversight of+-- @docs\/adr\/9-one-markdown-parse-configuration-and-source-scanned-authoring-checks.md@.+-- That record's rule is that a check catching an author's /mistake/ must read+-- what the author wrote, because the tree erases unresolvable syntax. "Is there+-- a code block under this heading" is a question about structure, which is+-- exactly what the tree records. The one erasure that reaches this function is+-- the ADR's accepted cost: a code block inside a footnote definition nothing+-- cites is deleted along with its definition, so it is invisible here.+computationBlocks :: Text -> [Text]+computationBlocks markdown =+ let CMarkGFM.Node _ _ topLevel = CMarkGFM.commonmarkToNode markdownOptions [] markdown+ in case dropWhile (not . isComputationHeading) topLevel of+ (CMarkGFM.Node _ (CMarkGFM.HEADING level) _ : rest) ->+ codeBlockLiterals (takeWhile (not . closesSection level) rest)+ _ -> []+ where+ isComputationHeading (CMarkGFM.Node _ (CMarkGFM.HEADING _) inner) =+ Text.toLower (Text.strip (inlineText inner)) == "computation"+ isComputationHeading _ = False++ -- A smaller level is a shallower heading, so @## @ inside a @# @ section+ -- keeps the section open and the next @# @ closes it.+ closesSection level (CMarkGFM.Node _ (CMarkGFM.HEADING other) _) = other <= level+ closesSection _ _ = False++ codeBlockLiterals nodes =+ [literal | CMarkGFM.Node _ (CMarkGFM.CODE_BLOCK _ literal) _ <- nodes]++-- | Concatenate all @TEXT@ and @CODE@ literals under a node list, recursively.+inlineText :: [CMarkGFM.Node] -> Text+inlineText = foldMap go+ where+ go (CMarkGFM.Node _ (CMarkGFM.TEXT literal) _) = literal+ go (CMarkGFM.Node _ (CMarkGFM.CODE literal) _) = literal+ go (CMarkGFM.Node _ _ inner) = inlineText inner++-- | The footnote labels a concept body uses, split by how it uses them.+--+-- A body may cite @[^x]@ without defining it, or define @[^x]:@ without citing+-- it, and the two mean different things to an author. For attribution both count+-- as "this document names source @x@", which is what 'footnoteLabelsUsed'+-- returns.+--+-- Labels appear in document order with duplicates removed, so that diagnostics+-- built from them are deterministic.+data FootnoteLabels = FootnoteLabels+ { footnoteReferences :: ![Text],+ footnoteDefinitions :: ![Text]+ }+ deriving stock (Generic, Eq, Show)++-- | Every label the body uses, references and definitions together, in document+-- order with duplicates removed.+footnoteLabelsUsed :: FootnoteLabels -> [Text]+footnoteLabelsUsed FootnoteLabels {footnoteReferences, footnoteDefinitions} =+ List.nub (footnoteReferences <> footnoteDefinitions)++-- | Extract footnote labels from a concept body.+--+-- This reads the body's __source text__ and uses the parse tree only to find the+-- regions that are code. It does not walk the tree for footnote nodes, and that+-- is deliberate rather than a shortcut: cmark-gfm reverts a citation with no+-- matching definition to plain text and deletes a definition nothing cites, so+-- the tree exposes only labels that are already internally consistent. The two+-- mistakes attribution checking exists to catch — a mistyped citation and an+-- uncited definition — are exactly the two the tree erases.+--+-- Scanning source text alone would match footnote syntax inside code, so the+-- parse supplies the exclusions. One narrow gap follows from parsing with+-- footnotes enabled: a fenced code block nested inside a footnote definition+-- that nothing cites is deleted along with its definition, so text inside it is+-- scanned. That costs a spurious label in a document that already has an uncited+-- definition.+extractFootnoteLabels :: Text -> FootnoteLabels+extractFootnoteLabels markdown =+ FootnoteLabels+ { footnoteReferences = List.nub (foldMap fst scanned),+ footnoteDefinitions = List.nub (foldMap snd scanned)+ }+ where+ scanned = zipWith scanLine [1 ..] (Text.lines markdown)+ isCode = codeRegionTest markdown++ scanLine lineNumber lineText =+ let bytes = Text.Encoding.encodeUtf8 lineText+ inCode = isCode lineNumber+ in case definitionAt bytes of+ Just (label, afterColon)+ | not (inCode (indentColumn bytes)) ->+ (references inCode bytes afterColon, [label])+ _ -> (references inCode bytes 0, [])++-- | The 1-based byte column at which a line's content starts.+indentColumn :: ByteString -> Int+indentColumn bytes =+ ByteString.length (ByteString.Char8.takeWhile (== ' ') bytes) + 1++-- | A footnote definition opens a line: at most three spaces of indentation,+-- then @[^label]:@. Returns the label and the byte offset just past the colon,+-- so the rest of the line can still be scanned for citations.+definitionAt :: ByteString -> Maybe (Text, Int)+definitionAt bytes = do+ let indent = indentColumn bytes - 1+ guard (indent <= 3)+ (label, afterBracket) <- labelAt bytes indent+ guard (ByteString.indexMaybe bytes afterBracket == Just colon)+ pure (label, afterBracket + 1)++-- | Every @[^label]@ citation on one line from the given byte offset onwards,+-- skipping any that falls inside code and any that is backslash-escaped.+references :: (Int -> Bool) -> ByteString -> Int -> [Text]+references inCode bytes = go+ where+ go offset =+ case ByteString.Char8.elemIndex '[' (ByteString.drop offset bytes) of+ Nothing -> []+ Just relative ->+ let start = offset + relative+ in case labelAt bytes start of+ Just (label, afterBracket)+ | not (escapedAt start),+ not (inCode (start + 1)) ->+ label : go afterBracket+ Just (_, afterBracket) -> go afterBracket+ Nothing -> go (start + 1)++ escapedAt start =+ start > 0 && ByteString.indexMaybe bytes (start - 1) == Just backslash++-- | Match @[^label]@ starting at a byte offset, returning the label and the+-- offset just past the closing bracket.+--+-- A label is one or more bytes that are none of whitespace, @[@, or @]@, which+-- is close enough to cmark-gfm's own rule for this purpose: the parser rejects+-- @[^has space]@ as a footnote and so does this.+labelAt :: ByteString -> Int -> Maybe (Text, Int)+labelAt bytes start = do+ guard (ByteString.indexMaybe bytes start == Just openBracket)+ guard (ByteString.indexMaybe bytes (start + 1) == Just caret)+ let label = ByteString.takeWhile isLabelByte (ByteString.drop (start + 2) bytes)+ afterLabel = start + 2 + ByteString.length label+ guard (not (ByteString.null label))+ guard (ByteString.indexMaybe bytes afterLabel == Just closeBracket)+ pure (Text.Encoding.decodeUtf8Lenient label, afterLabel + 1)+ where+ isLabelByte byte =+ byte /= openBracket+ && byte /= closeBracket+ && byte > 32+ && byte /= 127++openBracket, closeBracket, caret, colon, backslash :: Word8+openBracket = 91+closeBracket = 93+caret = 94+colon = 58+backslash = 92++-- | A region of a body that must not be scanned for footnote syntax.+data CodeRegion+ = -- | A code block, excluded by whole lines: its start and end line.+ CodeLines !Int !Int+ | -- | An inline code span, excluded by position: its start and end+ -- @(line, column)@.+ CodeSpan !(Int, Int) !(Int, Int)+ deriving stock (Generic, Eq, Show)++-- | Whether a @(line, column)@ position falls inside code.+--+-- Columns are __byte__ offsets into the line, 1-based, because that is what+-- cmark-gfm's position information reports: a line containing any multi-byte+-- character puts its later nodes at a column beyond its character count.+codeRegionTest :: Text -> (Int -> Int -> Bool)+codeRegionTest markdown = \lineNumber column ->+ any (covers lineNumber column) regions+ where+ regions = collect (CMarkGFM.commonmarkToNode markdownOptions [] markdown)++ collect (CMarkGFM.Node nodePosition nodeType childNodes) =+ case (nodeType, nodePosition) of+ (CMarkGFM.CODE_BLOCK _ _, Just posInfo) ->+ [CodeLines (CMarkGFM.startLine posInfo) (CMarkGFM.endLine posInfo)]+ (CMarkGFM.CODE _, Just posInfo) ->+ [ CodeSpan+ (CMarkGFM.startLine posInfo, CMarkGFM.startColumn posInfo)+ (CMarkGFM.endLine posInfo, CMarkGFM.endColumn posInfo)+ ]+ _ -> foldMap collect childNodes++ covers lineNumber column = \case+ CodeLines firstLine lastLine ->+ lineNumber >= firstLine && lineNumber <= lastLine+ CodeSpan spanStart spanEnd ->+ (lineNumber, column) >= spanStart && (lineNumber, column) <= spanEnd
+ src/Okf/Path.hs view
@@ -0,0 +1,141 @@+-- | The OKF v0.2 path-valued field grammar (specification §6.2).+--+-- Several frontmatter fields name a path or URI: @resource@,+-- @sources[].resource@, and the attested-computation fields @computation@,+-- @executor.resource@, and @attester.resource@. Each accepts an absolute URL, a+-- bundle-relative path beginning with @\/@, or an ordinary relative path.+--+-- Classification is total and offline: it never touches the filesystem and never+-- decides whether a target exists. Deciding that is the caller's job, because+-- what counts as existing depends on what the caller can see. 'Okf.Profile'+-- resolves only @.md@ targets, because it is handed a list of concepts and+-- nothing else.+--+-- Deliberately distinct from 'Okf.Graph.extractConceptLinks', which reads+-- Markdown links out of a concept /body/ and is free to drop anything it does+-- not recognize. A path-valued /field/ is something the author wrote on purpose,+-- so every shape it can take is named here rather than silently ignored.+module Okf.Path+ ( PathReference (..),+ classifyPathReference,+ collapseBundlePath,+ PathResolution (..),+ resolvePathReference,+ )+where++import Control.Monad (foldM)+import Data.Text qualified as Text+import Network.URI (parseURI, uriScheme)+import Okf.ConceptId (ConceptId, conceptIdToFilePath)+import Okf.Prelude+import System.FilePath ((</>))+import System.FilePath qualified as FilePath++-- | What one raw path-valued frontmatter value turned out to be.+data PathReference+ = -- | An absolute URL, carrying its case-folded scheme.+ ExternalUrl !Text+ | -- | A path inside the bundle, collapsed and expressed relative to the+ -- bundle root, with any fragment or query suffix removed.+ BundlePath !FilePath+ | -- | A relative path that climbs above the bundle root.+ EscapesBundle+ | -- | Text that is neither: empty, whitespace, or otherwise unusable.+ MalformedPath+ deriving stock (Generic, Eq, Ord, Show)++-- | Classify one raw value written on the given concept. A relative path is+-- resolved against the concept's own directory, matching how a Markdown link in+-- that concept's body resolves; a value beginning with @\/@ resolves from the+-- bundle root regardless of where the concept lives.+--+-- An absolute URL is recognized by having a URI scheme, so this accepts every+-- scheme rather than the three 'Okf.Graph' treats as external. Whether a given+-- scheme is /permitted/ is a profile question and is decided by the caller.+classifyPathReference :: ConceptId -> Text -> PathReference+classifyPathReference sourceConcept rawValue+ | Text.null trimmed = MalformedPath+ | Just scheme <- absoluteUrlScheme trimmed = ExternalUrl scheme+ | Text.null cleanText = MalformedPath+ | otherwise =+ case collapseBundlePath candidatePath of+ Nothing -> EscapesBundle+ Just [] -> MalformedPath+ Just collapsed -> BundlePath collapsed+ where+ trimmed = Text.strip rawValue+ cleanText = stripUrlSuffix trimmed+ cleanPath = Text.unpack cleanText+ sourceDirectory = FilePath.takeDirectory (conceptIdToFilePath sourceConcept)+ candidatePath+ | "/" `Text.isPrefixOf` cleanText = dropWhile (== '/') cleanPath+ | otherwise = sourceDirectory </> cleanPath++-- | The outcome of resolving a path-valued frontmatter field against a bundle:+-- 'classifyPathReference' plus the one question it deliberately does not answer.+data PathResolution+ = -- | An absolute URL, carrying its case-folded scheme. okf never fetches it,+ -- so this is as resolved as an external target gets.+ ResolvedExternal !Text+ | -- | Names a file the bundle contains, at the given bundle-relative path.+ ResolvedInBundle !FilePath+ | -- | Looks exactly like a bundle path, and the bundle holds no such file.+ DanglingInBundle !FilePath+ | -- | Climbs above the bundle root, so there is nothing in the bundle it+ -- could name.+ UnresolvableEscape+ | -- | Empty, whitespace, or otherwise unusable as either a URL or a path.+ UnresolvableMalformed+ deriving stock (Generic, Eq, Ord, Show)++-- | Resolve one raw path-valued value written on the given concept, asking the+-- supplied predicate whether a bundle-relative path names a file that exists.+--+-- The predicate is a plain function rather than a bundle type so that this+-- module stays below 'Okf.Bundle' in the import graph; a caller holding a+-- 'Okf.Bundle.BundleInventory' passes+-- @flip Okf.Bundle.bundleInventoryMember inventory@.+--+-- Deliberately a thin composition over 'classifyPathReference'. Its value is+-- that the five outcomes are named once, so every caller agrees on what they+-- mean — in particular that an external URL is /resolved/ rather than skipped,+-- and that a path which escapes the bundle is a different finding from one that+-- simply is not there.+resolvePathReference :: (FilePath -> Bool) -> ConceptId -> Text -> PathResolution+resolvePathReference exists sourceConcept rawValue =+ case classifyPathReference sourceConcept rawValue of+ ExternalUrl scheme -> ResolvedExternal scheme+ BundlePath target+ | exists target -> ResolvedInBundle target+ | otherwise -> DanglingInBundle target+ EscapesBundle -> UnresolvableEscape+ MalformedPath -> UnresolvableMalformed++-- | The case-folded scheme of an absolute URL, or 'Nothing' for anything that+-- is not one. A bundle-absolute path such as @\/references\/policy.md@ has no+-- scheme and so is never mistaken for a URL.+absoluteUrlScheme :: Text -> Maybe Text+absoluteUrlScheme rawValue = do+ parsed <- parseURI (Text.unpack rawValue)+ let scheme = Text.toCaseFold (Text.dropWhileEnd (== ':') (Text.pack (uriScheme parsed)))+ if Text.null scheme then Nothing else Just scheme++-- | Fold @.@ and @..@ segments, returning 'Nothing' for a path that climbs above+-- the bundle root. Exported because resolving a bundle-relative path is the same+-- operation wherever it is done, and a second copy would be free to drift.+collapseBundlePath :: FilePath -> Maybe FilePath+collapseBundlePath =+ fmap FilePath.joinPath . foldM step [] . FilePath.splitDirectories+ where+ step [] "." = Just []+ step acc "." = Just acc+ step [] ".." = Nothing+ step acc ".." = Just (init acc)+ step acc segment = Just (acc <> [segment])++-- | Drop a URL fragment or query suffix, so @policy.md#section@ resolves to the+-- same target as @policy.md@.+stripUrlSuffix :: Text -> Text+stripUrlSuffix =+ Text.takeWhile (\character -> character /= '#' && character /= '?')
src/Okf/Profile.hs view
@@ -15,2486 +15,3909 @@ FrontmatterRules (..), FieldCondition (..), HandleReferenceRule (..),- FieldRule (..),- NestedRules (..),- NestedFieldRule (..),- Cardinality (..),- FieldFormat (..),- TypeRule (..),- FieldPath (..),- FieldPathSegment (..),- loadProfileFile,- decodeProfileExpr,- profileFieldDescription,- CompiledProfile,- ProfileDefinitionError (..),- compileProfile,- compiledProfileSpec,- profileFieldDescriptionForType,-- -- * Validation- DocumentId (..),- parseDocumentId,- renderDocumentId,- documentIdsInBundle,- nextDocumentId,- ProfileViolation (..),- validateProfile,-- -- * Body inspection- schemaSectionColumns,- )-where--import CMarkGFM qualified-import Control.Exception (SomeException, catch)-import Data.Aeson (ToJSON (..), object, (.=))-import Data.Aeson.Key qualified as Aeson.Key-import Data.Aeson.KeyMap qualified as Aeson.KeyMap-import Data.Char (isAsciiLower, isAsciiUpper)-import Data.List qualified as List-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Maybe (mapMaybe)-import Data.Set qualified as Set-import Data.Text qualified as Text-import Data.Text.Read qualified as Text.Read-import Data.Time.Calendar (Day)-import Data.Time.Clock (UTCTime)-import Data.Time.Format.ISO8601 (iso8601ParseM)-import Data.Vector qualified as Vector-import Data.Void (Void)-import Dhall (FromDhall (..), auto, genericAutoWith)-import Dhall qualified-import Dhall.Core (Expr)-import Dhall.Src (Src)-import Network.URI (parseURI, uriScheme)-import Numeric.Natural (Natural)-import Okf.Bundle- ( Concept,- conceptDocument,- conceptIdOf,- conceptResource,- conceptType,- )-import Okf.ConceptId (ConceptId, renderConceptId)-import Okf.Document (Frontmatter, coreFrontmatterFields, frontmatterKeys, frontmatterLookup)-import Okf.Prelude hiding (List, (.=))-import Okf.Validation (ValidationProfile (..))-import "generic-lens" Data.Generics.Labels ()---- | A complete house profile. @description@ is prose documenting the profile as--- a whole; like every description in this module it is never checked against a--- bundle and can never produce a 'ProfileViolation'.-data ProfileSpec = ProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !FrontmatterRules,- allowUnknownTypes :: !Bool,- allowUnknownFields :: !Bool,- idField :: !(Maybe Text),- types :: ![TypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | Frontmatter keys the profile expects on every concept. @required@ is always--- checked, @recommended@ only under 'StrictAuthoring', and @optional@ never:--- an optional key is fully validated whenever it is present and its absence is--- never a deviation in any mode.-data FrontmatterRules = FrontmatterRules- { required :: ![FieldRule],- recommended :: ![FieldRule],- optional :: ![FieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | A same-scope predicate controlling whether a field's presence rule applies.--- The source field must compile to a closed scalar textual vocabulary.-data FieldCondition = FieldCondition- { field :: !Text,- hasValue :: ![Text]- }- deriving stock (Generic, Eq, Ord, Show)- deriving anyclass (FromDhall)---- | A top-level field whose textual values point either to a local document--- handle with one prefix or to an absolute URI with an explicitly allowed--- scheme. External targets are never resolved by okf.-data HandleReferenceRule = HandleReferenceRule- { localPrefix :: !Text,- externalUriSchemes :: ![Text],- allowSelf :: !Bool- }- deriving stock (Generic, Eq, Ord, Show)- deriving anyclass (FromDhall)---- | One documented frontmatter key. The description is prose for humans and is--- never checked against a bundle.-data FieldRule = FieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- elementFields :: !(Maybe NestedRules),- reference :: !(Maybe HandleReferenceRule),- when :: !(Maybe FieldCondition)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | Rules for the flat object stored in each element of a list-valued field.--- The nested rule type has no @elementFields@ member, which makes the public--- descriptor depth-bounded rather than recursive.-data NestedRules = NestedRules- { required :: ![NestedFieldRule],- recommended :: ![NestedFieldRule],- optional :: ![NestedFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data NestedFieldRule = NestedFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- when :: !(Maybe FieldCondition)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data Cardinality = Any | Scalar | List- deriving stock (Generic, Eq, Ord, Show)- deriving anyclass (FromDhall)---- | A named textual format. Formats constrain present text values but do not--- imply that a field must be present.-data FieldFormat- = Rfc3339Utc- | Date- | Uri- | UriWithScheme Text- | DocumentHandle Text- deriving stock (Generic, Eq, Ord, Show)- deriving anyclass (FromDhall)---- | One rule per allowed concept @type@ string.-data TypeRule = TypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !FrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)---- | Decode @type_@ from the Dhall field @type@ by stripping the trailing--- underscore; all other fields map by their exact name. (Mirrors how--- 'Okf.Bundle' uses a @type_@ field to avoid clashing with the @type@ keyword.)-instance FromDhall TypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | Encode a profile so tooling can consume the same descriptor okf reads.--- Written by hand rather than derived so the key order is stable and, more--- importantly, so 'TypeRule' emits @type@ rather than the Haskell field name--- @type_@ — matching the Dhall field and how 'Okf.Graph.Node' already encodes.-instance ToJSON ProfileSpec where- toJSON ProfileSpec {name, description, okfVersion, frontmatter, allowUnknownTypes, allowUnknownFields, idField, types = typeRules} =- object- [ "name" .= name,- "description" .= description,- "okfVersion" .= okfVersion,- "allowUnknownTypes" .= allowUnknownTypes,- "allowUnknownFields" .= allowUnknownFields,- "idField" .= idField,- "frontmatter" .= frontmatter,- "types" .= typeRules- ]--instance ToJSON FrontmatterRules where- toJSON FrontmatterRules {required, recommended, optional} =- object- [ "required" .= required,- "recommended" .= recommended,- "optional" .= optional- ]--instance ToJSON FieldCondition where- toJSON FieldCondition {field = fieldName, hasValue} =- object- [ "field" .= fieldName,- "hasValue" .= hasValue- ]--instance ToJSON HandleReferenceRule where- toJSON HandleReferenceRule {localPrefix, externalUriSchemes, allowSelf} =- object- [ "localPrefix" .= localPrefix,- "externalUriSchemes" .= externalUriSchemes,- "allowSelf" .= allowSelf- ]--instance ToJSON FieldRule where- toJSON FieldRule {field = fieldName, description, allowedValues, cardinality, format, elementFields, reference, when = condition} =- object- [ "field" .= fieldName,- "description" .= description,- "allowedValues" .= allowedValues,- "cardinality" .= cardinality,- "format" .= format,- "elementFields" .= elementFields,- "reference" .= reference,- "when" .= condition- ]--instance ToJSON NestedRules where- toJSON NestedRules {required, recommended, optional} =- object- [ "required" .= required,- "recommended" .= recommended,- "optional" .= optional- ]--instance ToJSON NestedFieldRule where- toJSON NestedFieldRule {field = fieldName, description, allowedValues, cardinality, format, when = condition} =- object- [ "field" .= fieldName,- "description" .= description,- "allowedValues" .= allowedValues,- "cardinality" .= cardinality,- "format" .= format,- "when" .= condition- ]--instance ToJSON Cardinality where- toJSON = String . cardinalityName--cardinalityName :: Cardinality -> Text-cardinalityName = \case- Any -> "any"- Scalar -> "scalar"- List -> "list"--instance ToJSON FieldFormat where- toJSON = \case- Rfc3339Utc -> String "rfc3339-utc"- Date -> String "date"- Uri -> String "uri"- UriWithScheme scheme -> object ["uriWithScheme" .= scheme]- DocumentHandle prefix -> object ["documentHandle" .= prefix]--instance ToJSON TypeRule where- toJSON- TypeRule- { type_ = ruleType,- description,- frontmatter,- pathPattern,- resourceScheme,- requireSchemaSection,- schemaColumns,- idPrefix- } =- object- [ "type" .= ruleType,- "description" .= description,- "frontmatter" .= frontmatter,- "pathPattern" .= pathPattern,- "resourceScheme" .= resourceScheme,- "requireSchemaSection" .= requireSchemaSection,- "schemaColumns" .= schemaColumns,- "idPrefix" .= idPrefix- ]---- | The okf 0.2.x profile record: frontmatter keys were bare strings and--- nothing carried a description. Decoded only as a fallback, so descriptors--- written before descriptions existed keep loading unchanged. Deliberately--- private and deliberately frozen — it is a record of a retired shape, not a--- second profile model. Exercised by--- @okf-core\/test\/fixtures\/profiles\/legacy-0.2.dhall@.-data LegacyProfileSpec = LegacyProfileSpec- { name :: !Text,- okfVersion :: !Text,- frontmatter :: !LegacyFrontmatterRules,- allowUnknownTypes :: !Bool,- idField :: !(Maybe Text),- types :: ![LegacyTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | The okf 0.2.x frontmatter record: two lists of bare key names.-data LegacyFrontmatterRules = LegacyFrontmatterRules- { required :: ![Text],- recommended :: ![Text]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | The okf 0.2.x per-@type@ rule: today's 'TypeRule' without descriptions or--- type-specific frontmatter.-data LegacyTypeRule = LegacyTypeRule- { type_ :: !Text,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall LegacyTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | The complete reference-aware descriptor generation, frozen before the--- @optional@ presence list was added to 'FrontmatterRules' and 'NestedRules'.--- This is the immediately preceding public descriptor generation: it matches--- today's shape exactly apart from that third list, so every descriptor written--- as a record literal against the published schema keeps loading. Exercised by--- @okf-core\/test\/fixtures\/profiles\/document-references-ep3.dhall@.-data ReferenceProfileFieldRule = ReferenceProfileFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- elementFields :: !(Maybe ReferenceProfileNestedRules),- reference :: !(Maybe HandleReferenceRule),- when :: !(Maybe FieldCondition)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ReferenceProfileNestedRules = ReferenceProfileNestedRules- { required :: ![ReferenceProfileNestedFieldRule],- recommended :: ![ReferenceProfileNestedFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ReferenceProfileNestedFieldRule = ReferenceProfileNestedFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- when :: !(Maybe FieldCondition)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ReferenceProfileFrontmatterRules = ReferenceProfileFrontmatterRules- { required :: ![ReferenceProfileFieldRule],- recommended :: ![ReferenceProfileFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ReferenceProfileSpec = ReferenceProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !ReferenceProfileFrontmatterRules,- allowUnknownTypes :: !Bool,- allowUnknownFields :: !Bool,- idField :: !(Maybe Text),- types :: ![ReferenceProfileTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ReferenceProfileTypeRule = ReferenceProfileTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !ReferenceProfileFrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall ReferenceProfileTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | The complete condition-aware descriptor generation, frozen before--- top-level document-reference policies were added.-data ConditionalProfileFieldRule = ConditionalProfileFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- elementFields :: !(Maybe ConditionalProfileNestedRules),- when :: !(Maybe FieldCondition)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ConditionalProfileNestedRules = ConditionalProfileNestedRules- { required :: ![ConditionalProfileNestedFieldRule],- recommended :: ![ConditionalProfileNestedFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ConditionalProfileNestedFieldRule = ConditionalProfileNestedFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- when :: !(Maybe FieldCondition)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ConditionalProfileFrontmatterRules = ConditionalProfileFrontmatterRules- { required :: ![ConditionalProfileFieldRule],- recommended :: ![ConditionalProfileFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ConditionalProfileSpec = ConditionalProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !ConditionalProfileFrontmatterRules,- allowUnknownTypes :: !Bool,- allowUnknownFields :: !Bool,- idField :: !(Maybe Text),- types :: ![ConditionalProfileTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data ConditionalProfileTypeRule = ConditionalProfileTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !ConditionalProfileFrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall ConditionalProfileTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | The complete bounded-nested descriptor generation, frozen before--- same-scope field conditions were added. This is the immediately preceding--- public descriptor generation.-data NestedProfileFieldRule = NestedProfileFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- elementFields :: !(Maybe NestedProfileRules)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data NestedProfileRules = NestedProfileRules- { required :: ![NestedProfileNestedFieldRule],- recommended :: ![NestedProfileNestedFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data NestedProfileNestedFieldRule = NestedProfileNestedFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data NestedProfileFrontmatterRules = NestedProfileFrontmatterRules- { required :: ![NestedProfileFieldRule],- recommended :: ![NestedProfileFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data NestedProfileSpec = NestedProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !NestedProfileFrontmatterRules,- allowUnknownTypes :: !Bool,- allowUnknownFields :: !Bool,- idField :: !(Maybe Text),- types :: ![NestedProfileTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data NestedProfileTypeRule = NestedProfileTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !NestedProfileFrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall NestedProfileTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | The complete EP-4 field rule, frozen before one-level nested records were--- added. This is the immediately preceding public descriptor generation.-data FormatFieldRule = FormatFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data FormatFrontmatterRules = FormatFrontmatterRules- { required :: ![FormatFieldRule],- recommended :: ![FormatFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data FormatProfileSpec = FormatProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !FormatFrontmatterRules,- allowUnknownTypes :: !Bool,- allowUnknownFields :: !Bool,- idField :: !(Maybe Text),- types :: ![FormatTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data FormatTypeRule = FormatTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !FormatFrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall FormatTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | The EP-3 field rule, frozen before named formats were added.-data CardinalityFieldRule = CardinalityFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data CardinalityFrontmatterRules = CardinalityFrontmatterRules- { required :: ![CardinalityFieldRule],- recommended :: ![CardinalityFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | The complete EP-3 profile shape, frozen before named formats were added.-data CardinalityProfileSpec = CardinalityProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !CardinalityFrontmatterRules,- allowUnknownTypes :: !Bool,- allowUnknownFields :: !Bool,- idField :: !(Maybe Text),- types :: ![CardinalityTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data CardinalityTypeRule = CardinalityTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !CardinalityFrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall CardinalityTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | The EP-2 field rule, frozen before cardinality was added.-data VocabularyFieldRule = VocabularyFieldRule- { field :: !Text,- description :: !(Maybe Text),- allowedValues :: ![Text]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data VocabularyFrontmatterRules = VocabularyFrontmatterRules- { required :: ![VocabularyFieldRule],- recommended :: ![VocabularyFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | The EP-2 profile shape, frozen before cardinality was added.-data VocabularyProfileSpec = VocabularyProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !VocabularyFrontmatterRules,- allowUnknownTypes :: !Bool,- allowUnknownFields :: !Bool,- idField :: !(Maybe Text),- types :: ![VocabularyTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data VocabularyTypeRule = VocabularyTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !VocabularyFrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall VocabularyTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | A field rule from either self-documenting schema generation, before value--- vocabularies were added.-data PreviousFieldRule = PreviousFieldRule- { field :: !Text,- description :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data PreviousFrontmatterRules = PreviousFrontmatterRules- { required :: ![PreviousFieldRule],- recommended :: ![PreviousFieldRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)---- | The type-aware shape from EP-1, frozen before vocabularies and field-name--- closure were added.-data TypeAwareProfileSpec = TypeAwareProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !PreviousFrontmatterRules,- allowUnknownTypes :: !Bool,- idField :: !(Maybe Text),- types :: ![TypeAwareTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data TypeAwareTypeRule = TypeAwareTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- frontmatter :: !PreviousFrontmatterRules,- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall TypeAwareTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)---- | The self-documenting profile shape published immediately before--- type-specific frontmatter rules. It is frozen as a compatibility decoder in--- exactly the same way as the older 0.2.x shape below.-data DescribedProfileSpec = DescribedProfileSpec- { name :: !Text,- description :: !(Maybe Text),- okfVersion :: !Text,- frontmatter :: !PreviousFrontmatterRules,- allowUnknownTypes :: !Bool,- idField :: !(Maybe Text),- types :: ![DescribedTypeRule]- }- deriving stock (Generic, Eq, Show)- deriving anyclass (FromDhall)--data DescribedTypeRule = DescribedTypeRule- { type_ :: !Text,- description :: !(Maybe Text),- pathPattern :: !(Maybe Text),- resourceScheme :: !(Maybe Text),- requireSchemaSection :: !Bool,- schemaColumns :: ![Text],- idPrefix :: !(Maybe Text)- }- deriving stock (Generic, Eq, Show)--instance FromDhall DescribedTypeRule where- autoWith _normalizer =- genericAutoWith- (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})- where- stripTrailingUnderscore fieldName =- fromMaybe fieldName (Text.stripSuffix "_" fieldName)--emptyFrontmatterRules :: FrontmatterRules-emptyFrontmatterRules = FrontmatterRules {required = [], recommended = [], optional = []}--upgradeReferenceProfileFrontmatter :: ReferenceProfileFrontmatterRules -> FrontmatterRules-upgradeReferenceProfileFrontmatter previous =- FrontmatterRules- { required = map upgradeField (previous ^. #required),- recommended = map upgradeField (previous ^. #recommended),- optional = []- }- where- upgradeField rule =- FieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = rule ^. #format,- elementFields = upgradeNestedRules <$> rule ^. #elementFields,- reference = rule ^. #reference,- when = rule ^. #when- }- upgradeNestedRules rules =- NestedRules- { required = map upgradeNestedField (rules ^. #required),- recommended = map upgradeNestedField (rules ^. #recommended),- optional = []- }- upgradeNestedField rule =- NestedFieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = rule ^. #format,- when = rule ^. #when- }--upgradePreviousFrontmatter :: PreviousFrontmatterRules -> FrontmatterRules-upgradePreviousFrontmatter previous =- FrontmatterRules- { required = map upgradeField (previous ^. #required),- recommended = map upgradeField (previous ^. #recommended),- optional = []- }- where- upgradeField rule =- FieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = [],- cardinality = Any,- format = Nothing,- elementFields = Nothing,- reference = Nothing,- when = Nothing- }--upgradeConditionalProfileFrontmatter :: ConditionalProfileFrontmatterRules -> FrontmatterRules-upgradeConditionalProfileFrontmatter previous =- FrontmatterRules- { required = map upgradeField (previous ^. #required),- recommended = map upgradeField (previous ^. #recommended),- optional = []- }- where- upgradeField rule =- FieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = rule ^. #format,- elementFields = upgradeNestedRules <$> rule ^. #elementFields,- reference = Nothing,- when = rule ^. #when- }- upgradeNestedRules rules =- NestedRules- { required = map upgradeNestedField (rules ^. #required),- recommended = map upgradeNestedField (rules ^. #recommended),- optional = []- }- upgradeNestedField rule =- NestedFieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = rule ^. #format,- when = rule ^. #when- }--upgradeNestedProfileFrontmatter :: NestedProfileFrontmatterRules -> FrontmatterRules-upgradeNestedProfileFrontmatter previous =- FrontmatterRules- { required = map upgradeField (previous ^. #required),- recommended = map upgradeField (previous ^. #recommended),- optional = []- }- where- upgradeField rule =- FieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = rule ^. #format,- elementFields = upgradeNestedProfileRules <$> rule ^. #elementFields,- reference = Nothing,- when = Nothing- }- upgradeNestedProfileRules rules =- NestedRules- { required = map upgradeNestedField (rules ^. #required),- recommended = map upgradeNestedField (rules ^. #recommended),- optional = []- }- upgradeNestedField rule =- NestedFieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = rule ^. #format,- when = Nothing- }--upgradeFormatFrontmatter :: FormatFrontmatterRules -> FrontmatterRules-upgradeFormatFrontmatter previous =- FrontmatterRules- { required = map upgradeField (previous ^. #required),- recommended = map upgradeField (previous ^. #recommended),- optional = []- }- where- upgradeField rule =- FieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = rule ^. #format,- elementFields = Nothing,- reference = Nothing,- when = Nothing- }--upgradeCardinalityFrontmatter :: CardinalityFrontmatterRules -> FrontmatterRules-upgradeCardinalityFrontmatter previous =- FrontmatterRules- { required = map upgradeField (previous ^. #required),- recommended = map upgradeField (previous ^. #recommended),- optional = []- }- where- upgradeField rule =- FieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = rule ^. #cardinality,- format = Nothing,- elementFields = Nothing,- reference = Nothing,- when = Nothing- }--upgradeVocabularyFrontmatter :: VocabularyFrontmatterRules -> FrontmatterRules-upgradeVocabularyFrontmatter previous =- FrontmatterRules- { required = map upgradeField (previous ^. #required),- recommended = map upgradeField (previous ^. #recommended),- optional = []- }- where- upgradeField rule =- FieldRule- { field = rule ^. #field,- description = rule ^. #description,- allowedValues = rule ^. #allowedValues,- cardinality = Any,- format = Nothing,- elementFields = Nothing,- reference = Nothing,- when = Nothing- }--upgradeReferenceProfile :: ReferenceProfileSpec -> ProfileSpec-upgradeReferenceProfile previous =- ProfileSpec- { name = previous ^. #name,- description = previous ^. #description,- okfVersion = previous ^. #okfVersion,- frontmatter = upgradeReferenceProfileFrontmatter (previous ^. #frontmatter),- allowUnknownTypes = previous ^. #allowUnknownTypes,- allowUnknownFields = previous ^. #allowUnknownFields,- idField = previous ^. #idField,- types = map upgradeRule (previous ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = upgradeReferenceProfileFrontmatter (rule ^. #frontmatter),- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }--upgradeConditionalProfile :: ConditionalProfileSpec -> ProfileSpec-upgradeConditionalProfile previous =- ProfileSpec- { name = previous ^. #name,- description = previous ^. #description,- okfVersion = previous ^. #okfVersion,- frontmatter = upgradeConditionalProfileFrontmatter (previous ^. #frontmatter),- allowUnknownTypes = previous ^. #allowUnknownTypes,- allowUnknownFields = previous ^. #allowUnknownFields,- idField = previous ^. #idField,- types = map upgradeRule (previous ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = upgradeConditionalProfileFrontmatter (rule ^. #frontmatter),- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }--upgradeNestedProfile :: NestedProfileSpec -> ProfileSpec-upgradeNestedProfile previous =- ProfileSpec- { name = previous ^. #name,- description = previous ^. #description,- okfVersion = previous ^. #okfVersion,- frontmatter = upgradeNestedProfileFrontmatter (previous ^. #frontmatter),- allowUnknownTypes = previous ^. #allowUnknownTypes,- allowUnknownFields = previous ^. #allowUnknownFields,- idField = previous ^. #idField,- types = map upgradeRule (previous ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = upgradeNestedProfileFrontmatter (rule ^. #frontmatter),- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }--upgradeFormatProfile :: FormatProfileSpec -> ProfileSpec-upgradeFormatProfile previous =- ProfileSpec- { name = previous ^. #name,- description = previous ^. #description,- okfVersion = previous ^. #okfVersion,- frontmatter = upgradeFormatFrontmatter (previous ^. #frontmatter),- allowUnknownTypes = previous ^. #allowUnknownTypes,- allowUnknownFields = previous ^. #allowUnknownFields,- idField = previous ^. #idField,- types = map upgradeRule (previous ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = upgradeFormatFrontmatter (rule ^. #frontmatter),- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }--upgradeCardinalityProfile :: CardinalityProfileSpec -> ProfileSpec-upgradeCardinalityProfile previous =- ProfileSpec- { name = previous ^. #name,- description = previous ^. #description,- okfVersion = previous ^. #okfVersion,- frontmatter = upgradeCardinalityFrontmatter (previous ^. #frontmatter),- allowUnknownTypes = previous ^. #allowUnknownTypes,- allowUnknownFields = previous ^. #allowUnknownFields,- idField = previous ^. #idField,- types = map upgradeRule (previous ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = upgradeCardinalityFrontmatter (rule ^. #frontmatter),- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }--upgradeVocabularyProfile :: VocabularyProfileSpec -> ProfileSpec-upgradeVocabularyProfile previous =- ProfileSpec- { name = previous ^. #name,- description = previous ^. #description,- okfVersion = previous ^. #okfVersion,- frontmatter = upgradeVocabularyFrontmatter (previous ^. #frontmatter),- allowUnknownTypes = previous ^. #allowUnknownTypes,- allowUnknownFields = previous ^. #allowUnknownFields,- idField = previous ^. #idField,- types = map upgradeRule (previous ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = upgradeVocabularyFrontmatter (rule ^. #frontmatter),- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }--upgradeTypeAwareProfile :: TypeAwareProfileSpec -> ProfileSpec-upgradeTypeAwareProfile previous =- ProfileSpec- { name = previous ^. #name,- description = previous ^. #description,- okfVersion = previous ^. #okfVersion,- frontmatter = upgradePreviousFrontmatter (previous ^. #frontmatter),- allowUnknownTypes = previous ^. #allowUnknownTypes,- allowUnknownFields = True,- idField = previous ^. #idField,- types = map upgradeRule (previous ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = upgradePreviousFrontmatter (rule ^. #frontmatter),- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }--upgradeDescribedProfile :: DescribedProfileSpec -> ProfileSpec-upgradeDescribedProfile described =- ProfileSpec- { name = described ^. #name,- description = described ^. #description,- okfVersion = described ^. #okfVersion,- frontmatter = upgradePreviousFrontmatter (described ^. #frontmatter),- allowUnknownTypes = described ^. #allowUnknownTypes,- allowUnknownFields = True,- idField = described ^. #idField,- types = map upgradeRule (described ^. #types)- }- where- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = rule ^. #description,- frontmatter = emptyFrontmatterRules,- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }---- | Lift a 0.2.x profile into the current shape by attaching no descriptions--- and empty type-specific frontmatter.-upgradeLegacyProfile :: LegacyProfileSpec -> ProfileSpec-upgradeLegacyProfile legacy =- ProfileSpec- { name = legacy ^. #name,- description = Nothing,- okfVersion = legacy ^. #okfVersion,- frontmatter =- FrontmatterRules- { required = map undocumented (legacy ^. #frontmatter . #required),- recommended = map undocumented (legacy ^. #frontmatter . #recommended),- optional = []- },- allowUnknownTypes = legacy ^. #allowUnknownTypes,- allowUnknownFields = True,- idField = legacy ^. #idField,- types = map upgradeRule (legacy ^. #types)- }- where- undocumented key = FieldRule {field = key, description = Nothing, allowedValues = [], cardinality = Any, format = Nothing, elementFields = Nothing, reference = Nothing, when = Nothing}- upgradeRule rule =- TypeRule- { type_ = rule ^. #type_,- description = Nothing,- frontmatter = emptyFrontmatterRules,- pathPattern = rule ^. #pathPattern,- resourceScheme = rule ^. #resourceScheme,- requireSchemaSection = rule ^. #requireSchemaSection,- schemaColumns = rule ^. #schemaColumns,- idPrefix = rule ^. #idPrefix- }---- | Load and decode a Dhall profile descriptor from a file path. Any evaluation--- or decoding failure is captured as a human-readable 'Left'.------ The reference-aware shape, condition-aware shape, bounded-nested shape, EP-4--- format shape, EP-3 cardinality shape, EP-2 vocabulary shape, type-aware EP-1--- shape, self-documenting shape, and okf 0.2.x shape are accepted by frozen--- fallback decoders and upgraded--- with their no-op defaults. When every decoder fails, the /current/ decoder's--- error is reported.-loadProfileFile :: FilePath -> IO (Either Text ProfileSpec)-loadProfileFile path = do- current <- tryDecode (Dhall.inputFile auto path)- case current of- Right spec -> pure (Right spec)- Left currentError -> do- referenceAware <- tryDecode (Dhall.inputFile auto path)- case referenceAware of- Right referenceSpec -> pure (Right (upgradeReferenceProfile referenceSpec))- Left _referenceError -> do- conditional <- tryDecode (Dhall.inputFile auto path)- case conditional of- Right conditionalSpec -> pure (Right (upgradeConditionalProfile conditionalSpec))- Left _conditionalError -> do- nested <- tryDecode (Dhall.inputFile auto path)- case nested of- Right nestedSpec -> pure (Right (upgradeNestedProfile nestedSpec))- Left _nestedError -> do- formatted <- tryDecode (Dhall.inputFile auto path)- case formatted of- Right formatSpec -> pure (Right (upgradeFormatProfile formatSpec))- Left _formatError -> do- cardinality <- tryDecode (Dhall.inputFile auto path)- case cardinality of- Right cardinalitySpec -> pure (Right (upgradeCardinalityProfile cardinalitySpec))- Left _cardinalityError -> do- vocabulary <- tryDecode (Dhall.inputFile auto path)- case vocabulary of- Right vocabularySpec -> pure (Right (upgradeVocabularyProfile vocabularySpec))- Left _vocabularyError -> do- typeAware <- tryDecode (Dhall.inputFile auto path)- case typeAware of- Right typeAwareSpec -> pure (Right (upgradeTypeAwareProfile typeAwareSpec))- Left _typeAwareError -> do- described <- tryDecode (Dhall.inputFile auto path)- case described of- Right describedSpec -> pure (Right (upgradeDescribedProfile describedSpec))- Left _describedError -> do- legacy <- tryDecode (Dhall.inputFile auto path)- pure $ case legacy of- Right legacySpec -> Right (upgradeLegacyProfile legacySpec)- Left _legacyError -> Left currentError- where- -- The ten calls look identical but are inferred at distinct result types;- -- @auto@ picks the corresponding current or frozen decoder.- tryDecode :: IO a -> IO (Either Text a)- tryDecode action =- (Right <$> action)- `catch` \(exception :: SomeException) -> pure (Left (Text.pack (show exception)))---- | Does an already-evaluated Dhall expression decode as a profile? Tries the--- current schema, then the reference-aware, condition-aware, bounded-nested,--- EP-4, EP-3, EP-2, EP-1, self-documenting, and okf 0.2.x schemas,--- so the published @okf-profiles@ package still enumerates. Uses--- 'Dhall.rawInput', which normalizes and runs the decoder's extractor without--- throwing, which is what lets registry enumeration be pure.-decodeProfileExpr :: Expr Src Void -> Maybe ProfileSpec-decodeProfileExpr expression =- Dhall.rawInput Dhall.auto expression- <|> fmap upgradeReferenceProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeConditionalProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeNestedProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeFormatProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeCardinalityProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeVocabularyProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeTypeAwareProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeDescribedProfile (Dhall.rawInput Dhall.auto expression)- <|> fmap upgradeLegacyProfile (Dhall.rawInput Dhall.auto expression)---- | The description a profile attaches to a frontmatter key, looking in--- @required@ first, then @recommended@, then @optional@. 'Nothing' when the key--- is undocumented or absent from the profile entirely.-profileFieldDescription :: ProfileSpec -> Text -> Maybe Text-profileFieldDescription spec key =- case [rule | rule <- rules, rule ^. #field == key] of- (rule : _) -> rule ^. #description- [] -> Nothing- where- rules =- spec ^. #frontmatter . #required- <> spec ^. #frontmatter . #recommended- <> spec ^. #frontmatter . #optional---- | A malformed profile definition. The optional type is absent for profile--- scope and present for a type-specific scope. The list name is @required@,--- @recommended@, or @optional@.-data ProfileDefinitionError- = DuplicateTypeRule Text- | DuplicateFieldRule (Maybe Text) Text Text- | ConflictingFieldRequirement (Maybe Text) Text- | UnsatisfiableVocabulary (Maybe Text) Text [Text] [Text]- | ConflictingCardinality (Maybe Text) Text Cardinality Cardinality- | ElementFieldsRequireList (Maybe Text) FieldPath Cardinality- | InvalidFormatParameter FieldPath FieldFormat Text- | ConflictingFieldFormat FieldPath FieldFormat FieldFormat- | EmptyConditionValues (Maybe Text) FieldPath FieldPath- | ConditionFieldNotDeclared (Maybe Text) FieldPath FieldPath- | ConditionFieldNotScalar (Maybe Text) FieldPath FieldPath Cardinality- | ConditionFieldOpenVocabulary (Maybe Text) FieldPath FieldPath- | ConditionFieldHasUnreachableValues (Maybe Text) FieldPath FieldPath [Text] [Text]- | SelfConditionalField (Maybe Text) FieldPath- | InvalidReferencePrefix (Maybe Text) FieldPath Text- | ReferencePrefixNotDeclared (Maybe Text) FieldPath Text- | ReferenceRequiresIdField (Maybe Text) FieldPath- | InvalidExternalReferenceScheme (Maybe Text) FieldPath Text- | ConflictingReferencePrefix Text FieldPath Text Text- | ReferenceWithFormat (Maybe Text) FieldPath FieldFormat- | -- | an @optional@ rule carries a @when@ condition, which gates only presence- -- and is therefore dead: an optional rule has no presence check at all- OptionalFieldWithCondition (Maybe Text) FieldPath- deriving stock (Generic, Eq, Ord, Show)--data FieldRequirement = RecommendedField | RequiredField- deriving stock (Eq, Ord, Show)--data CompiledCondition = CompiledCondition- { field :: !Text,- hasValue :: ![Text]- }- deriving stock (Generic, Eq, Show)--data PresenceClause = PresenceClause- { requirement :: !FieldRequirement,- condition :: !(Maybe CompiledCondition)- }- deriving stock (Generic, Eq, Show)--data EffectiveFieldRule = EffectiveFieldRule- { presenceClauses :: ![PresenceClause],- description :: !(Maybe Text),- allowedValues :: ![Text],- cardinality :: !Cardinality,- format :: !(Maybe FieldFormat),- elementFields :: !(Maybe (Map Text EffectiveFieldRule)),- reference :: !(Maybe HandleReferenceRule)- }- deriving stock (Generic, Eq, Show)---- | A raw profile whose authoring contradictions have been rejected and whose--- effective profile-plus-type frontmatter rules have been precomputed.-data CompiledProfile = CompiledProfile- { spec :: !ProfileSpec,- baseRules :: !(Map Text EffectiveFieldRule),- rulesByType :: !(Map Text (Map Text EffectiveFieldRule))- }- deriving stock (Generic, Eq, Show)--compiledProfileSpec :: CompiledProfile -> ProfileSpec-compiledProfileSpec compiled = compiled ^. #spec--compileProfile :: ProfileSpec -> Either (NonEmpty ProfileDefinitionError) CompiledProfile-compileProfile rawSpec =- case definitionErrors of- firstError : remainingErrors -> Left (firstError :| remainingErrors)- [] ->- Right- CompiledProfile- { spec = rawSpec,- baseRules,- rulesByType =- Map.fromList- [ (rule ^. #type_, mergeRules baseRules (compileRules (rule ^. #frontmatter)))- | rule <- rawSpec ^. #types- ]- }- where- baseRules = compileRules (rawSpec ^. #frontmatter)- typeNames = map (^. #type_) (rawSpec ^. #types)- definitionErrors =- List.sortOn definitionErrorKey $- map DuplicateTypeRule (duplicates typeNames)- <> scopeErrors Nothing (rawSpec ^. #frontmatter)- <> concat- [ scopeErrors (Just (rule ^. #type_)) (rule ^. #frontmatter)- | rule <- rawSpec ^. #types- ]- <> vocabularyErrors- <> cardinalityErrors- <> nestedCardinalityErrors- <> formatParameterErrors- <> conflictingFormatErrors- <> conditionDefinitionErrors- <> referenceDefinitionErrors-- definitionErrorKey = \case- DuplicateTypeRule ctype -> (1 :: Int, ctype, 0 :: Int, "", 0 :: Int)- DuplicateFieldRule scope listName key ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, listKey listName, key, 0)- ConflictingFieldRequirement scope key ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, 2, key, 0)- UnsatisfiableVocabulary scope key _ _ ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, 3, key, 0)- ConflictingCardinality scope key _ _ ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, 4, key, 0)- ElementFieldsRequireList scope path cardinality ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, 4, renderFieldPathKey path, fromEnum (cardinality == Scalar))- InvalidFormatParameter path fieldFormat parameter ->- (2, renderFieldPathKey path, 5, Text.pack (show fieldFormat), Text.length parameter)- ConflictingFieldFormat path profileFormat typeFormat ->- (2, renderFieldPathKey path, 6, Text.pack (show profileFormat), fromEnum (profileFormat == typeFormat))- EmptyConditionValues scope target source -> conditionErrorKey scope target source 7- ConditionFieldNotDeclared scope target source -> conditionErrorKey scope target source 8- ConditionFieldNotScalar scope target source cardinality ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, 9, renderFieldPathKey target <> ":" <> renderFieldPathKey source, fromEnum (cardinality == Scalar))- ConditionFieldOpenVocabulary scope target source -> conditionErrorKey scope target source 10- ConditionFieldHasUnreachableValues scope target source _ _ -> conditionErrorKey scope target source 11- SelfConditionalField scope target ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, 12, renderFieldPathKey target, 0)- InvalidReferencePrefix scope target prefix -> referenceErrorKey scope target 13 prefix- ReferencePrefixNotDeclared scope target prefix -> referenceErrorKey scope target 14 prefix- ReferenceRequiresIdField scope target -> referenceErrorKey scope target 15 ""- InvalidExternalReferenceScheme scope target scheme -> referenceErrorKey scope target 16 scheme- ConflictingReferencePrefix ctype target profilePrefix typePrefix ->- (1, ctype, 17, renderFieldPathKey target <> ":" <> profilePrefix, Text.length typePrefix)- ReferenceWithFormat scope target fieldFormat -> referenceErrorKey scope target 18 (Text.pack (show fieldFormat))- OptionalFieldWithCondition scope target ->- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, 19, renderFieldPathKey target, 0)-- scopeKey Nothing = (0, "")- scopeKey (Just ctype) = (1, ctype)- listKey "required" = 0- listKey _ = 1- conditionErrorKey scope target source rank =- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, rank, renderFieldPathKey target <> ":" <> renderFieldPathKey source, 0)- referenceErrorKey scope target rank detail =- let (scopeRank, typeName) = scopeKey scope- in (scopeRank, typeName, rank, renderFieldPathKey target <> ":" <> detail, 0)-- scopeErrors scope FrontmatterRules {required, recommended, optional} =- [DuplicateFieldRule scope "required" key | key <- duplicates (map (^. #field) required)]- <> [DuplicateFieldRule scope "recommended" key | key <- duplicates (map (^. #field) recommended)]- <> [DuplicateFieldRule scope "optional" key | key <- duplicates (map (^. #field) optional)]- <> [ ConflictingFieldRequirement scope key- | key <- presenceListCollisions (map (^. #field) required) (map (^. #field) recommended) (map (^. #field) optional)- ]- <> concatMap (nestedScopeErrors scope) (required <> recommended <> optional)-- nestedScopeErrors scope parentRule =- case parentRule ^. #elementFields of- Nothing -> []- Just NestedRules {required, recommended, optional} ->- let qualify key = parentRule ^. #field <> "." <> key- in [DuplicateFieldRule scope "nested required" (qualify key) | key <- duplicates (map (^. #field) required)]- <> [DuplicateFieldRule scope "nested recommended" (qualify key) | key <- duplicates (map (^. #field) recommended)]- <> [DuplicateFieldRule scope "nested optional" (qualify key) | key <- duplicates (map (^. #field) optional)]- <> [ ConflictingFieldRequirement scope (qualify key)- | key <- presenceListCollisions (map (^. #field) required) (map (^. #field) recommended) (map (^. #field) optional)- ]-- -- One key classified by more than one presence list at a single scope. The- -- profile cannot say both "always check for this" and "never check for- -- this", so any pairing is a definition error rather than a precedence rule.- presenceListCollisions requiredKeys recommendedKeys optionalKeys =- List.nub . List.sort . concat $- [ List.intersect requiredKeys recommendedKeys,- List.intersect requiredKeys optionalKeys,- List.intersect recommendedKeys optionalKeys- ]-- duplicates = mapMaybe duplicateHead . List.group . List.sort- duplicateHead (candidate : _ : _) = Just candidate- duplicateHead _ = Nothing-- vocabularyErrors =- [ UnsatisfiableVocabulary (Just (rule ^. #type_)) key profileValues typeValues- | rule <- rawSpec ^. #types,- let typeRules = compileRules (rule ^. #frontmatter),- (key, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),- let profileValues = profileRule ^. #allowedValues- typeValues = typeRule ^. #allowedValues,- not (null profileValues),- not (null typeValues),- null (mergeVocabulary profileValues typeValues)- ]- <> [ UnsatisfiableVocabulary (Just (rule ^. #type_)) (parentKey <> "." <> nestedKey) profileValues typeValues- | rule <- rawSpec ^. #types,- let typeRules = compileRules (rule ^. #frontmatter),- (parentKey, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),- Just profileNested <- [profileRule ^. #elementFields],- Just typeNested <- [typeRule ^. #elementFields],- (nestedKey, (profileNestedRule, typeNestedRule)) <- Map.toAscList (Map.intersectionWith (,) profileNested typeNested),- let profileValues = profileNestedRule ^. #allowedValues- typeValues = typeNestedRule ^. #allowedValues,- not (null profileValues),- not (null typeValues),- null (mergeVocabulary profileValues typeValues)- ]-- cardinalityErrors =- [ ConflictingCardinality (Just (rule ^. #type_)) key profileCardinality typeCardinality- | rule <- rawSpec ^. #types,- let typeRules = compileRules (rule ^. #frontmatter),- (key, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),- let profileCardinality = profileRule ^. #cardinality- typeCardinality = typeRule ^. #cardinality,- profileCardinality /= Any,- typeCardinality /= Any,- profileCardinality /= typeCardinality- ]- <> [ ConflictingCardinality (Just (rule ^. #type_)) (parentKey <> "." <> nestedKey) profileCardinality typeCardinality- | rule <- rawSpec ^. #types,- let typeRules = compileRules (rule ^. #frontmatter),- (parentKey, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),- Just profileNested <- [profileRule ^. #elementFields],- Just typeNested <- [typeRule ^. #elementFields],- (nestedKey, (profileNestedRule, typeNestedRule)) <- Map.toAscList (Map.intersectionWith (,) profileNested typeNested),- let profileCardinality = profileNestedRule ^. #cardinality- typeCardinality = typeNestedRule ^. #cardinality,- profileCardinality /= Any,- typeCardinality /= Any,- profileCardinality /= typeCardinality- ]-- nestedCardinalityErrors =- [ ElementFieldsRequireList scope (topLevelFieldPath (fieldRule ^. #field)) Scalar- | (scope, rules) <- (Nothing, rawSpec ^. #frontmatter) : [(Just (rule ^. #type_), rule ^. #frontmatter) | rule <- rawSpec ^. #types],- fieldRule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,- isJust (fieldRule ^. #elementFields),- fieldRule ^. #cardinality == Scalar- ]-- formatParameterErrors =- [ InvalidFormatParameter (topLevelFieldPath (rule ^. #field)) fieldFormat parameter- | rules <- (rawSpec ^. #frontmatter) : map (^. #frontmatter) (rawSpec ^. #types),- rule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,- Just (fieldFormat, parameter) <- [invalidFormatParameter =<< rule ^. #format]- ]- <> [ InvalidFormatParameter (nestedDefinitionPath (rule ^. #field) (nestedRule ^. #field)) fieldFormat parameter- | rules <- (rawSpec ^. #frontmatter) : map (^. #frontmatter) (rawSpec ^. #types),- rule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,- Just nestedRules <- [rule ^. #elementFields],- nestedRule <- nestedRules ^. #required <> nestedRules ^. #recommended <> nestedRules ^. #optional,- Just (fieldFormat, parameter) <- [invalidFormatParameter =<< nestedRule ^. #format]- ]-- conflictingFormatErrors =- [ ConflictingFieldFormat (topLevelFieldPath key) profileFormat typeFormat- | rule <- rawSpec ^. #types,- let typeRules = compileRules (rule ^. #frontmatter),- (key, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),- Just profileFormat <- [profileRule ^. #format],- Just typeFormat <- [typeRule ^. #format],- mergeFieldFormat (Just profileFormat) (Just typeFormat) == Nothing- ]- <> [ ConflictingFieldFormat (nestedDefinitionPath parentKey nestedKey) profileFormat typeFormat- | rule <- rawSpec ^. #types,- let typeRules = compileRules (rule ^. #frontmatter),- (parentKey, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),- Just profileNested <- [profileRule ^. #elementFields],- Just typeNested <- [typeRule ^. #elementFields],- (nestedKey, (profileNestedRule, typeNestedRule)) <- Map.toAscList (Map.intersectionWith (,) profileNested typeNested),- Just profileFormat <- [profileNestedRule ^. #format],- Just typeFormat <- [typeNestedRule ^. #format],- mergeFieldFormat (Just profileFormat) (Just typeFormat) == Nothing- ]-- conditionDefinitionErrors =- conditionErrors Nothing Nothing (rawSpec ^. #frontmatter) baseRules- <> concat- [ let typeRules = compileRules (rule ^. #frontmatter)- mergedRules = mergeRules baseRules typeRules- in conditionErrors (Just (rule ^. #type_)) Nothing (rule ^. #frontmatter) mergedRules- | rule <- rawSpec ^. #types- ]-- conditionErrors scope parent rules effectiveRules =- concatMap (fieldConditionErrors scope parent effectiveRules) (rules ^. #required <> rules ^. #recommended)- <> [ deadCondition scope parent (rawRule ^. #field)- | rawRule <- rules ^. #optional,- isJust (rawRule ^. #when)- ]- <> concat- [ case (rawRule ^. #elementFields, Map.lookup (rawRule ^. #field) effectiveRules >>= (^. #elementFields)) of- (Just nestedRules, Just effectiveNestedRules) ->- concatMap- (fieldConditionErrors scope (Just (rawRule ^. #field)) effectiveNestedRules)- (nestedRules ^. #required <> nestedRules ^. #recommended)- <> [ deadCondition scope (Just (rawRule ^. #field)) (nestedRule ^. #field)- | nestedRule <- nestedRules ^. #optional,- isJust (nestedRule ^. #when)- ]- _ -> []- | rawRule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional- ]-- -- A condition gates presence, and an optional rule has no presence clause to- -- gate, so the pairing is dead in the descriptor rather than a weaker rule.- deadCondition scope parent key =- OptionalFieldWithCondition- scope- (maybe (topLevelFieldPath key) (\parentKey -> nestedDefinitionPath parentKey key) parent)-- fieldConditionErrors scope parent effectiveRules rawRule =- case rawRule ^. #when of- Nothing -> []- Just FieldCondition {field = sourceKey, hasValue = conditionValues} ->- let targetKey = rawRule ^. #field- targetPath = maybe (topLevelFieldPath targetKey) (\parentKey -> nestedDefinitionPath parentKey targetKey) parent- sourcePath = maybe (topLevelFieldPath sourceKey) (\parentKey -> nestedDefinitionPath parentKey sourceKey) parent- normalizedValues = deduplicate conditionValues- sourceErrors =- case Map.lookup sourceKey effectiveRules of- Nothing -> [ConditionFieldNotDeclared scope targetPath sourcePath]- Just sourceRule ->- [ ConditionFieldNotScalar scope targetPath sourcePath (sourceRule ^. #cardinality)- | sourceRule ^. #cardinality /= Scalar- ]- <> [ ConditionFieldOpenVocabulary scope targetPath sourcePath- | null (sourceRule ^. #allowedValues)- ]- <> let unreachable = filter (`notElem` sourceRule ^. #allowedValues) normalizedValues- in [ ConditionFieldHasUnreachableValues scope targetPath sourcePath unreachable (sourceRule ^. #allowedValues)- | not (null (sourceRule ^. #allowedValues)),- not (null unreachable)- ]- in [EmptyConditionValues scope targetPath sourcePath | null normalizedValues]- <> [SelfConditionalField scope targetPath | targetKey == sourceKey]- <> sourceErrors-- referenceDefinitionErrors =- List.nub $- concatMap rawReferenceErrors scopedRules- <> concatMap mergedReferenceErrors (rawSpec ^. #types)- where- declaredPrefixes = mapMaybe (^. #idPrefix) (rawSpec ^. #types)- scopedRules =- (Nothing, rawSpec ^. #frontmatter)- : [(Just (rule ^. #type_), rule ^. #frontmatter) | rule <- rawSpec ^. #types]-- rawReferenceErrors (scope, rules) =- concatMap (fieldReferenceErrors scope) (rules ^. #required <> rules ^. #recommended <> rules ^. #optional)-- fieldReferenceErrors scope rule =- case rule ^. #reference of- Nothing -> []- Just policy ->- let path = topLevelFieldPath (rule ^. #field)- prefix = policy ^. #localPrefix- schemes = deduplicateSchemes (policy ^. #externalUriSchemes)- in [InvalidReferencePrefix scope path prefix | not (validDocumentHandlePrefix prefix)]- <> [ReferencePrefixNotDeclared scope path prefix | prefix `notElem` declaredPrefixes]- <> [ReferenceRequiresIdField scope path | isNothing (rawSpec ^. #idField)]- <> [InvalidExternalReferenceScheme scope path scheme | scheme <- schemes, not (validUriScheme scheme)]- <> [ReferenceWithFormat scope path fieldFormat | Just fieldFormat <- [rule ^. #format]]-- mergedReferenceErrors typeRule =- [ ConflictingReferencePrefix (typeRule ^. #type_) (topLevelFieldPath key) (profilePolicy ^. #localPrefix) (typePolicy ^. #localPrefix)- | let typeFields = compileRules (typeRule ^. #frontmatter),- (key, (profileRule, typeFieldRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeFields),- Just profilePolicy <- [profileRule ^. #reference],- Just typePolicy <- [typeFieldRule ^. #reference],- profilePolicy ^. #localPrefix /= typePolicy ^. #localPrefix- ]- <> [ ReferenceWithFormat (Just (typeRule ^. #type_)) (topLevelFieldPath key) fieldFormat- | let typeFields = compileRules (typeRule ^. #frontmatter),- (key, (profileRule, typeFieldRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeFields),- (referenceRule, formatRule) <- [(profileRule, typeFieldRule), (typeFieldRule, profileRule)],- isJust (referenceRule ^. #reference),- Just fieldFormat <- [formatRule ^. #format]- ]-- renderFieldPathKey (FieldPath (firstSegment :| remainingSegments)) =- Text.intercalate "." (map renderSegment (firstSegment : remainingSegments))- renderSegment (FieldName name) = name- renderSegment (ArrayIndex elementIndex) = Text.pack (show elementIndex)--compileRules :: FrontmatterRules -> Map Text EffectiveFieldRule-compileRules FrontmatterRules {required, recommended, optional} =- Map.fromList- ( [ (rule ^. #field, compileFieldRule RequiredField rule)- | rule <- required- ]- <> [ (rule ^. #field, compileFieldRule RecommendedField rule)- | rule <- recommended- ]- <> [ (rule ^. #field, compileOptionalFieldRule rule)- | rule <- optional- ]- )---- | Compile a rule the profile documents but never demands. It carries no--- presence clause, so 'applicablePresenceClause' can never find one to report in--- either validation mode, while every value check still runs from the--- present-value branch. This is also the value constraints a required or--- recommended rule is built from.-compileOptionalFieldRule :: FieldRule -> EffectiveFieldRule-compileOptionalFieldRule rule =- EffectiveFieldRule- { presenceClauses = [],- description = rule ^. #description,- allowedValues = deduplicate (rule ^. #allowedValues),- cardinality =- case (rule ^. #elementFields, rule ^. #cardinality) of- (Just _, Any) -> List- (_, cardinality) -> cardinality,- format = rule ^. #format,- elementFields = compileNestedRules <$> rule ^. #elementFields,- reference = compileReferenceRule <$> rule ^. #reference- }--compileFieldRule :: FieldRequirement -> FieldRule -> EffectiveFieldRule-compileFieldRule requirement rule =- (compileOptionalFieldRule rule)- { presenceClauses = [PresenceClause requirement (compileCondition <$> rule ^. #when)]- }--compileNestedRules :: NestedRules -> Map Text EffectiveFieldRule-compileNestedRules NestedRules {required, recommended, optional} =- Map.fromList- ( [ (rule ^. #field, compileNestedFieldRule RequiredField rule)- | rule <- required- ]- <> [ (rule ^. #field, compileNestedFieldRule RecommendedField rule)- | rule <- recommended- ]- <> [ (rule ^. #field, compileOptionalNestedFieldRule rule)- | rule <- optional- ]- )---- | The nested counterpart of 'compileOptionalFieldRule'.-compileOptionalNestedFieldRule :: NestedFieldRule -> EffectiveFieldRule-compileOptionalNestedFieldRule rule =- EffectiveFieldRule- { presenceClauses = [],- description = rule ^. #description,- allowedValues = deduplicate (rule ^. #allowedValues),- cardinality = rule ^. #cardinality,- format = rule ^. #format,- elementFields = Nothing,- reference = Nothing- }--compileNestedFieldRule :: FieldRequirement -> NestedFieldRule -> EffectiveFieldRule-compileNestedFieldRule requirement rule =- (compileOptionalNestedFieldRule rule)- { presenceClauses = [PresenceClause requirement (compileCondition <$> rule ^. #when)]- }--mergeRules :: Map Text EffectiveFieldRule -> Map Text EffectiveFieldRule -> Map Text EffectiveFieldRule-mergeRules = Map.unionWith mergeEffectiveFieldRule--mergeEffectiveFieldRule :: EffectiveFieldRule -> EffectiveFieldRule -> EffectiveFieldRule-mergeEffectiveFieldRule profileRule typeRule =- EffectiveFieldRule- { presenceClauses = profileRule ^. #presenceClauses <> typeRule ^. #presenceClauses,- description = typeRule ^. #description <|> profileRule ^. #description,- allowedValues = mergeVocabulary (profileRule ^. #allowedValues) (typeRule ^. #allowedValues),- cardinality = mergeCardinality (profileRule ^. #cardinality) (typeRule ^. #cardinality),- format = fromMaybe (profileRule ^. #format) (mergeFieldFormat (profileRule ^. #format) (typeRule ^. #format)),- elementFields = mergeElementFields (profileRule ^. #elementFields) (typeRule ^. #elementFields),- reference = fromMaybe (profileRule ^. #reference) (mergeReferenceRule (profileRule ^. #reference) (typeRule ^. #reference))- }--compileCondition :: FieldCondition -> CompiledCondition-compileCondition rawCondition =- CompiledCondition- { field = rawCondition ^. #field,- hasValue = deduplicate (rawCondition ^. #hasValue)- }--compileReferenceRule :: HandleReferenceRule -> HandleReferenceRule-compileReferenceRule policy =- HandleReferenceRule- { localPrefix = policy ^. #localPrefix,- externalUriSchemes = map Text.toCaseFold (deduplicateSchemes (policy ^. #externalUriSchemes)),- allowSelf = policy ^. #allowSelf- }--mergeElementFields :: Maybe (Map Text EffectiveFieldRule) -> Maybe (Map Text EffectiveFieldRule) -> Maybe (Map Text EffectiveFieldRule)-mergeElementFields Nothing typeRules = typeRules-mergeElementFields profileRules Nothing = profileRules-mergeElementFields (Just profileRules) (Just typeRules) = Just (Map.unionWith mergeEffectiveFieldRule profileRules typeRules)--mergeReferenceRule :: Maybe HandleReferenceRule -> Maybe HandleReferenceRule -> Maybe (Maybe HandleReferenceRule)-mergeReferenceRule Nothing typePolicy = Just typePolicy-mergeReferenceRule profilePolicy Nothing = Just profilePolicy-mergeReferenceRule (Just profilePolicy) (Just typePolicy)- | profilePolicy ^. #localPrefix == typePolicy ^. #localPrefix =- Just . Just $- HandleReferenceRule- { localPrefix = profilePolicy ^. #localPrefix,- externalUriSchemes =- filter- (`Set.member` Set.fromList (typePolicy ^. #externalUriSchemes))- (profilePolicy ^. #externalUriSchemes),- allowSelf = profilePolicy ^. #allowSelf && typePolicy ^. #allowSelf- }- | otherwise = Nothing--mergeFieldFormat :: Maybe FieldFormat -> Maybe FieldFormat -> Maybe (Maybe FieldFormat)-mergeFieldFormat Nothing typeFormat = Just typeFormat-mergeFieldFormat profileFormat Nothing = Just profileFormat-mergeFieldFormat (Just Uri) (Just typeFormat@(UriWithScheme _)) = Just (Just typeFormat)-mergeFieldFormat (Just profileFormat@(UriWithScheme _)) (Just Uri) = Just (Just profileFormat)-mergeFieldFormat profileFormat typeFormat- | profileFormat == typeFormat = Just profileFormat- | otherwise = Nothing--invalidFormatParameter :: FieldFormat -> Maybe (FieldFormat, Text)-invalidFormatParameter fieldFormat =- case fieldFormat of- UriWithScheme scheme- | not (validUriScheme scheme) -> Just (fieldFormat, scheme)- DocumentHandle prefix- | not (validDocumentHandlePrefix prefix) -> Just (fieldFormat, prefix)- _ -> Nothing--validUriScheme :: Text -> Bool-validUriScheme scheme =- case Text.uncons scheme of- Just (firstCharacter, rest) -> isAsciiLetter firstCharacter && Text.all validRest rest- Nothing -> False- where- validRest character =- isAsciiLetter character- || ('0' <= character && character <= '9')- || character `elem` ['+', '.', '-']--validDocumentHandlePrefix :: Text -> Bool-validDocumentHandlePrefix prefix =- case parseDocumentId (prefix <> "-1") of- Just documentId -> documentId ^. #prefix == prefix- Nothing -> False--isAsciiLetter :: Char -> Bool-isAsciiLetter character = isAsciiLower character || isAsciiUpper character--mergeCardinality :: Cardinality -> Cardinality -> Cardinality-mergeCardinality Any typeCardinality = typeCardinality-mergeCardinality profileCardinality Any = profileCardinality-mergeCardinality profileCardinality _typeCardinality = profileCardinality--mergeVocabulary :: [Text] -> [Text] -> [Text]-mergeVocabulary [] typeValues = typeValues-mergeVocabulary profileValues [] = profileValues-mergeVocabulary profileValues typeValues =- filter (`Set.member` Set.fromList typeValues) profileValues--deduplicate :: [Text] -> [Text]-deduplicate = go Set.empty- where- go _ [] = []- go seen (value : rest)- | value `Set.member` seen = go seen rest- | otherwise = value : go (Set.insert value seen) rest--deduplicateSchemes :: [Text] -> [Text]-deduplicateSchemes = go Set.empty- where- go _ [] = []- go seen (scheme : rest)- | normalized `Set.member` seen = go seen rest- | otherwise = scheme : go (Set.insert normalized seen) rest- where- normalized = Text.toCaseFold scheme--effectiveRulesForType :: CompiledProfile -> Text -> Map Text EffectiveFieldRule-effectiveRulesForType compiled ctype =- Map.findWithDefault (compiled ^. #baseRules) ctype (compiled ^. #rulesByType)--profileFieldDescriptionForType :: CompiledProfile -> Text -> Text -> Maybe Text-profileFieldDescriptionForType compiled ctype key =- Map.lookup key (effectiveRulesForType compiled ctype) >>= (^. #description)---- | A parsed document handle: an ASCII-letter-led alphanumeric prefix and a--- positive number, rendered as @PREFIX-N@.-data DocumentId = DocumentId- { prefix :: !Text,- number :: !Natural- }- deriving stock (Generic, Eq, Ord, Show)---- | Parse a strict document handle. The prefix contains one or more ASCII--- letters or digits and begins with a letter. It is followed by exactly one--- hyphen and a positive decimal number with no leading zero. Thus @ADR-7@--- parses, while @ADR-007@, @ADR-0@, @ADR-@, @-7@, @adr 7@, and--- @ADR-7-extra@ do not.-parseDocumentId :: Text -> Maybe DocumentId-parseDocumentId raw =- case Text.splitOn "-" raw of- [prefixText, numberText]- | validPrefix prefixText,- validNumberText numberText ->- case Text.Read.decimal numberText of- Right (parsedNumber, remainder)- | Text.null remainder,- parsedNumber > 0 ->- Just (DocumentId prefixText parsedNumber)- _ -> Nothing- _ -> Nothing- where- validPrefix value =- case Text.uncons value of- Just (firstCharacter, rest) ->- isAsciiLetter firstCharacter && Text.all isAsciiAlphaNumeric rest- Nothing -> False- validNumberText value =- case Text.uncons value of- Just (firstCharacter, rest) ->- firstCharacter >= '1'- && firstCharacter <= '9'- && Text.all isAsciiDigit rest- Nothing -> False- isAsciiDigit character =- character >= '0' && character <= '9'- isAsciiAlphaNumeric character =- isAsciiLetter character || isAsciiDigit character---- | Render a document handle as @PREFIX-N@.-renderDocumentId :: DocumentId -> Text-renderDocumentId DocumentId {prefix, number} =- prefix <> "-" <> Text.pack (show number)---- | Every well-formed handle under the profile's ID field, paired with the--- concept carrying it and sorted by prefix, number, then concept ID. Concepts--- without a well-formed handle are omitted. A profile with no ID field yields--- an empty list.-documentIdsInBundle :: ProfileSpec -> [Concept] -> [(DocumentId, ConceptId)]-documentIdsInBundle spec concepts =- case spec ^. #idField of- Nothing -> []- Just fieldName ->- List.sortOn- (\(documentId, cid) -> (documentId, renderConceptId cid))- [ (documentId, conceptIdOf concept)- | concept <- concepts,- Just (String rawDocumentId) <- [frontmatterLookup fieldName (conceptFrontmatter concept)],- Just documentId <- [parseDocumentId rawDocumentId]- ]---- | Allocate one more than the highest document-ID number already used for the--- given prefix, or number 1 when the prefix is unused. Gaps are deliberately--- not filled: reusing a retired number could make an old reference silently--- point at a different document.-nextDocumentId :: ProfileSpec -> [Concept] -> Text -> DocumentId-nextDocumentId spec concepts requestedPrefix =- DocumentId- { prefix = requestedPrefix,- number = highestNumber + 1- }- where- highestNumber =- List.foldl'- max- 0- [ documentId ^. #number- | (documentId, _) <- documentIdsInBundle spec concepts,- documentId ^. #prefix == requestedPrefix- ]---- | One structural segment of a frontmatter path. EP-2 produces top-level--- 'FieldName' paths; later bounded nested validation can append field names and--- array indexes without encoding paths as ad hoc text.-data FieldPathSegment- = FieldName Text- | ArrayIndex Int- deriving stock (Generic, Eq, Ord, Show)--newtype FieldPath = FieldPath- { segments :: NonEmpty FieldPathSegment- }- deriving stock (Generic, Eq, Ord, Show)--topLevelFieldPath :: Text -> FieldPath-topLevelFieldPath key = FieldPath (FieldName key :| [])--nestedDefinitionPath :: Text -> Text -> FieldPath-nestedDefinitionPath parent child =- FieldPath (FieldName parent :| [FieldName child])--nestedValuePath :: Text -> Int -> Text -> FieldPath-nestedValuePath parent elementIndex child =- FieldPath (FieldName parent :| [ArrayIndex elementIndex, FieldName child])--nestedElementPath :: Text -> Int -> FieldPath-nestedElementPath parent elementIndex =- FieldPath (FieldName parent :| [ArrayIndex elementIndex])---- | A single deviation from a profile. Advisory by default at the CLI layer.-data ProfileViolation- = -- | concept's @type@ is not listed in the profile and unknown types are disallowed- TypeNotInProfile ConceptId Text- | -- | a required frontmatter key is missing or empty (concept, key, activating condition)- MissingProfileField ConceptId Text (Maybe FieldCondition)- | -- | a recommended frontmatter key is missing under strict authoring- MissingRecommendedProfileField ConceptId Text (Maybe FieldCondition)- | -- | a required field is missing inside one list element record- MissingNestedProfileField ConceptId FieldPath (Maybe FieldCondition)- | -- | a recommended nested field is missing under strict authoring- MissingRecommendedNestedProfileField ConceptId FieldPath (Maybe FieldCondition)- | -- | a present value is outside the effective textual vocabulary- ValueNotInVocabulary ConceptId FieldPath [Text] Value- | -- | a present value has the wrong scalar/list shape- CardinalityMismatch ConceptId FieldPath Cardinality Value- | -- | a present value does not satisfy its named textual format- ValueFormatMismatch ConceptId FieldPath FieldFormat Value- | -- | a local reference has the right prefix but no valid owner in this bundle- DanglingHandleReference ConceptId FieldPath Text- | -- | a parsed local handle uses a prefix other than the declared target prefix- ReferenceHandlePrefixMismatch ConceptId FieldPath Text Text- | -- | a reference value is neither textual local handle nor valid absolute URI- MalformedDocumentReference ConceptId FieldPath Value- | -- | an absolute external URI uses a scheme the profile did not permit- ExternalReferenceSchemeNotAllowed ConceptId FieldPath Text [Text]- | -- | a local handle resolves to the concept carrying the reference- SelfDocumentReference ConceptId FieldPath Text- | -- | a closed profile does not declare this top-level field- FieldNotInProfile ConceptId Text- | -- | a declared list element is not an object record- NestedElementNotRecord ConceptId FieldPath Value- | -- | concept's file path does not match the type rule's pattern (concept, type, pattern)- PathPatternMismatch ConceptId Text Text- | -- | type rule requires a resource scheme but resource is absent (concept, type, scheme)- MissingResource ConceptId Text Text- | -- | resource present but its scheme is wrong (concept, expected scheme, actual resource)- ResourceSchemeMismatch ConceptId Text Text- | -- | required @# Schema@ section is absent (concept, type)- MissingSchemaSection ConceptId Text- | -- | @# Schema@ table columns do not match (concept, type, expected, actual)- SchemaColumnsMismatch ConceptId Text [Text] [Text]- | -- | type rule declares an @idPrefix@ but the concept has no handle (concept, type, prefix)- MissingDocumentId ConceptId Text Text- | -- | handle present but malformed for the declared prefix (concept, prefix, actual value)- MalformedDocumentId ConceptId Text Text- | -- | the same handle appears on more than one concept (handle, concept, other concept)- DuplicateDocumentId Text ConceptId ConceptId- deriving stock (Generic, Eq, Show)---- | Check every concept against a compiled profile, returning all deviations.--- Profile-wide rules apply even to unknown types; a matching type rule adds its--- frontmatter rules and the existing type-specific structural checks.-validateProfile :: ValidationProfile -> CompiledProfile -> [Concept] -> [ProfileViolation]-validateProfile validationProfile compiled concepts =- concatMap checkConcept sortedConcepts <> checkDuplicateDocumentIds spec sortedConcepts- where- spec = compiledProfileSpec compiled- sortedConcepts = List.sortOn (renderConceptId . conceptIdOf) concepts- rulesByType = [(rule ^. #type_, rule) | rule <- spec ^. #types]- validDocumentIdIndex = buildValidDocumentIdIndex spec rulesByType sortedConcepts-- checkConcept concept =- let cid = conceptIdOf concept- ctype = conceptType concept- fieldViolations = checkFields cid ctype concept <> checkUnknownFields cid ctype concept- in case lookup ctype rulesByType of- Nothing ->- [TypeNotInProfile cid ctype | not (spec ^. #allowUnknownTypes)] <> fieldViolations- Just rule ->- fieldViolations- <> checkPath cid ctype rule- <> checkResource cid ctype rule concept- <> checkSchema cid ctype rule concept- <> checkDocumentId spec cid ctype rule concept-- checkFields cid ctype concept =- concatMap checkField (Map.toAscList (effectiveRulesForType compiled ctype))- where- checkField (key, rule) =- case evaluateFieldValue (rule ^. #cardinality) (frontmatterLookup key (conceptFrontmatter concept)) of- FieldAbsent actual ->- presenceViolations key rule- <> maybe [] (vocabularyViolations key rule) actual- <> maybe [] (formatViolations key rule) actual- <> maybe [] (referenceViolations key rule) actual- FieldPresent actual ->- vocabularyViolations key rule actual- <> formatViolations key rule actual- <> referenceViolations key rule actual- <> nestedViolations key rule actual- FieldWrongShape actual -> [CardinalityMismatch cid (topLevelFieldPath key) (rule ^. #cardinality) actual]-- presenceViolations key rule =- case applicablePresenceClause validationProfile (\sourceKey -> frontmatterLookup sourceKey (conceptFrontmatter concept)) rule of- Nothing -> []- Just clause ->- [ case clause ^. #requirement of- RequiredField -> MissingProfileField cid key (conditionForViolation <$> clause ^. #condition)- RecommendedField -> MissingRecommendedProfileField cid key (conditionForViolation <$> clause ^. #condition)- ]- vocabularyViolations key rule actual =- [ ValueNotInVocabulary cid (topLevelFieldPath key) (rule ^. #allowedValues) actual- | not (null (rule ^. #allowedValues)),- not (valueMatchesVocabulary (rule ^. #allowedValues) actual)- ]- formatViolations key rule actual =- [ ValueFormatMismatch cid (topLevelFieldPath key) fieldFormat actual- | Just fieldFormat <- [rule ^. #format],- not (valueMatchesFormat fieldFormat actual)- ]- referenceViolations key rule actual =- case rule ^. #reference of- Nothing -> []- Just policy -> validateReferenceValue validDocumentIdIndex cid (topLevelFieldPath key) policy actual-- nestedViolations parentKey parentRule = \case- Array elementValues- | Just nestedRules <- parentRule ^. #elementFields ->- concat- [ case elementValue of- Object objectFields ->- concatMap- (checkNestedField parentKey elementIndex objectFields)- (Map.toAscList nestedRules)- _ -> [NestedElementNotRecord cid (nestedElementPath parentKey elementIndex) elementValue]- | (elementIndex, elementValue) <- zip [0 ..] (Vector.toList elementValues)- ]- _ -> []-- checkNestedField parentKey elementIndex objectFields (key, rule) =- let path = nestedValuePath parentKey elementIndex key- actualValue = Aeson.KeyMap.lookup (Aeson.Key.fromText key) objectFields- in case evaluateFieldValue (rule ^. #cardinality) actualValue of- FieldAbsent actual ->- nestedPresenceViolations objectFields path rule- <> maybe [] (nestedVocabularyViolations path rule) actual- <> maybe [] (nestedFormatViolations path rule) actual- FieldPresent actual ->- nestedVocabularyViolations path rule actual- <> nestedFormatViolations path rule actual- FieldWrongShape actual -> [CardinalityMismatch cid path (rule ^. #cardinality) actual]-- nestedPresenceViolations objectFields path rule =- case applicablePresenceClause validationProfile lookupSibling rule of- Nothing -> []- Just clause ->- [ case clause ^. #requirement of- RequiredField -> MissingNestedProfileField cid path (conditionForViolation <$> clause ^. #condition)- RecommendedField -> MissingRecommendedNestedProfileField cid path (conditionForViolation <$> clause ^. #condition)- ]- where- lookupSibling sourceKey = Aeson.KeyMap.lookup (Aeson.Key.fromText sourceKey) objectFields- nestedVocabularyViolations path rule actual =- [ ValueNotInVocabulary cid path (rule ^. #allowedValues) actual- | not (null (rule ^. #allowedValues)),- not (valueMatchesVocabulary (rule ^. #allowedValues) actual)- ]- nestedFormatViolations path rule actual =- [ ValueFormatMismatch cid path fieldFormat actual- | Just fieldFormat <- [rule ^. #format],- not (valueMatchesFormat fieldFormat actual)- ]-- checkUnknownFields cid ctype concept- | spec ^. #allowUnknownFields = []- | otherwise =- [ FieldNotInProfile cid key- | key <- frontmatterKeys (conceptFrontmatter concept),- key `Set.notMember` allowedFields ctype- ]-- allowedFields ctype =- coreFrontmatterFields- <> Map.keysSet (effectiveRulesForType compiled ctype)- <> maybe Set.empty Set.singleton (spec ^. #idField)---- | Index only handles that are valid owners under the compiled profile's raw--- type declarations: the concept has a matching type rule, that rule declares--- the parsed handle's exact prefix, and the profile declares the owning field.--- Multiple owners are retained so a duplicate target still counts as existing;--- the existing bundle-wide duplicate diagnostic remains authoritative.-buildValidDocumentIdIndex :: ProfileSpec -> [(Text, TypeRule)] -> [Concept] -> Map DocumentId [ConceptId]-buildValidDocumentIdIndex spec rulesByType concepts =- case spec ^. #idField of- Nothing -> Map.empty- Just fieldName ->- Map.fromListWith- (flip (<>))- [ (documentId, [conceptIdOf concept])- | concept <- concepts,- Just typeRule <- [lookup (conceptType concept) rulesByType],- Just expectedPrefix <- [typeRule ^. #idPrefix],- Just (String rawDocumentId) <- [frontmatterLookup fieldName (conceptFrontmatter concept)],- Just documentId <- [parseDocumentId rawDocumentId],- documentId ^. #prefix == expectedPrefix- ]--validateReferenceValue :: Map DocumentId [ConceptId] -> ConceptId -> FieldPath -> HandleReferenceRule -> Value -> [ProfileViolation]-validateReferenceValue validOwners sourceConcept path policy = \case- String rawReference -> validateReferenceText validOwners sourceConcept path policy rawReference- Array values ->- concat- [ case value of- String rawReference -> validateReferenceText validOwners sourceConcept (appendArrayIndex path elementIndex) policy rawReference- _ -> [MalformedDocumentReference sourceConcept (appendArrayIndex path elementIndex) value]- | (elementIndex, value) <- zip [0 ..] (Vector.toList values)- ]- actual -> [MalformedDocumentReference sourceConcept path actual]--validateReferenceText :: Map DocumentId [ConceptId] -> ConceptId -> FieldPath -> HandleReferenceRule -> Text -> [ProfileViolation]-validateReferenceText validOwners sourceConcept path policy rawReference =- case parseDocumentId rawReference of- Just documentId- | documentId ^. #prefix /= policy ^. #localPrefix ->- [ReferenceHandlePrefixMismatch sourceConcept path rawReference (policy ^. #localPrefix)]- | otherwise ->- case Map.lookup documentId validOwners of- Nothing -> [DanglingHandleReference sourceConcept path rawReference]- Just owners- | not (policy ^. #allowSelf),- sourceConcept `elem` owners ->- [SelfDocumentReference sourceConcept path rawReference]- | otherwise -> []- Nothing ->- case parseURI (Text.unpack rawReference) of- Just parsed- | not (Text.null normalizedScheme), normalizedScheme `elem` policy ^. #externalUriSchemes -> []- | not (Text.null normalizedScheme) ->- [ ExternalReferenceSchemeNotAllowed- sourceConcept- path- normalizedScheme- (policy ^. #externalUriSchemes)- ]- | otherwise -> [MalformedDocumentReference sourceConcept path (String rawReference)]- where- normalizedScheme = Text.toCaseFold (Text.dropWhileEnd (== ':') (Text.pack (uriScheme parsed)))- Nothing -> [MalformedDocumentReference sourceConcept path (String rawReference)]--appendArrayIndex :: FieldPath -> Int -> FieldPath-appendArrayIndex (FieldPath pathSegments) elementIndex =- FieldPath (pathSegments <> (ArrayIndex elementIndex :| []))--applicablePresenceClause :: ValidationProfile -> (Text -> Maybe Value) -> EffectiveFieldRule -> Maybe PresenceClause-applicablePresenceClause validationProfile lookupValue rule =- List.find applies requiredClauses- <|> if validationProfile == StrictAuthoring then List.find applies recommendedClauses else Nothing- where- requiredClauses = filter ((== RequiredField) . (^. #requirement)) (rule ^. #presenceClauses)- recommendedClauses = filter ((== RecommendedField) . (^. #requirement)) (rule ^. #presenceClauses)- applies clause = maybe True conditionApplies (clause ^. #condition)- conditionApplies condition =- case lookupValue (condition ^. #field) of- Just (String actual) -> actual `elem` condition ^. #hasValue- _ -> False--conditionForViolation :: CompiledCondition -> FieldCondition-conditionForViolation condition =- FieldCondition- { field = condition ^. #field,- hasValue = condition ^. #hasValue- }--valueMatchesVocabulary :: [Text] -> Value -> Bool-valueMatchesVocabulary allowed = \case- String value -> value `elem` allowed- Array values -> all elementMatches (Vector.toList values)- _ -> False- where- elementMatches (String value) = value `elem` allowed- elementMatches _ = False--valueMatchesFormat :: FieldFormat -> Value -> Bool-valueMatchesFormat fieldFormat = \case- String value -> textMatchesFormat fieldFormat value- Array values -> all elementMatches (Vector.toList values)- _ -> False- where- elementMatches (String value) = textMatchesFormat fieldFormat value- elementMatches _ = False--textMatchesFormat :: FieldFormat -> Text -> Bool-textMatchesFormat fieldFormat value =- case fieldFormat of- Rfc3339Utc ->- hasExtendedUtcShape value- && isJust (iso8601ParseM (Text.unpack value) :: Maybe UTCTime)- Date ->- hasExtendedDateShape value- && isJust (iso8601ParseM (Text.unpack value) :: Maybe Day)- Uri -> isJust (parseURI (Text.unpack value))- UriWithScheme expectedScheme ->- case parseURI (Text.unpack value) of- Just parsed ->- Text.toCaseFold (Text.dropWhileEnd (== ':') (Text.pack (uriScheme parsed)))- == Text.toCaseFold expectedScheme- Nothing -> False- DocumentHandle expectedPrefix ->- case parseDocumentId value of- Just documentId -> documentId ^. #prefix == expectedPrefix- Nothing -> False--hasExtendedDateShape :: Text -> Bool-hasExtendedDateShape value =- Text.length value == 10- && Text.index value 4 == '-'- && Text.index value 7 == '-'--hasExtendedUtcShape :: Text -> Bool-hasExtendedUtcShape value =- Text.length value >= 20- && Text.index value 4 == '-'- && Text.index value 7 == '-'- && Text.index value 10 == 'T'- && Text.index value 13 == ':'- && Text.index value 16 == ':'- && Text.last value == 'Z'--data FieldValueEvaluation- = FieldAbsent (Maybe Value)- | FieldPresent Value- | FieldWrongShape Value--evaluateFieldValue :: Cardinality -> Maybe Value -> FieldValueEvaluation-evaluateFieldValue _ Nothing = FieldAbsent Nothing-evaluateFieldValue Any (Just actual)- | legacyValueIsPresent actual = FieldPresent actual- | otherwise = FieldAbsent (Just actual)-evaluateFieldValue Scalar (Just actual) =- case actual of- String value- | Text.null (Text.strip value) -> FieldAbsent (Just actual)- | otherwise -> FieldPresent actual- Number _ -> FieldPresent actual- Bool _ -> FieldPresent actual- _ -> FieldWrongShape actual-evaluateFieldValue List (Just actual) =- case actual of- Array values- | null values -> FieldAbsent (Just actual)- | otherwise -> FieldPresent actual- _ -> FieldWrongShape actual--legacyValueIsPresent :: Value -> Bool-legacyValueIsPresent = \case- String value -> not (Text.null (Text.strip value))- Array values -> not (null values)- _ -> False---- | Check a profile-declared document ID for one concept.-checkDocumentId :: ProfileSpec -> ConceptId -> Text -> TypeRule -> Concept -> [ProfileViolation]-checkDocumentId spec cid ctype rule concept =- case (spec ^. #idField, rule ^. #idPrefix) of- (Just fieldName, Just expectedPrefix) ->- case frontmatterLookup fieldName (conceptFrontmatter concept) of- Just (String value)- | not (Text.null (Text.strip value)) ->- case parseDocumentId value of- Just documentId- | documentId ^. #prefix == expectedPrefix -> []- _ -> [MalformedDocumentId cid expectedPrefix value]- _ -> [MissingDocumentId cid ctype expectedPrefix]- _ -> []---- | Check every non-empty value under the profile's ID field for bundle-wide--- uniqueness. Concept IDs are sorted before grouping so output is deterministic.-checkDuplicateDocumentIds :: ProfileSpec -> [Concept] -> [ProfileViolation]-checkDuplicateDocumentIds spec concepts =- case spec ^. #idField of- Nothing -> []- Just fieldName ->- concatMap duplicateViolations (groupedHandles fieldName)- where- groupedHandles fieldName =- List.groupBy- (\(leftHandle, _) (rightHandle, _) -> leftHandle == rightHandle)- (handles fieldName)- handles fieldName =- List.sortOn- (\(handle, cid) -> (handle, renderConceptId cid))- [ (handle, conceptIdOf concept)- | concept <- concepts,- Just (String handle) <- [frontmatterLookup fieldName (conceptFrontmatter concept)],- not (Text.null (Text.strip handle))- ]- duplicateViolations ((handle, firstConcept) : duplicates) =- [ DuplicateDocumentId handle firstConcept duplicateConcept- | (_, duplicateConcept) <- duplicates- ]- duplicateViolations [] = []---- | Project a concept's frontmatter (the document's @frontmatter@ field).-conceptFrontmatter :: Concept -> Frontmatter-conceptFrontmatter concept = conceptDocument concept ^. #frontmatter---- | A type rule's @pathPattern@, when present, constrains where the concept's--- file may live.-checkPath :: ConceptId -> Text -> TypeRule -> [ProfileViolation]-checkPath cid ctype rule =- case rule ^. #pathPattern of- Nothing -> []- Just patternText- | matchPathPattern patternText cid -> []- | otherwise -> [PathPatternMismatch cid ctype patternText]---- | Match a concept ID against a segment-glob pattern. @*@ matches exactly one--- segment; a single trailing @**@ matches one or more remaining segments; every--- other segment matches literally. Both segment lists must be consumed exactly,--- except for the trailing @**@ case.-matchPathPattern :: Text -> ConceptId -> Bool-matchPathPattern patternText cid =- go (Text.splitOn "/" patternText) (Text.splitOn "/" (renderConceptId cid))- where- go [] [] = True- go ["**"] (_ : _) = True- go ("*" : ps) (_ : ss) = go ps ss- go (p : ps) (s : ss) = p == s && go ps ss- go _ _ = False---- | A type rule's @resourceScheme@, when present, requires a @resource:@ value--- whose scheme matches.-checkResource :: ConceptId -> Text -> TypeRule -> Concept -> [ProfileViolation]-checkResource cid ctype rule concept =- case rule ^. #resourceScheme of- Nothing -> []- Just scheme ->- case conceptResource concept of- Nothing -> [MissingResource cid ctype scheme]- Just value- | (scheme <> "://") `Text.isPrefixOf` value -> []- | otherwise -> [ResourceSchemeMismatch cid scheme value]---- | A type rule's @# Schema@ contract: when @requireSchemaSection@ is set, the--- body must contain a @# Schema@ section whose table header begins with the--- required @schemaColumns@ (case-insensitive, trimmed, compared as a prefix so a--- team may add trailing columns without tripping the check).-checkSchema :: ConceptId -> Text -> TypeRule -> Concept -> [ProfileViolation]-checkSchema cid ctype rule concept- | not (rule ^. #requireSchemaSection) = []- | otherwise =- case schemaSectionColumns (conceptDocument concept ^. #body) of- Nothing -> [MissingSchemaSection cid ctype]- Just actual ->- let expected = rule ^. #schemaColumns- norm = map (Text.toLower . Text.strip)- in [ SchemaColumnsMismatch cid ctype expected actual- | not (norm expected `List.isPrefixOf` norm actual)- ]---- | The header-row columns of the first GitHub-flavored table that follows the--- first top-level @# Schema@ heading, or 'Nothing' if there is no Schema heading--- or no following table. Columns are trimmed.-schemaSectionColumns :: Text -> Maybe [Text]-schemaSectionColumns markdown =- let CMarkGFM.Node _ _ topLevel = CMarkGFM.commonmarkToNode [] [CMarkGFM.extTable] markdown+ PathReferenceRule (..),+ FieldRule (..),+ NestedRules (..),+ NestedFieldRule (..),+ Cardinality (..),+ FieldFormat (..),+ TypeRule (..),+ FieldPath (..),+ FieldPathSegment (..),+ loadProfileFile,+ decodeProfileExpr,+ profileFieldDescription,+ CompiledProfile,+ ProfileDefinitionError (..),+ compileProfile,+ compiledProfileSpec,+ compiledProfileRequiredBundleVersion,+ profileFieldDescriptionForType,++ -- * Compiled rule inspection++ -- | A read-only window onto what a compiled profile actually demands. The+ -- rule types are abstract on purpose: read them through the accessors below+ -- so that later profile features can extend the compiled encoding without+ -- breaking consumers.+ --+ -- Two encodings are easy to misread and are worth stating up front. An+ -- __empty presence-clause list means the key is optional__, not that it is+ -- unconstrained; and an __empty allowed-value list means unconstrained__,+ -- not that no value is permitted.+ EffectiveFieldRule,+ PresenceClause,+ FieldRequirement (..),+ fieldRulePresenceClauses,+ presenceClauseRequirement,+ presenceClauseCondition,+ fieldRuleDescription,+ fieldRuleAllowedValues,+ fieldRuleCardinality,+ fieldRuleFormat,+ fieldRuleReference,+ fieldRulePath,+ fieldRuleElementFields,+ fieldRuleObjectFields,+ compiledProfileTypeNames,+ compiledProfileBaseRules,+ compiledProfileRulesForType,+ renderCardinalityName,+ renderFieldFormatName,++ -- * Validation+ DocumentId (..),+ parseDocumentId,+ renderDocumentId,+ documentIdsInBundle,+ nextDocumentId,+ ProfileViolation (..),+ validateProfile,+ validateProfileWith,+ validateProfileVersion,++ -- * Body inspection+ schemaSectionColumns,+ )+where++import CMarkGFM qualified+import Control.Exception (SomeException, catch)+import Data.Aeson (ToJSON (..), object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Aeson.Key+import Data.Aeson.KeyMap qualified as Aeson.KeyMap+import Data.Char (isAsciiLower, isAsciiUpper)+import Data.List qualified as List+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, mapMaybe)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Read qualified as Text.Read+import Data.Time.Calendar (Day)+import Data.Time.Clock (UTCTime)+import Data.Time.Format.ISO8601 (iso8601ParseM)+import Data.Vector qualified as Vector+import Data.Void (Void)+import Dhall (FromDhall (..), auto, genericAutoWith)+import Dhall qualified+import Dhall.Core (Expr)+import Dhall.Src (Src)+import Network.URI (parseURI, uriScheme)+import Numeric.Natural (Natural)+-- Imported qualified: 'Okf.Actor.HumanActor' is a constructor of 'Actor.Actor'+-- and 'HumanActor' is a constructor of 'FieldFormat', so an unqualified import+-- makes the two ambiguous.+import Okf.Actor qualified as Actor+import Okf.Bundle+ ( BundleInventory,+ Concept,+ bundleInventoryMember,+ bundleInventoryOfConcepts,+ conceptDocument,+ conceptIdOf,+ conceptResource,+ conceptType,+ )+import Okf.ConceptId (ConceptId, conceptIdFromFilePath, renderConceptId)+import Okf.Document+ ( Frontmatter,+ coreFrontmatterFields,+ fieldsSupersededInV02,+ frontmatterKeys,+ frontmatterLookup,+ )+import Okf.Index+ ( OkfVersion (..),+ VersionDeclaration (..),+ parseOkfVersion,+ renderOkfVersion,+ supportedOkfVersion,+ )+import Okf.Markdown (markdownOptions)+import Okf.Path (PathReference (..), classifyPathReference)+-- 'List' and 'Object' are 'Cardinality' constructors here; the names aeson uses+-- for the corresponding 'Value' constructors are reached as 'Aeson.Array' and+-- 'Aeson.Object'.+import Okf.Prelude hiding (List, Object, (.=))+import Okf.Validation (ValidationProfile (..))+import System.FilePath qualified as FilePath+import "generic-lens" Data.Generics.Labels ()++-- | A complete house profile. @description@ is prose documenting the profile as+-- a whole; like every description in this module it is never checked against a+-- bundle and can never produce a 'ProfileViolation'.+--+-- @requireBundleVersion@ and @okfVersion@ are easy to confuse and answer+-- different questions. @okfVersion@ says which version's rules /this profile+-- writes/, and 'compileProfile' checks the profile's own rules against it.+-- @requireBundleVersion@ says what the profile demands of a /bundle/: @Just+-- \"0.2\"@ means the bundle's root @index.md@ must declare @okf_version@ at 0.2+-- or later, checked by 'validateProfileVersion'. Neither constrains the other.+data ProfileSpec = ProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !FrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ requireBundleVersion :: !(Maybe Text),+ types :: ![TypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | Frontmatter keys the profile expects on every concept. @required@ is always+-- checked, @recommended@ only under 'StrictAuthoring', and @optional@ never:+-- an optional key is fully validated whenever it is present and its absence is+-- never a deviation in any mode.+data FrontmatterRules = FrontmatterRules+ { required :: ![FieldRule],+ recommended :: ![FieldRule],+ optional :: ![FieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | A same-scope predicate controlling whether a field's presence rule applies.+-- The source field must compile to a closed scalar textual vocabulary.+data FieldCondition = FieldCondition+ { field :: !Text,+ hasValue :: ![Text]+ }+ deriving stock (Generic, Eq, Ord, Show)+ deriving anyclass (FromDhall)++-- | A top-level field whose textual values point either to a local document+-- handle with one prefix or to an absolute URI with an explicitly allowed+-- scheme. External targets are never resolved by okf.+data HandleReferenceRule = HandleReferenceRule+ { localPrefix :: !Text,+ externalUriSchemes :: ![Text],+ allowSelf :: !Bool+ }+ deriving stock (Generic, Eq, Ord, Show)+ deriving anyclass (FromDhall)++-- | A field whose values name a path or URI per OKF v0.2 specification §6.2: an+-- absolute URL, a bundle-relative path beginning with @\/@, or an ordinary+-- relative path resolved against the concept's own directory.+--+-- Deliberately distinct from 'HandleReferenceRule'. A handle resolves against+-- the bundle's document-ID index and fails by carrying the wrong prefix or+-- having no owner; a path resolves against the concept tree and fails by not+-- being a §6.2 shape, by climbing above the bundle root, or by naming a concept+-- that is not there. Declaring both on one rule is a definition error.+--+-- An empty @externalUriSchemes@ means no absolute URL is permitted at all, so a+-- separate "must be a path, never a URL" flag would say nothing new. Which+-- bundle paths okf can resolve depends on the entry point: 'validateProfileWith'+-- resolves every file the bundle holds, including §6.3's+-- @references\/attesters\/revenue.py@, while 'validateProfile' is handed+-- concepts alone and so resolves @.md@ targets only.+data PathReferenceRule = PathReferenceRule+ { externalUriSchemes :: ![Text],+ allowSelf :: !Bool+ }+ deriving stock (Generic, Eq, Ord, Show)+ deriving anyclass (FromDhall)++-- | One documented frontmatter key. The description is prose for humans and is+-- never checked against a bundle.+--+-- @elementFields@ and @objectFields@ describe two different shapes.+-- @elementFields@ constrains the record inside /each element of a list/;+-- @objectFields@ constrains the record that /is/ the value. Declaring both means+-- either spelling is accepted and both are checked against the same member+-- rules, which is how a profile describes the OKF v0.2 @verified@ key.+data FieldRule = FieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe FieldFormat),+ elementFields :: !(Maybe NestedRules),+ objectFields :: !(Maybe NestedRules),+ reference :: !(Maybe HandleReferenceRule),+ path :: !(Maybe PathReferenceRule),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | Rules for the flat object stored in each element of a list-valued field, or+-- in the mapping that is the value of an object-valued field. The nested rule+-- type has neither an @elementFields@ nor an @objectFields@ member, which makes+-- the public descriptor depth-bounded rather than recursive.+data NestedRules = NestedRules+ { required :: ![NestedFieldRule],+ recommended :: ![NestedFieldRule],+ optional :: ![NestedFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | One field inside a list element record or an object-valued mapping. It+-- carries @path@ because @sources[].resource@ is the motivating path-valued+-- field of §6.2 and lives inside a list element; it deliberately carries no+-- @reference@, because no v0.2 field names a document handle at nested scope.+data NestedFieldRule = NestedFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe FieldFormat),+ path :: !(Maybe PathReferenceRule),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | Whether a key must be a single value, a non-empty list, a mapping, or any+-- of them. 'Object' is placed last so the derived 'Ord' keeps the relative+-- order of the three published alternatives, which the definition-error sort key+-- relies on.+data Cardinality = Any | Scalar | List | Object+ deriving stock (Generic, Eq, Ord, Show)++-- | Decoded from the three-alternative published union in+-- @okf-core\/dhall\/Cardinality.dhall@. 'Object' is deliberately unreachable+-- from Dhall: it is produced only by compilation, when a rule declares+-- @objectFields@. Adding an alternative to the published union would change the+-- type of every value written against it and would break descriptors pinned to+-- the previous schema, which no record-level fallback decoder can repair,+-- because every frozen generation in the chain refers to this same type. An+-- author who wants to say "this key must be a mapping" and nothing more writes+-- @objectFields = Some NestedRules::{=}@.+instance FromDhall Cardinality where+ autoWith _normalizer =+ Dhall.union+ ( (Any <$ Dhall.constructor "Any" Dhall.unit)+ <> (Scalar <$ Dhall.constructor "Scalar" Dhall.unit)+ <> (List <$ Dhall.constructor "List" Dhall.unit)+ )++-- | A named value format. Formats constrain present values but do not imply+-- that a field must be present.+--+-- The first five constrain text. 'Actor' and 'HumanActor' constrain text against+-- the OKF v0.2 actor convention of specification §7, classified by+-- 'Okf.Actor.parseActor'. 'Integer', 'NonNegativeInteger', and 'Boolean'+-- constrain a value that is not text at all; declaring one of them refines an+-- unspecified cardinality to 'Scalar', because otherwise a numeric or boolean+-- key is reported missing before its value is ever examined.+--+-- The OKF v0.2 alternatives are appended after 'DocumentHandle' so the derived+-- 'Ord' keeps the relative order of the original five, which the+-- definition-error sort key relies on.+data FieldFormat+ = Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme Text+ | DocumentHandle Text+ | Actor+ | HumanActor+ | Integer+ | NonNegativeInteger+ | Boolean+ deriving stock (Generic, Eq, Ord, Show)+ deriving anyclass (FromDhall)++-- | One rule per allowed concept @type@ string.+data TypeRule = TypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !FrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++-- | Decode @type_@ from the Dhall field @type@ by stripping the trailing+-- underscore; all other fields map by their exact name. (Mirrors how+-- 'Okf.Bundle' uses a @type_@ field to avoid clashing with the @type@ keyword.)+instance FromDhall TypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | Encode a profile so tooling can consume the same descriptor okf reads.+-- Written by hand rather than derived so the key order is stable and, more+-- importantly, so 'TypeRule' emits @type@ rather than the Haskell field name+-- @type_@ — matching the Dhall field and how 'Okf.Graph.Node' already encodes.+instance ToJSON ProfileSpec where+ toJSON ProfileSpec {name, description, okfVersion, frontmatter, allowUnknownTypes, allowUnknownFields, idField, requireBundleVersion, types = typeRules} =+ object+ [ "name" .= name,+ "description" .= description,+ "okfVersion" .= okfVersion,+ "requireBundleVersion" .= requireBundleVersion,+ "allowUnknownTypes" .= allowUnknownTypes,+ "allowUnknownFields" .= allowUnknownFields,+ "idField" .= idField,+ "frontmatter" .= frontmatter,+ "types" .= typeRules+ ]++instance ToJSON FrontmatterRules where+ toJSON FrontmatterRules {required, recommended, optional} =+ object+ [ "required" .= required,+ "recommended" .= recommended,+ "optional" .= optional+ ]++instance ToJSON FieldCondition where+ toJSON FieldCondition {field = fieldName, hasValue} =+ object+ [ "field" .= fieldName,+ "hasValue" .= hasValue+ ]++instance ToJSON HandleReferenceRule where+ toJSON HandleReferenceRule {localPrefix, externalUriSchemes, allowSelf} =+ object+ [ "localPrefix" .= localPrefix,+ "externalUriSchemes" .= externalUriSchemes,+ "allowSelf" .= allowSelf+ ]++instance ToJSON PathReferenceRule where+ toJSON PathReferenceRule {externalUriSchemes, allowSelf} =+ object+ [ "externalUriSchemes" .= externalUriSchemes,+ "allowSelf" .= allowSelf+ ]++instance ToJSON FieldRule where+ toJSON FieldRule {field = fieldName, description, allowedValues, cardinality, format, elementFields, objectFields, reference, path = pathRule, when = condition} =+ object+ [ "field" .= fieldName,+ "description" .= description,+ "allowedValues" .= allowedValues,+ "cardinality" .= cardinality,+ "format" .= format,+ "elementFields" .= elementFields,+ "reference" .= reference,+ "when" .= condition,+ -- Appended rather than placed beside @elementFields@, so that the list+ -- reads as "the keys this instance has always emitted, then the ones+ -- added since". A consumer keys on names, not position.+ "objectFields" .= objectFields,+ "path" .= pathRule+ ]++instance ToJSON NestedRules where+ toJSON NestedRules {required, recommended, optional} =+ object+ [ "required" .= required,+ "recommended" .= recommended,+ "optional" .= optional+ ]++instance ToJSON NestedFieldRule where+ toJSON NestedFieldRule {field = fieldName, description, allowedValues, cardinality, format, path = pathRule, when = condition} =+ object+ [ "field" .= fieldName,+ "description" .= description,+ "allowedValues" .= allowedValues,+ "cardinality" .= cardinality,+ "format" .= format,+ "when" .= condition,+ -- Appended for the same reason 'FieldRule' appends @objectFields@.+ "path" .= pathRule+ ]++instance ToJSON Cardinality where+ toJSON = String . cardinalityName++cardinalityName :: Cardinality -> Text+cardinalityName = \case+ Any -> "any"+ Scalar -> "scalar"+ List -> "list"+ Object -> "object"++instance ToJSON FieldFormat where+ toJSON = \case+ Rfc3339Utc -> String "rfc3339-utc"+ Date -> String "date"+ Uri -> String "uri"+ UriWithScheme scheme -> object ["uriWithScheme" .= scheme]+ DocumentHandle prefix -> object ["documentHandle" .= prefix]+ Actor -> String "actor"+ HumanActor -> String "human-actor"+ Integer -> String "integer"+ NonNegativeInteger -> String "non-negative-integer"+ Boolean -> String "boolean"++instance ToJSON TypeRule where+ toJSON+ TypeRule+ { type_ = ruleType,+ description,+ frontmatter,+ pathPattern,+ resourceScheme,+ requireSchemaSection,+ schemaColumns,+ idPrefix+ } =+ object+ [ "type" .= ruleType,+ "description" .= description,+ "frontmatter" .= frontmatter,+ "pathPattern" .= pathPattern,+ "resourceScheme" .= resourceScheme,+ "requireSchemaSection" .= requireSchemaSection,+ "schemaColumns" .= schemaColumns,+ "idPrefix" .= idPrefix+ ]++-- | The okf 0.2.x profile record: frontmatter keys were bare strings and+-- nothing carried a description. Decoded only as a fallback, so descriptors+-- written before descriptions existed keep loading unchanged. Deliberately+-- private and deliberately frozen — it is a record of a retired shape, not a+-- second profile model. Exercised by+-- @okf-core\/test\/fixtures\/profiles\/legacy-0.2.dhall@.+data LegacyProfileSpec = LegacyProfileSpec+ { name :: !Text,+ okfVersion :: !Text,+ frontmatter :: !LegacyFrontmatterRules,+ allowUnknownTypes :: !Bool,+ idField :: !(Maybe Text),+ types :: ![LegacyTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | The okf 0.2.x frontmatter record: two lists of bare key names.+data LegacyFrontmatterRules = LegacyFrontmatterRules+ { required :: ![Text],+ recommended :: ![Text]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | The okf 0.2.x per-@type@ rule: today's 'TypeRule' without descriptions or+-- type-specific frontmatter.+data LegacyTypeRule = LegacyTypeRule+ { type_ :: !Text,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall LegacyTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The five-alternative published format union, frozen before the OKF v0.2+-- value formats were added.+--+-- Unlike every other frozen shape below this is a /union/ rather than a record.+-- A Dhall union value carries its full alternative set in its type, so a+-- descriptor pinned to the five-alternative+-- @okf-core\/dhall\/FieldFormat.dhall@ does not typecheck against the current+-- decoder, and no record-level fallback can repair that: every frozen generation+-- would still refer to the widened 'FieldFormat'. Every frozen generation below+-- therefore refers to this type instead.+--+-- The instance is hand-written rather than derived so that the Dhall alternative+-- names stay @Rfc3339Utc@ and friends while the Haskell constructors stay+-- distinct from the current type's.+data PreV02FieldFormat+ = LegacyRfc3339Utc+ | LegacyDate+ | LegacyUri+ | LegacyUriWithScheme Text+ | LegacyDocumentHandle Text+ deriving stock (Generic, Eq, Ord, Show)++instance FromDhall PreV02FieldFormat where+ autoWith _normalizer =+ Dhall.union+ ( (LegacyRfc3339Utc <$ Dhall.constructor "Rfc3339Utc" Dhall.unit)+ <> (LegacyDate <$ Dhall.constructor "Date" Dhall.unit)+ <> (LegacyUri <$ Dhall.constructor "Uri" Dhall.unit)+ <> (LegacyUriWithScheme <$> Dhall.constructor "UriWithScheme" Dhall.auto)+ <> (LegacyDocumentHandle <$> Dhall.constructor "DocumentHandle" Dhall.auto)+ )++upgradePreV02FieldFormat :: PreV02FieldFormat -> FieldFormat+upgradePreV02FieldFormat = \case+ LegacyRfc3339Utc -> Rfc3339Utc+ LegacyDate -> Date+ LegacyUri -> Uri+ LegacyUriWithScheme scheme -> UriWithScheme scheme+ LegacyDocumentHandle prefix -> DocumentHandle prefix++-- | The complete descriptor generation frozen before a profile could require its+-- bundle to declare an OKF version. This is the immediately preceding public+-- descriptor generation: it is today's shape minus the @requireBundleVersion@+-- member on the top-level record. That member is the only difference, so every+-- rule record, every type rule, and every union is unchanged and is shared+-- rather than copied — the freezing rule of+-- @docs\/adr\/11-growing-the-profile-descriptor-language.md@ is about the+-- descriptor as a whole, and a generation that changes one record copies only+-- what changed. Exercised by+-- @okf-core\/test\/fixtures\/profiles\/pre-bundle-version.dhall@.+data PreBundleVersionProfileSpec = PreBundleVersionProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !FrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![TypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | The complete descriptor generation frozen before path-valued reference+-- rules were added: today's shape minus the @path@ member on 'FieldRule' and on+-- 'NestedFieldRule'. Because that is a record addition rather than a union+-- widening, 'Cardinality', 'FieldFormat', 'FieldCondition', and+-- 'HandleReferenceRule' are unchanged by it and so are shared rather than+-- copied; the two rule records and everything that contains them are copied.+-- Exercised by @okf-core\/test\/fixtures\/profiles\/path-references-mp8-ep3.dhall@.+data PrePathProfileFieldRule = PrePathProfileFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe FieldFormat),+ elementFields :: !(Maybe PrePathProfileNestedRules),+ objectFields :: !(Maybe PrePathProfileNestedRules),+ reference :: !(Maybe HandleReferenceRule),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PrePathProfileNestedRules = PrePathProfileNestedRules+ { required :: ![PrePathProfileNestedFieldRule],+ recommended :: ![PrePathProfileNestedFieldRule],+ optional :: ![PrePathProfileNestedFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PrePathProfileNestedFieldRule = PrePathProfileNestedFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe FieldFormat),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PrePathProfileFrontmatterRules = PrePathProfileFrontmatterRules+ { required :: ![PrePathProfileFieldRule],+ recommended :: ![PrePathProfileFieldRule],+ optional :: ![PrePathProfileFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PrePathProfileSpec = PrePathProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !PrePathProfileFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![PrePathProfileTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PrePathProfileTypeRule = PrePathProfileTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !PrePathProfileFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall PrePathProfileTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The complete object-rule descriptor generation, frozen before the OKF v0.2+-- value formats were added to 'FieldFormat'. Its records match the shape+-- 'PrePathProfileFieldRule' froze — no @path@ member — and the further+-- difference is that every @format@ member refers to the frozen+-- five-alternative 'PreV02FieldFormat'. Exercised by+-- @okf-core\/test\/fixtures\/profiles\/formats-mp8-ep2.dhall@.+data PreActorProfileFieldRule = PreActorProfileFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ elementFields :: !(Maybe PreActorProfileNestedRules),+ objectFields :: !(Maybe PreActorProfileNestedRules),+ reference :: !(Maybe HandleReferenceRule),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreActorProfileNestedRules = PreActorProfileNestedRules+ { required :: ![PreActorProfileNestedFieldRule],+ recommended :: ![PreActorProfileNestedFieldRule],+ optional :: ![PreActorProfileNestedFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreActorProfileNestedFieldRule = PreActorProfileNestedFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreActorProfileFrontmatterRules = PreActorProfileFrontmatterRules+ { required :: ![PreActorProfileFieldRule],+ recommended :: ![PreActorProfileFieldRule],+ optional :: ![PreActorProfileFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreActorProfileSpec = PreActorProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !PreActorProfileFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![PreActorProfileTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreActorProfileTypeRule = PreActorProfileTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !PreActorProfileFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall PreActorProfileTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The complete optional-presence descriptor generation, frozen before object+-- rules were added: it matches the shape before @objectFields@ was added to+-- 'FieldRule'. 'Cardinality', 'HandleReferenceRule', and 'FieldCondition' are+-- unchanged by every addition since and so are shared rather than copied; the+-- nested rule types are copied because their @format@ member now refers to the+-- frozen 'PreV02FieldFormat'. Exercised by+-- @okf-core\/test\/fixtures\/profiles\/object-fields-mp8-ep1.dhall@.+data PreObjectProfileFieldRule = PreObjectProfileFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ elementFields :: !(Maybe PreObjectProfileNestedRules),+ reference :: !(Maybe HandleReferenceRule),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreObjectProfileNestedRules = PreObjectProfileNestedRules+ { required :: ![PreObjectProfileNestedFieldRule],+ recommended :: ![PreObjectProfileNestedFieldRule],+ optional :: ![PreObjectProfileNestedFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreObjectProfileNestedFieldRule = PreObjectProfileNestedFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreObjectProfileFrontmatterRules = PreObjectProfileFrontmatterRules+ { required :: ![PreObjectProfileFieldRule],+ recommended :: ![PreObjectProfileFieldRule],+ optional :: ![PreObjectProfileFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreObjectProfileSpec = PreObjectProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !PreObjectProfileFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![PreObjectProfileTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreObjectProfileTypeRule = PreObjectProfileTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !PreObjectProfileFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall PreObjectProfileTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The complete reference-aware descriptor generation, frozen before the+-- @optional@ presence list was added to 'FrontmatterRules' and 'NestedRules'.+-- This is the immediately preceding public descriptor generation: it matches+-- today's shape exactly apart from that third list, so every descriptor written+-- as a record literal against the published schema keeps loading. Exercised by+-- @okf-core\/test\/fixtures\/profiles\/document-references-ep3.dhall@.+data ReferenceProfileFieldRule = ReferenceProfileFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ elementFields :: !(Maybe ReferenceProfileNestedRules),+ reference :: !(Maybe HandleReferenceRule),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ReferenceProfileNestedRules = ReferenceProfileNestedRules+ { required :: ![ReferenceProfileNestedFieldRule],+ recommended :: ![ReferenceProfileNestedFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ReferenceProfileNestedFieldRule = ReferenceProfileNestedFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ReferenceProfileFrontmatterRules = ReferenceProfileFrontmatterRules+ { required :: ![ReferenceProfileFieldRule],+ recommended :: ![ReferenceProfileFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ReferenceProfileSpec = ReferenceProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !ReferenceProfileFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![ReferenceProfileTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ReferenceProfileTypeRule = ReferenceProfileTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !ReferenceProfileFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall ReferenceProfileTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The complete condition-aware descriptor generation, frozen before+-- top-level document-reference policies were added.+data ConditionalProfileFieldRule = ConditionalProfileFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ elementFields :: !(Maybe ConditionalProfileNestedRules),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ConditionalProfileNestedRules = ConditionalProfileNestedRules+ { required :: ![ConditionalProfileNestedFieldRule],+ recommended :: ![ConditionalProfileNestedFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ConditionalProfileNestedFieldRule = ConditionalProfileNestedFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ when :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ConditionalProfileFrontmatterRules = ConditionalProfileFrontmatterRules+ { required :: ![ConditionalProfileFieldRule],+ recommended :: ![ConditionalProfileFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ConditionalProfileSpec = ConditionalProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !ConditionalProfileFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![ConditionalProfileTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data ConditionalProfileTypeRule = ConditionalProfileTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !ConditionalProfileFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall ConditionalProfileTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The complete bounded-nested descriptor generation, frozen before+-- same-scope field conditions were added. This is the immediately preceding+-- public descriptor generation.+data NestedProfileFieldRule = NestedProfileFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat),+ elementFields :: !(Maybe NestedProfileRules)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data NestedProfileRules = NestedProfileRules+ { required :: ![NestedProfileNestedFieldRule],+ recommended :: ![NestedProfileNestedFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data NestedProfileNestedFieldRule = NestedProfileNestedFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data NestedProfileFrontmatterRules = NestedProfileFrontmatterRules+ { required :: ![NestedProfileFieldRule],+ recommended :: ![NestedProfileFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data NestedProfileSpec = NestedProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !NestedProfileFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![NestedProfileTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data NestedProfileTypeRule = NestedProfileTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !NestedProfileFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall NestedProfileTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The complete EP-4 field rule, frozen before one-level nested records were+-- added. This is the immediately preceding public descriptor generation.+data FormatFieldRule = FormatFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe PreV02FieldFormat)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data FormatFrontmatterRules = FormatFrontmatterRules+ { required :: ![FormatFieldRule],+ recommended :: ![FormatFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data FormatProfileSpec = FormatProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !FormatFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![FormatTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data FormatTypeRule = FormatTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !FormatFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall FormatTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The EP-3 field rule, frozen before named formats were added.+data CardinalityFieldRule = CardinalityFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data CardinalityFrontmatterRules = CardinalityFrontmatterRules+ { required :: ![CardinalityFieldRule],+ recommended :: ![CardinalityFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | The complete EP-3 profile shape, frozen before named formats were added.+data CardinalityProfileSpec = CardinalityProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !CardinalityFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![CardinalityTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data CardinalityTypeRule = CardinalityTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !CardinalityFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall CardinalityTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The EP-2 field rule, frozen before cardinality was added.+data VocabularyFieldRule = VocabularyFieldRule+ { field :: !Text,+ description :: !(Maybe Text),+ allowedValues :: ![Text]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data VocabularyFrontmatterRules = VocabularyFrontmatterRules+ { required :: ![VocabularyFieldRule],+ recommended :: ![VocabularyFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | The EP-2 profile shape, frozen before cardinality was added.+data VocabularyProfileSpec = VocabularyProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !VocabularyFrontmatterRules,+ allowUnknownTypes :: !Bool,+ allowUnknownFields :: !Bool,+ idField :: !(Maybe Text),+ types :: ![VocabularyTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data VocabularyTypeRule = VocabularyTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !VocabularyFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall VocabularyTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | A field rule from either self-documenting schema generation, before value+-- vocabularies were added.+data PreviousFieldRule = PreviousFieldRule+ { field :: !Text,+ description :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data PreviousFrontmatterRules = PreviousFrontmatterRules+ { required :: ![PreviousFieldRule],+ recommended :: ![PreviousFieldRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++-- | The type-aware shape from EP-1, frozen before vocabularies and field-name+-- closure were added.+data TypeAwareProfileSpec = TypeAwareProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !PreviousFrontmatterRules,+ allowUnknownTypes :: !Bool,+ idField :: !(Maybe Text),+ types :: ![TypeAwareTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data TypeAwareTypeRule = TypeAwareTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ frontmatter :: !PreviousFrontmatterRules,+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall TypeAwareTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++-- | The self-documenting profile shape published immediately before+-- type-specific frontmatter rules. It is frozen as a compatibility decoder in+-- exactly the same way as the older 0.2.x shape below.+data DescribedProfileSpec = DescribedProfileSpec+ { name :: !Text,+ description :: !(Maybe Text),+ okfVersion :: !Text,+ frontmatter :: !PreviousFrontmatterRules,+ allowUnknownTypes :: !Bool,+ idField :: !(Maybe Text),+ types :: ![DescribedTypeRule]+ }+ deriving stock (Generic, Eq, Show)+ deriving anyclass (FromDhall)++data DescribedTypeRule = DescribedTypeRule+ { type_ :: !Text,+ description :: !(Maybe Text),+ pathPattern :: !(Maybe Text),+ resourceScheme :: !(Maybe Text),+ requireSchemaSection :: !Bool,+ schemaColumns :: ![Text],+ idPrefix :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++instance FromDhall DescribedTypeRule where+ autoWith _normalizer =+ genericAutoWith+ (Dhall.defaultInterpretOptions {Dhall.fieldModifier = stripTrailingUnderscore})+ where+ stripTrailingUnderscore fieldName =+ fromMaybe fieldName (Text.stripSuffix "_" fieldName)++emptyFrontmatterRules :: FrontmatterRules+emptyFrontmatterRules = FrontmatterRules {required = [], recommended = [], optional = []}++upgradePrePathProfileFrontmatter :: PrePathProfileFrontmatterRules -> FrontmatterRules+upgradePrePathProfileFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = map upgradeField (previous ^. #optional)+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = rule ^. #format,+ elementFields = upgradeNestedRules <$> rule ^. #elementFields,+ objectFields = upgradeNestedRules <$> rule ^. #objectFields,+ reference = rule ^. #reference,+ path = Nothing,+ when = rule ^. #when+ }+ upgradeNestedRules rules =+ NestedRules+ { required = map upgradeNestedField (rules ^. #required),+ recommended = map upgradeNestedField (rules ^. #recommended),+ optional = map upgradeNestedField (rules ^. #optional)+ }+ upgradeNestedField rule =+ NestedFieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = rule ^. #format,+ path = Nothing,+ when = rule ^. #when+ }++upgradePreActorProfileFrontmatter :: PreActorProfileFrontmatterRules -> FrontmatterRules+upgradePreActorProfileFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = map upgradeField (previous ^. #optional)+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ elementFields = upgradeNestedRules <$> rule ^. #elementFields,+ objectFields = upgradeNestedRules <$> rule ^. #objectFields,+ reference = rule ^. #reference,+ path = Nothing,+ when = rule ^. #when+ }+ upgradeNestedRules rules =+ NestedRules+ { required = map upgradeNestedField (rules ^. #required),+ recommended = map upgradeNestedField (rules ^. #recommended),+ optional = map upgradeNestedField (rules ^. #optional)+ }+ upgradeNestedField rule =+ NestedFieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ path = Nothing,+ when = rule ^. #when+ }++upgradePreObjectProfileFrontmatter :: PreObjectProfileFrontmatterRules -> FrontmatterRules+upgradePreObjectProfileFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = map upgradeField (previous ^. #optional)+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ elementFields = upgradeNestedRules <$> rule ^. #elementFields,+ objectFields = Nothing,+ reference = rule ^. #reference,+ path = Nothing,+ when = rule ^. #when+ }+ upgradeNestedRules rules =+ NestedRules+ { required = map upgradeNestedField (rules ^. #required),+ recommended = map upgradeNestedField (rules ^. #recommended),+ optional = map upgradeNestedField (rules ^. #optional)+ }+ upgradeNestedField rule =+ NestedFieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ path = Nothing,+ when = rule ^. #when+ }++upgradeReferenceProfileFrontmatter :: ReferenceProfileFrontmatterRules -> FrontmatterRules+upgradeReferenceProfileFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = []+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ elementFields = upgradeNestedRules <$> rule ^. #elementFields,+ objectFields = Nothing,+ reference = rule ^. #reference,+ path = Nothing,+ when = rule ^. #when+ }+ upgradeNestedRules rules =+ NestedRules+ { required = map upgradeNestedField (rules ^. #required),+ recommended = map upgradeNestedField (rules ^. #recommended),+ optional = []+ }+ upgradeNestedField rule =+ NestedFieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ path = Nothing,+ when = rule ^. #when+ }++upgradePreviousFrontmatter :: PreviousFrontmatterRules -> FrontmatterRules+upgradePreviousFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = []+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = [],+ cardinality = Any,+ format = Nothing,+ elementFields = Nothing,+ objectFields = Nothing,+ reference = Nothing,+ path = Nothing,+ when = Nothing+ }++upgradeConditionalProfileFrontmatter :: ConditionalProfileFrontmatterRules -> FrontmatterRules+upgradeConditionalProfileFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = []+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ elementFields = upgradeNestedRules <$> rule ^. #elementFields,+ objectFields = Nothing,+ reference = Nothing,+ path = Nothing,+ when = rule ^. #when+ }+ upgradeNestedRules rules =+ NestedRules+ { required = map upgradeNestedField (rules ^. #required),+ recommended = map upgradeNestedField (rules ^. #recommended),+ optional = []+ }+ upgradeNestedField rule =+ NestedFieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ path = Nothing,+ when = rule ^. #when+ }++upgradeNestedProfileFrontmatter :: NestedProfileFrontmatterRules -> FrontmatterRules+upgradeNestedProfileFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = []+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ elementFields = upgradeNestedProfileRules <$> rule ^. #elementFields,+ objectFields = Nothing,+ reference = Nothing,+ path = Nothing,+ when = Nothing+ }+ upgradeNestedProfileRules rules =+ NestedRules+ { required = map upgradeNestedField (rules ^. #required),+ recommended = map upgradeNestedField (rules ^. #recommended),+ optional = []+ }+ upgradeNestedField rule =+ NestedFieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ path = Nothing,+ when = Nothing+ }++upgradeFormatFrontmatter :: FormatFrontmatterRules -> FrontmatterRules+upgradeFormatFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = []+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = upgradePreV02FieldFormat <$> rule ^. #format,+ elementFields = Nothing,+ objectFields = Nothing,+ reference = Nothing,+ path = Nothing,+ when = Nothing+ }++upgradeCardinalityFrontmatter :: CardinalityFrontmatterRules -> FrontmatterRules+upgradeCardinalityFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = []+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = rule ^. #cardinality,+ format = Nothing,+ elementFields = Nothing,+ objectFields = Nothing,+ reference = Nothing,+ path = Nothing,+ when = Nothing+ }++upgradeVocabularyFrontmatter :: VocabularyFrontmatterRules -> FrontmatterRules+upgradeVocabularyFrontmatter previous =+ FrontmatterRules+ { required = map upgradeField (previous ^. #required),+ recommended = map upgradeField (previous ^. #recommended),+ optional = []+ }+ where+ upgradeField rule =+ FieldRule+ { field = rule ^. #field,+ description = rule ^. #description,+ allowedValues = rule ^. #allowedValues,+ cardinality = Any,+ format = Nothing,+ elementFields = Nothing,+ objectFields = Nothing,+ reference = Nothing,+ path = Nothing,+ when = Nothing+ }++-- | Lift the generation frozen before @requireBundleVersion@ forward. Every rule+-- record is shared with today's schema, so this copies members across and+-- supplies the one no-op default: a descriptor that predates the member demands+-- nothing of its bundle's version declaration, which is what it meant when it+-- was written.+upgradePreBundleVersionProfile :: PreBundleVersionProfileSpec -> ProfileSpec+upgradePreBundleVersionProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = previous ^. #frontmatter,+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = previous ^. #types+ }++upgradePrePathProfile :: PrePathProfileSpec -> ProfileSpec+upgradePrePathProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradePrePathProfileFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradePrePathProfileFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradePreActorProfile :: PreActorProfileSpec -> ProfileSpec+upgradePreActorProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradePreActorProfileFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradePreActorProfileFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradePreObjectProfile :: PreObjectProfileSpec -> ProfileSpec+upgradePreObjectProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradePreObjectProfileFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradePreObjectProfileFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeReferenceProfile :: ReferenceProfileSpec -> ProfileSpec+upgradeReferenceProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradeReferenceProfileFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradeReferenceProfileFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeConditionalProfile :: ConditionalProfileSpec -> ProfileSpec+upgradeConditionalProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradeConditionalProfileFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradeConditionalProfileFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeNestedProfile :: NestedProfileSpec -> ProfileSpec+upgradeNestedProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradeNestedProfileFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradeNestedProfileFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeFormatProfile :: FormatProfileSpec -> ProfileSpec+upgradeFormatProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradeFormatFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradeFormatFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeCardinalityProfile :: CardinalityProfileSpec -> ProfileSpec+upgradeCardinalityProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradeCardinalityFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradeCardinalityFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeVocabularyProfile :: VocabularyProfileSpec -> ProfileSpec+upgradeVocabularyProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradeVocabularyFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = previous ^. #allowUnknownFields,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradeVocabularyFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeTypeAwareProfile :: TypeAwareProfileSpec -> ProfileSpec+upgradeTypeAwareProfile previous =+ ProfileSpec+ { name = previous ^. #name,+ description = previous ^. #description,+ okfVersion = previous ^. #okfVersion,+ frontmatter = upgradePreviousFrontmatter (previous ^. #frontmatter),+ allowUnknownTypes = previous ^. #allowUnknownTypes,+ allowUnknownFields = True,+ idField = previous ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (previous ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = upgradePreviousFrontmatter (rule ^. #frontmatter),+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++upgradeDescribedProfile :: DescribedProfileSpec -> ProfileSpec+upgradeDescribedProfile described =+ ProfileSpec+ { name = described ^. #name,+ description = described ^. #description,+ okfVersion = described ^. #okfVersion,+ frontmatter = upgradePreviousFrontmatter (described ^. #frontmatter),+ allowUnknownTypes = described ^. #allowUnknownTypes,+ allowUnknownFields = True,+ idField = described ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (described ^. #types)+ }+ where+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = rule ^. #description,+ frontmatter = emptyFrontmatterRules,+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++-- | Lift a 0.2.x profile into the current shape by attaching no descriptions+-- and empty type-specific frontmatter.+upgradeLegacyProfile :: LegacyProfileSpec -> ProfileSpec+upgradeLegacyProfile legacy =+ ProfileSpec+ { name = legacy ^. #name,+ description = Nothing,+ okfVersion = legacy ^. #okfVersion,+ frontmatter =+ FrontmatterRules+ { required = map undocumented (legacy ^. #frontmatter . #required),+ recommended = map undocumented (legacy ^. #frontmatter . #recommended),+ optional = []+ },+ allowUnknownTypes = legacy ^. #allowUnknownTypes,+ allowUnknownFields = True,+ idField = legacy ^. #idField,+ requireBundleVersion = Nothing,+ types = map upgradeRule (legacy ^. #types)+ }+ where+ undocumented key = FieldRule {field = key, description = Nothing, allowedValues = [], cardinality = Any, format = Nothing, elementFields = Nothing, objectFields = Nothing, reference = Nothing, path = Nothing, when = Nothing}+ upgradeRule rule =+ TypeRule+ { type_ = rule ^. #type_,+ description = Nothing,+ frontmatter = emptyFrontmatterRules,+ pathPattern = rule ^. #pathPattern,+ resourceScheme = rule ^. #resourceScheme,+ requireSchemaSection = rule ^. #requireSchemaSection,+ schemaColumns = rule ^. #schemaColumns,+ idPrefix = rule ^. #idPrefix+ }++-- | Load and decode a Dhall profile descriptor from a file path. Any evaluation+-- or decoding failure is captured as a human-readable 'Left'.+--+-- The pre-bundle-version shape, pre-path shape, pre-actor shape, pre-object+-- shape, reference-aware shape,+-- condition-aware shape, bounded-nested shape, EP-4+-- format shape, EP-3 cardinality shape, EP-2 vocabulary shape, type-aware EP-1+-- shape, self-documenting shape, and okf 0.2.x shape are accepted by frozen+-- fallback decoders and upgraded+-- with their no-op defaults. When every decoder fails, the /current/ decoder's+-- error is reported.+loadProfileFile :: FilePath -> IO (Either Text ProfileSpec)+loadProfileFile path = do+ current <- tryDecode (Dhall.inputFile auto path)+ case current of+ Right spec -> pure (Right spec)+ -- Only the current decoder's error is kept. An author wants to know how+ -- their descriptor differs from today's schema, not from a retired one.+ Left currentError -> maybe (Left currentError) Right <$> firstFrozen frozenDecoders+ where+ -- Newest generation first. Each entry looks identical but is inferred at a+ -- distinct result type, fixed by the upgrade function it names; @auto@ then+ -- picks that generation's decoder. Adding a generation is one line here.+ frozenDecoders :: [IO (Maybe ProfileSpec)]+ frozenDecoders =+ [ attempt upgradePreBundleVersionProfile,+ attempt upgradePrePathProfile,+ attempt upgradePreActorProfile,+ attempt upgradePreObjectProfile,+ attempt upgradeReferenceProfile,+ attempt upgradeConditionalProfile,+ attempt upgradeNestedProfile,+ attempt upgradeFormatProfile,+ attempt upgradeCardinalityProfile,+ attempt upgradeVocabularyProfile,+ attempt upgradeTypeAwareProfile,+ attempt upgradeDescribedProfile,+ attempt upgradeLegacyProfile+ ]++ -- Short-circuiting, so a descriptor that decodes at the first frozen+ -- generation costs one extra parse rather than thirteen.+ firstFrozen :: [IO (Maybe ProfileSpec)] -> IO (Maybe ProfileSpec)+ firstFrozen [] = pure Nothing+ firstFrozen (decoder : remaining) =+ decoder >>= \case+ Just spec -> pure (Just spec)+ Nothing -> firstFrozen remaining++ attempt :: (FromDhall a) => (a -> ProfileSpec) -> IO (Maybe ProfileSpec)+ attempt upgrade = either (const Nothing) (Just . upgrade) <$> tryDecode (Dhall.inputFile auto path)++ tryDecode :: IO a -> IO (Either Text a)+ tryDecode action =+ (Right <$> action)+ `catch` \(exception :: SomeException) -> pure (Left (Text.pack (show exception)))++-- | Does an already-evaluated Dhall expression decode as a profile? Tries the+-- current schema, then the pre-bundle-version, pre-path, pre-actor, pre-object,+-- reference-aware,+-- condition-aware, bounded-nested, EP-4, EP-3, EP-2, EP-1, self-documenting, and+-- okf 0.2.x schemas, so the published @okf-profiles@ package still enumerates.+-- Uses 'Dhall.rawInput', which normalizes and runs the decoder's extractor+-- without throwing, which is what lets registry enumeration be pure.+decodeProfileExpr :: Expr Src Void -> Maybe ProfileSpec+decodeProfileExpr expression =+ Dhall.rawInput Dhall.auto expression+ <|> fmap upgradePreBundleVersionProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradePrePathProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradePreActorProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradePreObjectProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeReferenceProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeConditionalProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeNestedProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeFormatProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeCardinalityProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeVocabularyProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeTypeAwareProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeDescribedProfile (Dhall.rawInput Dhall.auto expression)+ <|> fmap upgradeLegacyProfile (Dhall.rawInput Dhall.auto expression)++-- | The description a profile attaches to a frontmatter key, looking in+-- @required@ first, then @recommended@, then @optional@. 'Nothing' when the key+-- is undocumented or absent from the profile entirely.+profileFieldDescription :: ProfileSpec -> Text -> Maybe Text+profileFieldDescription spec key =+ case [rule | rule <- rules, rule ^. #field == key] of+ (rule : _) -> rule ^. #description+ [] -> Nothing+ where+ rules =+ spec ^. #frontmatter . #required+ <> spec ^. #frontmatter . #recommended+ <> spec ^. #frontmatter . #optional++-- | A malformed profile definition. The optional type is absent for profile+-- scope and present for a type-specific scope. The list name is @required@,+-- @recommended@, or @optional@.+data ProfileDefinitionError+ = DuplicateTypeRule Text+ | DuplicateFieldRule (Maybe Text) Text Text+ | ConflictingFieldRequirement (Maybe Text) Text+ | UnsatisfiableVocabulary (Maybe Text) Text [Text] [Text]+ | ConflictingCardinality (Maybe Text) Text Cardinality Cardinality+ | ElementFieldsRequireList (Maybe Text) FieldPath Cardinality+ | InvalidFormatParameter FieldPath FieldFormat Text+ | ConflictingFieldFormat FieldPath FieldFormat FieldFormat+ | EmptyConditionValues (Maybe Text) FieldPath FieldPath+ | ConditionFieldNotDeclared (Maybe Text) FieldPath FieldPath+ | ConditionFieldNotScalar (Maybe Text) FieldPath FieldPath Cardinality+ | ConditionFieldOpenVocabulary (Maybe Text) FieldPath FieldPath+ | ConditionFieldHasUnreachableValues (Maybe Text) FieldPath FieldPath [Text] [Text]+ | SelfConditionalField (Maybe Text) FieldPath+ | InvalidReferencePrefix (Maybe Text) FieldPath Text+ | ReferencePrefixNotDeclared (Maybe Text) FieldPath Text+ | ReferenceRequiresIdField (Maybe Text) FieldPath+ | InvalidExternalReferenceScheme (Maybe Text) FieldPath Text+ | ConflictingReferencePrefix Text FieldPath Text Text+ | ReferenceWithFormat (Maybe Text) FieldPath FieldFormat+ | -- | an @optional@ rule carries a @when@ condition, which gates only presence+ -- and is therefore dead: an optional rule has no presence check at all+ OptionalFieldWithCondition (Maybe Text) FieldPath+ | -- | a rule declares @objectFields@ alongside an explicit scalar or list+ -- cardinality; an object is neither, so the pairing cannot be satisfied+ ObjectFieldsRequireObjectShape (Maybe Text) FieldPath Cardinality+ | -- | one rule declares both a document-handle policy and a path policy;+ -- a value cannot be resolved as both a handle and a path+ PathReferenceWithHandleReference (Maybe Text) FieldPath+ | -- | @okfVersion@ is not @\<major\>.\<minor\>@+ InvalidProfileOkfVersion Text+ | -- | @okfVersion@ names a major version okf does not implement, so okf cannot+ -- know which of its rules still hold+ ProfileOkfVersionNotUnderstood Text+ | -- | @requireBundleVersion@ is not @\<major\>.\<minor\>@, so no bundle+ -- declaration could ever be compared against it+ InvalidRequiredBundleVersion Text+ | -- | a required or recommended rule names a key the declared version+ -- supersedes (scope, path, declared version, version that superseded the key)+ FieldSupersededInOkfVersion (Maybe Text) FieldPath Text Text+ | -- | a rule names a value format introduced after the declared version+ -- (scope, path, format, declared version, version that introduced the format)+ FormatRequiresOkfVersion (Maybe Text) FieldPath FieldFormat Text Text+ deriving stock (Generic, Eq, Ord, Show)++-- | Whether a 'PresenceClause' demands a key or merely recommends it. There is+-- deliberately no @OptionalField@ constructor: an optional key is encoded as an+-- 'EffectiveFieldRule' with /no/ presence clauses at all.+data FieldRequirement = RecommendedField | RequiredField+ deriving stock (Generic, Eq, Ord, Show)++-- | One reason a key may have to be present, optionally gated by a condition on+-- another same-scope key. Abstract: read it with 'presenceClauseRequirement' and+-- 'presenceClauseCondition'.+data PresenceClause = PresenceClause+ { requirement :: !FieldRequirement,+ condition :: !(Maybe FieldCondition)+ }+ deriving stock (Generic, Eq, Show)++-- | Everything that actually applies to one frontmatter key for one concept+-- type: the profile-scope declaration merged with the type-scope one per+-- [ADR 5](docs/adr/5-compile-profile-rules-before-validation.md). Abstract: read+-- it with the @fieldRule*@ accessors below, never by pattern matching, so that+-- later profile features can extend it without breaking consumers.+data EffectiveFieldRule = EffectiveFieldRule+ { presenceClauses :: ![PresenceClause],+ description :: !(Maybe Text),+ allowedValues :: ![Text],+ cardinality :: !Cardinality,+ format :: !(Maybe FieldFormat),+ elementFields :: !(Maybe (Map Text EffectiveFieldRule)),+ objectFields :: !(Maybe (Map Text EffectiveFieldRule)),+ reference :: !(Maybe HandleReferenceRule),+ path :: !(Maybe PathReferenceRule)+ }+ deriving stock (Generic, Eq, Show)++-- | The stable lowercase display name for a cardinality: @any@, @scalar@,+-- @list@, or @object@. These are the names the CLI prints and the names+-- generated profile documentation uses, so a reader who has seen one recognizes+-- the other. @object@ is also the word the CLI already prints for an actual+-- mapping value, so a cardinality-mismatch message reads coherently.+renderCardinalityName :: Cardinality -> Text+renderCardinalityName = \case+ Any -> "any"+ Scalar -> "scalar"+ List -> "list"+ Object -> "object"++-- | The stable display name for a named format: @rfc3339-utc@, @date@, @uri@,+-- @uri-with-scheme(SCHEME)@, @document-handle(PREFIX)@, @actor@,+-- @human-actor@, @integer@, @non-negative-integer@, or @boolean@. As with+-- 'renderCardinalityName', this vocabulary is shared between the CLI and+-- generated documentation and must not drift between them.+renderFieldFormatName :: FieldFormat -> Text+renderFieldFormatName = \case+ Rfc3339Utc -> "rfc3339-utc"+ Date -> "date"+ Uri -> "uri"+ UriWithScheme scheme -> "uri-with-scheme(" <> scheme <> ")"+ DocumentHandle prefix -> "document-handle(" <> prefix <> ")"+ Actor -> "actor"+ HumanActor -> "human-actor"+ Integer -> "integer"+ NonNegativeInteger -> "non-negative-integer"+ Boolean -> "boolean"++-- | The presence clauses that govern whether this key must be present, in the+-- order the profile declared them. An __empty list means the key is optional__:+-- it is fully validated whenever it is present and never reported when absent,+-- in any validation mode. A clause with 'RequiredField' is always checked; a+-- clause with 'RecommendedField' is checked only under+-- 'Okf.Validation.StrictAuthoring'. A clause carrying a 'FieldCondition' applies+-- only when that condition holds for the document being checked.+fieldRulePresenceClauses :: EffectiveFieldRule -> [PresenceClause]+fieldRulePresenceClauses rule = rule ^. #presenceClauses++-- | Prose documenting the key, merged across profile and type scope with+-- type-level prose winning. Purely documentary: it is never checked against a+-- document and can never produce a 'ProfileViolation'.+fieldRuleDescription :: EffectiveFieldRule -> Maybe Text+fieldRuleDescription rule = rule ^. #description++-- | The closed vocabulary of permitted textual values. An __empty list means+-- unconstrained__, not "no value is permitted".+fieldRuleAllowedValues :: EffectiveFieldRule -> [Text]+fieldRuleAllowedValues rule = rule ^. #allowedValues++-- | Whether the key must be a single value, a non-empty list, or either.+fieldRuleCardinality :: EffectiveFieldRule -> Cardinality+fieldRuleCardinality rule = rule ^. #cardinality++-- | The named textual format constraining present values, if any.+fieldRuleFormat :: EffectiveFieldRule -> Maybe FieldFormat+fieldRuleFormat rule = rule ^. #format++-- | The document-reference policy for this key, if any.+fieldRuleReference :: EffectiveFieldRule -> Maybe HandleReferenceRule+fieldRuleReference rule = rule ^. #reference++-- | The path-valued policy for this key, if any. Distinct from+-- 'fieldRuleReference': a handle resolves against the bundle's document-ID+-- index, a path against its concept tree. A rule never carries both — compiling+-- one that does is a 'PathReferenceWithHandleReference' definition error — so a+-- consumer can read whichever is present without disambiguating.+--+-- Unlike 'fieldRuleReference' this can be present on a rule taken from+-- 'fieldRuleElementFields' or 'fieldRuleObjectFields', because+-- @sources[].resource@ is the field the policy exists for.+fieldRulePath :: EffectiveFieldRule -> Maybe PathReferenceRule+fieldRulePath rule = rule ^. #path++-- | Rules for the flat object stored in each element of a list-valued key,+-- keyed by nested key name, or 'Nothing' when the key declares no nested shape.+-- Nested rules are depth-bounded: a nested rule never itself has element fields,+-- so 'fieldRuleElementFields' on a value taken from this map is always+-- 'Nothing'.+fieldRuleElementFields :: EffectiveFieldRule -> Maybe (Map Text EffectiveFieldRule)+fieldRuleElementFields rule = rule ^. #elementFields++-- | Rules for the members of the mapping stored at this key, keyed by member+-- name, or 'Nothing' when the key declares no object shape. Like+-- 'fieldRuleElementFields' this is depth-bounded: a value taken from this map+-- always has 'Nothing' for both nested accessors. A rule may declare both, which+-- means either spelling of the value is accepted and both are checked against+-- the same member rules.+fieldRuleObjectFields :: EffectiveFieldRule -> Maybe (Map Text EffectiveFieldRule)+fieldRuleObjectFields rule = rule ^. #objectFields++-- | Whether this clause demands the key or merely recommends it.+presenceClauseRequirement :: PresenceClause -> FieldRequirement+presenceClauseRequirement clause = clause ^. #requirement++-- | The same-scope predicate gating this clause, or 'Nothing' when it always+-- applies.+presenceClauseCondition :: PresenceClause -> Maybe FieldCondition+presenceClauseCondition clause = clause ^. #condition++-- | A raw profile whose authoring contradictions have been rejected and whose+-- effective profile-plus-type frontmatter rules have been precomputed.+data CompiledProfile = CompiledProfile+ { spec :: !ProfileSpec,+ baseRules :: !(Map Text EffectiveFieldRule),+ rulesByType :: !(Map Text (Map Text EffectiveFieldRule)),+ -- | @requireBundleVersion@ already parsed, so 'validateProfileVersion' never+ -- re-parses and cannot disagree with what compilation accepted.+ requiredBundleVersion :: !(Maybe OkfVersion)+ }+ deriving stock (Generic, Eq, Show)++compiledProfileSpec :: CompiledProfile -> ProfileSpec+compiledProfileSpec compiled = compiled ^. #spec++-- | The minimum OKF version the profile requires its bundle to declare, already+-- parsed. 'Nothing' when the profile requires nothing, which is the default and+-- the case for almost every profile.+compiledProfileRequiredBundleVersion :: CompiledProfile -> Maybe OkfVersion+compiledProfileRequiredBundleVersion compiled = compiled ^. #requiredBundleVersion++-- | The concept @type@ strings the profile declares, in the order the descriptor+-- declares them. Declaration order is the author's and is preserved because+-- documentation and display should follow it rather than an alphabetical+-- reordering.+compiledProfileTypeNames :: CompiledProfile -> [Text]+compiledProfileTypeNames compiled =+ [rule ^. #type_ | rule <- compiledProfileSpec compiled ^. #types]++-- | The rules that apply to every document, whatever its type, keyed by+-- frontmatter key name.+compiledProfileBaseRules :: CompiledProfile -> Map Text EffectiveFieldRule+compiledProfileBaseRules compiled = compiled ^. #baseRules++-- | The rules that apply to a document of the given @type@: the profile-scope+-- rules merged with that type's own. A type the profile does not declare falls+-- back to the profile-scope rules alone, because a profile with+-- @allowUnknownTypes = True@ still applies its profile-wide expectations to a+-- document whose type it does not recognize.+compiledProfileRulesForType :: CompiledProfile -> Text -> Map Text EffectiveFieldRule+compiledProfileRulesForType = effectiveRulesForType++compileProfile :: ProfileSpec -> Either (NonEmpty ProfileDefinitionError) CompiledProfile+compileProfile rawSpec =+ case definitionErrors of+ firstError : remainingErrors -> Left (firstError :| remainingErrors)+ [] ->+ Right+ CompiledProfile+ { spec = rawSpec,+ baseRules,+ rulesByType =+ Map.fromList+ [ (rule ^. #type_, mergeRules baseRules (compileRules (rule ^. #frontmatter)))+ | rule <- rawSpec ^. #types+ ],+ -- Safe here and only here: 'requiredBundleVersionErrors' is one of+ -- the checks 'definitionErrors' collects, so reaching this branch+ -- means the value parsed.+ requiredBundleVersion = rawSpec ^. #requireBundleVersion >>= parseOkfVersion+ }+ where+ baseRules = compileRules (rawSpec ^. #frontmatter)+ typeNames = map (^. #type_) (rawSpec ^. #types)+ definitionErrors =+ List.sortOn definitionErrorKey $+ map DuplicateTypeRule (duplicates typeNames)+ <> scopeErrors Nothing (rawSpec ^. #frontmatter)+ <> concat+ [ scopeErrors (Just (rule ^. #type_)) (rule ^. #frontmatter)+ | rule <- rawSpec ^. #types+ ]+ <> vocabularyErrors+ <> cardinalityErrors+ <> nestedCardinalityErrors+ <> objectCardinalityErrors+ <> formatParameterErrors+ <> conflictingFormatErrors+ <> conditionDefinitionErrors+ <> referenceDefinitionErrors+ <> versionErrors+ <> requiredBundleVersionErrors++ definitionErrorKey = \case+ DuplicateTypeRule ctype -> (1 :: Int, ctype, 0 :: Int, "", 0 :: Int)+ DuplicateFieldRule scope listName key ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, listKey listName, key, 0)+ ConflictingFieldRequirement scope key ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 2, key, 0)+ UnsatisfiableVocabulary scope key _ _ ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 3, key, 0)+ ConflictingCardinality scope key _ _ ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 4, key, 0)+ ElementFieldsRequireList scope path cardinality ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 4, renderFieldPathKey path, fromEnum (cardinality == Scalar))+ InvalidFormatParameter path fieldFormat parameter ->+ (2, renderFieldPathKey path, 5, Text.pack (show fieldFormat), Text.length parameter)+ ConflictingFieldFormat path profileFormat typeFormat ->+ (2, renderFieldPathKey path, 6, Text.pack (show profileFormat), fromEnum (profileFormat == typeFormat))+ EmptyConditionValues scope target source -> conditionErrorKey scope target source 7+ ConditionFieldNotDeclared scope target source -> conditionErrorKey scope target source 8+ ConditionFieldNotScalar scope target source cardinality ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 9, renderFieldPathKey target <> ":" <> renderFieldPathKey source, fromEnum (cardinality == Scalar))+ ConditionFieldOpenVocabulary scope target source -> conditionErrorKey scope target source 10+ ConditionFieldHasUnreachableValues scope target source _ _ -> conditionErrorKey scope target source 11+ SelfConditionalField scope target ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 12, renderFieldPathKey target, 0)+ InvalidReferencePrefix scope target prefix -> referenceErrorKey scope target 13 prefix+ ReferencePrefixNotDeclared scope target prefix -> referenceErrorKey scope target 14 prefix+ ReferenceRequiresIdField scope target -> referenceErrorKey scope target 15 ""+ InvalidExternalReferenceScheme scope target scheme -> referenceErrorKey scope target 16 scheme+ ConflictingReferencePrefix ctype target profilePrefix typePrefix ->+ (1, ctype, 17, renderFieldPathKey target <> ":" <> profilePrefix, Text.length typePrefix)+ ReferenceWithFormat scope target fieldFormat -> referenceErrorKey scope target 18 (Text.pack (show fieldFormat))+ OptionalFieldWithCondition scope target ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 19, renderFieldPathKey target, 0)+ ObjectFieldsRequireObjectShape scope fieldPath cardinality ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 20, renderFieldPathKey fieldPath, fromEnum (cardinality == Scalar))+ PathReferenceWithHandleReference scope target -> referenceErrorKey scope target 21 ""+ -- The two version-parse errors are profile-wide rather than scoped, and+ -- rank below every scope rank: if the declared version is unreadable, every+ -- version-derived error below is downstream noise and the reader should see+ -- the cause first. Nothing constrains the first component to be+ -- non-negative.+ InvalidProfileOkfVersion rawVersion -> (-1, rawVersion, 0, "", 0)+ ProfileOkfVersionNotUnderstood rawVersion -> (-1, rawVersion, 1, "", 0)+ InvalidRequiredBundleVersion rawVersion -> (-1, rawVersion, 2, "", 0)+ FieldSupersededInOkfVersion scope path _declared supersededIn ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 23, renderFieldPathKey path <> ":" <> supersededIn, 0)+ FormatRequiresOkfVersion scope path fieldFormat _declared introducedIn ->+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, 24, renderFieldPathKey path <> ":" <> introducedIn, Text.length (Text.pack (show fieldFormat)))++ scopeKey Nothing = (0, "")+ scopeKey (Just ctype) = (1, ctype)+ listKey "required" = 0+ listKey _ = 1+ conditionErrorKey scope target source rank =+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, rank, renderFieldPathKey target <> ":" <> renderFieldPathKey source, 0)+ referenceErrorKey scope target rank detail =+ let (scopeRank, typeName) = scopeKey scope+ in (scopeRank, typeName, rank, renderFieldPathKey target <> ":" <> detail, 0)++ scopeErrors scope FrontmatterRules {required, recommended, optional} =+ [DuplicateFieldRule scope "required" key | key <- duplicates (map (^. #field) required)]+ <> [DuplicateFieldRule scope "recommended" key | key <- duplicates (map (^. #field) recommended)]+ <> [DuplicateFieldRule scope "optional" key | key <- duplicates (map (^. #field) optional)]+ <> [ ConflictingFieldRequirement scope key+ | key <- presenceListCollisions (map (^. #field) required) (map (^. #field) recommended) (map (^. #field) optional)+ ]+ <> concatMap (nestedScopeErrors scope) (required <> recommended <> optional)++ nestedScopeErrors scope parentRule =+ concatMap oneNestedRuleSet (declaredNestedRuleSets parentRule)+ where+ oneNestedRuleSet NestedRules {required, recommended, optional} =+ let qualify key = parentRule ^. #field <> "." <> key+ in [DuplicateFieldRule scope "nested required" (qualify key) | key <- duplicates (map (^. #field) required)]+ <> [DuplicateFieldRule scope "nested recommended" (qualify key) | key <- duplicates (map (^. #field) recommended)]+ <> [DuplicateFieldRule scope "nested optional" (qualify key) | key <- duplicates (map (^. #field) optional)]+ <> [ ConflictingFieldRequirement scope (qualify key)+ | key <- presenceListCollisions (map (^. #field) required) (map (^. #field) recommended) (map (^. #field) optional)+ ]++ -- One key classified by more than one presence list at a single scope. The+ -- profile cannot say both "always check for this" and "never check for+ -- this", so any pairing is a definition error rather than a precedence rule.+ presenceListCollisions requiredKeys recommendedKeys optionalKeys =+ List.nub . List.sort . concat $+ [ List.intersect requiredKeys recommendedKeys,+ List.intersect requiredKeys optionalKeys,+ List.intersect recommendedKeys optionalKeys+ ]++ duplicates = mapMaybe duplicateHead . List.group . List.sort+ duplicateHead (candidate : _ : _) = Just candidate+ duplicateHead _ = Nothing++ vocabularyErrors =+ [ UnsatisfiableVocabulary (Just (rule ^. #type_)) key profileValues typeValues+ | rule <- rawSpec ^. #types,+ let typeRules = compileRules (rule ^. #frontmatter),+ (key, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),+ let profileValues = profileRule ^. #allowedValues+ typeValues = typeRule ^. #allowedValues,+ not (null profileValues),+ not (null typeValues),+ null (mergeVocabulary profileValues typeValues)+ ]+ <> [ UnsatisfiableVocabulary (Just (rule ^. #type_)) (parentKey <> "." <> nestedKey) profileValues typeValues+ | rule <- rawSpec ^. #types,+ let typeRules = compileRules (rule ^. #frontmatter),+ (parentKey, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),+ (profileNested, typeNested) <- pairedNestedRuleMaps profileRule typeRule,+ (nestedKey, (profileNestedRule, typeNestedRule)) <- Map.toAscList (Map.intersectionWith (,) profileNested typeNested),+ let profileValues = profileNestedRule ^. #allowedValues+ typeValues = typeNestedRule ^. #allowedValues,+ not (null profileValues),+ not (null typeValues),+ null (mergeVocabulary profileValues typeValues)+ ]++ cardinalityErrors =+ [ ConflictingCardinality (Just (rule ^. #type_)) key profileCardinality typeCardinality+ | rule <- rawSpec ^. #types,+ let typeRules = compileRules (rule ^. #frontmatter),+ (key, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),+ let profileCardinality = profileRule ^. #cardinality+ typeCardinality = typeRule ^. #cardinality,+ profileCardinality /= Any,+ typeCardinality /= Any,+ profileCardinality /= typeCardinality+ ]+ <> [ ConflictingCardinality (Just (rule ^. #type_)) (parentKey <> "." <> nestedKey) profileCardinality typeCardinality+ | rule <- rawSpec ^. #types,+ let typeRules = compileRules (rule ^. #frontmatter),+ (parentKey, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),+ (profileNested, typeNested) <- pairedNestedRuleMaps profileRule typeRule,+ (nestedKey, (profileNestedRule, typeNestedRule)) <- Map.toAscList (Map.intersectionWith (,) profileNested typeNested),+ let profileCardinality = profileNestedRule ^. #cardinality+ typeCardinality = typeNestedRule ^. #cardinality,+ profileCardinality /= Any,+ typeCardinality /= Any,+ profileCardinality /= typeCardinality+ ]++ nestedCardinalityErrors =+ [ ElementFieldsRequireList scope (topLevelFieldPath (fieldRule ^. #field)) Scalar+ | (scope, rules) <- scopedFrontmatterRules,+ fieldRule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,+ isJust (fieldRule ^. #elementFields),+ fieldRule ^. #cardinality == Scalar+ ]++ -- The mirror image: @objectFields@ says the value is a mapping, and neither+ -- an explicit @scalar@ nor an explicit @list@ can be one.+ objectCardinalityErrors =+ [ ObjectFieldsRequireObjectShape scope (topLevelFieldPath (fieldRule ^. #field)) declared+ | (scope, rules) <- scopedFrontmatterRules,+ fieldRule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,+ isJust (fieldRule ^. #objectFields),+ let declared = fieldRule ^. #cardinality,+ declared == Scalar || declared == List+ ]++ scopedFrontmatterRules =+ (Nothing, rawSpec ^. #frontmatter)+ : [(Just (rule ^. #type_), rule ^. #frontmatter) | rule <- rawSpec ^. #types]++ -- The nested rule sets one raw rule declares: its list-element rules, its+ -- object-member rules, or both. Deduplicated because @mk.recordOrList@+ -- deliberately declares the same rules under both names, and a definition+ -- error names a path such as @verified.by@ that does not distinguish the two+ -- — so without this the same incoherence would be reported twice.+ declaredNestedRuleSets rawRule =+ List.nub (catMaybes [rawRule ^. #elementFields, rawRule ^. #objectFields])++ -- The same idea across two scopes: the nested maps a profile-scope rule and+ -- a type-scope rule both declare, paired shape with matching shape. Pairing+ -- an element map against an object map would compare rules that never meet.+ pairedNestedRuleMaps profileRule typeRule =+ List.nub+ [ (profileNested, typeNested)+ | nestedMap <- [(^. #elementFields), (^. #objectFields)],+ Just profileNested <- [nestedMap profileRule],+ Just typeNested <- [nestedMap typeRule]+ ]++ formatParameterErrors =+ [ InvalidFormatParameter (topLevelFieldPath (rule ^. #field)) fieldFormat parameter+ | rules <- (rawSpec ^. #frontmatter) : map (^. #frontmatter) (rawSpec ^. #types),+ rule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,+ Just (fieldFormat, parameter) <- [invalidFormatParameter =<< rule ^. #format]+ ]+ <> [ InvalidFormatParameter (nestedDefinitionPath (rule ^. #field) (nestedRule ^. #field)) fieldFormat parameter+ | rules <- (rawSpec ^. #frontmatter) : map (^. #frontmatter) (rawSpec ^. #types),+ rule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,+ nestedRules <- declaredNestedRuleSets rule,+ nestedRule <- nestedRules ^. #required <> nestedRules ^. #recommended <> nestedRules ^. #optional,+ Just (fieldFormat, parameter) <- [invalidFormatParameter =<< nestedRule ^. #format]+ ]++ conflictingFormatErrors =+ [ ConflictingFieldFormat (topLevelFieldPath key) profileFormat typeFormat+ | rule <- rawSpec ^. #types,+ let typeRules = compileRules (rule ^. #frontmatter),+ (key, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),+ Just profileFormat <- [profileRule ^. #format],+ Just typeFormat <- [typeRule ^. #format],+ mergeFieldFormat (Just profileFormat) (Just typeFormat) == Nothing+ ]+ <> [ ConflictingFieldFormat (nestedDefinitionPath parentKey nestedKey) profileFormat typeFormat+ | rule <- rawSpec ^. #types,+ let typeRules = compileRules (rule ^. #frontmatter),+ (parentKey, (profileRule, typeRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeRules),+ (profileNested, typeNested) <- pairedNestedRuleMaps profileRule typeRule,+ (nestedKey, (profileNestedRule, typeNestedRule)) <- Map.toAscList (Map.intersectionWith (,) profileNested typeNested),+ Just profileFormat <- [profileNestedRule ^. #format],+ Just typeFormat <- [typeNestedRule ^. #format],+ mergeFieldFormat (Just profileFormat) (Just typeFormat) == Nothing+ ]++ conditionDefinitionErrors =+ conditionErrors Nothing Nothing (rawSpec ^. #frontmatter) baseRules+ <> concat+ [ let typeRules = compileRules (rule ^. #frontmatter)+ mergedRules = mergeRules baseRules typeRules+ in conditionErrors (Just (rule ^. #type_)) Nothing (rule ^. #frontmatter) mergedRules+ | rule <- rawSpec ^. #types+ ]++ conditionErrors scope parent rules effectiveRules =+ concatMap (fieldConditionErrors scope parent effectiveRules) (rules ^. #required <> rules ^. #recommended)+ <> [ deadCondition scope parent (rawRule ^. #field)+ | rawRule <- rules ^. #optional,+ isJust (rawRule ^. #when)+ ]+ <> List.nub+ ( concat+ [ concatMap+ (fieldConditionErrors scope (Just (rawRule ^. #field)) effectiveNestedRules)+ (nestedRules ^. #required <> nestedRules ^. #recommended)+ <> [ deadCondition scope (Just (rawRule ^. #field)) (nestedRule ^. #field)+ | nestedRule <- nestedRules ^. #optional,+ isJust (nestedRule ^. #when)+ ]+ | rawRule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,+ let effectiveRule = Map.lookup (rawRule ^. #field) effectiveRules,+ (nestedRules, effectiveNestedRules) <-+ catMaybes+ [ (,) <$> (rawRule ^. #elementFields) <*> (effectiveRule >>= (^. #elementFields)),+ (,) <$> (rawRule ^. #objectFields) <*> (effectiveRule >>= (^. #objectFields))+ ]+ ]+ )++ -- A condition gates presence, and an optional rule has no presence clause to+ -- gate, so the pairing is dead in the descriptor rather than a weaker rule.+ deadCondition scope parent key =+ OptionalFieldWithCondition+ scope+ (maybe (topLevelFieldPath key) (\parentKey -> nestedDefinitionPath parentKey key) parent)++ fieldConditionErrors scope parent effectiveRules rawRule =+ case rawRule ^. #when of+ Nothing -> []+ Just FieldCondition {field = sourceKey, hasValue = conditionValues} ->+ let targetKey = rawRule ^. #field+ targetPath = maybe (topLevelFieldPath targetKey) (\parentKey -> nestedDefinitionPath parentKey targetKey) parent+ sourcePath = maybe (topLevelFieldPath sourceKey) (\parentKey -> nestedDefinitionPath parentKey sourceKey) parent+ normalizedValues = deduplicate conditionValues+ sourceErrors =+ case Map.lookup sourceKey effectiveRules of+ Nothing -> [ConditionFieldNotDeclared scope targetPath sourcePath]+ Just sourceRule ->+ [ ConditionFieldNotScalar scope targetPath sourcePath (sourceRule ^. #cardinality)+ | sourceRule ^. #cardinality /= Scalar+ ]+ <> [ ConditionFieldOpenVocabulary scope targetPath sourcePath+ | null (sourceRule ^. #allowedValues)+ ]+ <> let unreachable = filter (`notElem` sourceRule ^. #allowedValues) normalizedValues+ in [ ConditionFieldHasUnreachableValues scope targetPath sourcePath unreachable (sourceRule ^. #allowedValues)+ | not (null (sourceRule ^. #allowedValues)),+ not (null unreachable)+ ]+ in [EmptyConditionValues scope targetPath sourcePath | null normalizedValues]+ <> [SelfConditionalField scope targetPath | targetKey == sourceKey]+ <> sourceErrors++ referenceDefinitionErrors =+ List.nub $+ concatMap rawReferenceErrors scopedRules+ <> concatMap mergedReferenceErrors (rawSpec ^. #types)+ where+ declaredPrefixes = mapMaybe (^. #idPrefix) (rawSpec ^. #types)+ scopedRules =+ (Nothing, rawSpec ^. #frontmatter)+ : [(Just (rule ^. #type_), rule ^. #frontmatter) | rule <- rawSpec ^. #types]++ rawReferenceErrors (scope, rules) =+ concatMap (fieldReferenceErrors scope) topLevelRules+ <> concatMap (fieldPathErrors scope) topLevelRules+ -- Path rules are declarable at nested and object scope too, which+ -- is where @sources[].resource@ lives, so the walk descends. It+ -- hangs on 'declaredNestedRuleSets' rather than iterating+ -- @elementFields@ and @objectFields@ separately, because+ -- @mk.recordOrList@ declares one rule set under both names and a+ -- @FieldPath@ such as @sources.resource@ cannot tell them apart.+ <> [ nestedError+ | rule <- topLevelRules,+ nestedRules <- declaredNestedRuleSets rule,+ nestedRule <- nestedRules ^. #required <> nestedRules ^. #recommended <> nestedRules ^. #optional,+ nestedError <-+ pathPolicyErrors+ scope+ (nestedDefinitionPath (rule ^. #field) (nestedRule ^. #field))+ (nestedRule ^. #format)+ Nothing+ (nestedRule ^. #path)+ ]+ where+ topLevelRules = rules ^. #required <> rules ^. #recommended <> rules ^. #optional++ fieldReferenceErrors scope rule =+ case rule ^. #reference of+ Nothing -> []+ Just policy ->+ let path = topLevelFieldPath (rule ^. #field)+ prefix = policy ^. #localPrefix+ schemes = deduplicateSchemes (policy ^. #externalUriSchemes)+ in [InvalidReferencePrefix scope path prefix | not (validDocumentHandlePrefix prefix)]+ <> [ReferencePrefixNotDeclared scope path prefix | prefix `notElem` declaredPrefixes]+ <> [ReferenceRequiresIdField scope path | isNothing (rawSpec ^. #idField)]+ <> [InvalidExternalReferenceScheme scope path scheme | scheme <- schemes, not (validUriScheme scheme)]+ <> [ReferenceWithFormat scope path fieldFormat | Just fieldFormat <- [rule ^. #format]]++ fieldPathErrors scope rule =+ pathPolicyErrors+ scope+ (topLevelFieldPath (rule ^. #field))+ (rule ^. #format)+ (rule ^. #reference)+ (rule ^. #path)++ -- The three ways a path policy can be incoherent on its own. Two reuse+ -- the handle-reference constructors because the claim is identical: a+ -- scheme that is not a legal URI scheme, and a structural interpretation+ -- of a value paired with a textual format that would be checked against+ -- the same text. The third is genuinely new — a value cannot be resolved+ -- as both a @PREFIX-N@ handle and a §6.2 path — and cannot arise at+ -- nested scope, where 'NestedFieldRule' carries no handle policy.+ pathPolicyErrors scope path declaredFormat handlePolicy = \case+ Nothing -> []+ Just policy ->+ [ InvalidExternalReferenceScheme scope path scheme+ | scheme <- deduplicateSchemes (policy ^. #externalUriSchemes),+ not (validUriScheme scheme)+ ]+ <> [ReferenceWithFormat scope path fieldFormat | Just fieldFormat <- [declaredFormat]]+ <> [PathReferenceWithHandleReference scope path | isJust handlePolicy]++ mergedReferenceErrors typeRule =+ [ ConflictingReferencePrefix (typeRule ^. #type_) (topLevelFieldPath key) (profilePolicy ^. #localPrefix) (typePolicy ^. #localPrefix)+ | let typeFields = compileRules (typeRule ^. #frontmatter),+ (key, (profileRule, typeFieldRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeFields),+ Just profilePolicy <- [profileRule ^. #reference],+ Just typePolicy <- [typeFieldRule ^. #reference],+ profilePolicy ^. #localPrefix /= typePolicy ^. #localPrefix+ ]+ <> [ ReferenceWithFormat (Just (typeRule ^. #type_)) (topLevelFieldPath key) fieldFormat+ | let typeFields = compileRules (typeRule ^. #frontmatter),+ (key, (profileRule, typeFieldRule)) <- Map.toAscList (Map.intersectionWith (,) baseRules typeFields),+ (referenceRule, formatRule) <- [(profileRule, typeFieldRule), (typeFieldRule, profileRule)],+ isJust (referenceRule ^. #reference),+ Just fieldFormat <- [formatRule ^. #format]+ ]++ -- Only a value okf cannot parse is rejected. An unknown /major/ is+ -- deliberately accepted, unlike in @okfVersion@: there the profile is asking+ -- okf to interpret rules it may not understand, while here it is stating a+ -- minimum that a bundle's own declaration is compared against, which stays+ -- meaningful whatever the major is.+ requiredBundleVersionErrors =+ case rawSpec ^. #requireBundleVersion of+ Just rawVersion+ | isNothing (parseOkfVersion rawVersion) -> [InvalidRequiredBundleVersion rawVersion]+ _ -> []++ versionErrors =+ case effectiveProfileVersion (rawSpec ^. #okfVersion) of+ Left versionError -> [versionError]+ Right effectiveVersion ->+ let atLeastV02 = effectiveVersion >= okfVersion02+ v02Text = renderOkfVersion okfVersion02+ -- The /effective/ version, not the raw declared string: a profile+ -- declaring 0.9 is checked as 0.2 and its diagnostics should say so+ -- rather than repeating a number okf did not act on.+ declaredText = renderOkfVersion effectiveVersion+ in -- A key v0.2 superseded, demanded by a profile declaring v0.2 or+ -- later. Deliberately /not/ checked in the optional list: a team+ -- migrating a corpus wants @generated@ required and @timestamp@+ -- tolerated but not demanded, and the optional list says exactly+ -- that. This is the one check whose answer depends on which presence+ -- list a rule came from, so it reads the raw lists rather than the+ -- compiled map, which erases the distinction into presence clauses.+ [ FieldSupersededInOkfVersion scope (topLevelFieldPath (rule ^. #field)) declaredText v02Text+ | atLeastV02,+ (scope, rules) <- scopedFrontmatterRules,+ rule <- rules ^. #required <> rules ^. #recommended,+ rule ^. #field `elem` fieldsSupersededInV02+ ]+ -- The actor formats encode the specification §7 convention that+ -- v0.2 introduced. A format is an okf descriptor feature rather+ -- than a key name, so unlike the mirror case below it has no+ -- house-convention reading: @FieldFormat.Actor@ /is/ §7.+ <> [ FormatRequiresOkfVersion scope path fieldFormat declaredText v02Text+ | not atLeastV02,+ (scope, path, fieldFormat) <- scopedFormats,+ fieldFormat `elem` [Actor, HumanActor]+ ]++ -- There is deliberately no mirror check, "a profile declaring v0.1 names a+ -- key v0.2 introduced", which is why 'fieldsIntroducedInV02' is unused here.+ -- A profile key /name/ does not imply the OKF core key of that name: per+ -- docs/adr/1-profile-declared-document-ids.md, constraining keys the core+ -- format does not own is what profiles are for, and @status@, @sources@, and+ -- @verified@ are ordinary words teams were already using as house conventions+ -- before v0.2 claimed them. Such a check is both retroactive and ambiguous,+ -- which docs/adr/11-growing-the-profile-descriptor-language.md forbids.++ -- Every declared format paired with the path it sits at, top-level first and+ -- then nested. Only the format checks descend: 'fieldsIntroducedInV02' and+ -- 'fieldsSupersededInV02' name /concept-level/ frontmatter keys, so matching+ -- them against a member of a record would reject a profile whose element+ -- happens to be called @status@ for having a v0.2 key it does not have.+ scopedFormats =+ [ (scope, topLevelFieldPath (rule ^. #field), fieldFormat)+ | (scope, rules) <- scopedFrontmatterRules,+ rule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,+ Just fieldFormat <- [rule ^. #format]+ ]+ <> List.nub+ [ (scope, nestedDefinitionPath (rule ^. #field) (nestedRule ^. #field), fieldFormat)+ | (scope, rules) <- scopedFrontmatterRules,+ rule <- rules ^. #required <> rules ^. #recommended <> rules ^. #optional,+ nestedRules <- declaredNestedRuleSets rule,+ nestedRule <- nestedRules ^. #required <> nestedRules ^. #recommended <> nestedRules ^. #optional,+ Just fieldFormat <- [nestedRule ^. #format]+ ]++ renderFieldPathKey (FieldPath (firstSegment :| remainingSegments)) =+ Text.intercalate "." (map renderSegment (firstSegment : remainingSegments))+ renderSegment (FieldName name) = name+ renderSegment (ArrayIndex elementIndex) = Text.pack (show elementIndex)++-- | The OKF version that introduced the v0.2 frontmatter families and the actor+-- convention. Written out rather than reusing 'supportedOkfVersion', which+-- happens to equal it today: one is "what okf implements" and the other is "what+-- introduced these keys", and they will diverge at the next minor bump.+okfVersion02 :: OkfVersion+okfVersion02 = OkfVersion {okfVersionMajor = 0, okfVersionMinor = 2}++-- | The OKF version a profile's rules are checked against, or why okf cannot+-- tell.+--+-- A higher /minor/ within a known major is clamped, mirroring+-- 'Okf.Validation.versionGate': specification §12 defines a minor bump as+-- backward-compatible additions, so every rule a v0.3 profile can express is one+-- okf already understands.+--+-- An unknown /major/ is deliberately an error, where the bundle side reads+-- best-effort — this is a considered divergence, not an oversight. §12's+-- best-effort instruction is about bundles, which may come from a third party+-- okf cannot ask. A profile is not a document okf is asked to read; it is an+-- instruction to okf about what to check, written by an author who is present.+-- Silently ignoring an instruction okf cannot interpret is worse than saying so.+-- See @docs\/adr\/10-okf-version-declaration-and-best-effort-reading.md@.+effectiveProfileVersion :: Text -> Either ProfileDefinitionError OkfVersion+effectiveProfileVersion rawVersion =+ case parseOkfVersion rawVersion of+ Nothing -> Left (InvalidProfileOkfVersion rawVersion)+ Just declared+ | okfVersionMajor declared == okfVersionMajor supportedOkfVersion ->+ Right (min declared supportedOkfVersion)+ | otherwise -> Left (ProfileOkfVersionNotUnderstood rawVersion)++compileRules :: FrontmatterRules -> Map Text EffectiveFieldRule+compileRules FrontmatterRules {required, recommended, optional} =+ Map.fromList+ ( [ (rule ^. #field, compileFieldRule RequiredField rule)+ | rule <- required+ ]+ <> [ (rule ^. #field, compileFieldRule RecommendedField rule)+ | rule <- recommended+ ]+ <> [ (rule ^. #field, compileOptionalFieldRule rule)+ | rule <- optional+ ]+ )++-- | Compile a rule the profile documents but never demands. It carries no+-- presence clause, so 'applicablePresenceClause' can never find one to report in+-- either validation mode, while every value check still runs from the+-- present-value branch. This is also the value constraints a required or+-- recommended rule is built from.+compileOptionalFieldRule :: FieldRule -> EffectiveFieldRule+compileOptionalFieldRule rule =+ EffectiveFieldRule+ { presenceClauses = [],+ description = rule ^. #description,+ allowedValues = deduplicate (rule ^. #allowedValues),+ cardinality =+ -- Declaring a nested shape and no explicit cardinality refines what the+ -- value may be, because the shape only makes sense against one. A rule+ -- declaring both shapes stays 'Any', which is what lets either spelling+ -- of the OKF v0.2 @verified@ key satisfy it.+ case (rule ^. #objectFields, rule ^. #elementFields, rule ^. #cardinality) of+ (Just _, Just _, Any) -> Any+ (Just _, Nothing, Any) -> Object+ (Nothing, Just _, Any) -> List+ (_, _, Any) -> refineCardinalityForFormat (rule ^. #format)+ (_, _, declared) -> declared,+ format = rule ^. #format,+ elementFields = compileNestedRules <$> rule ^. #elementFields,+ objectFields = compileNestedRules <$> rule ^. #objectFields,+ reference = compileReferenceRule <$> rule ^. #reference,+ path = compilePathRule <$> rule ^. #path+ }++-- | The cardinality a rule with no declared one takes from its format.+--+-- A non-textual format implies a scalar, and without this the rule would be+-- useless: the 'Any' cardinality routes presence through 'legacyValueIsPresent',+-- which counts only non-empty text and non-empty arrays, so a @usage_count:+-- 5000@ is reported /missing/ before its value is ever examined. Refining here+-- rather than widening 'legacyValueIsPresent' keeps the meaning of every+-- descriptor that already exists — in particular a key whose value is @false@+-- keeps being reported as missing when no rule says otherwise.+--+-- An explicitly declared cardinality always wins, including @list@: a list of+-- integers is a coherent thing to demand, so pairing a numeric format with+-- 'List' is left alone rather than made an error.+refineCardinalityForFormat :: Maybe FieldFormat -> Cardinality+refineCardinalityForFormat = \case+ Just Integer -> Scalar+ Just NonNegativeInteger -> Scalar+ Just Boolean -> Scalar+ _ -> Any++compileFieldRule :: FieldRequirement -> FieldRule -> EffectiveFieldRule+compileFieldRule requirement rule =+ (compileOptionalFieldRule rule)+ { presenceClauses = [PresenceClause requirement (compileCondition <$> rule ^. #when)]+ }++compileNestedRules :: NestedRules -> Map Text EffectiveFieldRule+compileNestedRules NestedRules {required, recommended, optional} =+ Map.fromList+ ( [ (rule ^. #field, compileNestedFieldRule RequiredField rule)+ | rule <- required+ ]+ <> [ (rule ^. #field, compileNestedFieldRule RecommendedField rule)+ | rule <- recommended+ ]+ <> [ (rule ^. #field, compileOptionalNestedFieldRule rule)+ | rule <- optional+ ]+ )++-- | The nested counterpart of 'compileOptionalFieldRule'.+compileOptionalNestedFieldRule :: NestedFieldRule -> EffectiveFieldRule+compileOptionalNestedFieldRule rule =+ EffectiveFieldRule+ { presenceClauses = [],+ description = rule ^. #description,+ allowedValues = deduplicate (rule ^. #allowedValues),+ cardinality =+ case rule ^. #cardinality of+ Any -> refineCardinalityForFormat (rule ^. #format)+ declared -> declared,+ format = rule ^. #format,+ elementFields = Nothing,+ -- Nested rules stay depth-bounded: 'NestedFieldRule' has no object member,+ -- so a profile cannot constrain @sources[0].usage_window.from@.+ objectFields = Nothing,+ -- Still 'Nothing': 'NestedFieldRule' carries no document-handle policy.+ reference = Nothing,+ -- But it does carry a path policy, which is the point of the member —+ -- @sources[].resource@ is only reachable here.+ path = compilePathRule <$> rule ^. #path+ }++compileNestedFieldRule :: FieldRequirement -> NestedFieldRule -> EffectiveFieldRule+compileNestedFieldRule requirement rule =+ (compileOptionalNestedFieldRule rule)+ { presenceClauses = [PresenceClause requirement (compileCondition <$> rule ^. #when)]+ }++mergeRules :: Map Text EffectiveFieldRule -> Map Text EffectiveFieldRule -> Map Text EffectiveFieldRule+mergeRules = Map.unionWith mergeEffectiveFieldRule++mergeEffectiveFieldRule :: EffectiveFieldRule -> EffectiveFieldRule -> EffectiveFieldRule+mergeEffectiveFieldRule profileRule typeRule =+ EffectiveFieldRule+ { presenceClauses = profileRule ^. #presenceClauses <> typeRule ^. #presenceClauses,+ description = typeRule ^. #description <|> profileRule ^. #description,+ allowedValues = mergeVocabulary (profileRule ^. #allowedValues) (typeRule ^. #allowedValues),+ cardinality = mergeCardinality (profileRule ^. #cardinality) (typeRule ^. #cardinality),+ format = fromMaybe (profileRule ^. #format) (mergeFieldFormat (profileRule ^. #format) (typeRule ^. #format)),+ elementFields = mergeNestedRuleMaps (profileRule ^. #elementFields) (typeRule ^. #elementFields),+ objectFields = mergeNestedRuleMaps (profileRule ^. #objectFields) (typeRule ^. #objectFields),+ reference = fromMaybe (profileRule ^. #reference) (mergeReferenceRule (profileRule ^. #reference) (typeRule ^. #reference)),+ path = mergePathRule (profileRule ^. #path) (typeRule ^. #path)+ }++-- | Normalize a declared condition for storage in a 'PresenceClause': the shape+-- is unchanged, but the accepted-value list is deduplicated so that a clause+-- reported in a 'ProfileViolation' does not repeat a value the author wrote+-- twice.+compileCondition :: FieldCondition -> FieldCondition+compileCondition rawCondition =+ FieldCondition+ { field = rawCondition ^. #field,+ hasValue = deduplicate (rawCondition ^. #hasValue)+ }++compileReferenceRule :: HandleReferenceRule -> HandleReferenceRule+compileReferenceRule policy =+ HandleReferenceRule+ { localPrefix = policy ^. #localPrefix,+ externalUriSchemes = map Text.toCaseFold (deduplicateSchemes (policy ^. #externalUriSchemes)),+ allowSelf = policy ^. #allowSelf+ }++compilePathRule :: PathReferenceRule -> PathReferenceRule+compilePathRule policy =+ PathReferenceRule+ { externalUriSchemes = map Text.toCaseFold (deduplicateSchemes (policy ^. #externalUriSchemes)),+ allowSelf = policy ^. #allowSelf+ }++-- | Combine a profile-scope path policy with a type-scope one: intersect the+-- permitted schemes and require both scopes to allow self-reference.+--+-- Unlike 'mergeReferenceRule' this is total and needs no @Maybe@-of-@Maybe@+-- result, because a path policy has no @localPrefix@ — the one thing two+-- handle policies can flatly disagree about. Narrowing to the intersection is+-- the same direction 'mergeVocabulary' takes: a type rule tightens the+-- profile-wide rule and never loosens it.+mergePathRule :: Maybe PathReferenceRule -> Maybe PathReferenceRule -> Maybe PathReferenceRule+mergePathRule Nothing typePolicy = typePolicy+mergePathRule profilePolicy Nothing = profilePolicy+mergePathRule (Just profilePolicy) (Just typePolicy) =+ Just+ PathReferenceRule+ { externalUriSchemes =+ filter+ (`Set.member` Set.fromList (typePolicy ^. #externalUriSchemes))+ (profilePolicy ^. #externalUriSchemes),+ allowSelf = profilePolicy ^. #allowSelf && typePolicy ^. #allowSelf+ }++-- | Merge one scope's map of member rules with another's. Used for both+-- @elementFields@ and @objectFields@; it was named for the former until the+-- latter existed and is a plain union-with-merge either way.+mergeNestedRuleMaps :: Maybe (Map Text EffectiveFieldRule) -> Maybe (Map Text EffectiveFieldRule) -> Maybe (Map Text EffectiveFieldRule)+mergeNestedRuleMaps Nothing typeRules = typeRules+mergeNestedRuleMaps profileRules Nothing = profileRules+mergeNestedRuleMaps (Just profileRules) (Just typeRules) = Just (Map.unionWith mergeEffectiveFieldRule profileRules typeRules)++mergeReferenceRule :: Maybe HandleReferenceRule -> Maybe HandleReferenceRule -> Maybe (Maybe HandleReferenceRule)+mergeReferenceRule Nothing typePolicy = Just typePolicy+mergeReferenceRule profilePolicy Nothing = Just profilePolicy+mergeReferenceRule (Just profilePolicy) (Just typePolicy)+ | profilePolicy ^. #localPrefix == typePolicy ^. #localPrefix =+ Just . Just $+ HandleReferenceRule+ { localPrefix = profilePolicy ^. #localPrefix,+ externalUriSchemes =+ filter+ (`Set.member` Set.fromList (typePolicy ^. #externalUriSchemes))+ (profilePolicy ^. #externalUriSchemes),+ allowSelf = profilePolicy ^. #allowSelf && typePolicy ^. #allowSelf+ }+ | otherwise = Nothing++mergeFieldFormat :: Maybe FieldFormat -> Maybe FieldFormat -> Maybe (Maybe FieldFormat)+mergeFieldFormat Nothing typeFormat = Just typeFormat+mergeFieldFormat profileFormat Nothing = Just profileFormat+mergeFieldFormat (Just Uri) (Just typeFormat@(UriWithScheme _)) = Just (Just typeFormat)+mergeFieldFormat (Just profileFormat@(UriWithScheme _)) (Just Uri) = Just (Just profileFormat)+mergeFieldFormat (Just Actor) (Just HumanActor) = Just (Just HumanActor)+mergeFieldFormat (Just HumanActor) (Just Actor) = Just (Just HumanActor)+mergeFieldFormat (Just Integer) (Just NonNegativeInteger) = Just (Just NonNegativeInteger)+mergeFieldFormat (Just NonNegativeInteger) (Just Integer) = Just (Just NonNegativeInteger)+mergeFieldFormat profileFormat typeFormat+ | profileFormat == typeFormat = Just profileFormat+ | otherwise = Nothing++invalidFormatParameter :: FieldFormat -> Maybe (FieldFormat, Text)+invalidFormatParameter fieldFormat =+ case fieldFormat of+ UriWithScheme scheme+ | not (validUriScheme scheme) -> Just (fieldFormat, scheme)+ DocumentHandle prefix+ | not (validDocumentHandlePrefix prefix) -> Just (fieldFormat, prefix)+ _ -> Nothing++validUriScheme :: Text -> Bool+validUriScheme scheme =+ case Text.uncons scheme of+ Just (firstCharacter, rest) -> isAsciiLetter firstCharacter && Text.all validRest rest+ Nothing -> False+ where+ validRest character =+ isAsciiLetter character+ || ('0' <= character && character <= '9')+ || character `elem` ['+', '.', '-']++validDocumentHandlePrefix :: Text -> Bool+validDocumentHandlePrefix prefix =+ case parseDocumentId (prefix <> "-1") of+ Just documentId -> documentId ^. #prefix == prefix+ Nothing -> False++isAsciiLetter :: Char -> Bool+isAsciiLetter character = isAsciiLower character || isAsciiUpper character++mergeCardinality :: Cardinality -> Cardinality -> Cardinality+mergeCardinality Any typeCardinality = typeCardinality+mergeCardinality profileCardinality Any = profileCardinality+mergeCardinality profileCardinality _typeCardinality = profileCardinality++mergeVocabulary :: [Text] -> [Text] -> [Text]+mergeVocabulary [] typeValues = typeValues+mergeVocabulary profileValues [] = profileValues+mergeVocabulary profileValues typeValues =+ filter (`Set.member` Set.fromList typeValues) profileValues++deduplicate :: [Text] -> [Text]+deduplicate = go Set.empty+ where+ go _ [] = []+ go seen (value : rest)+ | value `Set.member` seen = go seen rest+ | otherwise = value : go (Set.insert value seen) rest++deduplicateSchemes :: [Text] -> [Text]+deduplicateSchemes = go Set.empty+ where+ go _ [] = []+ go seen (scheme : rest)+ | normalized `Set.member` seen = go seen rest+ | otherwise = scheme : go (Set.insert normalized seen) rest+ where+ normalized = Text.toCaseFold scheme++effectiveRulesForType :: CompiledProfile -> Text -> Map Text EffectiveFieldRule+effectiveRulesForType compiled ctype =+ Map.findWithDefault (compiled ^. #baseRules) ctype (compiled ^. #rulesByType)++profileFieldDescriptionForType :: CompiledProfile -> Text -> Text -> Maybe Text+profileFieldDescriptionForType compiled ctype key =+ Map.lookup key (effectiveRulesForType compiled ctype) >>= (^. #description)++-- | A parsed document handle: an ASCII-letter-led alphanumeric prefix and a+-- positive number, rendered as @PREFIX-N@.+data DocumentId = DocumentId+ { prefix :: !Text,+ number :: !Natural+ }+ deriving stock (Generic, Eq, Ord, Show)++-- | Parse a strict document handle. The prefix contains one or more ASCII+-- letters or digits and begins with a letter. It is followed by exactly one+-- hyphen and a positive decimal number with no leading zero. Thus @ADR-7@+-- parses, while @ADR-007@, @ADR-0@, @ADR-@, @-7@, @adr 7@, and+-- @ADR-7-extra@ do not.+parseDocumentId :: Text -> Maybe DocumentId+parseDocumentId raw =+ case Text.splitOn "-" raw of+ [prefixText, numberText]+ | validPrefix prefixText,+ validNumberText numberText ->+ case Text.Read.decimal numberText of+ Right (parsedNumber, remainder)+ | Text.null remainder,+ parsedNumber > 0 ->+ Just (DocumentId prefixText parsedNumber)+ _ -> Nothing+ _ -> Nothing+ where+ validPrefix value =+ case Text.uncons value of+ Just (firstCharacter, rest) ->+ isAsciiLetter firstCharacter && Text.all isAsciiAlphaNumeric rest+ Nothing -> False+ validNumberText value =+ case Text.uncons value of+ Just (firstCharacter, rest) ->+ firstCharacter >= '1'+ && firstCharacter <= '9'+ && Text.all isAsciiDigit rest+ Nothing -> False+ isAsciiDigit character =+ character >= '0' && character <= '9'+ isAsciiAlphaNumeric character =+ isAsciiLetter character || isAsciiDigit character++-- | Render a document handle as @PREFIX-N@.+renderDocumentId :: DocumentId -> Text+renderDocumentId DocumentId {prefix, number} =+ prefix <> "-" <> Text.pack (show number)++-- | Every well-formed handle under the profile's ID field, paired with the+-- concept carrying it and sorted by prefix, number, then concept ID. Concepts+-- without a well-formed handle are omitted. A profile with no ID field yields+-- an empty list.+documentIdsInBundle :: ProfileSpec -> [Concept] -> [(DocumentId, ConceptId)]+documentIdsInBundle spec concepts =+ case spec ^. #idField of+ Nothing -> []+ Just fieldName ->+ List.sortOn+ (\(documentId, cid) -> (documentId, renderConceptId cid))+ [ (documentId, conceptIdOf concept)+ | concept <- concepts,+ Just (String rawDocumentId) <- [frontmatterLookup fieldName (conceptFrontmatter concept)],+ Just documentId <- [parseDocumentId rawDocumentId]+ ]++-- | Allocate one more than the highest document-ID number already used for the+-- given prefix, or number 1 when the prefix is unused. Gaps are deliberately+-- not filled: reusing a retired number could make an old reference silently+-- point at a different document.+nextDocumentId :: ProfileSpec -> [Concept] -> Text -> DocumentId+nextDocumentId spec concepts requestedPrefix =+ DocumentId+ { prefix = requestedPrefix,+ number = highestNumber + 1+ }+ where+ highestNumber =+ List.foldl'+ max+ 0+ [ documentId ^. #number+ | (documentId, _) <- documentIdsInBundle spec concepts,+ documentId ^. #prefix == requestedPrefix+ ]++-- | One structural segment of a frontmatter path. EP-2 produces top-level+-- 'FieldName' paths; later bounded nested validation can append field names and+-- array indexes without encoding paths as ad hoc text.+data FieldPathSegment+ = FieldName Text+ | ArrayIndex Int+ deriving stock (Generic, Eq, Ord, Show)++newtype FieldPath = FieldPath+ { segments :: NonEmpty FieldPathSegment+ }+ deriving stock (Generic, Eq, Ord, Show)++topLevelFieldPath :: Text -> FieldPath+topLevelFieldPath key = FieldPath (FieldName key :| [])++nestedDefinitionPath :: Text -> Text -> FieldPath+nestedDefinitionPath parent child =+ FieldPath (FieldName parent :| [FieldName child])++nestedValuePath :: Text -> Int -> Text -> FieldPath+nestedValuePath parent elementIndex child =+ FieldPath (FieldName parent :| [ArrayIndex elementIndex, FieldName child])++nestedElementPath :: Text -> Int -> FieldPath+nestedElementPath parent elementIndex =+ FieldPath (FieldName parent :| [ArrayIndex elementIndex])++-- | A single deviation from a profile. Advisory by default at the CLI layer.+data ProfileViolation+ = -- | concept's @type@ is not listed in the profile and unknown types are disallowed+ TypeNotInProfile ConceptId Text+ | -- | a required frontmatter key is missing or empty (concept, key, activating condition)+ MissingProfileField ConceptId Text (Maybe FieldCondition)+ | -- | a recommended frontmatter key is missing under strict authoring+ MissingRecommendedProfileField ConceptId Text (Maybe FieldCondition)+ | -- | a required field is missing inside one list element record+ MissingNestedProfileField ConceptId FieldPath (Maybe FieldCondition)+ | -- | a recommended nested field is missing under strict authoring+ MissingRecommendedNestedProfileField ConceptId FieldPath (Maybe FieldCondition)+ | -- | a present value is outside the effective textual vocabulary+ ValueNotInVocabulary ConceptId FieldPath [Text] Value+ | -- | a present value has the wrong scalar/list shape+ CardinalityMismatch ConceptId FieldPath Cardinality Value+ | -- | a present value does not satisfy its named textual format+ ValueFormatMismatch ConceptId FieldPath FieldFormat Value+ | -- | a local reference has the right prefix but no valid owner in this bundle+ DanglingHandleReference ConceptId FieldPath Text+ | -- | a parsed local handle uses a prefix other than the declared target prefix+ ReferenceHandlePrefixMismatch ConceptId FieldPath Text Text+ | -- | a reference value is neither textual local handle nor valid absolute URI+ MalformedDocumentReference ConceptId FieldPath Value+ | -- | an absolute external URI uses a scheme the profile did not permit+ ExternalReferenceSchemeNotAllowed ConceptId FieldPath Text [Text]+ | -- | a local handle, or a bundle path, resolves to the concept carrying it+ SelfDocumentReference ConceptId FieldPath Text+ | -- | a path-valued field's value is not one of the three shapes of §6.2+ MalformedPathReference ConceptId FieldPath Value+ | -- | a relative path climbs above the bundle root+ PathEscapesBundle ConceptId FieldPath Text+ | -- | a bundle path names a concept that does not exist in this bundle+ DanglingPathReference ConceptId FieldPath Text+ | -- | a closed profile does not declare this top-level field+ FieldNotInProfile ConceptId Text+ | -- | a declared list element is not an object record+ NestedElementNotRecord ConceptId FieldPath Value+ | -- | concept's file path does not match the type rule's pattern (concept, type, pattern)+ PathPatternMismatch ConceptId Text Text+ | -- | type rule requires a resource scheme but resource is absent (concept, type, scheme)+ MissingResource ConceptId Text Text+ | -- | resource present but its scheme is wrong (concept, expected scheme, actual resource)+ ResourceSchemeMismatch ConceptId Text Text+ | -- | required @# Schema@ section is absent (concept, type)+ MissingSchemaSection ConceptId Text+ | -- | @# Schema@ table columns do not match (concept, type, expected, actual)+ SchemaColumnsMismatch ConceptId Text [Text] [Text]+ | -- | type rule declares an @idPrefix@ but the concept has no handle (concept, type, prefix)+ MissingDocumentId ConceptId Text Text+ | -- | handle present but malformed for the declared prefix (concept, prefix, actual value)+ MalformedDocumentId ConceptId Text Text+ | -- | the same handle appears on more than one concept (handle, concept, other concept)+ DuplicateDocumentId Text ConceptId ConceptId+ | -- | the profile requires the bundle to declare an OKF version it does not+ -- (required version, what the bundle declared, 'Nothing' when undeclared)+ --+ -- The only violation that belongs to the bundle rather than to a concept, so+ -- a consumer grouping violations by 'ConceptId' has nothing to key it on.+ RequiredBundleVersionUnmet Text (Maybe Text)+ deriving stock (Generic, Eq, Show)++-- | Check every concept against a compiled profile, returning all deviations.+-- Profile-wide rules apply even to unknown types; a matching type rule adds its+-- frontmatter rules and the existing type-specific structural checks.+--+-- Existence is decided against the concepts themselves, so a @path@ rule can+-- tell whether a target names a concept and cannot tell whether it names+-- @references\/attesters\/revenue.py@; such a target is accepted unchecked.+-- Callers holding a real directory should use 'validateProfileWith'.+validateProfile :: ValidationProfile -> CompiledProfile -> [Concept] -> [ProfileViolation]+validateProfile =+ validateProfileWithPresence conceptsOnly bundleInventoryOfConcepts+ where+ -- Narrowed to @.md@ on purpose. A caller with concepts and no directory can+ -- decide a @.md@ target, because a 'Concept' /is/ a non-reserved @.md@+ -- file — but it has never been able to see §6.3's+ -- @references/attesters/revenue.py@, and answering 'TargetAbsent' for it+ -- would turn a question okf cannot ask into a rejection.+ conceptsOnly conceptInventory resolved+ | FilePath.takeExtension resolved /= ".md" = TargetUnknown+ | bundleInventoryMember resolved conceptInventory = TargetPresent+ | otherwise = TargetAbsent++-- | 'validateProfile' with the bundle's full file inventory, so a @path@ rule+-- can decide whether a target that is not a concept exists.+--+-- Specification §6.3's own example of a path target is+-- @references\/attesters\/revenue.py@, which is not a concept and which+-- 'validateProfile' therefore accepts unchecked. A caller that walked a real+-- directory has 'Okf.Bundle.walkBundleInventory' and can do better. See+-- @docs\/adr\/13-the-references-convention-and-non-markdown-files.md@.+--+-- Passing an inventory does not make validation touch the filesystem. The+-- inventory is data read once during the bundle walk, where okf is already doing+-- IO, which is the shape @docs\/adr\/5-compile-profile-rules-before-validation.md@+-- requires and the one the core §6.2 check already uses.+validateProfileWith :: BundleInventory -> ValidationProfile -> CompiledProfile -> [Concept] -> [ProfileViolation]+validateProfileWith inventory =+ validateProfileWithPresence everyFile (const inventory)+ where+ everyFile fullInventory resolved+ | bundleInventoryMember resolved fullInventory = TargetPresent+ | otherwise = TargetAbsent++-- | Check a bundle's specification §12 version declaration against the profile's+-- @requireBundleVersion@ setting. Returns @[]@ when the profile requires nothing,+-- which is the default and the case for almost every profile.+--+-- Deliberately a separate entry point rather than a parameter on+-- 'validateProfile' and 'validateProfileWith'. Those two are public and their+-- signatures are depended on downstream, and this check consults no concepts at+-- all: threading a bundle-scoped question through a concept-walking function+-- would be misleading as well as breaking.+--+-- §12 makes the declaration a MAY, so okf's own validator never asks for one —+-- see @docs\/adr\/10-okf-version-declaration-and-best-effort-reading.md@. This is+-- the house-convention half: nothing is reported unless a profile author wrote+-- the field.+--+-- A declaration okf cannot parse counts as unmet. It cannot be compared, okf+-- already reports it separately as a strict-mode authoring lint, and silently+-- passing it would let a typo satisfy a requirement.+validateProfileVersion :: VersionDeclaration -> CompiledProfile -> [ProfileViolation]+validateProfileVersion declaration compiled =+ case compiledProfileRequiredBundleVersion compiled of+ Nothing -> []+ Just required ->+ let unmet = [RequiredBundleVersionUnmet (renderOkfVersion required) declaredText]+ in case declaration of+ -- A bundle ahead of the house minimum is not a deviation: §12 defines+ -- a minor bump as backward-compatible additions.+ VersionDeclared declared | declared >= required -> []+ VersionDeclared _ -> unmet+ VersionUnparseable _ -> unmet+ VersionUndeclared -> unmet+ where+ declaredText = case declaration of+ VersionDeclared declared -> Just (renderOkfVersion declared)+ VersionUnparseable rawVersion -> Just rawVersion+ VersionUndeclared -> Nothing++-- | The shared body of 'validateProfile' and 'validateProfileWith'.+--+-- The first argument decides what a @path@ rule may conclude about a resolved+-- §6.2 target; the second supplies the inventory it consults, derived from the+-- concepts when the caller has no directory to walk. Keeping one implementation+-- is what stops the two entry points from drifting apart on everything /except/+-- the one question that distinguishes them.+validateProfileWithPresence ::+ (BundleInventory -> FilePath -> PathTargetPresence) ->+ ([Concept] -> BundleInventory) ->+ ValidationProfile ->+ CompiledProfile ->+ [Concept] ->+ [ProfileViolation]+validateProfileWithPresence presenceRule inventoryOf validationProfile compiled concepts =+ concatMap checkConcept sortedConcepts <> checkDuplicateDocumentIds spec sortedConcepts+ where+ spec = compiledProfileSpec compiled+ sortedConcepts = List.sortOn (renderConceptId . conceptIdOf) concepts+ rulesByType = [(rule ^. #type_, rule) | rule <- spec ^. #types]+ validDocumentIdIndex = buildValidDocumentIdIndex spec rulesByType sortedConcepts+ -- What this caller can say about a resolved §6.2 path, built once.+ presenceOf = presenceRule (inventoryOf sortedConcepts)++ checkConcept concept =+ let cid = conceptIdOf concept+ ctype = conceptType concept+ fieldViolations = checkFields cid ctype concept <> checkUnknownFields cid ctype concept+ in case lookup ctype rulesByType of+ Nothing ->+ [TypeNotInProfile cid ctype | not (spec ^. #allowUnknownTypes)] <> fieldViolations+ Just rule ->+ fieldViolations+ <> checkPath cid ctype rule+ <> checkResource cid ctype rule concept+ <> checkSchema cid ctype rule concept+ <> checkDocumentId spec cid ctype rule concept++ checkFields cid ctype concept =+ concatMap checkField (Map.toAscList (effectiveRulesForType compiled ctype))+ where+ checkField (key, rule) =+ case evaluateFieldValue rule (frontmatterLookup key (conceptFrontmatter concept)) of+ FieldAbsent actual ->+ presenceViolations key rule+ <> maybe [] (vocabularyViolations key rule) actual+ <> maybe [] (formatViolations key rule) actual+ <> maybe [] (referenceViolations key rule) actual+ <> maybe [] (pathViolations (topLevelFieldPath key) rule) actual+ FieldPresent actual ->+ vocabularyViolations key rule actual+ <> formatViolations key rule actual+ <> referenceViolations key rule actual+ <> pathViolations (topLevelFieldPath key) rule actual+ <> nestedViolations key rule actual+ <> objectViolations key rule actual+ FieldWrongShape actual -> [CardinalityMismatch cid (topLevelFieldPath key) (rule ^. #cardinality) actual]++ presenceViolations key rule =+ case applicablePresenceClause validationProfile (\sourceKey -> frontmatterLookup sourceKey (conceptFrontmatter concept)) rule of+ Nothing -> []+ Just clause ->+ [ case clause ^. #requirement of+ RequiredField -> MissingProfileField cid key (clause ^. #condition)+ RecommendedField -> MissingRecommendedProfileField cid key (clause ^. #condition)+ ]+ vocabularyViolations key rule actual =+ [ ValueNotInVocabulary cid (topLevelFieldPath key) (rule ^. #allowedValues) actual+ | not (null (rule ^. #allowedValues)),+ not (valueMatchesVocabulary (rule ^. #allowedValues) actual)+ ]+ formatViolations key rule actual =+ [ ValueFormatMismatch cid (topLevelFieldPath key) fieldFormat actual+ | Just fieldFormat <- [rule ^. #format],+ not (valueMatchesFormat fieldFormat actual)+ ]+ referenceViolations key rule actual =+ case rule ^. #reference of+ Nothing -> []+ Just policy -> validateReferenceValue validDocumentIdIndex cid (topLevelFieldPath key) policy actual++ -- Takes a 'FieldPath' rather than a key because it is shared by all+ -- three scopes: a top-level key, a member of a list element, and a+ -- member of an object-valued mapping.+ pathViolations fieldPath rule actual =+ case rule ^. #path of+ Nothing -> []+ Just policy -> validatePathValue presenceOf cid fieldPath policy actual++ nestedViolations parentKey parentRule = \case+ Array elementValues+ | Just nestedRules <- parentRule ^. #elementFields ->+ concat+ [ case elementValue of+ Aeson.Object members ->+ concatMap+ (checkRecordMember (nestedValuePath parentKey elementIndex) members)+ (Map.toAscList nestedRules)+ _ -> [NestedElementNotRecord cid (nestedElementPath parentKey elementIndex) elementValue]+ | (elementIndex, elementValue) <- zip [0 ..] (Vector.toList elementValues)+ ]+ _ -> []++ -- The mapping spelling of the same idea. The value /is/ the record, so+ -- there is no index in the path: a member is reported as+ -- @generated.by@ rather than @generated[0].by@.+ objectViolations parentKey parentRule = \case+ Aeson.Object members+ | Just objectRules <- parentRule ^. #objectFields ->+ concatMap+ (checkRecordMember (nestedDefinitionPath parentKey) members)+ (Map.toAscList objectRules)+ _ -> []++ -- Check one member of one record. Shared by the list-element and+ -- object-value walks, which differ only in how they name the member;+ -- the sibling lookup that resolves a @when@ condition stays scoped to+ -- the record the member lives in either way.+ checkRecordMember buildPath members (key, rule) =+ let path = buildPath key+ actualValue = Aeson.KeyMap.lookup (Aeson.Key.fromText key) members+ in case evaluateFieldValue rule actualValue of+ FieldAbsent actual ->+ nestedPresenceViolations members path rule+ <> maybe [] (nestedVocabularyViolations path rule) actual+ <> maybe [] (nestedFormatViolations path rule) actual+ <> maybe [] (pathViolations path rule) actual+ FieldPresent actual ->+ nestedVocabularyViolations path rule actual+ <> nestedFormatViolations path rule actual+ <> pathViolations path rule actual+ FieldWrongShape actual -> [CardinalityMismatch cid path (rule ^. #cardinality) actual]++ nestedPresenceViolations objectFields path rule =+ case applicablePresenceClause validationProfile lookupSibling rule of+ Nothing -> []+ Just clause ->+ [ case clause ^. #requirement of+ RequiredField -> MissingNestedProfileField cid path (clause ^. #condition)+ RecommendedField -> MissingRecommendedNestedProfileField cid path (clause ^. #condition)+ ]+ where+ lookupSibling sourceKey = Aeson.KeyMap.lookup (Aeson.Key.fromText sourceKey) objectFields+ nestedVocabularyViolations path rule actual =+ [ ValueNotInVocabulary cid path (rule ^. #allowedValues) actual+ | not (null (rule ^. #allowedValues)),+ not (valueMatchesVocabulary (rule ^. #allowedValues) actual)+ ]+ nestedFormatViolations path rule actual =+ [ ValueFormatMismatch cid path fieldFormat actual+ | Just fieldFormat <- [rule ^. #format],+ not (valueMatchesFormat fieldFormat actual)+ ]++ checkUnknownFields cid ctype concept+ | spec ^. #allowUnknownFields = []+ | otherwise =+ [ FieldNotInProfile cid key+ | key <- frontmatterKeys (conceptFrontmatter concept),+ key `Set.notMember` allowedFields ctype+ ]++ allowedFields ctype =+ coreFrontmatterFields+ <> Map.keysSet (effectiveRulesForType compiled ctype)+ <> maybe Set.empty Set.singleton (spec ^. #idField)++-- | Index only handles that are valid owners under the compiled profile's raw+-- type declarations: the concept has a matching type rule, that rule declares+-- the parsed handle's exact prefix, and the profile declares the owning field.+-- Multiple owners are retained so a duplicate target still counts as existing;+-- the existing bundle-wide duplicate diagnostic remains authoritative.+buildValidDocumentIdIndex :: ProfileSpec -> [(Text, TypeRule)] -> [Concept] -> Map DocumentId [ConceptId]+buildValidDocumentIdIndex spec rulesByType concepts =+ case spec ^. #idField of+ Nothing -> Map.empty+ Just fieldName ->+ Map.fromListWith+ (flip (<>))+ [ (documentId, [conceptIdOf concept])+ | concept <- concepts,+ Just typeRule <- [lookup (conceptType concept) rulesByType],+ Just expectedPrefix <- [typeRule ^. #idPrefix],+ Just (String rawDocumentId) <- [frontmatterLookup fieldName (conceptFrontmatter concept)],+ Just documentId <- [parseDocumentId rawDocumentId],+ documentId ^. #prefix == expectedPrefix+ ]++validateReferenceValue :: Map DocumentId [ConceptId] -> ConceptId -> FieldPath -> HandleReferenceRule -> Value -> [ProfileViolation]+validateReferenceValue validOwners sourceConcept path policy = \case+ String rawReference -> validateReferenceText validOwners sourceConcept path policy rawReference+ Array values ->+ concat+ [ case value of+ String rawReference -> validateReferenceText validOwners sourceConcept (appendArrayIndex path elementIndex) policy rawReference+ _ -> [MalformedDocumentReference sourceConcept (appendArrayIndex path elementIndex) value]+ | (elementIndex, value) <- zip [0 ..] (Vector.toList values)+ ]+ actual -> [MalformedDocumentReference sourceConcept path actual]++validateReferenceText :: Map DocumentId [ConceptId] -> ConceptId -> FieldPath -> HandleReferenceRule -> Text -> [ProfileViolation]+validateReferenceText validOwners sourceConcept path policy rawReference =+ case parseDocumentId rawReference of+ Just documentId+ | documentId ^. #prefix /= policy ^. #localPrefix ->+ [ReferenceHandlePrefixMismatch sourceConcept path rawReference (policy ^. #localPrefix)]+ | otherwise ->+ case Map.lookup documentId validOwners of+ Nothing -> [DanglingHandleReference sourceConcept path rawReference]+ Just owners+ | not (policy ^. #allowSelf),+ sourceConcept `elem` owners ->+ [SelfDocumentReference sourceConcept path rawReference]+ | otherwise -> []+ Nothing ->+ case parseURI (Text.unpack rawReference) of+ Just parsed+ | not (Text.null normalizedScheme), normalizedScheme `elem` policy ^. #externalUriSchemes -> []+ | not (Text.null normalizedScheme) ->+ [ ExternalReferenceSchemeNotAllowed+ sourceConcept+ path+ normalizedScheme+ (policy ^. #externalUriSchemes)+ ]+ | otherwise -> [MalformedDocumentReference sourceConcept path (String rawReference)]+ where+ normalizedScheme = Text.toCaseFold (Text.dropWhileEnd (== ':') (Text.pack (uriScheme parsed)))+ Nothing -> [MalformedDocumentReference sourceConcept path (String rawReference)]++-- | Check one path-valued frontmatter value, at any scope. A list is checked+-- element-wise so that a diagnostic names @sources[1].resource@ rather than the+-- whole list.+validatePathValue :: (FilePath -> PathTargetPresence) -> ConceptId -> FieldPath -> PathReferenceRule -> Value -> [ProfileViolation]+validatePathValue presenceOf sourceConcept path policy = \case+ String rawPath -> validatePathText presenceOf sourceConcept path policy rawPath+ Array values ->+ concat+ [ case value of+ String rawPath ->+ validatePathText presenceOf sourceConcept (appendArrayIndex path elementIndex) policy rawPath+ _ -> [MalformedPathReference sourceConcept (appendArrayIndex path elementIndex) value]+ | (elementIndex, value) <- zip [0 ..] (Vector.toList values)+ ]+ actual -> [MalformedPathReference sourceConcept path actual]++-- | What a caller can say about a resolved §6.2 bundle path.+--+-- Three answers rather than two, because "I cannot tell" is a real state and+-- collapsing it into "absent" turns silence into a rejection. 'validateProfile'+-- is handed concepts alone, so it genuinely cannot say whether §6.3's+-- @references\/attesters\/revenue.py@ is there, and reporting it as dangling+-- would be a claim okf did not check.+data PathTargetPresence+ = -- | The bundle holds it.+ TargetPresent+ | -- | The bundle does not hold it, and the caller can see enough to be sure.+ TargetAbsent+ | -- | The caller cannot answer for this path.+ TargetUnknown+ deriving stock (Generic, Eq, Show)++-- | Check one path value against the §6.2 grammar and the profile's policy.+--+-- Every diagnostic carries the raw text the author wrote rather than the+-- collapsed path okf computed from it, so the message names something findable+-- in the file.+--+-- Existence is decided by the supplied lookup, which reports what the caller can+-- actually see: 'validateProfileWith' holds a real directory's inventory and so+-- resolves §6.3's own @references\/attesters\/revenue.py@, while+-- 'validateProfile' holds the concepts alone and answers 'TargetUnknown' for it.+--+-- The @.md@ branch still goes through 'conceptIdFromFilePath', because that is+-- what decides self-reference and what makes 'MalformedPathReference' reachable+-- for a path no concept ID could be built from. What it no longer decides is+-- whether the target is there.+validatePathText :: (FilePath -> PathTargetPresence) -> ConceptId -> FieldPath -> PathReferenceRule -> Text -> [ProfileViolation]+validatePathText presenceOf sourceConcept path policy rawPath =+ case classifyPathReference sourceConcept rawPath of+ ExternalUrl scheme+ | scheme `elem` policy ^. #externalUriSchemes -> []+ | otherwise ->+ [ExternalReferenceSchemeNotAllowed sourceConcept path scheme (policy ^. #externalUriSchemes)]+ EscapesBundle -> [PathEscapesBundle sourceConcept path rawPath]+ MalformedPath -> [MalformedPathReference sourceConcept path (String rawPath)]+ BundlePath resolved+ | FilePath.takeExtension resolved == ".md" ->+ case conceptIdFromFilePath resolved of+ Left _ -> [MalformedPathReference sourceConcept path (String rawPath)]+ Right target+ | target == sourceConcept,+ not (policy ^. #allowSelf) ->+ [SelfDocumentReference sourceConcept path rawPath]+ | otherwise -> danglingWhenAbsent resolved+ | otherwise -> danglingWhenAbsent resolved+ where+ danglingWhenAbsent resolved = case presenceOf resolved of+ TargetAbsent -> [DanglingPathReference sourceConcept path rawPath]+ TargetPresent -> []+ TargetUnknown -> []++appendArrayIndex :: FieldPath -> Int -> FieldPath+appendArrayIndex (FieldPath pathSegments) elementIndex =+ FieldPath (pathSegments <> (ArrayIndex elementIndex :| []))++applicablePresenceClause :: ValidationProfile -> (Text -> Maybe Value) -> EffectiveFieldRule -> Maybe PresenceClause+applicablePresenceClause validationProfile lookupValue rule =+ List.find applies requiredClauses+ <|> if validationProfile == StrictAuthoring then List.find applies recommendedClauses else Nothing+ where+ requiredClauses = filter ((== RequiredField) . (^. #requirement)) (rule ^. #presenceClauses)+ recommendedClauses = filter ((== RecommendedField) . (^. #requirement)) (rule ^. #presenceClauses)+ applies clause = maybe True conditionApplies (clause ^. #condition)+ conditionApplies condition =+ case lookupValue (condition ^. #field) of+ Just (String actual) -> actual `elem` condition ^. #hasValue+ _ -> False++valueMatchesVocabulary :: [Text] -> Value -> Bool+valueMatchesVocabulary allowed = \case+ String value -> value `elem` allowed+ Array values -> all elementMatches (Vector.toList values)+ _ -> False+ where+ elementMatches (String value) = value `elem` allowed+ elementMatches _ = False++-- | Whether a present value satisfies a format. Each format declares which+-- value shapes it can match at all: the textual formats match a string, the+-- numeric ones a YAML number, and 'Boolean' a YAML boolean. A list is matched+-- by recursing, so a list of integers satisfies an @integer@ format for the+-- same reason a list of timestamps satisfies @rfc3339-utc@ — a format+-- constrains a value, and a list is a list of values.+valueMatchesFormat :: FieldFormat -> Value -> Bool+valueMatchesFormat fieldFormat actual =+ case actual of+ String value -> textMatchesFormat fieldFormat value+ Array values -> all (valueMatchesFormat fieldFormat) (Vector.toList values)+ Number _ -> numberMatchesFormat fieldFormat actual+ Bool _ -> fieldFormat == Boolean+ _ -> False++textMatchesFormat :: FieldFormat -> Text -> Bool+textMatchesFormat fieldFormat value =+ case fieldFormat of+ Rfc3339Utc ->+ hasExtendedUtcShape value+ && isJust (iso8601ParseM (Text.unpack value) :: Maybe UTCTime)+ Date ->+ hasExtendedDateShape value+ && isJust (iso8601ParseM (Text.unpack value) :: Maybe Day)+ Uri -> isJust (parseURI (Text.unpack value))+ UriWithScheme expectedScheme ->+ case parseURI (Text.unpack value) of+ Just parsed ->+ Text.toCaseFold (Text.dropWhileEnd (== ':') (Text.pack (uriScheme parsed)))+ == Text.toCaseFold expectedScheme+ Nothing -> False+ DocumentHandle expectedPrefix ->+ case parseDocumentId value of+ Just documentId -> documentId ^. #prefix == expectedPrefix+ Nothing -> False+ -- Specification §7 defines exactly three actor shapes, and+ -- 'Actor.parseActor' classifies them, so the format is a case match on its+ -- result rather than a second parser. A value the convention does not+ -- define — the specification's own illustrative @team:ga4-docs@ among them —+ -- is 'Actor.UnclassifiedActor' and is reported.+ Actor ->+ case Actor.parseActor value of+ Actor.UnclassifiedActor _ -> False+ _ -> True+ HumanActor -> Actor.isHumanActor (Actor.parseActor value)+ -- A numeric or boolean format never matches text. A @usage_count: "5000"@+ -- is a quoted string in YAML and is reported rather than coerced, which is+ -- the whole point of being able to declare the format.+ Integer -> False+ NonNegativeInteger -> False+ Boolean -> False++-- | Whether a YAML number satisfies a numeric format. Uses aeson's own+-- 'Integer' decoder, which already rejects a non-integral number, rather than+-- reaching for @scientific@: that package is a dependency of @aeson@ but not of+-- @okf-core@, so it is importable in a scratch experiment and not from here.+-- 'Okf.Document.objectInteger' takes the same route.+numberMatchesFormat :: FieldFormat -> Value -> Bool+numberMatchesFormat fieldFormat actual =+ case Aeson.fromJSON actual :: Aeson.Result Integer of+ Aeson.Error _ -> False+ Aeson.Success parsed ->+ case fieldFormat of+ Integer -> True+ NonNegativeInteger -> parsed >= 0+ _ -> False++hasExtendedDateShape :: Text -> Bool+hasExtendedDateShape value =+ Text.length value == 10+ && Text.index value 4 == '-'+ && Text.index value 7 == '-'++hasExtendedUtcShape :: Text -> Bool+hasExtendedUtcShape value =+ Text.length value >= 20+ && Text.index value 4 == '-'+ && Text.index value 7 == '-'+ && Text.index value 10 == 'T'+ && Text.index value 13 == ':'+ && Text.index value 16 == ':'+ && Text.last value == 'Z'++data FieldValueEvaluation+ = FieldAbsent (Maybe Value)+ | FieldPresent Value+ | FieldWrongShape Value++-- | Decide whether a frontmatter value counts as present for one rule. Takes the+-- whole rule rather than just its cardinality because the 'Any' case has to know+-- whether the rule declares object members: a rule accepting both spellings of+-- the OKF v0.2 @verified@ key stays at 'Any' cardinality and must still count a+-- mapping as present.+--+-- This is the single point at which okf decides presence, so a change here+-- affects every profile check.+evaluateFieldValue :: EffectiveFieldRule -> Maybe Value -> FieldValueEvaluation+evaluateFieldValue _ Nothing = FieldAbsent Nothing+evaluateFieldValue rule (Just actual) =+ case rule ^. #cardinality of+ Any+ | legacyValueIsPresent actual -> FieldPresent actual+ -- A rule that also accepts a mapping counts a non-empty one as present.+ -- Without this a profile declaring both shapes would report `verified`+ -- missing on a document that writes it as a bare mapping.+ | isJust (rule ^. #objectFields), nonEmptyObject actual -> FieldPresent actual+ | otherwise -> FieldAbsent (Just actual)+ Scalar ->+ case actual of+ String value+ | Text.null (Text.strip value) -> FieldAbsent (Just actual)+ | otherwise -> FieldPresent actual+ Number _ -> FieldPresent actual+ Bool _ -> FieldPresent actual+ _ -> FieldWrongShape actual+ List ->+ case actual of+ Array values+ | null values -> FieldAbsent (Just actual)+ | otherwise -> FieldPresent actual+ _ -> FieldWrongShape actual+ Object+ -- An empty mapping is treated as absent for the same reason an empty list+ -- is: a key written with no content is the author saying nothing, and+ -- reporting it missing is more useful than reporting it present and empty.+ | nonEmptyObject actual -> FieldPresent actual+ | isObject actual -> FieldAbsent (Just actual)+ | otherwise -> FieldWrongShape actual++isObject :: Value -> Bool+isObject = \case+ Aeson.Object _ -> True+ _ -> False++nonEmptyObject :: Value -> Bool+nonEmptyObject = \case+ Aeson.Object members -> not (Aeson.KeyMap.null members)+ _ -> False++legacyValueIsPresent :: Value -> Bool+legacyValueIsPresent = \case+ String value -> not (Text.null (Text.strip value))+ Array values -> not (null values)+ _ -> False++-- | Check a profile-declared document ID for one concept.+checkDocumentId :: ProfileSpec -> ConceptId -> Text -> TypeRule -> Concept -> [ProfileViolation]+checkDocumentId spec cid ctype rule concept =+ case (spec ^. #idField, rule ^. #idPrefix) of+ (Just fieldName, Just expectedPrefix) ->+ case frontmatterLookup fieldName (conceptFrontmatter concept) of+ Just (String value)+ | not (Text.null (Text.strip value)) ->+ case parseDocumentId value of+ Just documentId+ | documentId ^. #prefix == expectedPrefix -> []+ _ -> [MalformedDocumentId cid expectedPrefix value]+ _ -> [MissingDocumentId cid ctype expectedPrefix]+ _ -> []++-- | Check every non-empty value under the profile's ID field for bundle-wide+-- uniqueness. Concept IDs are sorted before grouping so output is deterministic.+checkDuplicateDocumentIds :: ProfileSpec -> [Concept] -> [ProfileViolation]+checkDuplicateDocumentIds spec concepts =+ case spec ^. #idField of+ Nothing -> []+ Just fieldName ->+ concatMap duplicateViolations (groupedHandles fieldName)+ where+ groupedHandles fieldName =+ List.groupBy+ (\(leftHandle, _) (rightHandle, _) -> leftHandle == rightHandle)+ (handles fieldName)+ handles fieldName =+ List.sortOn+ (\(handle, cid) -> (handle, renderConceptId cid))+ [ (handle, conceptIdOf concept)+ | concept <- concepts,+ Just (String handle) <- [frontmatterLookup fieldName (conceptFrontmatter concept)],+ not (Text.null (Text.strip handle))+ ]+ duplicateViolations ((handle, firstConcept) : duplicates) =+ [ DuplicateDocumentId handle firstConcept duplicateConcept+ | (_, duplicateConcept) <- duplicates+ ]+ duplicateViolations [] = []++-- | Project a concept's frontmatter (the document's @frontmatter@ field).+conceptFrontmatter :: Concept -> Frontmatter+conceptFrontmatter concept = conceptDocument concept ^. #frontmatter++-- | A type rule's @pathPattern@, when present, constrains where the concept's+-- file may live.+checkPath :: ConceptId -> Text -> TypeRule -> [ProfileViolation]+checkPath cid ctype rule =+ case rule ^. #pathPattern of+ Nothing -> []+ Just patternText+ | matchPathPattern patternText cid -> []+ | otherwise -> [PathPatternMismatch cid ctype patternText]++-- | Match a concept ID against a segment-glob pattern. @*@ matches exactly one+-- segment; a single trailing @**@ matches one or more remaining segments; every+-- other segment matches literally. Both segment lists must be consumed exactly,+-- except for the trailing @**@ case.+matchPathPattern :: Text -> ConceptId -> Bool+matchPathPattern patternText cid =+ go (Text.splitOn "/" patternText) (Text.splitOn "/" (renderConceptId cid))+ where+ go [] [] = True+ go ["**"] (_ : _) = True+ go ("*" : ps) (_ : ss) = go ps ss+ go (p : ps) (s : ss) = p == s && go ps ss+ go _ _ = False++-- | A type rule's @resourceScheme@, when present, requires a @resource:@ value+-- whose scheme matches.+checkResource :: ConceptId -> Text -> TypeRule -> Concept -> [ProfileViolation]+checkResource cid ctype rule concept =+ case rule ^. #resourceScheme of+ Nothing -> []+ Just scheme ->+ case conceptResource concept of+ Nothing -> [MissingResource cid ctype scheme]+ Just value+ | (scheme <> "://") `Text.isPrefixOf` value -> []+ | otherwise -> [ResourceSchemeMismatch cid scheme value]++-- | A type rule's @# Schema@ contract: when @requireSchemaSection@ is set, the+-- body must contain a @# Schema@ section whose table header begins with the+-- required @schemaColumns@ (case-insensitive, trimmed, compared as a prefix so a+-- team may add trailing columns without tripping the check).+checkSchema :: ConceptId -> Text -> TypeRule -> Concept -> [ProfileViolation]+checkSchema cid ctype rule concept+ | not (rule ^. #requireSchemaSection) = []+ | otherwise =+ case schemaSectionColumns (conceptDocument concept ^. #body) of+ Nothing -> [MissingSchemaSection cid ctype]+ Just actual ->+ let expected = rule ^. #schemaColumns+ norm = map (Text.toLower . Text.strip)+ in [ SchemaColumnsMismatch cid ctype expected actual+ | not (norm expected `List.isPrefixOf` norm actual)+ ]++-- | The header-row columns of the first GitHub-flavored table that follows the+-- first top-level @# Schema@ heading, or 'Nothing' if there is no Schema heading+-- or no following table. Columns are trimmed.+schemaSectionColumns :: Text -> Maybe [Text]+schemaSectionColumns markdown =+ let CMarkGFM.Node _ _ topLevel = CMarkGFM.commonmarkToNode markdownOptions [CMarkGFM.extTable] markdown in firstTableAfterSchema topLevel firstTableAfterSchema :: [CMarkGFM.Node] -> Maybe [Text]
+ src/Okf/Profile/Documentation.hs view
@@ -0,0 +1,586 @@+{-# LANGUAGE PackageImports #-}++-- | Render an OKF profile as an OKF bundle that documents it.+--+-- A profile describes a team's house conventions for a directory tree of+-- Markdown documents. It can be read as Dhall source or dumped by+-- @okf profile show@, but neither form is something a team browses, links to,+-- or reviews in a pull request. 'renderProfileDocumentation' turns a compiled+-- profile into ordinary 'Concept's: a root document describing the profile as a+-- whole plus one document per declared concept type, cross-linked with+-- bundle-absolute Markdown links. Because the result is an ordinary OKF bundle,+-- every tool okf ships works on it — @okf validate@, @okf graph@, @okf show@,+-- @okf index@ — and so does any downstream OKF consumer.+--+-- Rules are rendered from the /compiled/ profile, so each type's page shows the+-- profile-scope and type-scope declarations already merged per+-- [ADR 5](docs/adr/5-compile-profile-rules-before-validation.md). A reader+-- learns what actually applies to a concept of that type rather than having to+-- compose two declaration sites in their head.+--+-- == The published output contract+--+-- Other tools key on the following, so it is stated here rather than left to be+-- inferred from the code. In particular+-- @docs/profiles/profile-documentation.dhall@ encodes the same contract in+-- Dhall; any change here must change that descriptor in the same commit.+--+-- * The root concept's ID is 'rootConceptId', default @profile@.+-- * Each type concept's ID is @\<typeDirectory\>\/\<slug\>@, default directory+-- @types@, where the slug is 'profileDocumentationSlug' of the declared+-- @type@ string, with @type-N@ substituted when that slug is empty and @-N@+-- appended on collision, @N@ being the one-based declaration index.+-- * The root concept's frontmatter @type@ is 'profileConceptType'+-- (@OKF Profile@); each type concept's is 'profileTypeConceptType'+-- (@OKF Profile Type@). Both are exported as constants so a consumer keys on+-- the constant rather than a literal.+-- * Every generated concept carries @type@, @title@, and @description@. It+-- carries @generated@ if and only if 'generated' is 'Just', which it is by+-- default, naming 'defaultDocumentationActor'. It carries the superseded+-- v0.1 @timestamp@ if and only if 'timestamp' is 'Just', which it is not by+-- default. It carries no other frontmatter key — in particular no+-- @resource@ and no @tags@.+-- * @title@ on a type concept is the profile's @type@ string verbatim, not the+-- slug: the title is what a reader must write in their own frontmatter.+-- * Every cross-link is bundle-absolute and produced by+-- 'Okf.ConceptId.renderConceptLink', so 'Okf.Graph.buildGraph' resolves it and+-- 'Okf.Validation.validateBundle' finds no dangling reference.+-- * Output is a deterministic function of the compiled profile and the options.+-- Nothing reads the clock, the environment, or the filesystem — in+-- particular @generated.at@ is whatever the caller passes and is absent by+-- default — so generated documentation can be committed and used as a CI+-- drift check.+module Okf.Profile.Documentation+ ( DocumentationOptions (..),+ defaultDocumentationOptions,+ defaultDocumentationActor,+ DocumentationError (..),+ profileConceptType,+ profileTypeConceptType,+ profileDocumentationSlug,+ renderProfileDocumentation,+ )+where++import Data.Char qualified as Char+import Data.Map.Strict qualified as Map+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Okf.Actor (Actor (..))+import Okf.Bundle+import Okf.ConceptId+import Okf.Document+import Okf.Prelude hiding (List)+import Okf.Profile+import "generic-lens" Data.Generics.Labels ()++-- | How to lay out a generated documentation bundle. Start from+-- 'defaultDocumentationOptions' and override what you need, so that later+-- additions to this record do not break your call site.+data DocumentationOptions = DocumentationOptions+ { -- | Concept ID of the document describing the profile as a whole.+ rootConceptId :: !Text,+ -- | Directory holding one document per declared concept type.+ typeDirectory :: !Text,+ -- | Value for the @timestamp@ frontmatter key on every generated document.+ -- 'Nothing' omits the key entirely. The generator never reads the clock:+ -- output must be byte-identical across runs so it can be committed and+ -- diffed. Note that 'Okf.Validation.StrictAuthoring' requires a timestamp,+ -- so a caller wanting strict-clean output must supply one. Superseded by+ -- 'generated', which 'defaultDocumentationOptions' supplies instead.+ timestamp :: !(Maybe Text),+ -- | The OKF v0.2 @generated@ family (specification §5.2) written on every+ -- generated document. 'Nothing' omits the key entirely, which produces a+ -- bundle that 'Okf.Validation.StrictAuthoring' reports as missing+ -- provenance; 'defaultDocumentationOptions' therefore supplies one.+ --+ -- The generator still never reads the clock: @generatedAt@ is whatever the+ -- caller passes and is 'Nothing' by default, so two runs of the same+ -- command produce identical bytes.+ generated :: !(Maybe Generated)+ }+ deriving stock (Generic, Eq, Show)++-- | The actor 'defaultDocumentationOptions' names as the producer of a+-- generated documentation bundle: @process:okf-profile-document@.+--+-- Deliberately carries no version. Specification §7 would also permit+-- @okf\/\<version\>@, but generated documentation is meant to be committed and+-- checked with @git diff --exit-code@, and a version-bearing default would+-- change every generated byte on every okf release. A caller who wants the+-- producing version passes it explicitly.+defaultDocumentationActor :: Actor+defaultDocumentationActor = ProcessActor "okf-profile-document"++-- | The default layout: a root document at @profile@ and one document per type+-- under @types/@, with no v0.1 timestamp and a @generated@ family naming+-- 'defaultDocumentationActor'.+defaultDocumentationOptions :: DocumentationOptions+defaultDocumentationOptions =+ DocumentationOptions+ { rootConceptId = "profile",+ typeDirectory = "types",+ timestamp = Nothing,+ generated = Just (Generated defaultDocumentationActor Nothing)+ }++-- | The only way generation can fail: a caller supplied a layout option that is+-- not a legal concept path. No profile can cause a failure — slugging is total+-- and collisions are resolved positionally — so a profile that compiles always+-- documents.+data DocumentationError+ = InvalidRootConceptId !Text !ConceptIdError+ | InvalidTypeDirectory !Text !ConceptIdError+ deriving stock (Generic, Eq, Show)++-- | The @type@ frontmatter value on the document describing a profile.+profileConceptType :: Text+profileConceptType = "OKF Profile"++-- | The @type@ frontmatter value on each document describing one declared+-- concept type.+profileTypeConceptType :: Text+profileTypeConceptType = "OKF Profile Type"++-- | Turn a free-text profile @type@ string into a concept-ID segment.+--+-- ASCII letters are lowercased; every character that is not an ASCII letter or+-- digit becomes a hyphen; runs of hyphens collapse to one; leading and trailing+-- hyphens are dropped. @\"BigQuery Table\"@ becomes @\"bigquery-table\"@ and+-- @\"C++ Header\"@ becomes @\"c-header\"@.+--+-- The result is empty when the input contains no ASCII alphanumeric character+-- at all. 'renderProfileDocumentation' substitutes a positional fallback,+-- @type-N@ for the Nth declared type counting from one, in that case, and+-- disambiguates two type strings that slug identically by appending @-N@ to the+-- later one. Both fallbacks are deterministic functions of declaration order.+profileDocumentationSlug :: Text -> Text+profileDocumentationSlug raw =+ Text.intercalate "-" (filter (not . Text.null) pieces)+ where+ pieces = Text.split (not . isSlugChar) (Text.map Char.toLower raw)+ isSlugChar character =+ Char.isAsciiLower character+ || Char.isAsciiUpper character+ || Char.isDigit character++-- | Render a compiled profile as a list of concepts: the root profile concept+-- first, then one concept per declared type in profile declaration order.+--+-- Total on the profile side. See the module header for the output contract.+renderProfileDocumentation ::+ DocumentationOptions ->+ CompiledProfile ->+ Either DocumentationError [Concept]+renderProfileDocumentation options compiled = do+ (rootId, typeLayout) <- documentationLayout options compiled+ let rootConcept = renderRootConcept options compiled rootId typeLayout+ typeConcepts =+ [ renderTypeConcept options compiled rootId typeName typeConceptId+ | (typeName, typeConceptId) <- typeLayout+ ]+ pure (rootConcept : typeConcepts)++-- | The root concept ID and, for each declared type in declaration order, its+-- name paired with its concept ID.+documentationLayout ::+ DocumentationOptions ->+ CompiledProfile ->+ Either DocumentationError (ConceptId, [(Text, ConceptId)])+documentationLayout options compiled = do+ rootId <- first (InvalidRootConceptId rawRoot) (parseConceptId rawRoot)+ _validDirectory <- first (InvalidTypeDirectory rawDirectory) (parseConceptId rawDirectory)+ pure (rootId, assignSlugs Set.empty (zip [1 :: Int ..] (compiledProfileTypeNames compiled)))+ where+ rawRoot = options ^. #rootConceptId+ rawDirectory = options ^. #typeDirectory++ assignSlugs _used [] = []+ assignSlugs used ((declarationIndex, typeName) : remaining) =+ let slug = uniqueSlug used declarationIndex (profileDocumentationSlug typeName)+ in (typeName, typeConceptIdFor slug) : assignSlugs (Set.insert slug used) remaining++ -- The slug is ASCII alphanumerics and hyphens and the directory has already+ -- parsed as a concept path, so this parse cannot fail. Fail loudly rather+ -- than silently if a future change breaks that invariant.+ typeConceptIdFor slug =+ case parseConceptId (rawDirectory <> "/" <> slug) of+ Right conceptId -> conceptId+ Left err ->+ error+ ( "Okf.Profile.Documentation: internal invariant broken, "+ <> "a validated type directory and a slug produced an invalid "+ <> "concept ID: "+ <> show err+ )++-- | Pick a slug not yet used: the natural slug, else @type-N@ when it is empty,+-- else the stem with an increasing numeric suffix starting at the declaration+-- index. Deterministic in declaration order.+uniqueSlug :: Set Text -> Int -> Text -> Text+uniqueSlug used declarationIndex base = go Nothing+ where+ stem = if Text.null base then "type-" <> renderInt declarationIndex else base+ go suffix =+ let candidate = maybe stem (\n -> stem <> "-" <> renderInt n) suffix+ in if Set.member candidate used+ then go (Just (maybe declarationIndex (+ 1) suffix))+ else candidate++renderInt :: Int -> Text+renderInt = Text.pack . show++-- * The root profile concept++renderRootConcept ::+ DocumentationOptions ->+ CompiledProfile ->+ ConceptId ->+ [(Text, ConceptId)] ->+ Concept+renderRootConcept options compiled rootId typeLayout =+ conceptFromDocument rootId (OKFDocument frontmatter body)+ where+ spec = compiledProfileSpec compiled+ profileName = spec ^. #name+ declaredDescription = nonBlank (spec ^. #description)+ frontmatter =+ withGenerated options $+ okfCommon+ OkfCommon+ { commonType = profileConceptType,+ commonTitle = Just profileName,+ commonDescription = Just (effectiveProfileDescription spec),+ commonTimestamp = options ^. #timestamp+ }+ body =+ unlinesText $+ ["# " <> profileName, ""]+ -- Only the profile's own prose goes in the body. The synthesized+ -- fallback exists to keep the frontmatter strict-clean; repeating it+ -- here would tell the reader nothing they cannot see in the heading.+ <> maybe [] (\prose -> [prose, ""]) declaredDescription+ <> [ "## Settings",+ "",+ "- OKF version: " <> code (spec ^. #okfVersion),+ "- Required bundle version: " <> maybe "none" code (spec ^. #requireBundleVersion),+ "- Unknown concept types: " <> permitted (spec ^. #allowUnknownTypes),+ "- Unknown frontmatter keys: " <> permitted (spec ^. #allowUnknownFields),+ "- Document ID field: " <> maybe "none" code (spec ^. #idField),+ "",+ "## Frontmatter rules",+ "",+ "These rules apply to every concept in a bundle governed by this profile,",+ "whatever its type. Each concept type's own page repeats them merged with that",+ "type's rules, which is the form that actually applies.",+ ""+ ]+ <> baseRuleLines+ <> [ "## Concept types",+ ""+ ]+ <> typeLines++ baseRuleLines =+ case Map.toAscList (compiledProfileBaseRules compiled) of+ [] -> ["(none declared)", ""]+ rules -> concatMap (uncurry (renderFieldRule 3)) rules++ typeLines+ | null typeLayout = ["(none declared)"]+ | otherwise =+ [ "- " <> renderConceptLink typeConceptId typeName <> descriptionSuffix typeName+ | (typeName, typeConceptId) <- typeLayout+ ]++ typeDescriptions =+ Map.fromList+ [ (rule ^. #type_, nonBlank (rule ^. #description))+ | rule <- spec ^. #types+ ]++ descriptionSuffix typeName =+ case Map.lookup typeName typeDescriptions of+ Just (Just prose) -> " — " <> prose+ _ -> ""++ permitted allowed = if allowed then "allowed" else "rejected"++-- * One concept per declared type++renderTypeConcept ::+ DocumentationOptions ->+ CompiledProfile ->+ ConceptId ->+ Text ->+ ConceptId ->+ Concept+renderTypeConcept options compiled rootId typeName typeConceptId =+ conceptFromDocument typeConceptId (OKFDocument frontmatter body)+ where+ spec = compiledProfileSpec compiled+ profileName = spec ^. #name+ typeRule =+ Map.lookup typeName (Map.fromList [(rule ^. #type_, rule) | rule <- spec ^. #types])+ description =+ case typeRule >>= (nonBlank . (^. #description)) of+ Just prose -> prose+ Nothing ->+ "Concept type \""+ <> typeName+ <> "\" as declared by the "+ <> profileName+ <> " profile."+ frontmatter =+ withGenerated options $+ okfCommon+ OkfCommon+ { commonType = profileTypeConceptType,+ commonTitle = Just typeName,+ commonDescription = Just description,+ commonTimestamp = options ^. #timestamp+ }+ rules = compiledProfileRulesForType compiled typeName+ body =+ unlinesText $+ ["# " <> typeName, ""]+ -- As on the profile page, only the type rule's own prose is repeated+ -- in the body; the synthesized fallback is frontmatter-only.+ <> maybe [] (\prose -> [prose, ""]) (typeRule >>= (nonBlank . (^. #description)))+ <> [ "Declared by the " <> renderConceptLink rootId profileName <> " profile.",+ "",+ "## Type settings",+ "",+ "- Path pattern: " <> maybe "none" code (typeRule >>= (^. #pathPattern)),+ "- Resource URI scheme: " <> maybe "none" code (typeRule >>= (^. #resourceScheme)),+ "- Requires a `# Schema` section: " <> yesNo (maybe False (^. #requireSchemaSection) typeRule),+ "- Schema columns: " <> codeListOr "none" (maybe [] (^. #schemaColumns) typeRule),+ "- Document ID prefix: " <> maybe "none" code (typeRule >>= (^. #idPrefix)),+ "",+ "## Frontmatter rules",+ "",+ "Every rule below is the effective rule for a concept of type "+ <> code typeName+ <> ":",+ "the profile-wide rule and this type's own rule, already merged.",+ ""+ ]+ <> group "Required" (Map.toAscList (Map.filter ((== PresenceRequired) . presenceClassOf) rules))+ <> group "Recommended" (Map.toAscList (Map.filter ((== PresenceRecommended) . presenceClassOf) rules))+ <> group "Optional" (Map.toAscList (Map.filter ((== PresenceOptional) . presenceClassOf) rules))++ group heading [] = ["### " <> heading, "", "(none)", ""]+ group heading members =+ ["### " <> heading, ""] <> concatMap (uncurry (renderFieldRule 4)) members++ yesNo condition = if condition then "yes" else "no"++-- * Rendering one field rule++-- | Which section of a type page a rule belongs in. A rule is Required if any+-- of its presence clauses demands the key, Recommended if it has any clause at+-- all, and Optional when it has none — the empty-clause encoding ADR 5 chose+-- for the third presence class.+data PresenceClass = PresenceRequired | PresenceRecommended | PresenceOptional+ deriving stock (Eq, Show)++presenceClassOf :: EffectiveFieldRule -> PresenceClass+presenceClassOf rule =+ case fieldRulePresenceClauses rule of+ [] -> PresenceOptional+ clauses+ | any isRequired clauses -> PresenceRequired+ | otherwise -> PresenceRecommended+ where+ isRequired clause = presenceClauseRequirement clause == RequiredField++-- | The lines documenting one frontmatter key, at the given heading level: a+-- heading naming the key and its presence, the key's prose when it has any,+-- then a fixed bullet list of value constraints so the shape never shifts.+renderFieldRule :: Int -> Text -> EffectiveFieldRule -> [Text]+renderFieldRule level key rule =+ [ Text.replicate level "#" <> " " <> code key <> " — " <> presencePhrase rule,+ ""+ ]+ <> maybe [] (\prose -> [prose, ""]) (nonBlank (fieldRuleDescription rule))+ <> constraintBullets+ <> [""]+ where+ constraintBullets =+ [ "- Allowed values: " <> codeListOr "any" (fieldRuleAllowedValues rule),+ "- Cardinality: " <> renderCardinalityName (fieldRuleCardinality rule),+ "- Format: " <> maybe "none" renderFieldFormatName (fieldRuleFormat rule),+ "- Reference: " <> maybe "none" renderReference (fieldRuleReference rule),+ "- Path: " <> maybe "none" renderPathRule (fieldRulePath rule)+ ]+ <> conditionBullets+ <> objectFieldBullets+ <> elementFieldBullets+ <> strictNote++ conditionBullets =+ case [clause | clause <- fieldRulePresenceClauses rule, isJust (presenceClauseCondition clause)] of+ [] -> ["- Condition: none"]+ [single] -> ["- Condition: applies only when " <> clausePhrase single]+ many_ ->+ ["- Condition:"]+ <> [" - applies only when " <> clausePhrase clause | clause <- many_]++ clausePhrase clause =+ maybe "always" conditionPhrase (presenceClauseCondition clause)++ -- The members of the mapping that *is* the value, as opposed to the members+ -- of each element of a list. A rule may declare both, in which case both+ -- bullets carry content and either spelling of the value is accepted.+ objectFieldBullets =+ case fieldRuleObjectFields rule of+ Nothing -> ["- Object fields: none"]+ Just members ->+ ["- Object fields:"]+ <> [" - " <> renderElementField memberKey memberRule | (memberKey, memberRule) <- Map.toAscList members]++ elementFieldBullets =+ case fieldRuleElementFields rule of+ Nothing -> ["- Element fields: none"]+ Just nested ->+ ["- Element fields:"]+ <> [" - " <> renderElementField nestedKey nestedRule | (nestedKey, nestedRule) <- Map.toAscList nested]++ strictNote =+ case presenceClassOf rule of+ PresenceRecommended -> ["- Checked only under `--strict`"]+ _ -> []++-- | Nested element rules are depth-bounded at one level, so this is flat by+-- construction: 'fieldRuleElementFields' on a nested rule is always 'Nothing'.+--+-- The path clause is emitted only when the member declares one, unlike the+-- fixed bullet list of 'renderFieldRule'. This line is already a dense+-- semicolon-separated run and a member declares no path policy far more often+-- than not, so a @path: none@ on every member of every record would cost more+-- than it says. A nested path policy is nevertheless the motivating case for+-- path rules — @sources[].resource@ lives here — so it must be visible when it+-- is there.+renderElementField :: Text -> EffectiveFieldRule -> Text+renderElementField key rule =+ code key+ <> " — "+ <> Text.intercalate+ "; "+ ( [ presencePhrase rule,+ "allowed values: " <> codeListOr "any" (fieldRuleAllowedValues rule),+ "cardinality: " <> renderCardinalityName (fieldRuleCardinality rule),+ "format: " <> maybe "none" renderFieldFormatName (fieldRuleFormat rule)+ ]+ <> foldMap (\policy -> ["path: " <> renderPathRule policy]) (fieldRulePath rule)+ )+ <> maybe "" (" — " <>) (nonBlank (fieldRuleDescription rule))++-- | How a key's presence reads in a heading: @required@, @recommended@,+-- @optional@, or @required when \`status\` is \`superseded\`@.+presencePhrase :: EffectiveFieldRule -> Text+presencePhrase rule =+ case fieldRulePresenceClauses rule of+ [] -> "optional"+ clauses ->+ let required = [clause | clause <- clauses, presenceClauseRequirement clause == RequiredField]+ unconditionalRequired =+ [clause | clause <- required, isNothing (presenceClauseCondition clause)]+ in case (unconditionalRequired, required) of+ (_ : _, _) -> "required"+ ([], firstRequired : _) ->+ "required when " <> maybe "always" conditionPhrase (presenceClauseCondition firstRequired)+ ([], []) -> "recommended"++-- | A same-scope predicate in prose: @\`status\` is \`superseded\`@, or+-- @\`status\` is one of \`a\`, \`b\`@ for more than one accepted value.+conditionPhrase :: FieldCondition -> Text+conditionPhrase condition =+ case condition ^. #hasValue of+ [] -> code (condition ^. #field) <> " has any value"+ [single] -> code (condition ^. #field) <> " is " <> code single+ values -> code (condition ^. #field) <> " is one of " <> codeListOr "any" values++renderReference :: HandleReferenceRule -> Text+renderReference policy =+ Text.intercalate+ "; "+ [ "local handles with prefix " <> code (policy ^. #localPrefix),+ externalPhrase,+ if policy ^. #allowSelf then "self-reference allowed" else "self-reference not allowed"+ ]+ where+ externalPhrase =+ case policy ^. #externalUriSchemes of+ [] -> "external URIs not allowed"+ [single] -> "external URIs with scheme " <> code single+ schemes -> "external URIs with schemes " <> codeListOr "any" schemes++-- | A path policy in prose, deliberately parallel to 'renderReference' so the+-- two rule kinds read as siblings. It says what okf resolves as well as what it+-- permits, because the @.md@-only existence check is the surprising part.+renderPathRule :: PathReferenceRule -> Text+renderPathRule policy =+ Text.intercalate+ "; "+ [ "bundle paths resolved to concepts",+ externalPhrase,+ if policy ^. #allowSelf then "self-reference allowed" else "self-reference not allowed"+ ]+ where+ externalPhrase =+ case policy ^. #externalUriSchemes of+ [] -> "external URLs not allowed"+ [single] -> "external URLs with scheme " <> code single+ schemes -> "external URLs with schemes " <> codeListOr "any" schemes++-- * Small shared helpers++-- | Add the OKF v0.2 @generated@ family when the options carry one. Written+-- through 'Okf.Document.setGenerated' so this module never learns that+-- @generated@ is a mapping of an actor and a datetime.+withGenerated :: DocumentationOptions -> Frontmatter -> Frontmatter+withGenerated options = maybe id setGenerated (options ^. #generated)++-- | The profile's own prose, or a synthesized sentence when it declares none.+-- Generated documents always carry a @description@ so that they pass+-- 'Okf.Validation.StrictAuthoring', which most profiles' own prose would not+-- guarantee today.+effectiveProfileDescription :: ProfileSpec -> Text+effectiveProfileDescription spec =+ case nonBlank (spec ^. #description) of+ Just prose -> prose+ Nothing ->+ "OKF profile \""+ <> spec ^. #name+ <> "\" declaring "+ <> conceptTypeCountPhrase (length (spec ^. #types))+ <> "."++conceptTypeCountPhrase :: Int -> Text+conceptTypeCountPhrase = \case+ 0 -> "no concept types"+ 1 -> "1 concept type"+ n -> renderInt n <> " concept types"++-- | 'Nothing' for an absent or all-whitespace value, so a profile that declares+-- @description = Some \"\"@ is treated the same as one that declares none.+nonBlank :: Maybe Text -> Maybe Text+nonBlank value = do+ raw <- value+ let trimmed = Text.strip raw+ if Text.null trimmed then Nothing else Just trimmed++code :: Text -> Text+code value = "`" <> value <> "`"++-- | A comma-separated list of backticked values, or the given word when empty.+codeListOr :: Text -> [Text] -> Text+codeListOr emptyWord = \case+ [] -> emptyWord+ values -> Text.intercalate ", " (map code values)++-- | Join body lines, giving the body a single trailing newline.+unlinesText :: [Text] -> Text+unlinesText = Text.unlines
+ src/Okf/Trust.hs view
@@ -0,0 +1,120 @@+-- | Trust and freshness derivations for OKF v0.2 concepts.+--+-- Everything here is __derived on read and never stored__. Specification §5.3+-- says "Consumers /derive/ a trust tier", and §5.1 says credibility "is+-- /inferred/ from the signals, the same way trust tiers are (§5.3), not+-- stored". Accordingly this module exports plain functions over frontmatter+-- values rather than fields on 'Okf.Bundle.Concept': a stored derivation can go+-- stale relative to the frontmatter it summarises, which the projection+-- contract on @Okf.Bundle.conceptAt@ already forbids. See+-- @docs\/adr\/8-derived-not-stored-trust-and-credibility.md@.+--+-- This module also never reads the clock. 'staleness' takes the current day as+-- an argument so it stays pure and testable against a fixed date, and so that+-- two calls in one run cannot disagree about what "today" is. The command-line+-- tool reads the clock once and passes the day down.+module Okf.Trust+ ( TrustTier (..),+ trustTier,+ renderTrustTier,+ latestVerification,+ Staleness (..),+ staleness,+ renderStaleness,+ )+where++import Data.Text qualified as Text+import Data.Time (Day, defaultTimeLocale, parseTimeM)+import Okf.Actor (isHumanActor)+import Okf.Document (Verification (..))+import Okf.Prelude++-- | A concept's trust tier, lowest to highest, per specification §5.3.+--+-- Tiers are advisory signals, not access control: §5.3 states that "A concept+-- with no trust frontmatter is still consumable; consumers MUST NOT reject it".+-- The 'Ord' instance orders them lowest to highest so callers can compare.+data TrustTier+ = -- | No usable @verified@ entry.+ Unverified+ | -- | Verified by non-@human:@ actors only.+ MachineConfirmed+ | -- | Verified by at least one @human:\<id\>@ actor.+ HumanReviewed+ deriving stock (Generic, Eq, Ord, Show)++-- | Derive a trust tier from a concept's @verified@ entries, per §5.3.+--+-- The @human:@ test comes from 'Okf.Actor.isHumanActor' rather than being+-- re-derived here: §5.3 makes that single test the sole discriminator between+-- the two verified tiers, and two copies of it would eventually disagree.+trustTier :: [Verification] -> TrustTier+trustTier verifications+ | null verifications = Unverified+ | any (isHumanActor . verificationBy) verifications = HumanReviewed+ | otherwise = MachineConfirmed++-- | Render a tier in the specification's own words, so CLI output and+-- documentation match §5.3 for a reader with the specification open.+renderTrustTier :: TrustTier -> Text+renderTrustTier = \case+ Unverified -> "unverified"+ MachineConfirmed -> "machine-confirmed"+ HumanReviewed -> "human-reviewed"++-- | The most recent verification time, implementing §5.2's "'How recently' is+-- the latest @at@". Entries without an @at@ are skipped.+--+-- Compares the raw strings. ISO 8601 datetimes in a fixed-width UTC form sort+-- lexicographically in chronological order, the same shortcut+-- @Okf.Validation@ already takes for log dates. This breaks if a producer+-- writes a non-UTC offset such as @2026-06-25T09:00:00+01:00@, which sorts by+-- its local wall-clock reading rather than its instant. Profiles can require+-- the UTC form with the existing @Rfc3339Utc@ field format.+latestVerification :: [Verification] -> Maybe Text+latestVerification verifications =+ case [occurredAt | Verification {verificationAt = Just occurredAt} <- verifications] of+ [] -> Nothing+ times -> Just (maximum times)++-- | Whether a concept has passed its @stale_after@ date (specification §5.5).+data Staleness+ = -- | @stale_after@ is present and today is before it.+ Fresh+ | -- | Today is on or after @stale_after@, which is carried here.+ Stale !Day+ | -- | @stale_after@ is present but is not a @YYYY-MM-DD@ date. The original+ -- text is preserved so a caller can report it.+ StaleAfterUnparseable !Text+ | -- | No @stale_after@ key, so freshness is unknown rather than assured.+ NoStaleAfter+ deriving stock (Generic, Eq, Show)++-- | Decide staleness against a caller-supplied day.+--+-- Implements §5.5 literally: "A concept is stale when @today >= stale_after@".+-- The comparison is inclusive, so a concept whose @stale_after@ is exactly+-- today is stale.+--+-- A value that does not parse yields 'StaleAfterUnparseable' rather than being+-- treated as fresh. Silently ignoring a malformed freshness deadline is the+-- worst available behaviour: it reports a concept as trustworthy on the+-- strength of a field nobody could read.+staleness :: Day -> Maybe Text -> Staleness+staleness today = \case+ Nothing -> NoStaleAfter+ Just raw ->+ case parseTimeM True defaultTimeLocale "%Y-%m-%d" (Text.unpack raw) of+ Nothing -> StaleAfterUnparseable raw+ Just deadline+ | today >= deadline -> Stale deadline+ | otherwise -> Fresh++-- | Render staleness as a short phrase for command-line output.+renderStaleness :: Staleness -> Text+renderStaleness = \case+ Fresh -> "ok"+ NoStaleAfter -> "ok"+ Stale deadline -> "stale since " <> Text.pack (show deadline)+ StaleAfterUnparseable raw -> "unparseable stale_after " <> raw
src/Okf/Validation.hs view
@@ -4,6 +4,9 @@ ValidationProfile (..), validateDocument, BundleValidationError (..),+ VersionGate (..),+ versionGate,+ gateDeclaresAtLeast, validateBundle, validateBundleLogs, validateLogs,@@ -13,14 +16,38 @@ ) where +import Data.Aeson.Key qualified as AesonKey+import Data.Aeson.KeyMap qualified as KeyMap import Data.List qualified as List import Data.Text qualified as Text import Data.Vector qualified as Vector-import Okf.Bundle (Concept, LogFile, conceptDocument, conceptIdOf, conceptSourcePath, logContent, logSourcePath)+import Okf.Bundle+ ( BundleInventory,+ Concept,+ LogFile,+ bundleInventoryMember,+ conceptAttester,+ conceptComputation,+ conceptDocument,+ conceptExecutor,+ conceptIdOf,+ conceptResource,+ conceptSourcePath,+ logContent,+ logSourcePath,+ ) import Okf.ConceptId (ConceptId) import Okf.Document import Okf.Graph (danglingReferences, duplicateConceptIds)+import Okf.Index+ ( OkfVersion (..),+ VersionDeclaration (..),+ renderOkfVersion,+ supportedOkfVersion,+ ) import Okf.Log (Log (logDays), LogDay (logDate), LogValidationError, validateLog)+import Okf.Markdown (extractFootnoteLabels, footnoteLabelsUsed)+import Okf.Path (PathResolution (..), resolvePathReference) import Okf.Prelude import System.FilePath qualified as FilePath @@ -36,6 +63,73 @@ | FieldMustBeNonEmptyText Text | MissingRecommendedField Text | FieldMustBeListOfText Text+ | -- | Neither the OKF v0.2 @generated@ family (§5.2) nor the legacy v0.1+ -- @timestamp@ it supersedes (§13.1) records when the content last changed.+ MissingGeneratedField+ | -- | @generated@ is present but carries no textual @by@ actor, which+ -- specification §5.2 marks REQUIRED within the mapping.+ GeneratedMustHaveActor+ | -- | A @sources@ entry omits the @resource@ that specification §5.1 marks+ -- REQUIRED within an entry. Carries the entry's zero-based index in the raw+ -- YAML list, which is the only way to name an entry that may have no @id@.+ SourceMissingResource Int+ | -- | Two @sources@ entries in one document share an @id@.+ DuplicateSourceId Text+ | -- | The body attributes a claim with a footnote label that names no+ -- @sources@ entry in the same document, so the attribution resolves to+ -- nothing. Specification §5.1 makes the label the join key into @sources@.+ FootnoteLabelNotInSources Text+ | -- | A @sources@ entry carries an @id@ that no footnote in the body cites.+ -- A lint rather than a defect: §5.1 says an @id@ SHOULD be present when the+ -- body cites the source, which implies an id exists in order to be cited,+ -- but never requires the citation.+ SourceIdNotCited Text+ | -- | A concept carries a superseded OKF v0.1 construct in a bundle whose+ -- root index declares OKF v0.2 or later. Carries the legacy field's name.+ -- Reading the legacy construct still works — see+ -- @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@ — but a bundle that has+ -- said it targets v0.2 is describing an authoring mistake rather than+ -- exercising a compatibility path.+ LegacyFieldInDeclaredV2 Text+ | -- | A concept declaring @type: Attested Computation@ carries no @runtime@,+ -- which specification §10.2 marks REQUIRED for that type — it is "the single+ -- field that says how to run the computation, and so how the executor and+ -- attester interpret it and what @parameters@ mean", so without it a+ -- parameter has no binding semantics at all.+ --+ -- Strict-only. §11's conformance list has three items and none is a+ -- computation field, and §11 separately says a consumer "MUST NOT reject a+ -- bundle because of ... Unknown @type@ values". "REQUIRED for this type"+ -- binds the producer; it does not license a consumer to refuse. This is+ -- therefore an authoring lint, placed exactly where+ -- @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@ places every other+ -- optional-family presence check.+ AttestedComputationMissingRuntime+ | -- | A concept declaring @type: Attested Computation@ offers no computation+ -- at all: no @computation@ path and no code block under @# Computation@.+ -- Specification §10.3 says the computation is provided in one of two ways,+ -- so a contract carrying neither promises a sanctioned computation and then+ -- does not carry one.+ --+ -- Strict-only, for the reason 'AttestedComputationMissingRuntime' gives.+ AttestedComputationHasNoComputation+ | -- | The concept offers both a @computation@ path and a body block.+ -- Specification §10.3 permits exactly one — "set @computation@ to a path+ -- (§6.2) and omit the body fence" — and two leaves a consumer with no way to+ -- know which one the producer sanctioned.+ --+ -- Strict-only, for the reason 'AttestedComputationMissingRuntime' gives.+ AttestedComputationHasBothComputations+ | -- | The @# Computation@ section holds more than one code block, where+ -- specification §10.3 says "a single fenced code block". Carries the count,+ -- so the diagnostic can say how many were found.+ --+ -- The section is bounded at the next heading of the same or a shallower+ -- level, so a code block under a later heading is not counted here; see+ -- 'Okf.Markdown.computationBlocks'.+ --+ -- Strict-only, for the reason 'AttestedComputationMissingRuntime' gives.+ AttestedComputationHasManyBlocks Int deriving stock (Generic, Eq, Show) -- | A whole-bundle validation problem.@@ -44,13 +138,111 @@ DocumentInvalid ConceptId ValidationError | -- | A source concept links to a target that is not present in the bundle. DanglingReference ConceptId ConceptId+ | -- | A path-valued frontmatter field (specification §6.2) names a bundle path+ -- that no file in the bundle matches. Carries the concept, the frontmatter+ -- field path as written (@resource@, or @executor.resource@), the resolved+ -- bundle-relative target, and — when the value was relative and the same text+ -- read as bundle-relative /would/ resolve — that alternative target.+ --+ -- The fourth field exists because specification §10.2's own worked example+ -- writes @executor.resource: references\/skills\/run-on-bq.md@ while §10.4+ -- puts computations under @computations\/@, so an author copying the+ -- specification writes a relative path that names+ -- @computations\/references\/...@ and is told about a path they did not+ -- write. §6.2 resolution is correct and unchanged; the diagnostic simply says+ -- what the author almost certainly meant. See+ -- @docs\/adr\/13-the-references-convention-and-non-markdown-files.md@.+ --+ -- Distinct from 'DanglingReference', which reports a Markdown link in a+ -- concept /body/ naming a missing /concept/. A frontmatter path may name a+ -- file that is not a concept at all — §6.3's @references\/attesters\/+ -- revenue.py@ — so it cannot be reported as a 'ConceptId' pair.+ --+ -- Like 'DanglingReference' this is an authoring-time lint that goes beyond+ -- conformance: §11 says a consumer MUST NOT reject a bundle over a broken+ -- cross-link, because §6.1 permits a link to knowledge not yet written.+ DanglingFrontmatterPath ConceptId Text FilePath (Maybe FilePath) | -- | The same concept ID was assembled more than once. DuplicateConceptId ConceptId | -- | A reserved log file does not match the required log structure. LogInvalid FilePath LogValidationError+ | -- | The bundle root's @index.md@ carries an @okf_version@ whose value is+ -- not of the form @\<major\>.\<minor\>@ (specification §12).+ BundleVersionUnparseable Text+ | -- | The bundle declares a version with a major okf does not know. Per §12+ -- the bundle is still read, best effort, with no version-specific checks.+ BundleVersionNotUnderstood Text deriving stock (Generic, Eq, Show) --- | A concept whose timestamp appears newer than its nearest covering log.+-- | What a bundle's declared version implies for validation.+--+-- This is the one place in okf that answers that question. Every OKF v0.1+-- compatibility tolerance is applied unconditionally where it is read — that is+-- the policy of @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@ — and a+-- family that wants to know whether the bundle has opted into a later version's+-- rules asks 'gateDeclaresAtLeast' here rather than testing the declaration+-- itself. Scattering version tests is what this type exists to prevent.+data VersionGate = VersionGate+ { gateDeclaration :: !VersionDeclaration,+ -- | The version okf reads the bundle as, after §12's best-effort rules.+ -- 'Nothing' means no version-specific rule applies.+ gateEffective :: !(Maybe OkfVersion),+ -- | The declared version okf could not make sense of, if any.+ gateNotUnderstood :: !(Maybe Text)+ }+ deriving stock (Generic, Eq, Show)++-- | Apply specification §12's rules for reading a declared version.+--+-- §12 defines a minor bump as backward-compatible additions and a major bump as+-- possibly breaking, and asks consumers that do not understand a declared+-- version to "attempt best-effort consumption rather than refusing the bundle".+-- Three cases follow:+--+-- * A version okf understands is read as itself.+--+-- * A known major with a higher minor — @0.3@ today — is read as the highest+-- version okf understands within that major. The additions okf has never+-- heard of are, by §12's definition of a minor bump, additions it can ignore.+--+-- * An unknown major — @1.0@ — is read with no version-specific rules at all,+-- and reported once under 'StrictAuthoring'. §12 permits a major bump to+-- rename required fields and change reserved filenames, so okf genuinely+-- cannot know which of its rules still hold.+--+-- An absent or unparseable declaration also applies no version-specific rules.+-- An undeclared bundle is precisely the case the unconditional v0.1 fallbacks+-- exist to serve, and it is the shape of almost every bundle in existence.+versionGate :: VersionDeclaration -> VersionGate+versionGate declaration =+ case declaration of+ VersionDeclared version+ | okfVersionMajor version == okfVersionMajor supportedOkfVersion ->+ understood (Just (min version supportedOkfVersion))+ | otherwise ->+ VersionGate+ { gateDeclaration = declaration,+ gateEffective = Nothing,+ gateNotUnderstood = Just (renderOkfVersion version)+ }+ VersionUndeclared -> understood Nothing+ VersionUnparseable _ -> understood Nothing+ where+ understood effective =+ VersionGate+ { gateDeclaration = declaration,+ gateEffective = effective,+ gateNotUnderstood = Nothing+ }++-- | Whether the bundle has declared itself to target at least the given+-- version. This is how a check asks "may I hold this bundle to my rules?"; a+-- future v0.3 family asks the same question with a different argument.+gateDeclaresAtLeast :: OkfVersion -> VersionGate -> Bool+gateDeclaresAtLeast minimumVersion VersionGate {gateEffective} =+ maybe False (>= minimumVersion) gateEffective++-- | A concept whose generated date appears newer than its nearest covering log. data LogStaleness = LogStaleness { staleConcept :: !ConceptId, staleConceptDate :: !Text,@@ -62,18 +254,160 @@ -- | Validate a whole bundle: per-document checks under the given profile, plus -- referential integrity (no links to missing concepts) and uniqueness of -- concept IDs. An empty list means the bundle is valid under the profile.-validateBundle :: ValidationProfile -> [Concept] -> [BundleValidationError]-validateBundle profile concepts =- perDocument <> dangling <> duplicates+--+-- The 'VersionDeclaration' is what the bundle root's @index.md@ says about the+-- version it targets, read with 'Okf.Index.readBundleVersion'. Pass+-- 'VersionUndeclared' for a bundle whose declaration is unknown or irrelevant:+-- that is the reading almost every bundle gets, and it applies no+-- version-specific rules. Everything the declaration implies is decided by+-- 'versionGate'.+--+-- The 'BundleInventory' is every file the bundle holds, read with+-- 'Okf.Bundle.walkBundleInventory'. It is what lets a path-valued frontmatter+-- field be resolved against a target that is not a concept — a @references\/@+-- script, a CSV — without giving validation a filesystem handle. A caller with+-- no directory to walk passes 'Okf.Bundle.bundleInventoryOfConcepts', which+-- reports the concepts' own paths and nothing else.+validateBundle :: ValidationProfile -> VersionDeclaration -> BundleInventory -> [Concept] -> [BundleValidationError]+validateBundle profile declaration inventory concepts =+ perDocument <> dangling <> frontmatterPaths <> duplicates <> versionErrors where+ gate = versionGate declaration+ frontmatterPaths = case profile of+ PermissiveConformance -> []+ StrictAuthoring -> danglingFrontmatterPaths inventory concepts perDocument = [ DocumentInvalid (conceptIdOf concept) err | concept <- concepts,- err <- validateDocument profile (conceptDocument concept)+ err <-+ validateDocument profile (conceptDocument concept)+ <> legacyFieldsUnderDeclaredVersion profile gate (conceptDocument concept) ] dangling = uncurry DanglingReference <$> danglingReferences concepts duplicates = DuplicateConceptId <$> duplicateConceptIds concepts+ versionErrors = case profile of+ PermissiveConformance -> []+ StrictAuthoring ->+ [BundleVersionUnparseable rawVersion | VersionUnparseable rawVersion <- [declaration]]+ <> [BundleVersionNotUnderstood version | Just version <- [gateNotUnderstood gate]] +-- | Strict-mode check that a path-valued frontmatter field names a file the+-- bundle actually holds (specification §6.2).+--+-- Strict-mode only, and deliberately not gated on the bundle declaring+-- @okf_version: "0.2"@. Every other version-sensitive check asks+-- 'gateDeclaresAtLeast' rather than testing the declaration, and this one is the+-- exception worth naming: neither @resource@ nor the §6.2 path grammar is a v0.2+-- addition, so a dangling @resource@ is just as wrong in an undeclared bundle,+-- which is the shape of almost every bundle in existence.+--+-- Only 'DanglingInBundle' is reported. The three other unresolved outcomes are+-- passed over on purpose, and it is not an oversight:+--+-- * 'UnresolvableEscape' and 'UnresolvableMalformed' would fire on correct+-- documents. §4.1 defines @resource@ as "a URI that uniquely identifies the+-- underlying asset", and a producer writing a bare @analytics.tables.orders@+-- is writing a legitimate §4.1 value that carries no scheme and so classifies+-- as a bundle path. Only the dangling case is safe to report, because it means+-- the value looks exactly like a bundle path and there is simply no such file.+--+-- * 'ResolvedExternal' is resolved: okf has no network access and never fetches.+danglingFrontmatterPaths :: BundleInventory -> [Concept] -> [BundleValidationError]+danglingFrontmatterPaths inventory concepts =+ [ DanglingFrontmatterPath+ (conceptIdOf concept)+ fieldName+ target+ (bundleRelativeAlternative existsInBundle (conceptIdOf concept) rawValue target)+ | concept <- concepts,+ (fieldName, rawValue) <- pathValuedFields concept,+ DanglingInBundle target <-+ [resolvePathReference existsInBundle (conceptIdOf concept) rawValue]+ ]+ where+ existsInBundle = flip bundleInventoryMember inventory++-- | For a relative value that resolved to nothing, the bundle-relative target+-- the same text /would/ have named had it been written with a leading @\/@ —+-- when that target is a file the bundle actually holds.+--+-- Specification §10.2's worked example writes+-- @executor.resource: references\/skills\/run-on-bq.md@ and §10.4 puts+-- computations under @computations\/@, so a bundle assembled from the+-- specification's own text names @computations\/references\/skills\/run-on-bq.md@+-- and is told about a path nobody wrote. §6.2 defines exactly three forms and+-- resolution is unchanged; this only lets the diagnostic name the spelling that+-- works.+--+-- Silent in three cases, each on purpose. A value already written with a leading+-- @\/@ was read from the bundle root already, so there is no alternative reading+-- to offer. A concept at the bundle root resolves both readings to the same path,+-- so the hint would suggest exactly what was just rejected. And a root-anchored+-- reading that also names nothing is no more use to the author than the original.+bundleRelativeAlternative :: (FilePath -> Bool) -> ConceptId -> Text -> FilePath -> Maybe FilePath+bundleRelativeAlternative exists sourceConcept rawValue target+ | "/" `Text.isPrefixOf` Text.strip rawValue = Nothing+ | otherwise = case resolvePathReference exists sourceConcept ("/" <> Text.strip rawValue) of+ ResolvedInBundle alternative | alternative /= target -> Just alternative+ _ -> Nothing++-- | The path-valued frontmatter fields okf resolves without being asked, paired+-- with the field name a diagnostic names so the author knows which line to fix.+--+-- Specification §6.2 names five path-valued fields. Two of them are absent here+-- for different reasons, and both are decisions rather than gaps.+--+-- @sources[].resource@ is excluded because §5.1 sanctions a value that is not a+-- path: an entry's resource names "either a concrete artifact a consumer can+-- follow ... or a population or scope descriptor it cannot", and+-- 'Okf.Document.sourceResource' says in as many words never to treat it as one.+-- @examples\/ddd-ordering@ carries such a descriptor today, and a check+-- reporting every unresolvable bundle path would report that correct bundle as+-- broken. A team whose corpus does use followable paths there opts in by writing+-- a profile: @path@ on a @NestedFieldRule@ reaches @sources.resource@ already.+--+-- @computation@, @executor.resource@, and @attester.resource@ are here, and+-- unlike @sources[].resource@ nothing in §10 sanctions a non-path value for+-- them: §10.2 defines @computation@ as "a path (§6.2) to a file holding the+-- computation" and both @resource@ members as naming code or run instructions "a+-- runner ... follows", which a scope descriptor is not. Their whole purpose is+-- to be followed, and §6.3 puts the files they name inside the bundle under+-- @references\/@, so a value naming nothing is exactly the authoring mistake+-- this check exists to catch. A target that is not Markdown — §6.3's own+-- @references\/attesters\/revenue.py@ — resolves like any other, because the+-- 'BundleInventory' records every file rather than only the concepts.+--+-- The nested field names are written with a dot, as @executor.resource@, so the+-- diagnostic names the line an author must fix rather than the mapping holding+-- it.+pathValuedFields :: Concept -> [(Text, Text)]+pathValuedFields concept =+ [("resource", value) | Just value <- [conceptResource concept]]+ <> [("computation", value) | Just value <- [conceptComputation concept]]+ <> [("executor.resource", value) | Just executor <- [conceptExecutor concept], Just value <- [executorResource executor]]+ <> [("attester.resource", value) | Just attester <- [conceptAttester concept], Just value <- [attesterResource attester]]++-- | Strict-mode check that a bundle which has declared OKF v0.2 carries no+-- superseded v0.1 construct.+--+-- okf reads a v0.1 @timestamp@ whenever @generated@ is absent, always and+-- silently; @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@ fixes that as the+-- policy and this check does not change it. What it adds is the other half of+-- the answer that ADR left open. A bundle that has explicitly said+-- @okf_version: "0.2"@ and still carries @timestamp@ is not exercising a+-- compatibility path; it is describing a document nobody finished migrating,+-- and only the declaration makes that distinction possible.+--+-- Strict-mode only, and silent for an undeclared bundle.+legacyFieldsUnderDeclaredVersion :: ValidationProfile -> VersionGate -> OKFDocument -> [ValidationError]+legacyFieldsUnderDeclaredVersion PermissiveConformance _ _ = []+legacyFieldsUnderDeclaredVersion StrictAuthoring gate OKFDocument {frontmatter}+ | not (gateDeclaresAtLeast (OkfVersion {okfVersionMajor = 0, okfVersionMinor = 2}) gate) = []+ | isJust (frontmatterLookup "timestamp" frontmatter),+ isNothing (frontmatterLookup "generated" frontmatter) =+ [LegacyFieldInDeclaredV2 "timestamp"]+ | otherwise = []+ -- | Validate all parsed @log.md@ files discovered in a bundle. validateBundleLogs :: [LogFile] -> [BundleValidationError] validateBundleLogs = validateLogs@@ -86,12 +420,12 @@ err <- validateLog (logContent logFile) ] --- | Find concepts whose timestamp date is newer than their nearest enclosing log.+-- | Find concepts whose generated date is newer than their nearest enclosing log. logStaleness :: [Concept] -> [LogFile] -> [LogStaleness] logStaleness concepts logs = [ staleness | concept <- concepts,- Just conceptDate <- [conceptTimestampDate concept],+ Just conceptDate <- [conceptGeneratedDate concept], let nearest = nearestEnclosingLog (conceptSourcePath concept) logs, Just staleness <- [staleIfNeeded concept conceptDate nearest] ]@@ -104,8 +438,194 @@ <> case profile of PermissiveConformance -> [] StrictAuthoring ->- foldMap (requireNonEmptyText MissingRecommendedField `flip` document) ["title", "description", "timestamp"]+ foldMap (requireNonEmptyText MissingRecommendedField `flip` document) ["title", "description"]+ <> requireGenerated document+ <> checkSources document+ <> checkFootnoteAttribution document+ <> requireComputationRuntime document+ <> requireOneComputation document +-- | Strict-mode check on the OKF v0.2 attested computation contract+-- (specification §10.2).+--+-- Fires only on a concept whose @type@ is exactly+-- 'Okf.Document.attestedComputationType', and reports only the one field §10.2+-- marks REQUIRED. Everything else the contract can get wrong — a @parameters@+-- entry with no @type@, an @executor@ with no @resource@, an @attester@ naming a+-- file nobody wrote — is a house convention a profile already expresses, with+-- @objectFields@ reaching inside @executor@ and a @TypeRule@ scoping it to this+-- type. Adding those here would be inventing a taxonomy the specification+-- declines to fix, which is the mistake+-- @docs\/plans\/47-enforce-the-profile-declared-okfversion-and-ship-a-v0-2-reference-profile.md@+-- made and withdrew.+--+-- A whitespace-only @runtime@ counts as absent: it names no runtime, and §10.2's+-- whole point is that the value is what gives @parameters@ their meaning.+requireComputationRuntime :: OKFDocument -> [ValidationError]+requireComputationRuntime OKFDocument {frontmatter}+ | frontmatterLookup "type" frontmatter /= Just (String attestedComputationType) = []+ | maybe True (Text.null . Text.strip) (readRuntime frontmatter) =+ [AttestedComputationMissingRuntime]+ | otherwise = []++-- | Strict-mode check on specification §10.3's exactly-one rule.+--+-- §10.3 provides the computation "in one of two ways" — inline, as a code block+-- in the body under @# Computation@, or by file, by setting @computation@ to a+-- path and omitting the body block. Neither and both are equally wrong, and so+-- is one @# Computation@ section holding two blocks.+--+-- Fires only on a concept whose @type@ is exactly+-- 'Okf.Document.attestedComputationType', like every other check on this+-- contract. Strict-only for the reason+-- @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@ gives: §11's conformance+-- list does not reach a computation field, and §11 separately forbids rejecting+-- a bundle over an unknown @type@ value, so "the format requires this" binds the+-- producer rather than licensing a consumer to refuse.+--+-- At most one diagnostic per document, ambiguity first. A document with a+-- @computation@ key and two body blocks has one thing to fix, not two, and the+-- ambiguity is the more serious half.+requireOneComputation :: OKFDocument -> [ValidationError]+requireOneComputation document@OKFDocument {frontmatter}+ | frontmatterLookup "type" frontmatter /= Just (String attestedComputationType) = []+ | not (null files) && not (null inlines) = [AttestedComputationHasBothComputations]+ | length inlines > 1 = [AttestedComputationHasManyBlocks (length inlines)]+ | null files && null inlines = [AttestedComputationHasNoComputation]+ | otherwise = []+ where+ (files, inlines) = List.partition isFile (readComputationSources document)+ isFile = \case+ ComputationFile _ -> True+ ComputationInline _ -> False++-- | Strict-mode checks on the OKF v0.2 @sources@ family (specification §5.1).+--+-- Both diagnostics fire only when @sources@ is present, and only under+-- 'StrictAuthoring': §11 forbids rejecting a bundle for a missing optional+-- family, and @sources@ is optional.+--+-- Note this inspects the __raw YAML list__ rather than 'readSources' output.+-- 'readSources' has already dropped entries without a @resource@, so the parsed+-- list cannot report them, and an index into it would not match the index a+-- person sees in the file.+--+-- No check resolves a @resource@ or reports it as dangling. §5.1 permits a+-- resource to name "a population or scope descriptor" a consumer cannot follow,+-- such as @all queries in BigQuery project X@.+checkSources :: OKFDocument -> [ValidationError]+checkSources OKFDocument {frontmatter} =+ case frontmatterLookup "sources" frontmatter of+ Just (Array entries) -> missingResource entries <> duplicateIds entries+ _ -> []+ where+ missingResource entries =+ [ SourceMissingResource entryIndex+ | (entryIndex, Object entryFields) <- zip [0 ..] (Vector.toList entries),+ isNothing (entryTextField "resource" entryFields)+ ]+ -- Scoped to one document. §5.1 does not require ids to be unique across a+ -- bundle, only that a label unambiguously names one entry where it is used.+ duplicateIds entries =+ DuplicateSourceId+ <$> appearingMoreThanOnce+ [ entryId+ | Object entryFields <- Vector.toList entries,+ Just entryId <- [entryTextField "id" entryFields]+ ]+ entryTextField key entryFields =+ case KeyMap.lookup (AesonKey.fromText key) entryFields of+ Just (String value) | not (Text.null (Text.strip value)) -> Just value+ _ -> Nothing+ appearingMoreThanOnce values =+ List.nub [value | (value, count) <- countOccurrences values, count > (1 :: Int)]+ countOccurrences values =+ [(value, length (filter (== value) values)) | value <- List.nub values]++-- | Strict-mode checks joining the body's footnote labels to @sources@ entry+-- ids (specification §5.1's per-claim attribution).+--+-- §5.1: "The footnote label is the join key into @sources@; consumers resolve+-- attribution through the matching entry, not by parsing the footnote prose."+-- Labels are keyed rather than positional because agents rewrite these documents+-- constantly and a positional index misattributes silently the moment the list+-- is reordered. The join is therefore worth checking in both directions, at two+-- different severities.+--+-- Each direction is gated on the other side having opted into per-claim+-- attribution, and the two gates are the same rule seen from opposite ends.+--+-- Labels are checked only when the document has a @sources@ key. Markdown+-- footnotes are legal prose used for ordinary purposes, and a document that has+-- not opted into structured provenance is making no attribution claim;+-- reporting every footnote in such a body would make the check hostile.+--+-- Ids are checked only when the body cites at least one footnote label. §5.1+-- asks for an @id@ "when the body cites the source", so an id in a body that+-- cites nothing is simply not being used for per-claim attribution — the same+-- reason an entry with no @id@ at all is never reported. Without this gate every+-- @sources@ document that uses no footnotes, which is the common shape, would+-- emit one lint per entry.+--+-- The join is document-local, matching §5.1. Ids are not required to be unique+-- across a bundle, so a label naming an id in some other document means nothing.+-- Within one document 'DuplicateSourceId' already guarantees "the matching+-- entry" is well defined.+--+-- Both diagnostics are strict-mode only, per §11's rule that a consumer must not+-- reject a bundle over optional frontmatter.+checkFootnoteAttribution :: OKFDocument -> [ValidationError]+checkFootnoteAttribution OKFDocument {frontmatter, body} =+ case frontmatterLookup "sources" frontmatter of+ Nothing -> []+ Just sourcesValue ->+ let entryIds = sourceIdsOf sourcesValue+ labels = footnoteLabelsUsed (extractFootnoteLabels body)+ in [FootnoteLabelNotInSources label | label <- labels, label `notElem` entryIds]+ <> [ SourceIdNotCited entryId+ | not (null labels),+ entryId <- entryIds,+ entryId `notElem` labels+ ]+ where+ -- Read ids from the raw YAML list rather than 'readSources', for the same+ -- reason 'checkSources' does: 'readSources' drops entries with no+ -- @resource@, and an id the author wrote should still join even when the+ -- entry holding it is separately malformed.+ sourceIdsOf = \case+ Array entries ->+ List.nub+ [ entryId+ | Object entryFields <- Vector.toList entries,+ Just (String rawId) <- [KeyMap.lookup (AesonKey.fromText "id") entryFields],+ let entryId = Text.strip rawId,+ not (Text.null entryId)+ ]+ _ -> []++-- | Strict-mode check for the OKF v0.2 @generated@ family (specification §5.2).+--+-- A document satisfies "when was this last changed" with either @generated@+-- carrying a @by@ actor or, falling back per §13.1, a non-empty legacy v0.1+-- @timestamp@. Reading the legacy key is deliberately silent: a warning on+-- every v0.1 document would make the tool unusable against existing bundles.+-- See @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@.+--+-- Both diagnostics are strict-mode only. Specification §11 forbids rejecting a+-- bundle for a missing optional frontmatter field, and @generated@ is optional.+requireGenerated :: OKFDocument -> [ValidationError]+requireGenerated document@OKFDocument {frontmatter} =+ case frontmatterLookup "generated" frontmatter of+ Nothing+ | hasLegacyTimestamp -> []+ | otherwise -> [MissingGeneratedField]+ Just _+ | isJust (readGenerated frontmatter) -> []+ | otherwise -> [GeneratedMustHaveActor]+ where+ hasLegacyTimestamp =+ null (requireNonEmptyText MissingRecommendedField "timestamp" document)+ requireNonEmptyText :: (Text -> ValidationError) -> Text -> OKFDocument -> [ValidationError] requireNonEmptyText missing key OKFDocument {frontmatter} = case frontmatterLookup key frontmatter of@@ -127,12 +647,25 @@ isString (String _) = True isString _ = False -conceptTimestampDate :: Concept -> Maybe Text-conceptTimestampDate concept =- case frontmatterLookup "timestamp" (frontmatter (conceptDocument concept)) of- Just (String timestamp)- | Text.length timestamp >= 10 -> Just (Text.take 10 timestamp)- _ -> Nothing+-- | The @YYYY-MM-DD@ date a concept's content last changed, read from the OKF+-- v0.2 @generated.at@ (specification §5.2) in preference to the legacy v0.1+-- @timestamp@ it supersedes (§13.1). When both are present @generated.at@ wins.+--+-- An ISO 8601 datetime carries the date in its first ten characters, so+-- anything shorter is not a usable date and yields 'Nothing'.+conceptGeneratedDate :: Concept -> Maybe Text+conceptGeneratedDate concept =+ datePrefix (generatedAt =<< readGenerated conceptFrontmatter)+ <|> datePrefix (legacyTimestamp conceptFrontmatter)+ where+ conceptFrontmatter = frontmatter (conceptDocument concept)+ legacyTimestamp frontmatterValue =+ case frontmatterLookup "timestamp" frontmatterValue of+ Just (String timestamp) -> Just timestamp+ _ -> Nothing+ datePrefix = \case+ Just value | Text.length value >= 10 -> Just (Text.take 10 value)+ _ -> Nothing staleIfNeeded :: Concept -> Text -> Maybe LogFile -> Maybe LogStaleness staleIfNeeded concept conceptDate nearest =
test/Main.hs view
@@ -5,2982 +5,6275 @@ import Data.Aeson (object, toJSON, (.=)) import Data.Foldable (for_, toList) import Data.List qualified as List-import Data.Text qualified as Text-import Data.Text.IO qualified as Text.IO-import Okf.Bundle-import Okf.ConceptId-import Okf.Discovery-import Okf.Document-import Okf.Graph-import Okf.Index-import Okf.Log-import Okf.Prelude hiding (List, setField, (.=))-import Okf.Profile-import Okf.Profile.Registry-import Okf.Validation-import System.Directory- ( createDirectoryIfMissing,- doesDirectoryExist,- doesFileExist,- getTemporaryDirectory,- removeDirectoryRecursive,- )-import System.Exit (exitFailure)-import System.FilePath (normalise, takeDirectory, (</>))-import System.IO.Temp (createTempDirectory)-import "generic-lens" Data.Generics.Labels ()--main :: IO ()-main = do- results <-- sequence- [ test "parse valid document with YAML frontmatter" testParseValidDocument,- test "parse document with no frontmatter as empty-frontmatter body" testParseNoFrontmatter,- test "reject unterminated frontmatter" testRejectUnterminatedFrontmatter,- test "reject frontmatter that is not a YAML mapping" testRejectNonMappingFrontmatter,- test "validate permissive profile with only type" testPermissiveValidation,- test "validate strict profile requiring title description timestamp" testStrictValidation,- test "validate rejects tags that are not a string list" testRejectInvalidTags,- test "round-trip preserves semantic frontmatter and body" testRoundTrip,- test "reject invalid concept id segment" testRejectInvalidConceptId,- test "convert concept id tables/users to tables/users.md" testConceptIdToFilePath,- testIO "walkBundle reports a structured IO error for a missing root" testWalkBundleMissingRoot,- testIO "walkBundle skips index.md and log.md" testWalkBundleSkipsReserved,- testIO "walkBundle discovers nested concept IDs" testWalkBundleDiscoversNestedConceptIds,- testIO "discoverBundleRoots finds a directory holding index.md" testDiscoverIndexMd,- testIO "discoverBundleRoots finds a directory holding a typed concept" testDiscoverTypedConcept,- testIO "discoverBundleRoots ignores markdown without a type field" testDiscoverIgnoresPlainMarkdown,- testIO "discoverBundleRoots does not descend into a bundle it found" testDiscoverPrunesNestedBundles,- testIO "discoverBundleRoots skips hidden and build directories" testDiscoverSkipsNoise,- testIO "discoverBundleRoots honours maxDepth" testDiscoverHonoursMaxDepth,- testIO "discoverBundleRoots reports a fixture bundle as its own root" testDiscoverFixtureBundle,- test "parseLog/serializeLog round-trips a canonical log" testLogRoundTrip,- test "validateLog flags a non-ISO date heading" testValidateLogNonIsoDate,- test "validateLog flags an empty date group" testValidateLogEmptyDay,- test "validateLog flags out-of-order days" testValidateLogOutOfOrder,- testIO "walkLogs discovers nested log.md files" testWalkLogsDiscoversNested,- test "logStaleness flags a concept newer than its nearest log" testLogStalenessFlagsNewerConcept,- test "logStaleness prefers the deepest enclosing log" testLogStalenessPrefersDeepestLog,- test "appendLogEntry inserts newest-first and prepends within a day" testAppendLogEntry,- testIO "generateIndex groups documents by frontmatter type" testGenerateIndexGroupsByType,- testIO "extractLinks resolves relative and absolute bundle links" testExtractLinksResolveBundleLinks,- testIO "extractLinks ignores external markdown URLs" testExtractLinksIgnoresExternalUrls,- testIO "buildGraph includes only edges to existing concepts" testBuildGraphIncludesKnownEdges,- testIO "writeBundleIndexes is deterministic" testWriteBundleIndexesDeterministic,- testIO "fixture valid bundle validates and graphs expected edges" testFixtureValidBundle,- testIO "fixture graph JSON shape is stable" testFixtureGraphJsonShape,- testIO "fixture unterminated frontmatter reports parse error" testFixtureUnterminatedFrontmatter,- testIO "fixture missing type reports validation error" testFixtureMissingType,- test "frontmatter builder round-trips through serialize and parse" testFrontmatterBuilderRoundTrip,- test "serializeDocument emits deterministic key order" testSerializeDeterministicKeyOrder,- test "rendered concept link round-trips through extractConceptLinks" testConceptLinkRoundTrip,- test "over-escaping relative links do not resolve inside bundle" testRejectOverEscapingRelativeLink,- test "validateBundle reports a dangling reference" testValidateBundleDanglingReference,- test "validateBundle accepts a bundle whose links all resolve" testValidateBundleAcceptsResolved,- test "duplicateConceptIds finds repeated ids" testDuplicateConceptIds,- test "conceptFromDocument derives typed fields from frontmatter" testConceptFromDocumentDerivesFields,- testIO "writeBundle then walkBundle round-trips" testWriteBundleRoundTrip,- testIO "fixture dangling link reports a bundle validation error" testFixtureDanglingLink,- testIO "loadProfileFile decodes the postgresql fixture" testLoadProfileFixture,- testIO "loadProfileFile decodes record-completed document ID rules" testLoadDocumentIdProfileFixture,- 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,- testIO "loadProfileFile accepts the frozen EP-3 cardinality schema" testLoadCardinalityCompatibilityFixture,- testIO "loadProfileFile accepts the frozen EP-4 format schema" testLoadFormatCompatibilityFixture,- testIO "loadProfileFile decodes bounded nested review rules" testLoadNestedReviewsProfileFixture,- testIO "loadProfileFile preserves the frozen bounded-nested schema" testLoadNestedCompatibilityFixture,- testIO "loadProfileFile decodes same-scope conditions" testLoadConditionalFieldsProfileFixture,- testIO "loadProfileFile preserves the frozen condition-aware schema" testLoadConditionalCompatibilityFixture,- testIO "loadProfileFile preserves the frozen reference-aware schema" testLoadReferenceCompatibilityFixture,- testIO "loadProfileFile still accepts an okf 0.2.x descriptor" testLoadLegacyProfileFixture,- testIO "profileFieldDescription finds required and recommended prose" testProfileFieldDescription,- testIO "profileFieldDescription finds optional prose" testOptionalFieldDescription,- testIO "profile JSON encoding emits type, not type_" testProfileJsonShape,- test "field condition JSON encoding is stable" testFieldConditionJsonShape,- test "handle reference JSON encoding is stable" testHandleReferenceJsonShape,- test "field format JSON encoding is stable" testFieldFormatJsonShape,- testIO "loadRegistry enumerates nested profiles and skips non-profiles" testRegistryEnumeratesProfiles,- testIO "loadRegistry reports a bare profile as a root entry" testRegistryRootProfile,- testIO "resolveRegistryRef prefers package.dhall inside a directory" testResolveRegistryRef,- testIO "loadRegistry reports a missing registry as Left" testRegistryLoadFailure,- test "parseDocumentId accepts only canonical handles" testParseDocumentId,- testIO "documentIdsInBundle sorts handles by prefix and number" testDocumentIdsInBundle,- test "nextDocumentId skips gaps and starts unused prefixes at one" testNextDocumentId,- testIO "findConceptsByDocumentId resolves and reports duplicate handles" testFindConceptsByDocumentId,- test "compileProfile rejects ambiguous definitions deterministically" testCompileProfileDefinitionErrors,- test "compiled rules merge profile and type requirements" testCompiledProfileMerge,- test "compiled vocabularies intersect in profile declaration order" testCompiledVocabularyIntersection,- test "compileProfile rejects disjoint vocabularies" testUnsatisfiableVocabulary,- test "compiled cardinality uses Any as identity and rejects contradictions" testCompiledCardinality,- test "profile vocabularies validate strings, lists, and shapes" testVocabularyValidation,- test "profile cardinality validates all JSON shapes and presence" testCardinalityValidation,- test "cardinality suppresses redundant vocabulary shape errors" testCardinalityVocabularyInteraction,- test "compiled formats refine Uri and reject contradictions" testCompiledFieldFormats,- test "compileProfile rejects invalid format parameters" testInvalidFormatParameters,- test "named formats validate parser boundaries, lists, and shapes" testNamedFormatValidation,- test "compiled nested rules merge and reject impossible outer cardinality" testCompiledNestedRules,- test "nested record validation reports indexed paths and strict recommendations" testNestedRecordValidation,- test "compileProfile rejects invalid same-scope field conditions" testConditionDefinitionErrors,- test "top-level conditions gate presence without gating value checks" testTopLevelConditionalPresence,- test "nested conditions use siblings and avoid cascading diagnostics" testNestedConditionalPresence,- test "compileProfile rejects invalid document reference policies" testReferenceDefinitionErrors,- test "document references resolve local handles and explicit external URIs" testDocumentReferenceValidation,- test "optional fields are never missing but are fully value-checked" testOptionalFieldPresence,- test "optional reference fields resolve handles when present" testOptionalReferenceValidation,- test "optional nested fields are never missing inside records" testOptionalNestedFieldPresence,- test "optional fields count as declared under closed field names" testOptionalFieldClosure,- test "optional at one scope does not cancel the other scope's clause" testOptionalDoesNotCancelOtherScope,- test "compileProfile rejects optional collisions and dead conditions" testOptionalDefinitionErrors,- test "closed profiles reject unknown fields and isolate type fields" testClosedFieldValidation,- test "profile rules apply to unknown concept types" testProfileRulesApplyToUnknownTypes,- test "strict profile validation checks recommendations" testStrictProfileRecommendations,- test "validateProfile accepts a conforming table concept" testProfileConformingTable,- test "validateProfile flags a type not in the vocabulary" testProfileUnknownType,- test "validateProfile flags a missing required field" testProfileMissingField,- test "validateProfile flags a resource scheme mismatch" testProfileResourceMismatch,- test "validateProfile flags a path pattern mismatch" testProfilePathMismatch,- test "validateProfile flags a missing # Schema section" testProfileMissingSchema,- test "validateProfile flags mismatched # Schema columns" testProfileSchemaColumnsMismatch,- test "validateProfile accepts a conforming document ID" testProfileConformingDocumentId,- test "validateProfile flags a missing document ID" testProfileMissingDocumentId,- test "validateProfile flags malformed document IDs" testProfileMalformedDocumentIds,- test "validateProfile flags duplicate document IDs" testProfileDuplicateDocumentIds,- test "validateProfile document ID checks are off by default" testProfileDocumentIdsOffByDefault,- test "schemaSectionColumns reads the header row of the Schema table" testSchemaSectionColumns,- testIO "validateProfile reports the expected deviations for the fixture bundle" testProfileDeviationsFixture,- testIO "validateProfile reports document ID fixture deviations" testDocumentIdDeviationsFixture,- testIO "type-aware fixture is permissive but reports one strict recommendation" testTypeAwareProfileFixture,- testIO "closed-field fixture reports missing and misspelled fields" testClosedFieldsFixture,- testIO "cardinality fixture reports scalar and list mismatches" testCardinalityFixture,- testIO "format fixture reports parser-backed mismatches" testFormatsFixture,- testIO "nested review fixture validates records with indexed diagnostics" testNestedReviewsFixture,- testIO "conditional fixture covers ADR, PostgreSQL, and review scopes" testConditionalFieldsFixture,- testIO "document reference fixture covers local, external, self, and duplicate targets" testDocumentReferencesFixture,- testIO "optional-field fixture reports only the recommendation and bad values" testOptionalFieldsFixture- ]- unless (and results) exitFailure--test :: Text -> Either Text () -> IO Bool-test name assertion =- case assertion of- Right () -> do- putStrLn ("PASS " <> Text.unpack name)- pure True- Left message -> do- putStrLn ("FAIL " <> Text.unpack name <> ": " <> Text.unpack message)- pure False--testIO :: Text -> IO (Either Text ()) -> IO Bool-testIO name assertion = do- result <- assertion- test name result--testParseValidDocument :: Either Text ()-testParseValidDocument = do- document <- firstShow (parseDocument sampleDocument)- assertEqual (Just (String "BigQuery Table")) (frontmatterLookup "type" (document ^. #frontmatter))- assertEqual "# Schema\n\nBody text.\n" (body document)--testParseNoFrontmatter :: Either Text ()-testParseNoFrontmatter = do- document <- firstShow (parseDocument "# Draft\n")- assertEqual Nothing (frontmatterLookup "type" (document ^. #frontmatter))- assertEqual "# Draft\n" (body document)--testRejectUnterminatedFrontmatter :: Either Text ()-testRejectUnterminatedFrontmatter =- assertEqual (Left UnterminatedFrontmatter) (parseDocument "---\ntype: BigQuery Table\n")--testRejectNonMappingFrontmatter :: Either Text ()-testRejectNonMappingFrontmatter =- assertEqual (Left FrontmatterNotMapping) (parseDocument "---\n- one\n- two\n---\nBody\n")--testPermissiveValidation :: Either Text ()-testPermissiveValidation = do- document <- firstShow (parseDocument "---\ntype: BigQuery Table\n---\nBody\n")- assertEqual [] (validateDocument PermissiveConformance document)--testStrictValidation :: Either Text ()-testStrictValidation = do- document <- firstShow (parseDocument "---\ntype: BigQuery Table\n---\nBody\n")- let errors = validateDocument StrictAuthoring document- assertBool "missing title" (MissingRecommendedField "title" `List.elem` errors)- assertBool "missing description" (MissingRecommendedField "description" `List.elem` errors)- assertBool "missing timestamp" (MissingRecommendedField "timestamp" `List.elem` errors)--testRejectInvalidTags :: Either Text ()-testRejectInvalidTags = do- document <- firstShow (parseDocument "---\ntype: BigQuery Table\ntags: orders\n---\nBody\n")- assertEqual [FieldMustBeListOfText "tags"] (validateDocument PermissiveConformance document)--testRoundTrip :: Either Text ()-testRoundTrip = do- document <- firstShow (parseDocument sampleDocument)- assertEqual [] (validateDocument PermissiveConformance document)- assertEqual [] (validateDocument StrictAuthoring document)- reparsed <- firstShow (parseDocument (serializeDocument document))- assertEqual (document ^. #frontmatter) (reparsed ^. #frontmatter)- assertEqual (body document) (body reparsed)--testRejectInvalidConceptId :: Either Text ()-testRejectInvalidConceptId =- assertEqual (Left (InvalidConceptIdSegment "-users")) (parseConceptId "tables/-users")--testConceptIdToFilePath :: Either Text ()-testConceptIdToFilePath = do- conceptId <- firstShow (parseConceptId "tables/users")- assertEqual "tables/users.md" (conceptIdToFilePath conceptId)--testWalkBundleMissingRoot :: IO (Either Text ())-testWalkBundleMissingRoot = do- temporaryDirectory <- getTemporaryDirectory- root <- createTempDirectory temporaryDirectory "okf-core-missing-parent"- let missingRoot = root </> "missing"- result <- walkBundle missingRoot- removeDirectoryRecursive root- pure- ( case result of- Left (BundleIoError path message)- | path == missingRoot && "does not exist" `Text.isInfixOf` message -> Right ()- other -> Left ("expected missing-root BundleIoError, got " <> Text.pack (show other))- )--testWalkBundleSkipsReserved :: IO (Either Text ())-testWalkBundleSkipsReserved =- withFixtureBundle- ( \root -> do- concepts <- readBundle root- pure (assertEqual ["datasets/sales", "tables/customers", "tables/orders"] (renderConceptId . conceptIdOf <$> concepts))- )--testWalkBundleDiscoversNestedConceptIds :: IO (Either Text ())-testWalkBundleDiscoversNestedConceptIds =- withFixtureBundle- ( \root -> do- concepts <- readBundle root- pure- ( do- expected <- firstShow (parseConceptId "tables/orders")- assertBool "nested concept exists" (isJust (findConcept expected concepts))- )- )---- | Build a throwaway directory tree, run an action on it, and clean up.-withDiscoveryTree :: String -> [(FilePath, Text)] -> (FilePath -> IO a) -> IO a-withDiscoveryTree label files action = do- temporaryDirectory <- getTemporaryDirectory- root <- createTempDirectory temporaryDirectory label- for_ files $ \(relativePath, content) -> do- createDirectoryIfMissing True (root </> takeDirectory relativePath)- Text.IO.writeFile (root </> relativePath) content- result <- action root- removeDirectoryRecursive root- pure result--typedConcept :: Text -> Text-typedConcept titleText =- Text.unlines ["---", "type: Table", "title: " <> titleText, "---", "", "# " <> titleText]--plainMarkdown :: Text-plainMarkdown = "# Just prose\n\nNo frontmatter here.\n"--testDiscoverIndexMd :: IO (Either Text ())-testDiscoverIndexMd =- withDiscoveryTree "okf-discovery-index" [("kb/index.md", "# Index\n")] $ \root -> do- found <- discoverBundleRoots defaultDiscoveryOptions root- pure (assertEqual [normalise (root </> "kb")] found)--testDiscoverTypedConcept :: IO (Either Text ())-testDiscoverTypedConcept =- withDiscoveryTree "okf-discovery-typed" [("kb/tables/orders.md", typedConcept "Orders")] $ \root -> do- found <- discoverBundleRoots defaultDiscoveryOptions root- pure (assertEqual [normalise (root </> "kb" </> "tables")] found)--testDiscoverIgnoresPlainMarkdown :: IO (Either Text ())-testDiscoverIgnoresPlainMarkdown =- withDiscoveryTree- "okf-discovery-plain"- [("notes/README.md", plainMarkdown), ("notes/CHANGELOG.md", plainMarkdown)]- $ \root -> do- found <- discoverBundleRoots defaultDiscoveryOptions root- pure (assertEqual [] found)--testDiscoverPrunesNestedBundles :: IO (Either Text ())-testDiscoverPrunesNestedBundles =- withDiscoveryTree- "okf-discovery-prune"- [ ("kb/index.md", "# Index\n"),- ("kb/tables/index.md", "# Tables\n"),- ("kb/tables/orders.md", typedConcept "Orders")- ]- $ \root -> do- found <- discoverBundleRoots defaultDiscoveryOptions root- pure (assertEqual [normalise (root </> "kb")] found)--testDiscoverSkipsNoise :: IO (Either Text ())-testDiscoverSkipsNoise =- withDiscoveryTree- "okf-discovery-noise"- [ (".hidden/index.md", "# Hidden\n"),- ("dist-newstyle/index.md", "# Build output\n"),- ("kb/index.md", "# Index\n")- ]- $ \root -> do- found <- discoverBundleRoots defaultDiscoveryOptions root- pure (assertEqual [normalise (root </> "kb")] found)--testDiscoverHonoursMaxDepth :: IO (Either Text ())-testDiscoverHonoursMaxDepth =- withDiscoveryTree "okf-discovery-depth" [("a/b/c/index.md", "# Deep\n")] $ \root -> do- shallow <- discoverBundleRoots defaultDiscoveryOptions {maxDepth = 2} root- deep <- discoverBundleRoots defaultDiscoveryOptions {maxDepth = 3} root- pure (assertEqual [] shallow >> assertEqual [normalise (root </> "a" </> "b" </> "c")] deep)--testDiscoverFixtureBundle :: IO (Either Text ())-testDiscoverFixtureBundle = do- bundle <- fixturePath "valid-bundle"- found <- discoverBundleRoots defaultDiscoveryOptions bundle- pure (assertEqual [normalise bundle] found)--testLogRoundTrip :: Either Text ()-testLogRoundTrip = do- let canonicalLog =- Text.unlines- [ "# Directory Update Log",- "",- "## 2026-06-23",- "* **Update**: Refreshed [orders](tables/orders.md).",- "* **Creation**: Added customers.",- "",- "## 2026-06-01",- "* Deprecated a stale note."- ]- parsed = parseLog canonicalLog- reparsed = parseLog (serializeLog parsed)- assertEqual- ( Log- { logTitle = "Directory Update Log",- logDays =- [ LogDay- { logDate = "2026-06-23",- logEntries =- [ LogEntry (Just "Update") "Refreshed [orders](tables/orders.md).",- LogEntry (Just "Creation") "Added customers."- ]- },- LogDay- { logDate = "2026-06-01",- logEntries = [LogEntry Nothing "Deprecated a stale note."]- }- ]- }- )- parsed- assertEqual parsed reparsed--testValidateLogNonIsoDate :: Either Text ()-testValidateLogNonIsoDate =- assertBool- "expected LogDateNotIso"- (LogDateNotIso "not-a-date" `List.elem` validateLog (parseLog "# Log\n\n## not-a-date\n* **Update**: oops\n"))--testValidateLogEmptyDay :: Either Text ()-testValidateLogEmptyDay =- assertBool- "expected LogEmptyDay"- (LogEmptyDay "2026-06-23" `List.elem` validateLog (parseLog "# Log\n\n## 2026-06-23\n"))--testValidateLogOutOfOrder :: Either Text ()-testValidateLogOutOfOrder =- assertBool- "expected LogDaysOutOfOrder"- ( LogDaysOutOfOrder "2026-01-01" "2026-06-23"- `List.elem` validateLog (parseLog "# Log\n\n## 2026-01-01\n* Old.\n\n## 2026-06-23\n* New.\n")- )--testWalkLogsDiscoversNested :: IO (Either Text ())-testWalkLogsDiscoversNested = do- temporaryDirectory <- getTemporaryDirectory- root <- createTempDirectory temporaryDirectory "okf-core-logs"- createDirectoryIfMissing True (root </> "tables")- Text.IO.writeFile (root </> "log.md") "# Root Log\n\n## 2026-06-23\n* Root entry.\n"- Text.IO.writeFile (root </> "tables" </> "log.md") "# Tables Log\n\n## 2026-06-22\n* Tables entry.\n"- result <- walkLogs root- removeDirectoryRecursive root- pure- ( case result of- Right logs -> assertEqual ["log.md", "tables/log.md"] (logSourcePath <$> logs)- Left bundleError -> Left ("expected logs, got " <> Text.pack (show bundleError))- )--testLogStalenessFlagsNewerConcept :: Either Text ()-testLogStalenessFlagsNewerConcept = do- staleId <- parseTestConceptId "stale"- staleConcept <- testConceptWithTimestamp "stale" "2026-06-23T00:00:00Z"- currentConcept <- testConceptWithTimestamp "current" "2026-01-01T00:00:00Z"- let logs = [LogFile "log.md" (parseLog "# Log\n\n## 2026-06-01\n* **Update**: logged.\n")]- assertEqual- [ LogStaleness- { staleConcept = staleId,- staleConceptDate = "2026-06-23",- staleLogPath = Just "log.md",- staleLogDate = Just "2026-06-01"- }- ]- (logStaleness [staleConcept, currentConcept] logs)--testLogStalenessPrefersDeepestLog :: Either Text ()-testLogStalenessPrefersDeepestLog = do- usersId <- parseTestConceptId "tables/users"- usersConcept <- testConceptWithTimestamp "tables/users" "2026-06-21T00:00:00Z"- let logs =- [ LogFile "log.md" (parseLog "# Root Log\n\n## 2026-06-01\n* **Update**: root.\n"),- LogFile "tables/log.md" (parseLog "# Tables Log\n\n## 2026-06-20\n* **Update**: tables.\n")- ]- assertEqual- [ LogStaleness- { staleConcept = usersId,- staleConceptDate = "2026-06-21",- staleLogPath = Just "tables/log.md",- staleLogDate = Just "2026-06-20"- }- ]- (logStaleness [usersConcept] logs)--testAppendLogEntry :: Either Text ()-testAppendLogEntry =- assertEqual- ( Log- { logTitle = "Log",- logDays =- [ LogDay "2026-06-23" [LogEntry (Just "Update") "new"],- LogDay "2026-06-01" [LogEntry (Just "Update") "prepended", LogEntry (Just "Creation") "old"]- ]- }- )- ( appendLogEntry- "2026-06-01"- (LogEntry (Just "Update") "prepended")- ( appendLogEntry- "2026-06-23"- (LogEntry (Just "Update") "new")- (Log "Log" [LogDay "2026-06-01" [LogEntry (Just "Creation") "old"]])- )- )--testGenerateIndexGroupsByType :: IO (Either Text ())-testGenerateIndexGroupsByType =- withFixtureBundle- ( \root -> do- concepts <- readBundle root- pure- ( do- orders <- requireConcept "tables/orders" concepts- customers <- requireConcept "tables/customers" concepts- let rendered = renderIndex [] [orders, customers]- assertBool "has type heading" ("# BigQuery Table" `Text.isInfixOf` rendered)- assertBool "has orders bullet" ("[Orders](orders.md) - Order records." `Text.isInfixOf` rendered)- assertBool "has customers bullet" ("[Customers](customers.md) - Customer records." `Text.isInfixOf` rendered)- )- )--testExtractLinksResolveBundleLinks :: IO (Either Text ())-testExtractLinksResolveBundleLinks =- withFixtureBundle- ( \root -> do- concepts <- readBundle root- pure- ( do- orders <- requireConcept "tables/orders" concepts- customers <- firstShow (parseConceptId "tables/customers")- sales <- firstShow (parseConceptId "datasets/sales")- let links = extractConceptLinks orders- assertBool "absolute or ./ customers link" (customers `List.elem` links)- assertBool "../ sales link" (sales `List.elem` links)- )- )--testExtractLinksIgnoresExternalUrls :: IO (Either Text ())-testExtractLinksIgnoresExternalUrls =- withFixtureBundle- ( \root -> do- concepts <- readBundle root- pure- ( do- orders <- requireConcept "tables/orders" concepts- assertEqual 4 (length (extractConceptLinks orders))- )- )--testBuildGraphIncludesKnownEdges :: IO (Either Text ())-testBuildGraphIncludesKnownEdges =- withFixtureBundle- ( \root -> do- concepts <- readBundle root- pure- ( do- orders <- firstShow (parseConceptId "tables/orders")- customers <- firstShow (parseConceptId "tables/customers")- missing <- firstShow (parseConceptId "missing")- let graph = buildGraph concepts- assertEqual 3 (length (nodes graph))- assertBool "known edge exists" (Edge {source = orders, target = customers} `List.elem` edges graph)- assertBool "broken edge excluded" (Edge {source = orders, target = missing} `notElem` edges graph)- )- )--testWriteBundleIndexesDeterministic :: IO (Either Text ())-testWriteBundleIndexesDeterministic =- withFixtureBundle- ( \root -> do- firstResult <- writeBundleIndexes root- firstIndex <- Text.IO.readFile (root </> "tables" </> "index.md")- secondResult <- writeBundleIndexes root- secondIndex <- Text.IO.readFile (root </> "tables" </> "index.md")- pure- ( do- firstShow firstResult- firstShow secondResult- assertEqual firstIndex secondIndex- assertBool "tables index has BigQuery Table section" ("# BigQuery Table" `Text.isInfixOf` secondIndex)- )- )--testFixtureValidBundle :: IO (Either Text ())-testFixtureValidBundle = do- root <- fixturePath "valid-bundle"- concepts <- readBundle root- pure- ( do- orders <- firstShow (parseConceptId "tables/orders")- customers <- firstShow (parseConceptId "tables/customers")- sales <- firstShow (parseConceptId "datasets/sales")- assertEqual 4 (length concepts)- assertEqual [] (foldMap (validateDocument PermissiveConformance . conceptDocument) concepts)- let graph = buildGraph concepts- assertBool "orders to customers" (Edge {source = orders, target = customers} `List.elem` edges graph)- assertBool "orders to sales" (Edge {source = orders, target = sales} `List.elem` edges graph)- )--testFixtureGraphJsonShape :: IO (Either Text ())-testFixtureGraphJsonShape = do- root <- fixturePath "valid-bundle"- concepts <- readBundle root- orders <- requireConceptIO "tables/orders" concepts- pure- ( case filter (\Node {id = nodeId} -> nodeId == conceptIdOf orders) (nodes (buildGraph concepts)) of- [ordersNode] ->- assertEqual- ( object- [ "id" .= ("tables/orders" :: Text),- "label" .= ("Orders" :: Text),- "type" .= ("BigQuery Table" :: Text),- "description" .= Just ("Order fact table." :: Text),- "resource" .= Just ("bigquery://analytics.tables.orders" :: Text),- "tags" .= ["orders" :: Text, "sales"]- ]- )- (toJSON ordersNode)- other -> Left ("expected one orders node, got " <> Text.pack (show (length other)))- )--testFixtureUnterminatedFrontmatter :: IO (Either Text ())-testFixtureUnterminatedFrontmatter = do- root <- fixturePath "invalid-unterminated-frontmatter"- result <- walkBundle root- pure- ( case result of- Left (InvalidConceptDocument "broken.md" UnterminatedFrontmatter) -> Right ()- other -> Left ("expected unterminated frontmatter error, got " <> Text.pack (show other))- )--testFixtureMissingType :: IO (Either Text ())-testFixtureMissingType = do- root <- fixturePath "invalid-missing-type"- concepts <- readBundle root- pure- ( do- assertEqual 1 (length concepts)- case foldMap (validateDocument PermissiveConformance . conceptDocument) concepts of- [MissingRequiredField "type"] -> Right ()- other -> Left ("expected missing type error, got " <> Text.pack (show other))- )--testFrontmatterBuilderRoundTrip :: Either Text ()-testFrontmatterBuilderRoundTrip = do- let frontmatterValue =- setField "version" (String "0.2.0")- . setTags ["orders", "sales"]- . setResource "bigquery://analytics.tables.orders"- $ okfCommon- OkfCommon- { commonType = "BigQuery Table",- commonTitle = Just "Orders",- commonDescription = Just "Order fact table.",- commonTimestamp = Just "2026-06-16T00:00:00Z"- }- original = OKFDocument frontmatterValue "# Orders\n\nBody text.\n"- reparsed <- firstShow (parseDocument (serializeDocument original))- assertEqual (original ^. #frontmatter) (reparsed ^. #frontmatter)- assertEqual (body original) (body reparsed)--testSerializeDeterministicKeyOrder :: Either Text ()-testSerializeDeterministicKeyOrder = do- let frontmatterValue =- setField "zeta" (String "z")- . setField "alpha" (String "a")- . setTags ["t"]- . setResource "res://x"- . setType "Recipe"- . setTimestamp "2026-06-16T00:00:00Z"- . setDescription "Desc"- . setTitle "Demo"- $ emptyFrontmatter- rendered = serializeDocument (OKFDocument frontmatterValue "# Demo\n")- expectedOrder =- ["type:", "title:", "description:", "timestamp:", "resource:", "tags:", "alpha:", "zeta:"]- keyIndices <- traverse (\key -> maybe (Left ("missing key " <> key)) Right (substringIndex key rendered)) expectedOrder- assertBool ("keys not in deterministic order: " <> Text.pack (show keyIndices)) (strictlyIncreasing keyIndices)--testConceptLinkRoundTrip :: Either Text ()-testConceptLinkRoundTrip = do- sourceId <- parseTestConceptId "recipes/haskell-library-repo"- let targetStrings = ["orders", "modules/nix-haskell-flake", "refs/source-system.v1"]- mapM_- ( \rawTarget -> do- targetId <- parseTestConceptId rawTarget- let extracted = extractFromBodyLinkingTo sourceId targetId- assertEqual [targetId] extracted- )- targetStrings--parseTestConceptId :: Text -> Either Text ConceptId-parseTestConceptId rawId =- first (\err -> "bad concept id " <> rawId <> ": " <> Text.pack (show err)) (parseConceptId rawId)--extractFromBodyLinkingTo :: ConceptId -> ConceptId -> [ConceptId]-extractFromBodyLinkingTo sourceId targetId =- extractConceptLinks- (conceptFromDocument sourceId (OKFDocument (setType "Test" emptyFrontmatter) ("See " <> renderConceptLink targetId "link" <> ".\n")))--testRejectOverEscapingRelativeLink :: Either Text ()-testRejectOverEscapingRelativeLink = do- sourceId <- parseTestConceptId "a/b/source"- targetId <- parseTestConceptId "tables/orders"- let concept =- conceptFromDocument- sourceId- (OKFDocument (setType "Test" emptyFrontmatter) "[Escapes](../../../tables/orders.md)\n")- assertEqual [] (extractConceptLinks concept)- assertEqual [] (validateBundle PermissiveConformance [concept, targetConcept targetId])- where- targetConcept targetId =- conceptFromDocument- targetId- (OKFDocument (setType "Test" emptyFrontmatter) "# Orders\n")--testValidateBundleDanglingReference :: Either Text ()-testValidateBundleDanglingReference = do- aId <- parseTestConceptId "a"- bId <- parseTestConceptId "b"- conceptA <- testConcept "a" ("See " <> renderConceptLink bId "b" <> ".\n")- assertEqual [DanglingReference aId bId] (validateBundle StrictAuthoring [conceptA])--testValidateBundleAcceptsResolved :: Either Text ()-testValidateBundleAcceptsResolved = do- bId <- parseTestConceptId "b"- conceptA <- testConcept "a" ("See " <> renderConceptLink bId "b" <> ".\n")- conceptB <- testConcept "b" "Standalone.\n"- assertEqual [] (validateBundle StrictAuthoring [conceptA, conceptB])--testDuplicateConceptIds :: Either Text ()-testDuplicateConceptIds = do- aId <- parseTestConceptId "a"- conceptA <- testConcept "a" "First.\n"- conceptAAgain <- testConcept "a" "Second.\n"- assertEqual [aId] (duplicateConceptIds [conceptA, conceptAAgain])---- | Build an in-memory concept via the public 'conceptFromDocument' constructor,--- so its typed fields are derived from the frontmatter and cannot diverge.--- Includes all StrictAuthoring fields so per-document validation passes and--- bundle-level checks can be isolated.-testConcept :: Text -> Text -> Either Text Concept-testConcept rawId bodyText = do- conceptId <- parseTestConceptId rawId- let frontmatterValue =- okfCommon- OkfCommon- { commonType = "Test",- commonTitle = Just "Title",- commonDescription = Just "Description",- commonTimestamp = Just "2026-06-16T00:00:00Z"- }- pure (conceptFromDocument conceptId (OKFDocument frontmatterValue bodyText))--testConceptWithTimestamp :: Text -> Text -> Either Text Concept-testConceptWithTimestamp rawId timestamp = do- conceptId <- parseTestConceptId rawId- let frontmatterValue =- okfCommon- OkfCommon- { commonType = "Test",- commonTitle = Just "Title",- commonDescription = Just "Description",- commonTimestamp = Just timestamp- }- pure (conceptFromDocument conceptId (OKFDocument frontmatterValue "# Test\n"))--testConceptFromDocumentDerivesFields :: Either Text ()-testConceptFromDocumentDerivesFields = do- conceptId <- parseTestConceptId "tables/orders"- let frontmatterValue =- okfCommon- OkfCommon- { commonType = "BigQuery Table",- commonTitle = Just "Orders",- commonDescription = Nothing,- commonTimestamp = Nothing- }- concept = conceptFromDocument conceptId (OKFDocument frontmatterValue "# Orders\n")- assertEqual "BigQuery Table" (conceptType concept)- assertEqual (Just "Orders") (conceptTitle concept)- assertEqual "tables/orders.md" (conceptSourcePath concept)--testWriteBundleRoundTrip :: IO (Either Text ())-testWriteBundleRoundTrip = do- temporaryDirectory <- getTemporaryDirectory- root <- createTempDirectory temporaryDirectory "okf-core-writebundle"- let buildConcepts = do- orders <- testConcept "tables/orders" "# Orders\n\nOrder records.\n"- customers <- testConcept "tables/customers" "# Customers\n\nCustomer records.\n"- pure [orders, customers]- case buildConcepts of- Left message -> do- removeDirectoryRecursive root- pure (Left message)- Right concepts -> do- writeBundle root concepts- recovered <- readBundle root- removeDirectoryRecursive root- pure- ( do- assertEqual- (List.sort (renderConceptId . conceptIdOf <$> concepts))- (List.sort (renderConceptId . conceptIdOf <$> recovered))- assertEqual- (List.sort ((body . conceptDocument) <$> concepts))- (List.sort ((body . conceptDocument) <$> recovered))- )--testFixtureDanglingLink :: IO (Either Text ())-testFixtureDanglingLink = do- root <- fixturePath "invalid-dangling-link"- concepts <- readBundle root- pure- ( case validateBundle PermissiveConformance concepts of- errs- | any isDangling errs -> Right ()- | otherwise -> Left ("expected a DanglingReference, got: " <> Text.pack (show errs))- )- where- isDangling DanglingReference {} = True- isDangling _ = False---- | Resolve a fixture file path regardless of whether tests run from the repo--- root or the package directory (mirrors 'fixturePath' for files).-fixtureFilePath :: FilePath -> IO FilePath-fixtureFilePath name = findExisting candidates- where- candidates =- [ "okf-core" </> "test" </> "fixtures" </> name,- "test" </> "fixtures" </> name- ]- findExisting [] = fail ("fixture file not found: " <> name)- findExisting (candidate : rest) = do- exists <- doesFileExist candidate- if exists then pure candidate else findExisting rest---- | Milestone 1: the Dhall descriptor round-trips into a 'ProfileSpec'.-testLoadProfileFixture :: IO (Either Text ())-testLoadProfileFixture = do- path <- fixtureFilePath "profiles/postgresql.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load profile: " <> err)- Right spec -> do- assertEqual "shinzui-postgresql" (spec ^. #name)- assertEqual- (Just "Conventions for documenting a PostgreSQL database as an OKF bundle.")- (spec ^. #description)- assertEqual False (spec ^. #allowUnknownTypes)- assertEqual ["type", "title"] (map (^. #field) (spec ^. #frontmatter . #required))- assertEqual- [ Just "The OKF concept type; must be one of the type rules below.",- Just "Human-readable name of the object, as a reader would say it."- ]- (map (^. #description) (spec ^. #frontmatter . #required))- -- `timestamp` is written with bare record completion, so it carries no prose.- assertEqual- [ Just "One or two sentences on what this object is for.",- Nothing,- Just "postgresql:// URI locating the live object."- ]- (map (^. #description) (spec ^. #frontmatter . #recommended))- assertEqual- ["PostgreSQL Schema", "PostgreSQL Table", "PostgreSQL View"]- (map (^. #type_) (spec ^. #types))- assertEqual- (Just "One physical table in a schema, including its column list.")- (spec ^. #types . to (!! 1) . #description)--testLoadDocumentIdProfileFixture :: IO (Either Text ())-testLoadDocumentIdProfileFixture = do- path <- fixtureFilePath "profiles/decisions.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load document ID profile: " <> err)- Right spec -> do- assertEqual (Just "docId") (spec ^. #idField)- assertEqual [Just "ADR"] (map (^. #idPrefix) (spec ^. #types))- -- Written with the mk/FieldRule.dhall constructors, which normalize to- -- exactly what record completion produces.- assertEqual ["type", "title"] (map (^. #field) (spec ^. #frontmatter . #required))- assertEqual- [Just "The OKF concept type; must be a type rule below.", Nothing]- (map (^. #description) (spec ^. #frontmatter . #required))--testLoadDescribedProfileFixture :: IO (Either Text ())-testLoadDescribedProfileFixture = do- path <- fixtureFilePath "profiles/described.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load described profile: " <> err)- Right spec -> do- assertEqual "described" (spec ^. #name)- assertEqual ["Described Concept"] (map (^. #type_) (spec ^. #types))- assertEqual [emptyTestFrontmatterRules] (map (^. #frontmatter) (spec ^. #types))- assertEqual True (spec ^. #allowUnknownFields)- assertEqual [[]] (map (^. #allowedValues) (spec ^. #frontmatter . #required))- assertEqual [Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))--testLoadTypeAwareCompatibilityFixture :: IO (Either Text ())-testLoadTypeAwareCompatibilityFixture = do- path <- fixtureFilePath "profiles/type-aware-ep1.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load EP-1 profile: " <> err)- Right spec -> do- assertEqual "type-aware-ep1" (spec ^. #name)- assertEqual True (spec ^. #allowUnknownFields)- assertEqual [[]] (map (^. #allowedValues) (spec ^. #frontmatter . #required))- assertEqual [[[]]] (map (map (^. #allowedValues) . (^. #frontmatter . #required)) (spec ^. #types))- assertEqual [Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))--testLoadVocabularyCompatibilityFixture :: IO (Either Text ())-testLoadVocabularyCompatibilityFixture = do- path <- fixtureFilePath "profiles/vocabulary-ep2.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load EP-2 profile: " <> err)- Right spec -> do- assertEqual "vocabulary-ep2" (spec ^. #name)- assertEqual False (spec ^. #allowUnknownFields)- assertEqual [[], ["draft", "accepted"]] (map (^. #allowedValues) (spec ^. #frontmatter . #required))- assertEqual [Any, Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))- assertEqual [Nothing, Nothing] (map (^. #format) (spec ^. #frontmatter . #required))--testLoadCardinalityCompatibilityFixture :: IO (Either Text ())-testLoadCardinalityCompatibilityFixture = do- path <- fixtureFilePath "profiles/cardinality-ep3.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load EP-3 profile: " <> err)- Right spec -> do- assertEqual "cardinality-ep3" (spec ^. #name)- assertEqual False (spec ^. #allowUnknownFields)- assertEqual [Any, Scalar] (map (^. #cardinality) (spec ^. #frontmatter . #required))- assertEqual [Nothing, Nothing] (map (^. #format) (spec ^. #frontmatter . #required))--testLoadFormatCompatibilityFixture :: IO (Either Text ())-testLoadFormatCompatibilityFixture = do- path <- fixtureFilePath "profiles/formats-ep4.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load EP-4 profile: " <> err)- Right spec -> do- assertEqual "formats-ep4" (spec ^. #name)- assertEqual [Any, Scalar] (map (^. #cardinality) (spec ^. #frontmatter . #required))- assertEqual [Nothing, Just Rfc3339Utc] (map (^. #format) (spec ^. #frontmatter . #required))- assertEqual [Nothing, Nothing] (map (^. #elementFields) (spec ^. #frontmatter . #required))--testLoadNestedReviewsProfileFixture :: IO (Either Text ())-testLoadNestedReviewsProfileFixture = do- path <- fixtureFilePath "profiles/nested-reviews.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load nested review profile: " <> err)- Right spec ->- case [rules | rule <- spec ^. #frontmatter . #required, rule ^. #field == "reviews", Just rules <- [rule ^. #elementFields]] of- [NestedRules {required, recommended}] -> do- assertEqual ["kind", "reviewer", "reviewed_at", "document_timestamp", "scope", "outcome", "context"] (map (^. #field) required)- assertEqual ["notes"] (map (^. #field) recommended)- _ -> Left "expected exactly one reviews rule with elementFields"--testLoadNestedCompatibilityFixture :: IO (Either Text ())-testLoadNestedCompatibilityFixture = do- path <- fixtureFilePath "profiles/nested-reviews-ep1.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load frozen nested profile: " <> err)- Right spec ->- case spec ^. #frontmatter . #required of- [rule] -> do- assertEqual Nothing (rule ^. #when)- case rule ^. #elementFields of- Just NestedRules {required = [nestedRule]} -> do- assertEqual "kind" (nestedRule ^. #field)- assertEqual Nothing (nestedRule ^. #when)- _ -> Left "expected the frozen nested rule to survive the compatibility upgrade"- _ -> Left "expected one frozen top-level rule"--testLoadConditionalFieldsProfileFixture :: IO (Either Text ())-testLoadConditionalFieldsProfileFixture = do- path <- fixtureFilePath "profiles/conditional-fields.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load conditional profile: " <> err)- Right spec -> do- assertEqual "conditional-fields" (spec ^. #name)- compiled <- firstShow (compileProfile spec)- assertEqual spec (compiledProfileSpec compiled)--testLoadConditionalCompatibilityFixture :: IO (Either Text ())-testLoadConditionalCompatibilityFixture = do- path <- fixtureFilePath "profiles/conditional-fields-ep2.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load frozen condition-aware profile: " <> err)- Right spec ->- case spec ^. #frontmatter . #required of- [sourceRule, targetRule, reviewsRule] -> do- assertEqual Nothing (sourceRule ^. #reference)- assertEqual (Just (FieldCondition "status" ["superseded"])) (targetRule ^. #when)- assertEqual Nothing (targetRule ^. #reference)- case reviewsRule ^. #elementFields of- Just NestedRules {required = [_kindRule, providerRule]} ->- assertEqual (Just (FieldCondition "kind" ["model"])) (providerRule ^. #when)- _ -> Left "expected frozen nested conditions to survive the compatibility upgrade"- _ -> Left "expected three frozen condition-aware top-level rules"---- | The immediately preceding generation: a descriptor that spells out the--- reference-aware record types with no @optional@ list anywhere still loads,--- keeps every field it did declare, and behaves as though each optional list--- were empty.-testLoadReferenceCompatibilityFixture :: IO (Either Text ())-testLoadReferenceCompatibilityFixture = do- path <- fixtureFilePath "profiles/document-references-ep3.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load frozen reference-aware profile: " <> err)- Right spec -> do- assertEqual [] (spec ^. #frontmatter . #optional)- assertEqual [[]] (map (^. #frontmatter . #optional) (spec ^. #types))- case spec ^. #frontmatter . #recommended of- [referenceRule, conditionRule, reviewsRule] -> do- assertEqual- (Just (HandleReferenceRule "ADR" ["mori"] False))- (referenceRule ^. #reference)- assertEqual (Just (FieldCondition "status" ["superseded"])) (conditionRule ^. #when)- case reviewsRule ^. #elementFields of- Just NestedRules {required = [kindRule], recommended = [notesRule], optional = nestedOptional} -> do- assertEqual "kind" (kindRule ^. #field)- assertEqual "notes" (notesRule ^. #field)- assertEqual [] (map (^. #field) nestedOptional)- _ -> Left "expected the frozen nested rules to survive with an empty optional list"- _ -> Left "expected three frozen reference-aware recommended rules"---- | The backwards-compatibility guarantee: a descriptor frozen in the okf 0.2.x--- shape — bare-string frontmatter keys, no descriptions anywhere — still loads,--- via the legacy fallback decoder, with every description absent.-testLoadLegacyProfileFixture :: IO (Either Text ())-testLoadLegacyProfileFixture = do- path <- fixtureFilePath "profiles/legacy-0.2.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load legacy profile: " <> err)- Right spec -> do- assertEqual "legacy" (spec ^. #name)- assertEqual Nothing (spec ^. #description)- assertEqual ["type", "title"] (map (^. #field) (spec ^. #frontmatter . #required))- assertEqual [Nothing, Nothing] (map (^. #description) (spec ^. #frontmatter . #required))- assertEqual ["Legacy Concept"] (map (^. #type_) (spec ^. #types))- assertEqual [Nothing] (map (^. #description) (spec ^. #types))- assertEqual True (spec ^. #allowUnknownFields)- assertEqual [[], []] (map (^. #allowedValues) (spec ^. #frontmatter . #required))- assertEqual [Any, Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))--testProfileFieldDescription :: IO (Either Text ())-testProfileFieldDescription = do- path <- fixtureFilePath "profiles/postgresql.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load profile: " <> err)- Right spec -> do- assertEqual- (Just "Human-readable name of the object, as a reader would say it.")- (profileFieldDescription spec "title")- assertEqual- (Just "postgresql:// URI locating the live object.")- (profileFieldDescription spec "resource")- assertEqual Nothing (profileFieldDescription spec "timestamp")- assertEqual Nothing (profileFieldDescription spec "nope")---- | Prose declared on an optional rule is as discoverable as prose on a required--- or recommended one; the third list is searched last, after the two that can--- produce a missing-field diagnostic.-testOptionalFieldDescription :: IO (Either Text ())-testOptionalFieldDescription = do- path <- fixtureFilePath "profiles/decisions.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load decisions profile: " <> err)- Right spec ->- assertEqual- (Just "The decision this one replaces, when it replaces one.")- (profileFieldDescription spec "supersedes")---- | The JSON encoding is pinned field by field, so a future refactor cannot--- silently rename a key. The @type@ key matters most: the Haskell field is--- @type_@, and consumers must never see that.-testProfileJsonShape :: IO (Either Text ())-testProfileJsonShape = do- path <- fixtureFilePath "profiles/decisions.dhall"- result <- loadProfileFile path- pure $ case result of- Left err -> Left ("failed to load document ID profile: " <> err)- Right spec ->- assertEqual- ( object- [ "name" .= ("decisions" :: Text),- "description" .= ("How this team records architectural decisions." :: Text),- "okfVersion" .= ("0.1" :: Text),- "allowUnknownTypes" .= False,- "allowUnknownFields" .= True,- "idField" .= ("docId" :: Text),- "frontmatter"- .= object- [ "required"- .= [ object- [ "field" .= ("type" :: Text),- "description"- .= ("The OKF concept type; must be a type rule below." :: Text),- "allowedValues" .= ([] :: [Text]),- "cardinality" .= ("any" :: Text),- "format" .= (Nothing :: Maybe Text),- "elementFields" .= (Nothing :: Maybe Value),- "reference" .= (Nothing :: Maybe HandleReferenceRule),- "when" .= (Nothing :: Maybe FieldCondition)- ],- object- [ "field" .= ("title" :: Text),- "description" .= (Nothing :: Maybe Text),- "allowedValues" .= ([] :: [Text]),- "cardinality" .= ("any" :: Text),- "format" .= (Nothing :: Maybe Text),- "elementFields" .= (Nothing :: Maybe Value),- "reference" .= (Nothing :: Maybe HandleReferenceRule),- "when" .= (Nothing :: Maybe FieldCondition)- ]- ],- "recommended"- .= [ object- [ "field" .= ("status" :: Text),- "description"- .= ("One of: proposed, accepted, superseded." :: Text),- "allowedValues" .= ([] :: [Text]),- "cardinality" .= ("any" :: Text),- "format" .= (Nothing :: Maybe Text),- "elementFields" .= (Nothing :: Maybe Value),- "reference" .= (Nothing :: Maybe HandleReferenceRule),- "when" .= (Nothing :: Maybe FieldCondition)- ]- ],- "optional"- .= [ object- [ "field" .= ("supersedes" :: Text),- "description"- .= ("The decision this one replaces, when it replaces one." :: Text),- "allowedValues" .= ([] :: [Text]),- "cardinality" .= ("any" :: Text),- "format" .= (Nothing :: Maybe Text),- "elementFields" .= (Nothing :: Maybe Value),- "reference" .= (Nothing :: Maybe HandleReferenceRule),- "when" .= (Nothing :: Maybe FieldCondition)- ]- ]- ],- "types"- .= [ object- [ "type" .= ("Decision Record" :: Text),- "description"- .= ("One accepted decision, never edited after acceptance." :: Text),- "frontmatter"- .= object- [ "required" .= ([] :: [FieldRule]),- "recommended" .= ([] :: [FieldRule]),- "optional" .= ([] :: [FieldRule])- ],- "pathPattern" .= ("decisions/*" :: Text),- "resourceScheme" .= (Nothing :: Maybe Text),- "requireSchemaSection" .= False,- "schemaColumns" .= ([] :: [Text]),- "idPrefix" .= ("ADR" :: Text)- ]- ]- ]- )- (toJSON spec)--testFieldFormatJsonShape :: Either Text ()-testFieldFormatJsonShape =- assertEqual- [ String "rfc3339-utc",- String "date",- String "uri",- object ["uriWithScheme" .= ("mori" :: Text)],- object ["documentHandle" .= ("ADR" :: Text)]- ]- (map toJSON [Rfc3339Utc, Date, Uri, UriWithScheme "mori", DocumentHandle "ADR"])--testFieldConditionJsonShape :: Either Text ()-testFieldConditionJsonShape =- assertEqual- (object ["field" .= ("status" :: Text), "hasValue" .= (["superseded"] :: [Text])])- (toJSON (FieldCondition "status" ["superseded"]))--testHandleReferenceJsonShape :: Either Text ()-testHandleReferenceJsonShape =- assertEqual- ( object- [ "localPrefix" .= ("ADR" :: Text),- "externalUriSchemes" .= (["mori", "https"] :: [Text]),- "allowSelf" .= False- ]- )- (toJSON (HandleReferenceRule "ADR" ["mori", "https"] False))---- | A registry record enumerates every field that decodes as a profile, one--- level down as well as at the top, sorted by export path. The @Profile@ schema--- record and the @note@ string contribute nothing.-testRegistryEnumeratesProfiles :: IO (Either Text ())-testRegistryEnumeratesProfiles = do- path <- fixtureFilePath "registry/package.dhall"- loaded <- loadRegistry (RegistryFile path)- pure $ case loaded of- Left err -> Left ("failed to load fixture registry: " <> err)- Right entries -> do- -- `legacy` is a frozen okf 0.2.x descriptor: it enumerates only because- -- the registry walk falls back to the legacy decoder.- assertEqual ["legacy", "nested.decisions", "postgresql"] (map (^. #export) entries)- case findRegistryEntry "legacy" entries of- Nothing -> Left "expected an entry at export path legacy"- Just entry -> do- assertEqual "legacy" (entry ^. #spec . #name)- assertEqual Nothing (entry ^. #spec . #description)- case findRegistryEntry "postgresql" entries of- Nothing -> Left "expected an entry at export path postgresql"- Just entry -> assertEqual "shinzui-postgresql" (entry ^. #spec . #name)- assertEqual Nothing (findRegistryEntry "nope" entries)- assertBool- "expected findRegistryEntry to resolve the nested export"- (isJust (findRegistryEntry "nested.decisions" entries))---- | A registry reference that is itself a profile yields one entry whose export--- path is empty.-testRegistryRootProfile :: IO (Either Text ())-testRegistryRootProfile = do- path <- fixtureFilePath "profiles/decisions.dhall"- loaded <- loadRegistry (RegistryFile path)- pure $ case loaded of- Left err -> Left ("failed to load root profile registry: " <> err)- Right entries -> do- assertEqual [""] (map (^. #export) entries)- assertEqual ["decisions"] (map (^. #spec . #name) entries)---- | A directory holding @package.dhall@ resolves to that file; anything else--- is handed to Dhall verbatim.-testResolveRegistryRef :: IO (Either Text ())-testResolveRegistryRef = do- directory <- fixturePath "registry"- resolvedDirectory <- resolveRegistryRef (Text.pack directory)- filePath <- fixtureFilePath "profiles/decisions.dhall"- resolvedFile <- resolveRegistryRef (Text.pack filePath)- resolvedExpression <- resolveRegistryRef "./nowhere/at/all.dhall"- pure $ do- assertEqual (RegistryFile (directory </> "package.dhall")) resolvedDirectory- assertEqual (RegistryFile filePath) resolvedFile- assertEqual (RegistryExpression "./nowhere/at/all.dhall") resolvedExpression---- | A reference that cannot be evaluated reports an error rather than throwing.-testRegistryLoadFailure :: IO (Either Text ())-testRegistryLoadFailure = do- loaded <- loadRegistry (RegistryFile "/nonexistent/registry.dhall")- pure $ case loaded of- Right entries -> Left ("expected a load failure, got " <> Text.pack (show (length entries)) <> " entries")- Left message -> assertBool "expected a non-empty error message" (not (Text.null message))--testParseDocumentId :: Either Text ()-testParseDocumentId = do- assertEqual- (Just (DocumentId {prefix = "ADR", number = 7}))- (parseDocumentId "ADR-7")- mapM_- (\invalid -> assertEqual Nothing (parseDocumentId invalid))- ["ADR-007", "ADR-0", "ADR-", "-7", "ADR 7", "ADR-7-extra"]- assertEqual (Just "ADR-7") (renderDocumentId <$> parseDocumentId "ADR-7")--testDocumentIdsInBundle :: IO (Either Text ())-testDocumentIdsInBundle = do- descriptorPath <- fixtureFilePath "profiles/decisions.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "doc-ids"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load document ID profile: " <> err)- Right spec -> do- useMarkdown <- parseTestConceptId "decisions/use-markdown"- usePostgres <- parseTestConceptId "decisions/use-postgres"- adoptOkf <- parseTestConceptId "decisions/adopt-okf"- assertEqual- [ (DocumentId "ADR" 1, useMarkdown),- (DocumentId "ADR" 2, usePostgres),- (DocumentId "ADR" 3, adoptOkf)- ]- (documentIdsInBundle spec concepts)--testNextDocumentId :: Either Text ()-testNextDocumentId = do- firstConcept <-- profileConcept- "decisions/first"- [("type", String "Decision Record"), ("title", String "First"), ("docId", String "ADR-1")]- "# First\n"- thirdConcept <-- profileConcept- "decisions/third"- [("type", String "Decision Record"), ("title", String "Third"), ("docId", String "ADR-3")]- "# Third\n"- let concepts = [firstConcept, thirdConcept]- assertEqual (DocumentId "ADR" 4) (nextDocumentId testDocumentIdProfileSpec concepts "ADR")- assertEqual (DocumentId "RFC" 1) (nextDocumentId testDocumentIdProfileSpec concepts "RFC")--testFindConceptsByDocumentId :: IO (Either Text ())-testFindConceptsByDocumentId = do- validRoot <- fixturePath "doc-ids"- validConcepts <- readBundle validRoot- deviationRoot <- fixturePath "doc-id-deviations"- deviationConcepts <- readBundle deviationRoot- pure $ do- usePostgres <- parseTestConceptId "decisions/use-postgres"- firstId <- parseTestConceptId "decisions/first"- secondId <- parseTestConceptId "decisions/second"- assertEqual- [usePostgres]- (conceptIdOf <$> findConceptsByDocumentId Nothing "ADR-2" validConcepts)- assertEqual- [firstId, secondId]- (conceptIdOf <$> findConceptsByDocumentId (Just "docId") "ADR-1" deviationConcepts)---- | An undocumented frontmatter key: the validation tests care about names, not--- prose, and descriptions never affect validation.-requiredField :: Text -> FieldRule-requiredField key = FieldRule {field = key, description = Nothing, allowedValues = [], cardinality = Any, format = Nothing, elementFields = Nothing, reference = Nothing, when = Nothing}---- | A standalone profile literal so the validation tests do not depend on the--- Dhall fixture. One rule: PostgreSQL Table, fully constrained.-testProfileSpec :: ProfileSpec-testProfileSpec =- ProfileSpec- { name = "test-postgresql",- description = Nothing,- okfVersion = "0.1",- frontmatter =- FrontmatterRules- { required = [requiredField "type", requiredField "title"],- recommended = [],- optional = []- },- allowUnknownTypes = False,- allowUnknownFields = True,- idField = Nothing,- types =- [ TypeRule- { type_ = "PostgreSQL Table",- description = Nothing,- frontmatter = emptyTestFrontmatterRules,- pathPattern = Just "schemas/*/tables/*",- resourceScheme = Just "postgresql",- requireSchemaSection = True,- schemaColumns = ["Column", "Type", "Nullable", "Description"],- idPrefix = Nothing- }- ]- }--testDocumentIdProfileSpec :: ProfileSpec-testDocumentIdProfileSpec =- ProfileSpec- { name = "test-decisions",- description = Nothing,- okfVersion = "0.1",- frontmatter =- FrontmatterRules- { required = [requiredField "type", requiredField "title"],- recommended = [],- optional = []- },- allowUnknownTypes = False,- allowUnknownFields = True,- idField = Just "docId",- types =- [ TypeRule- { type_ = "Decision Record",- description = Nothing,- frontmatter = emptyTestFrontmatterRules,- pathPattern = Just "decisions/*",- resourceScheme = Nothing,- requireSchemaSection = False,- schemaColumns = [],- idPrefix = Just "ADR"- }- ]- }--emptyTestFrontmatterRules :: FrontmatterRules-emptyTestFrontmatterRules = FrontmatterRules {required = [], recommended = [], optional = []}--typeAwareProfileSpec :: ProfileSpec-typeAwareProfileSpec =- ProfileSpec- { name = "type-aware",- description = Nothing,- okfVersion = "0.1",- frontmatter =- FrontmatterRules- { required = [FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing, FieldRule "title" (Just "Global title.") [] Any Nothing Nothing Nothing Nothing],- recommended = [FieldRule "owner" (Just "Profile-level owner.") [] Any Nothing Nothing Nothing Nothing],- optional = []- },- allowUnknownTypes = True,- allowUnknownFields = True,- idField = Nothing,- types =- [ TypeRule- { type_ = "Owned Concept",- description = Nothing,- frontmatter =- FrontmatterRules- { required = [FieldRule "owner" (Just "Responsible person.") [] Any Nothing Nothing Nothing Nothing],- recommended = [FieldRule "reviewer" (Just "Second pair of eyes.") [] Any Nothing Nothing Nothing Nothing, FieldRule "title" (Just "Type title.") [] Any Nothing Nothing Nothing Nothing],- optional = []- },- pathPattern = Nothing,- resourceScheme = Nothing,- requireSchemaSection = False,- schemaColumns = [],- idPrefix = Nothing- }- ]- }--testCompileProfileDefinitionErrors :: Either Text ()-testCompileProfileDefinitionErrors = do- let duplicateField = FieldRule "title" Nothing [] Any Nothing Nothing Nothing Nothing- invalid =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required = [duplicateField, duplicateField],- recommended = [duplicateField],- optional = []- },- types = (typeAwareProfileSpec ^. #types) <> (typeAwareProfileSpec ^. #types)- }- case compileProfile invalid of- Right _ -> Left "expected invalid profile definition"- Left errors ->- assertEqual- [ DuplicateFieldRule Nothing "required" "title",- ConflictingFieldRequirement Nothing "title",- DuplicateTypeRule "Owned Concept"- ]- (toList errors)--testCompiledProfileMerge :: Either Text ()-testCompiledProfileMerge = do- compiled <- firstShow (compileProfile typeAwareProfileSpec)- assertEqual (Just "Type title.") (profileFieldDescriptionForType compiled "Owned Concept" "title")- assertEqual (Just "Responsible person.") (profileFieldDescriptionForType compiled "Owned Concept" "owner")- assertEqual (Just "Global title.") (profileFieldDescriptionForType compiled "Unknown Concept" "title")- concept <- profileConcept "owned/one" [("type", String "Owned Concept")] "# One\n"- cid <- parseTestConceptId "owned/one"- assertEqual- [MissingProfileField cid "owner" Nothing, MissingProfileField cid "title" Nothing]- (validateProfile PermissiveConformance compiled [concept])--vocabularyProfileSpec :: ProfileSpec-vocabularyProfileSpec =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required = [FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],- recommended = [FieldRule "status" Nothing ["draft", "approved", "approved"] Any Nothing Nothing Nothing Nothing],- optional = []- },- types =- [ withTypeFrontmatter- FrontmatterRules- { required = [FieldRule "status" Nothing ["approved", "archived"] Any Nothing Nothing Nothing Nothing],- recommended = [],- optional = []- }- (firstTypeRule typeAwareProfileSpec)- ]- }--fieldPath :: Text -> FieldPath-fieldPath key = FieldPath (FieldName key :| [])--testCompiledVocabularyIntersection :: Either Text ()-testCompiledVocabularyIntersection = do- compiled <- firstShow (compileProfile vocabularyProfileSpec)- concept <- profileConcept "owned/one" [("type", String "Owned Concept"), ("status", String "draft")] "# One\n"- cid <- parseTestConceptId "owned/one"- assertEqual- [ValueNotInVocabulary cid (fieldPath "status") ["approved"] (String "draft")]- (validateProfile PermissiveConformance compiled [concept])--testUnsatisfiableVocabulary :: Either Text ()-testUnsatisfiableVocabulary = do- let disjoint =- vocabularyProfileSpec- { types =- [ withTypeFrontmatter- FrontmatterRules- { required = [FieldRule "status" Nothing ["closed"] Any Nothing Nothing Nothing Nothing],- recommended = [],- optional = []- }- (firstTypeRule vocabularyProfileSpec)- ]- }- assertEqual- (Left (UnsatisfiableVocabulary (Just "Owned Concept") "status" ["draft", "approved"] ["closed"] :| []))- (compileProfile disjoint)--testCompiledCardinality :: Either Text ()-testCompiledCardinality = do- let profileRules =- FrontmatterRules- { required = [FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],- recommended = [FieldRule "status" Nothing [] Scalar Nothing Nothing Nothing Nothing],- optional = []- }- typeRules cardinality =- FrontmatterRules- { required = [FieldRule "status" Nothing [] cardinality Nothing Nothing Nothing Nothing],- recommended = [],- optional = []- }- baseType = firstTypeRule typeAwareProfileSpec- compatible =- typeAwareProfileSpec- { frontmatter = profileRules,- types = [withTypeFrontmatter (typeRules Any) baseType]- }- contradictory = compatible {types = [withTypeFrontmatter (typeRules List) baseType]}- compiled <- firstShow (compileProfile compatible)- valid <- profileConcept "owned/cardinality" [("type", String "Owned Concept"), ("status", Number 3)] "# Valid\n"- invalid <- profileConcept "owned/cardinality" [("type", String "Owned Concept"), ("status", toJSON (["draft"] :: [Text]))] "# Invalid\n"- cid <- parseTestConceptId "owned/cardinality"- assertEqual [] (validateProfile PermissiveConformance compiled [valid])- assertEqual- [CardinalityMismatch cid (fieldPath "status") Scalar (toJSON (["draft"] :: [Text]))]- (validateProfile PermissiveConformance compiled [invalid])- assertEqual- (Left (ConflictingCardinality (Just "Owned Concept") "status" Scalar List :| []))- (compileProfile contradictory)--testVocabularyValidation :: Either Text ()-testVocabularyValidation = do- let openVocabulary =- vocabularyProfileSpec- { frontmatter =- FrontmatterRules- { required = [FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],- recommended = [FieldRule "status" Nothing ["draft", "approved"] Any Nothing Nothing Nothing Nothing],- optional = []- },- types = []- }- compiled <- firstShow (compileProfile openVocabulary)- validString <- profileConcept "valid-string" [("type", String "Extension"), ("status", String "draft")] "# Valid\n"- validList <- profileConcept "valid-list" [("type", String "Extension"), ("status", toJSON (["draft", "approved"] :: [Text]))] "# Valid\n"- absent <- profileConcept "absent" [("type", String "Extension")] "# Absent\n"- invalidString <- profileConcept "invalid-string" [("type", String "Extension"), ("status", String "banana")] "# Invalid\n"- invalidList <- profileConcept "invalid-list" [("type", String "Extension"), ("status", toJSON (["draft", "banana"] :: [Text]))] "# Invalid\n"- invalidShape <- profileConcept "invalid-shape" [("type", String "Extension"), ("status", toJSON (1 :: Int))] "# Invalid\n"- invalidStringId <- parseTestConceptId "invalid-string"- invalidListId <- parseTestConceptId "invalid-list"- invalidShapeId <- parseTestConceptId "invalid-shape"- assertEqual [] (validateProfile PermissiveConformance compiled [validString, validList, absent])- assertEqual- [ValueNotInVocabulary invalidStringId (fieldPath "status") ["draft", "approved"] (String "banana")]- (validateProfile PermissiveConformance compiled [invalidString])- assertEqual- [ValueNotInVocabulary invalidListId (fieldPath "status") ["draft", "approved"] (toJSON (["draft", "banana"] :: [Text]))]- (validateProfile PermissiveConformance compiled [invalidList])- assertEqual- [ValueNotInVocabulary invalidShapeId (fieldPath "status") ["draft", "approved"] (toJSON (1 :: Int))]- (validateProfile PermissiveConformance compiled [invalidShape])--testCardinalityValidation :: Either Text ()-testCardinalityValidation = do- cid <- parseTestConceptId "cardinality"- let check cardinality actual = do- compiled <- firstShow (compileProfile (singleCardinalityProfile True cardinality []))- concept <- profileConcept "cardinality" [("type", String "Extension"), ("value", actual)] "# Cardinality\n"- pure (validateProfile PermissiveConformance compiled [concept])- mismatch cardinality actual = [CardinalityMismatch cid (fieldPath "value") cardinality actual]- objectValue = object ["nested" .= (True :: Bool)]- textList = toJSON (["one"] :: [Text])- emptyList = toJSON ([] :: [Text])- for_ [String "one", Number 0, Bool False] $ \actual ->- check Scalar actual >>= assertEqual []- for_ [textList, objectValue, Null] $ \actual ->- check Scalar actual >>= assertEqual (mismatch Scalar actual)- check List textList >>= assertEqual []- for_ [String "one", Number 0, Bool False, objectValue, Null] $ \actual ->- check List actual >>= assertEqual (mismatch List actual)- check Any (String "one") >>= assertEqual []- check Any textList >>= assertEqual []- check Any (Bool False) >>= assertEqual [MissingProfileField cid "value" Nothing]- check Scalar (String " ") >>= assertEqual [MissingProfileField cid "value" Nothing]- check List emptyList >>= assertEqual [MissingProfileField cid "value" Nothing]- optionalCompiled <- firstShow (compileProfile (singleCardinalityProfile False Scalar []))- optionalConcept <- profileConcept "cardinality" [("type", String "Extension"), ("value", textList)] "# Optional\n"- assertEqual- (mismatch Scalar textList)- (validateProfile PermissiveConformance optionalCompiled [optionalConcept])--testCardinalityVocabularyInteraction :: Either Text ()-testCardinalityVocabularyInteraction = do- cid <- parseTestConceptId "cardinality"- scalarCompiled <- firstShow (compileProfile (singleCardinalityProfile True Scalar ["draft"]))- let objectValue = object ["status" .= ("draft" :: Text)]- objectConcept <- profileConcept "cardinality" [("type", String "Extension"), ("value", objectValue)] "# Object\n"- assertEqual- [CardinalityMismatch cid (fieldPath "value") Scalar objectValue]- (validateProfile PermissiveConformance scalarCompiled [objectConcept])- listCompiled <- firstShow (compileProfile (singleCardinalityProfile True List ["draft"]))- let mixedList = toJSON ([String "draft", Number 1] :: [Value])- listConcept <- profileConcept "cardinality" [("type", String "Extension"), ("value", mixedList)] "# List\n"- assertEqual- [ValueNotInVocabulary cid (fieldPath "value") ["draft"] mixedList]- (validateProfile PermissiveConformance listCompiled [listConcept])--testCompiledFieldFormats :: Either Text ()-testCompiledFieldFormats = do- let baseType = firstTypeRule typeAwareProfileSpec- profileWith profileFormat typeFormat =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required = [FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing, FieldRule "homepage" Nothing [] Any (Just profileFormat) Nothing Nothing Nothing],- recommended = [],- optional = []- },- types =- [ withTypeFrontmatter- FrontmatterRules- { required = [FieldRule "homepage" Nothing [] Any (Just typeFormat) Nothing Nothing Nothing],- recommended = [],- optional = []- }- baseType- ]- }- refined <- firstShow (compileProfile (profileWith Uri (UriWithScheme "https")))- valid <- profileConcept "format-refinement" [("type", String "Owned Concept"), ("homepage", String "HTTPS://example.test/path")] "# Valid\n"- invalid <- profileConcept "format-refinement" [("type", String "Owned Concept"), ("homepage", String "http://example.test/path")] "# Invalid\n"- cid <- parseTestConceptId "format-refinement"- assertEqual [] (validateProfile PermissiveConformance refined [valid])- assertEqual- [ValueFormatMismatch cid (fieldPath "homepage") (UriWithScheme "https") (String "http://example.test/path")]- (validateProfile PermissiveConformance refined [invalid])- assertEqual- (Left (ConflictingFieldFormat (fieldPath "homepage") Date Rfc3339Utc :| []))- (compileProfile (profileWith Date Rfc3339Utc))--testInvalidFormatParameters :: Either Text ()-testInvalidFormatParameters = do- let invalid =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required =- [ FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing,- FieldRule "handle" Nothing [] Any (Just (DocumentHandle "1ADR")) Nothing Nothing Nothing,- FieldRule "source" Nothing [] Any (Just (UriWithScheme "https_")) Nothing Nothing Nothing- ],- recommended = [],- optional = []- },- types = []- }- assertEqual- ( Left- ( InvalidFormatParameter (fieldPath "handle") (DocumentHandle "1ADR") "1ADR"- :| [InvalidFormatParameter (fieldPath "source") (UriWithScheme "https_") "https_"]- )- )- (compileProfile invalid)--testNamedFormatValidation :: Either Text ()-testNamedFormatValidation = do- cid <- parseTestConceptId "format"- let check fieldFormat cardinality actual = do- compiled <- firstShow (compileProfile (singleFormatProfile cardinality fieldFormat))- concept <-- profileConcept- "format"- ([("type", String "Extension")] <> maybe [] (\value -> [("value", value)]) actual)- "# Format\n"- pure (validateProfile PermissiveConformance compiled [concept])- mismatch fieldFormat actual = [ValueFormatMismatch cid (fieldPath "value") fieldFormat actual]- for_ ["2024-02-29T23:59:59Z", "2026-07-29T17:00:00.125Z"] $ \value ->- check Rfc3339Utc Any (Just (String value)) >>= assertEqual []- for_ ["2026-13-45T99:99:99Z", "2026-07-29T17:00:00+01:00", "2026-07-29 17:00:00Z"] $ \value ->- check Rfc3339Utc Any (Just (String value)) >>= assertEqual (mismatch Rfc3339Utc (String value))- check Date Any (Just (String "2024-02-29")) >>= assertEqual []- for_ ["2023-02-29", "2026-13-01", "2026-07-29T00:00:00Z"] $ \value ->- check Date Any (Just (String value)) >>= assertEqual (mismatch Date (String value))- for_ ["https://example.test/path", "urn:example:item"] $ \value ->- check Uri Any (Just (String value)) >>= assertEqual []- for_ ["relative/path", "https://example.test/bad%ZZ"] $ \value ->- check Uri Any (Just (String value)) >>= assertEqual (mismatch Uri (String value))- check (UriWithScheme "mori") Any (Just (String "MORI://haskell/time")) >>= assertEqual []- check (UriWithScheme "mori") Any (Just (String "https://example.test"))- >>= assertEqual (mismatch (UriWithScheme "mori") (String "https://example.test"))- check (DocumentHandle "ADR") Any (Just (String "ADR-7")) >>= assertEqual []- for_ ["ADR-007", "IR-7"] $ \value ->- check (DocumentHandle "ADR") Any (Just (String value))- >>= assertEqual (mismatch (DocumentHandle "ADR") (String value))- let validUris = toJSON (["https://example.test", "urn:example:item"] :: [Text])- mixedUris = toJSON ([String "https://example.test", Number 1] :: [Value])- check Uri List (Just validUris) >>= assertEqual []- check Uri List (Just mixedUris) >>= assertEqual (mismatch Uri mixedUris)- check Uri Scalar (Just validUris)- >>= assertEqual [CardinalityMismatch cid (fieldPath "value") Scalar validUris]- check Uri Any (Just (Number 1)) >>= assertEqual (mismatch Uri (Number 1))- check Uri Any Nothing >>= assertEqual []--singleFormatProfile :: Cardinality -> FieldFormat -> ProfileSpec-singleFormatProfile cardinality fieldFormat =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required = [FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],- recommended = [FieldRule "value" Nothing [] cardinality (Just fieldFormat) Nothing Nothing Nothing],- optional = []- },- allowUnknownTypes = True,- types = []- }--singleCardinalityProfile :: Bool -> Cardinality -> [Text] -> ProfileSpec-singleCardinalityProfile isRequired cardinality allowed =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required = [FieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing] <> [rule | isRequired],- recommended = [rule | not isRequired],- optional = []- },- allowUnknownTypes = True,- types = []- }- where- rule = FieldRule "value" Nothing allowed cardinality Nothing Nothing Nothing Nothing--testCompiledNestedRules :: Either Text ()-testCompiledNestedRules = do- let profileRules =- NestedRules- { required = [NestedFieldRule "kind" Nothing ["decision", "implementation"] Any Nothing Nothing],- recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing],- optional = []- }- typeRules =- NestedRules- { required =- [ NestedFieldRule "kind" Nothing ["implementation", "operations"] Any Nothing Nothing,- NestedFieldRule "outcome" Nothing ["approved", "rejected"] Any Nothing Nothing- ],- recommended = [],- optional = []- }- base = nestedProfileWithRules Any profileRules (Just typeRules)- compiled <- firstShow (compileProfile base)- concept <-- profileConcept- "reviewed/merge"- [ ("type", String "Reviewed Concept"),- ( "reviews",- toJSON- [ object- [ "kind" .= ("decision" :: Text),- "outcome" .= ("approved" :: Text)- ]- ]- )- ]- "# Merge\n"- cid <- parseTestConceptId "reviewed/merge"- assertEqual- [ ValueNotInVocabulary- cid- (nestedTestPath 0 "kind")- ["implementation"]- (String "decision")- ]- (validateProfile PermissiveConformance compiled [concept])- let impossible = nestedProfileWithRules Scalar profileRules Nothing- assertEqual- (Left (ElementFieldsRequireList Nothing (fieldPath "reviews") Scalar :| []))- (compileProfile impossible)--testNestedRecordValidation :: Either Text ()-testNestedRecordValidation = do- compiled <- firstShow (compileProfile nestedReviewProfileSpec)- let firstReview =- object- [ "kind" .= ("human" :: Text),- "reviewer" .= ("Ari" :: Text),- "reviewed_at" .= ("2026-07-29T16:00:00Z" :: Text),- "document_timestamp" .= ("2026-07-29T17:00:00Z" :: Text),- "scope" .= ("content" :: Text),- "outcome" .= ("approved" :: Text),- "context" .= ("Complete" :: Text),- "notes" .= ("No blockers" :: Text),- "provider" .= ("allowed-extra-key" :: Text)- ]- thirdReview =- object- [ "kind" .= ("model" :: Text),- "reviewer" .= ("Bo" :: Text),- "reviewed_at" .= ("2026-13-45T99:99:99Z" :: Text),- "document_timestamp" .= ("2026-07-29T17:00:00Z" :: Text),- "scope" .= ("invalid" :: Text),- "context" .= (["wrong"] :: [Text])- ]- reviewValues = toJSON [firstReview, String "not-a-record", thirdReview]- concept <-- profileConcept- "reviewed/bad"- [("type", String "Reviewed Concept"), ("reviews", reviewValues)]- "# Bad\n"- cid <- parseTestConceptId "reviewed/bad"- let permissiveExpected =- [ NestedElementNotRecord cid (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),- CardinalityMismatch cid (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),- MissingNestedProfileField cid (nestedTestPath 2 "outcome") Nothing,- ValueFormatMismatch cid (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),- ValueNotInVocabulary cid (nestedTestPath 2 "scope") reviewScopes (String "invalid")- ]- assertEqual permissiveExpected (validateProfile PermissiveConformance compiled [concept])- assertEqual- [ NestedElementNotRecord cid (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),- CardinalityMismatch cid (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),- MissingRecommendedNestedProfileField cid (nestedTestPath 2 "notes") Nothing,- MissingNestedProfileField cid (nestedTestPath 2 "outcome") Nothing,- ValueFormatMismatch cid (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),- ValueNotInVocabulary cid (nestedTestPath 2 "scope") reviewScopes (String "invalid")- ]- (validateProfile StrictAuthoring compiled [concept])--nestedProfileWithRules :: Cardinality -> NestedRules -> Maybe NestedRules -> ProfileSpec-nestedProfileWithRules outerCardinality profileNested typeNested =- ProfileSpec- { name = "nested-merge",- description = Nothing,- okfVersion = "0.1",- frontmatter =- FrontmatterRules- { required =- [ requiredField "type",- FieldRule "reviews" Nothing [] outerCardinality Nothing (Just profileNested) Nothing Nothing- ],- recommended = [],- optional = []- },- allowUnknownTypes = False,- allowUnknownFields = True,- idField = Nothing,- types =- [ TypeRule- { type_ = "Reviewed Concept",- description = Nothing,- frontmatter =- FrontmatterRules- { required = maybe [] (\rules -> [FieldRule "reviews" Nothing [] Any Nothing (Just rules) Nothing Nothing]) typeNested,- recommended = [],- optional = []- },- pathPattern = Nothing,- resourceScheme = Nothing,- requireSchemaSection = False,- schemaColumns = [],- idPrefix = Nothing- }- ]- }--nestedReviewProfileSpec :: ProfileSpec-nestedReviewProfileSpec =- nestedProfileWithRules Any nestedRules Nothing- where- nestedRules =- NestedRules- { required =- [ NestedFieldRule "kind" Nothing ["human", "model"] Any Nothing Nothing,- NestedFieldRule "reviewer" Nothing [] Scalar Nothing Nothing,- NestedFieldRule "reviewed_at" Nothing [] Any (Just Rfc3339Utc) Nothing,- NestedFieldRule "document_timestamp" Nothing [] Any (Just Rfc3339Utc) Nothing,- NestedFieldRule "scope" Nothing reviewScopes Any Nothing Nothing,- NestedFieldRule "outcome" Nothing ["approved", "changes-requested", "commented"] Any Nothing Nothing,- NestedFieldRule "context" Nothing [] Scalar Nothing Nothing- ],- recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing],- optional = []- }--nestedTestPath :: Int -> Text -> FieldPath-nestedTestPath elementIndex key =- FieldPath (FieldName "reviews" :| [ArrayIndex elementIndex, FieldName key])--reviewScopes :: [Text]-reviewScopes = ["content", "technical-accuracy", "editorial", "catalog-metadata", "content-and-metadata"]--testConditionDefinitionErrors :: Either Text ()-testConditionDefinitionErrors = do- let source key values sourceCardinality =- FieldRule key Nothing values sourceCardinality Nothing Nothing Nothing Nothing- target key sourceKey values =- FieldRule key Nothing [] Any Nothing Nothing Nothing (Just (FieldCondition sourceKey values))- compileWith rules =- compileProfile- typeAwareProfileSpec- { frontmatter = FrontmatterRules {required = rules, recommended = [], optional = []},- allowUnknownTypes = True,- types = []- }- targetPath key = fieldPath key- assertEqual- (Left (EmptyConditionValues Nothing (targetPath "target") (targetPath "status") :| []))- (compileWith [source "status" ["active"] Scalar, target "target" "status" []])- assertEqual- (Left (ConditionFieldNotDeclared Nothing (targetPath "target") (targetPath "missing") :| []))- (compileWith [target "target" "missing" ["active"]])- assertEqual- (Left (ConditionFieldNotScalar Nothing (targetPath "target") (targetPath "status") List :| []))- (compileWith [source "status" ["active"] List, target "target" "status" ["active"]])- assertEqual- (Left (ConditionFieldOpenVocabulary Nothing (targetPath "target") (targetPath "status") :| []))- (compileWith [source "status" [] Scalar, target "target" "status" ["active"]])- assertEqual- (Left (ConditionFieldHasUnreachableValues Nothing (targetPath "target") (targetPath "status") ["superseded"] ["active"] :| []))- (compileWith [source "status" ["active"] Scalar, target "target" "status" ["superseded"]])- assertEqual- (Left (SelfConditionalField Nothing (targetPath "status") :| []))- (compileWith [FieldRule "status" Nothing ["active"] Scalar Nothing Nothing Nothing (Just (FieldCondition "status" ["active"]))])- let nestedCrossScope =- NestedRules- { required =- [ NestedFieldRule "kind" Nothing ["human", "model"] Scalar Nothing Nothing,- NestedFieldRule "provider" Nothing [] Scalar Nothing (Just (FieldCondition "status" ["active"]))- ],- recommended = [],- optional = []- }- crossScopeProfile =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required =- [ source "status" ["active"] Scalar,- FieldRule "reviews" Nothing [] List Nothing (Just nestedCrossScope) Nothing Nothing- ],- recommended = [],- optional = []- },- allowUnknownTypes = True,- types = []- }- assertEqual- (Left (ConditionFieldNotDeclared Nothing (nestedDefinitionTestPath "reviews" "provider") (nestedDefinitionTestPath "reviews" "status") :| []))- (compileProfile crossScopeProfile)- where- nestedDefinitionTestPath parent child =- FieldPath (FieldName parent :| [FieldName child])--testTopLevelConditionalPresence :: Either Text ()-testTopLevelConditionalPresence = do- let statusRule = FieldRule "status" Nothing ["active", "superseded"] Scalar Nothing Nothing Nothing Nothing- recommendedTarget =- FieldRule "supersededBy" Nothing ["ADR-1"] Scalar Nothing Nothing Nothing (Just (FieldCondition "status" ["active"]))- requiredTarget =- FieldRule "supersededBy" Nothing ["ADR-1"] Scalar Nothing Nothing Nothing (Just (FieldCondition "status" ["superseded"]))- base =- typeAwareProfileSpec- { frontmatter = FrontmatterRules {required = [requiredField "type", statusRule], recommended = [recommendedTarget], optional = []},- allowUnknownTypes = True,- types =- [ withTypeFrontmatter- FrontmatterRules {required = [requiredTarget], recommended = [], optional = []}- (firstTypeRule typeAwareProfileSpec)- ]- }- compiled <- firstShow (compileProfile base)- active <- profileConcept "active" [("type", String "Owned Concept"), ("status", String "active")] "# Active\n"- superseded <- profileConcept "superseded" [("type", String "Owned Concept"), ("status", String "superseded")] "# Superseded\n"- invalidPresent <- profileConcept "invalid-present" [("type", String "Owned Concept"), ("status", String "active"), ("supersededBy", String "ADR-2")] "# Invalid\n"- missingSource <- profileConcept "missing-source" [("type", String "Owned Concept")] "# Missing source\n"- invalidSource <- profileConcept "invalid-source" [("type", String "Owned Concept"), ("status", String "unknown")] "# Invalid source\n"- wrongShapeSource <- profileConcept "wrong-shape-source" [("type", String "Owned Concept"), ("status", toJSON (["active"] :: [Text]))] "# Wrong shape\n"- activeId <- parseTestConceptId "active"- supersededId <- parseTestConceptId "superseded"- invalidId <- parseTestConceptId "invalid-present"- missingSourceId <- parseTestConceptId "missing-source"- invalidSourceId <- parseTestConceptId "invalid-source"- wrongShapeSourceId <- parseTestConceptId "wrong-shape-source"- assertEqual [] (validateProfile PermissiveConformance compiled [active])- assertEqual- [MissingRecommendedProfileField activeId "supersededBy" (Just (FieldCondition "status" ["active"]))]- (validateProfile StrictAuthoring compiled [active])- assertEqual- [MissingProfileField supersededId "supersededBy" (Just (FieldCondition "status" ["superseded"]))]- (validateProfile PermissiveConformance compiled [superseded])- assertEqual- [ValueNotInVocabulary invalidId (fieldPath "supersededBy") ["ADR-1"] (String "ADR-2")]- (validateProfile PermissiveConformance compiled [invalidPresent])- assertEqual- [MissingProfileField missingSourceId "status" Nothing]- (validateProfile PermissiveConformance compiled [missingSource])- assertEqual- [ValueNotInVocabulary invalidSourceId (fieldPath "status") ["active", "superseded"] (String "unknown")]- (validateProfile PermissiveConformance compiled [invalidSource])- assertEqual- [CardinalityMismatch wrongShapeSourceId (fieldPath "status") Scalar (toJSON (["active"] :: [Text]))]- (validateProfile PermissiveConformance compiled [wrongShapeSource])--testNestedConditionalPresence :: Either Text ()-testNestedConditionalPresence = do- let nestedRules =- NestedRules- { required =- [ NestedFieldRule "kind" Nothing ["human", "model"] Scalar Nothing Nothing,- NestedFieldRule "provider" Nothing [] Scalar Nothing (Just (FieldCondition "kind" ["model"]))- ],- recommended =- [NestedFieldRule "notes" Nothing [] Scalar Nothing (Just (FieldCondition "kind" ["human"]))],- optional = []- }- spec = nestedProfileWithRules List nestedRules Nothing- compiled <- firstShow (compileProfile spec)- concept <-- profileConcept- "conditional-reviews"- [ ("type", String "Reviewed Concept"),- ("reviews", toJSON [object ["kind" .= ("model" :: Text)], object ["kind" .= ("human" :: Text)], object []])- ]- "# Conditional reviews\n"- cid <- parseTestConceptId "conditional-reviews"- assertEqual- [MissingNestedProfileField cid (nestedTestPath 0 "provider") (Just (FieldCondition "kind" ["model"])), MissingNestedProfileField cid (nestedTestPath 2 "kind") Nothing]- (validateProfile PermissiveConformance compiled [concept])- assertEqual- [ MissingNestedProfileField cid (nestedTestPath 0 "provider") (Just (FieldCondition "kind" ["model"])),- MissingRecommendedNestedProfileField cid (nestedTestPath 1 "notes") (Just (FieldCondition "kind" ["human"])),- MissingNestedProfileField cid (nestedTestPath 2 "kind") Nothing- ]- (validateProfile StrictAuthoring compiled [concept])--testReferenceDefinitionErrors :: Either Text ()-testReferenceDefinitionErrors = do- let referenceRule key prefix schemes fieldFormat =- FieldRule- key- Nothing- []- Scalar- fieldFormat- Nothing- (Just (HandleReferenceRule prefix schemes False))- Nothing- baseType = firstTypeRule testDocumentIdProfileSpec- specWith profileIdField typeRules profileRules =- testDocumentIdProfileSpec- { frontmatter = FrontmatterRules {required = profileRules, recommended = [], optional = []},- idField = profileIdField,- types = typeRules- }- path = fieldPath "supersedes"- invalidPrefixType = baseType {idPrefix = Just "1ADR"}- invalidPrefixSpec = specWith (Just "docId") [invalidPrefixType] [referenceRule "supersedes" "1ADR" [] Nothing]- undeclaredPrefixSpec = specWith (Just "docId") [baseType] [referenceRule "supersedes" "PAT" [] Nothing]- missingIdFieldSpec = specWith Nothing [baseType] [referenceRule "supersedes" "ADR" [] Nothing]- invalidSchemeSpec = specWith (Just "docId") [baseType] [referenceRule "supersedes" "ADR" ["mori_", "MORI_"] Nothing]- formatSpec = specWith (Just "docId") [baseType] [referenceRule "supersedes" "ADR" [] (Just (DocumentHandle "ADR"))]- typeReference = referenceRule "supersedes" "RFC" [] Nothing- conflictingType :: TypeRule- conflictingType = baseType & #frontmatter .~ FrontmatterRules {required = [typeReference], recommended = [], optional = []}- rfcType = baseType {type_ = "RFC", idPrefix = Just "RFC", pathPattern = Nothing}- conflictSpec = specWith (Just "docId") [conflictingType, rfcType] [referenceRule "supersedes" "ADR" [] Nothing]- assertEqual- (Left (InvalidReferencePrefix Nothing path "1ADR" :| []))- (compileProfile invalidPrefixSpec)- assertEqual- (Left (ReferencePrefixNotDeclared Nothing path "PAT" :| []))- (compileProfile undeclaredPrefixSpec)- assertEqual- (Left (ReferenceRequiresIdField Nothing path :| []))- (compileProfile missingIdFieldSpec)- assertEqual- (Left (InvalidExternalReferenceScheme Nothing path "mori_" :| []))- (compileProfile invalidSchemeSpec)- assertEqual- (Left (ReferenceWithFormat Nothing path (DocumentHandle "ADR") :| []))- (compileProfile formatSpec)- assertEqual- (Left (ConflictingReferencePrefix "Decision Record" path "ADR" "RFC" :| []))- (compileProfile conflictSpec)--testDocumentReferenceValidation :: Either Text ()-testDocumentReferenceValidation = do- let referencePolicy = HandleReferenceRule "ADR" ["mori", "MORI"] False- selfPolicy = HandleReferenceRule "ADR" [] True- referenceRules =- [ FieldRule "references" Nothing [] List Nothing Nothing (Just referencePolicy) Nothing,- FieldRule "selfReference" Nothing [] Scalar Nothing Nothing (Just selfPolicy) Nothing- ]- spec :: ProfileSpec- spec =- testDocumentIdProfileSpec- & #frontmatter- .~ FrontmatterRules- { required = [requiredField "type", requiredField "title"],- recommended = referenceRules,- optional = []- }- compiled <- firstShow (compileProfile spec)- duplicateA <- decisionConcept "decisions/duplicate-a" "Duplicate A" "ADR-3" []- duplicateB <- decisionConcept "decisions/duplicate-b" "Duplicate B" "ADR-3" []- source <-- decisionConcept- "decisions/source"- "Source"- "ADR-1"- [ ( "references",- toJSON- [ String "ADR-2",- String "ADR-99",- String "PAT-3",- String "not a reference",- String "https://example.test/external",- String "MORI://shinzui/okf/docs/one",- String "ADR-1",- Number 7,- String "ADR-3"- ]- ),- ("selfReference", String "ADR-1")- ]- target <- decisionConcept "decisions/target" "Target" "ADR-2" []- duplicateAId <- parseTestConceptId "decisions/duplicate-a"- duplicateBId <- parseTestConceptId "decisions/duplicate-b"- sourceId <- parseTestConceptId "decisions/source"- assertEqual- [ DanglingHandleReference sourceId (indexedPath "references" 1) "ADR-99",- ReferenceHandlePrefixMismatch sourceId (indexedPath "references" 2) "PAT-3" "ADR",- MalformedDocumentReference sourceId (indexedPath "references" 3) (String "not a reference"),- ExternalReferenceSchemeNotAllowed sourceId (indexedPath "references" 4) "https" ["mori"],- SelfDocumentReference sourceId (indexedPath "references" 6) "ADR-1",- MalformedDocumentReference sourceId (indexedPath "references" 7) (Number 7),- DuplicateDocumentId "ADR-3" duplicateAId duplicateBId- ]- (validateProfile PermissiveConformance compiled [target, source, duplicateB, duplicateA])- where- decisionConcept cid title documentId extraFields =- profileConcept- cid- ([("type", String "Decision Record"), ("title", String title), ("docId", String documentId)] <> extraFields)- ("# " <> title <> "\n")- indexedPath key elementIndex = FieldPath (FieldName key :| [ArrayIndex elementIndex])---- | The whole point of the third presence list: absence is silent in both--- validation modes, while a present value is checked exactly as hard as it would--- be under @required@.-testOptionalFieldPresence :: Either Text ()-testOptionalFieldPresence = do- let optionalRules =- [ FieldRule "supersedes" Nothing ["ADR-1", "ADR-2"] Scalar Nothing Nothing Nothing Nothing,- FieldRule "reviewedAt" Nothing [] Any (Just Rfc3339Utc) Nothing Nothing Nothing,- FieldRule "tags" Nothing [] List Nothing Nothing Nothing Nothing- ]- spec =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required = [requiredField "type"],- recommended = [requiredField "owner"],- optional = optionalRules- },- allowUnknownTypes = True,- types = []- }- compiled <- firstShow (compileProfile spec)- absent <- profileConcept "optional/absent" [("type", String "Extension"), ("owner", String "Ari")] "# Absent\n"- -- A correctly shaped empty value counts as absent, so it is as silent as a key- -- that was never written. (A blank value on a field that also declares a- -- vocabulary or format still fails that check; presence and value are- -- independent, which is exactly what this feature relies on.)- emptied <-- profileConcept- "optional/emptied"- [ ("type", String "Extension"),- ("owner", String "Ari"),- ("tags", toJSON ([] :: [Text]))- ]- "# Emptied\n"- valid <-- profileConcept- "optional/valid"- [ ("type", String "Extension"),- ("owner", String "Ari"),- ("supersedes", String "ADR-1"),- ("reviewedAt", String "2026-07-30T00:00:00Z"),- ("tags", toJSON (["profiles"] :: [Text]))- ]- "# Valid\n"- invalid <-- profileConcept- "optional/invalid"- [ ("type", String "Extension"),- ("owner", String "Ari"),- ("supersedes", String "ADR-9"),- ("reviewedAt", String "2026-13-45T99:99:99Z"),- ("tags", String "profiles")- ]- "# Invalid\n"- invalidId <- parseTestConceptId "optional/invalid"- for_ [PermissiveConformance, StrictAuthoring] $ \validationProfile -> do- assertEqual [] (validateProfile validationProfile compiled [absent])- assertEqual [] (validateProfile validationProfile compiled [emptied])- assertEqual [] (validateProfile validationProfile compiled [valid])- assertEqual- [ ValueFormatMismatch invalidId (fieldPath "reviewedAt") Rfc3339Utc (String "2026-13-45T99:99:99Z"),- ValueNotInVocabulary invalidId (fieldPath "supersedes") ["ADR-1", "ADR-2"] (String "ADR-9"),- CardinalityMismatch invalidId (fieldPath "tags") List (String "profiles")- ]- (validateProfile validationProfile compiled [invalid])---- | An optional field carrying a document-reference policy resolves handles the--- same way a required one does; only the absence check differs.-testOptionalReferenceValidation :: Either Text ()-testOptionalReferenceValidation = do- let spec :: ProfileSpec- spec =- testDocumentIdProfileSpec- & #frontmatter- .~ FrontmatterRules- { required = [requiredField "type", requiredField "title"],- recommended = [],- optional = [FieldRule "supersedes" Nothing [] Scalar Nothing Nothing (Just (HandleReferenceRule "ADR" [] False)) Nothing]- }- compiled <- firstShow (compileProfile spec)- target <- decisionTestConcept "decisions/target" "Target" "ADR-1" []- silent <- decisionTestConcept "decisions/silent" "Silent" "ADR-2" []- dangling <- decisionTestConcept "decisions/dangling" "Dangling" "ADR-3" [("supersedes", String "ADR-99")]- danglingId <- parseTestConceptId "decisions/dangling"- for_ [PermissiveConformance, StrictAuthoring] $ \validationProfile -> do- assertEqual [] (validateProfile validationProfile compiled [target, silent])- assertEqual- [DanglingHandleReference danglingId (fieldPath "supersedes") "ADR-99"]- (validateProfile validationProfile compiled [target, dangling])---- | Optional members of a list-element record behave the same way inside every--- record: never missing, always checked when present.-testOptionalNestedFieldPresence :: Either Text ()-testOptionalNestedFieldPresence = do- let nestedRules =- NestedRules- { required = [NestedFieldRule "kind" Nothing ["human", "model"] Scalar Nothing Nothing],- recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing],- optional = [NestedFieldRule "model" Nothing ["opus", "sonnet"] Scalar Nothing Nothing]- }- compiled <- firstShow (compileProfile (nestedProfileWithRules Any nestedRules Nothing))- concept <-- profileConcept- "reviewed/optional"- [ ("type", String "Reviewed Concept"),- ( "reviews",- toJSON- [ object ["kind" .= ("human" :: Text), "notes" .= ("looks good" :: Text)],- object ["kind" .= ("model" :: Text), "notes" .= ("ran it" :: Text), "model" .= ("opus" :: Text)],- object ["kind" .= ("model" :: Text), "notes" .= ("ran it" :: Text), "model" .= ("gpt" :: Text)]- ]- )- ]- "# Optional\n"- cid <- parseTestConceptId "reviewed/optional"- for_ [PermissiveConformance, StrictAuthoring] $ \validationProfile ->- assertEqual- [ValueNotInVocabulary cid (nestedTestPath 2 "model") ["opus", "sonnet"] (String "gpt")]- (validateProfile validationProfile compiled [concept])---- | An optional key is declared for the purposes of field-name closure, so a--- closed profile accepts it and still catches a misspelling of it.-testOptionalFieldClosure :: Either Text ()-testOptionalFieldClosure = do- let closed =- typeAwareProfileSpec- { frontmatter =- FrontmatterRules- { required = [requiredField "type"],- recommended = [],- optional = [requiredField "supersedes"]- },- allowUnknownTypes = True,- allowUnknownFields = False,- types = []- }- compiled <- firstShow (compileProfile closed)- declared <- profileConcept "closed/declared" [("type", String "Extension"), ("supersedes", String "ADR-1")] "# Declared\n"- typo <- profileConcept "closed/typo" [("type", String "Extension"), ("supersedse", String "ADR-1")] "# Typo\n"- typoId <- parseTestConceptId "closed/typo"- assertEqual [] (validateProfile PermissiveConformance compiled [declared])- assertEqual- [FieldNotInProfile typoId "supersedse"]- (validateProfile PermissiveConformance compiled [typo])---- | Declaring a key optional at one scope does not cancel the other scope's--- presence clause. Merging accumulates clauses precisely so a type rule can--- narrow but never silently weaken a profile-wide expectation.-testOptionalDoesNotCancelOtherScope :: Either Text ()-testOptionalDoesNotCancelOtherScope = do- let ownerRule = requiredField "owner"- specWith profileRules typeRules =- typeAwareProfileSpec- { frontmatter = profileRules,- allowUnknownTypes = True,- types = [withTypeFrontmatter typeRules (firstTypeRule typeAwareProfileSpec)]- }- recommendedThenOptional =- specWith- FrontmatterRules {required = [requiredField "type"], recommended = [ownerRule], optional = []}- FrontmatterRules {required = [], recommended = [], optional = [ownerRule]}- optionalThenRecommended =- specWith- FrontmatterRules {required = [requiredField "type"], recommended = [], optional = [ownerRule]}- FrontmatterRules {required = [], recommended = [ownerRule], optional = []}- concept <- profileConcept "owned/one" [("type", String "Owned Concept")] "# One\n"- cid <- parseTestConceptId "owned/one"- for_ [recommendedThenOptional, optionalThenRecommended] $ \spec -> do- compiled <- firstShow (compileProfile spec)- assertEqual [] (validateProfile PermissiveConformance compiled [concept])- assertEqual- [MissingRecommendedProfileField cid "owner" Nothing]- (validateProfile StrictAuthoring compiled [concept])---- | Compilation rejects the two contradictions the third list makes possible: a--- key classified twice at one scope, and a condition on a rule that has no--- presence check for it to gate.-testOptionalDefinitionErrors :: Either Text ()-testOptionalDefinitionErrors = do- let key name = requiredField name- specWith rules =- typeAwareProfileSpec {frontmatter = rules, allowUnknownTypes = True, types = []}- conditioned name sourceKey =- FieldRule name Nothing [] Any Nothing Nothing Nothing (Just (FieldCondition sourceKey ["active"]))- statusRule = FieldRule "status" Nothing ["active"] Scalar Nothing Nothing Nothing Nothing- assertEqual- (Left (ConflictingFieldRequirement Nothing "owner" :| []))- (compileProfile (specWith FrontmatterRules {required = [key "type", key "owner"], recommended = [], optional = [key "owner"]}))- assertEqual- (Left (ConflictingFieldRequirement Nothing "owner" :| []))- (compileProfile (specWith FrontmatterRules {required = [key "type"], recommended = [key "owner"], optional = [key "owner"]}))- assertEqual- (Left (DuplicateFieldRule Nothing "optional" "owner" :| []))- (compileProfile (specWith FrontmatterRules {required = [key "type"], recommended = [], optional = [key "owner", key "owner"]}))- assertEqual- (Left (OptionalFieldWithCondition Nothing (fieldPath "supersededBy") :| []))- ( compileProfile- (specWith FrontmatterRules {required = [key "type", statusRule], recommended = [], optional = [conditioned "supersededBy" "status"]})- )- let nestedRules =- NestedRules- { required = [NestedFieldRule "kind" Nothing ["model"] Scalar Nothing Nothing],- recommended = [],- optional = [NestedFieldRule "model" Nothing [] Scalar Nothing (Just (FieldCondition "kind" ["model"]))]- }- assertEqual- (Left (OptionalFieldWithCondition Nothing (FieldPath (FieldName "reviews" :| [FieldName "model"])) :| []))- (compileProfile (nestedProfileWithRules Any nestedRules Nothing))- let optionalParent =- specWith- FrontmatterRules- { required = [key "type"],- recommended = [],- optional = [FieldRule "reviews" Nothing [] List Nothing (Just nestedRules) Nothing Nothing]- }- assertEqual- (Left (OptionalFieldWithCondition Nothing (FieldPath (FieldName "reviews" :| [FieldName "model"])) :| []))- (compileProfile optionalParent)--decisionTestConcept :: Text -> Text -> Text -> [(Text, Value)] -> Either Text Concept-decisionTestConcept cid title documentId extraFields =- profileConcept- cid- ([("type", String "Decision Record"), ("title", String title), ("docId", String documentId)] <> extraFields)- ("# " <> title <> "\n")---- | The end-to-end proof, run against the fixture bundle a reader can also run--- from the command line. Absence of the three optional keys is silent in both--- modes; the one genuine recommendation still fails under strict authoring; the--- conditional requirement in the same type still fires; and every optional key--- that /is/ present is checked as hard as a required one.-testOptionalFieldsFixture :: IO (Either Text ())-testOptionalFieldsFixture = do- descriptorPath <- fixtureFilePath "profiles/optional-fields.dhall"- conditionPath <- fixtureFilePath "profiles/optional-conditional-invalid.dhall"- collisionPath <- fixtureFilePath "profiles/optional-collision-invalid.dhall"- loaded <- loadProfileFile descriptorPath- conditionLoaded <- loadProfileFile conditionPath- collisionLoaded <- loadProfileFile collisionPath- root <- fixturePath "profile-optional-fields"- concepts <- readBundle root- pure $ do- spec <- first ("failed to load optional-fields profile: " <>) loaded- compiled <- firstShow (compileProfile spec)- conditionSpec <- first ("failed to load invalid optional-condition profile: " <>) conditionLoaded- collisionSpec <- first ("failed to load invalid optional-collision profile: " <>) collisionLoaded- assertEqual- ( Left- ( OptionalFieldWithCondition (Just "Decision Record") (FieldPath (FieldName "reviews" :| [FieldName "model"]))- :| [OptionalFieldWithCondition (Just "Decision Record") (fieldPath "supersededBy")]- )- )- (compileProfile conditionSpec)- assertEqual- ( Left- ( ConflictingFieldRequirement Nothing "reviewedBy"- :| [ConflictingFieldRequirement (Just "Decision Record") "owner"]- )- )- (compileProfile collisionSpec)- accepted <- parseTestConceptId "decisions/accepted"- badSupersedes <- parseTestConceptId "decisions/bad-supersedes"- superseded <- parseTestConceptId "decisions/superseded"- let valueViolations =- [ ValueFormatMismatch badSupersedes (fieldPath "decidedAt") Rfc3339Utc (String "not a timestamp"),- ValueNotInVocabulary badSupersedes (nestedReviewPath 0 "model") ["opus", "sonnet"] (String "gpt"),- DanglingHandleReference badSupersedes (fieldPath "supersedes") "ADR-99",- MissingProfileField superseded "supersededBy" (Just (FieldCondition "status" ["superseded"]))- ]- assertEqual valueViolations (validateProfile PermissiveConformance compiled concepts)- assertEqual- (MissingRecommendedProfileField accepted "reviewedBy" Nothing : valueViolations)- (validateProfile StrictAuthoring compiled concepts)- where- nestedReviewPath elementIndex key =- FieldPath (FieldName "reviews" :| [ArrayIndex elementIndex, FieldName key])--testClosedFieldValidation :: Either Text ()-testClosedFieldValidation = do- let ownedRule :: TypeRule- ownedRule =- withTypeFrontmatter- FrontmatterRules {required = [requiredField "owner"], recommended = [], optional = []}- (firstTypeRule typeAwareProfileSpec)- reviewRule =- withTypeName- "Review"- (withTypeFrontmatter FrontmatterRules {required = [requiredField "reviewer"], recommended = [], optional = []} ownedRule)- closed =- typeAwareProfileSpec- { frontmatter = FrontmatterRules {required = [requiredField "type", requiredField "status"], recommended = [], optional = []},- allowUnknownFields = False,- idField = Just "requestId",- types = [ownedRule, reviewRule]- }- compiled <- firstShow (compileProfile closed)- typo <-- profileConcept- "owned/typo"- [ ("type", String "Owned Concept"),- ("title", String "Typo"),- ("description", String "Core"),- ("timestamp", String "2026-07-29T00:00:00Z"),- ("resource", String "https://example.test/typo"),- ("tags", toJSON (["profiles"] :: [Text])),- ("requestId", String "IR-1"),- ("owner", String "Ari"),- ("reviewer", String "Bo"),- ("stauts", String "draft")- ]- "# Typo\n"- cid <- parseTestConceptId "owned/typo"- assertEqual- [ MissingProfileField cid "status" Nothing,- FieldNotInProfile cid "reviewer",- FieldNotInProfile cid "stauts"- ]- (validateProfile PermissiveConformance compiled [typo])- let reopened = closed {allowUnknownFields = True}- reopenedCompiled <- firstShow (compileProfile reopened)- assertEqual- [MissingProfileField cid "status" Nothing]- (validateProfile PermissiveConformance reopenedCompiled [typo])--firstTypeRule :: ProfileSpec -> TypeRule-firstTypeRule spec =- case spec ^. #types of- rule : _ -> rule- [] -> error "test profile unexpectedly has no type rules"--withTypeFrontmatter :: FrontmatterRules -> TypeRule -> TypeRule-withTypeFrontmatter- replacement- TypeRule- { type_,- description,- pathPattern,- resourceScheme,- requireSchemaSection,- schemaColumns,- idPrefix- } =- TypeRule- { type_,- description,- frontmatter = replacement,- pathPattern,- resourceScheme,- requireSchemaSection,- schemaColumns,- idPrefix- }--withTypeName :: Text -> TypeRule -> TypeRule-withTypeName- replacement- TypeRule- { description,- frontmatter,- pathPattern,- resourceScheme,- requireSchemaSection,- schemaColumns,- idPrefix- } =- TypeRule- { type_ = replacement,- description,- frontmatter,- pathPattern,- resourceScheme,- requireSchemaSection,- schemaColumns,- idPrefix- }--testProfileRulesApplyToUnknownTypes :: Either Text ()-testProfileRulesApplyToUnknownTypes = do- compiled <- firstShow (compileProfile typeAwareProfileSpec)- concept <- profileConcept "extensions/one" [("type", String "Extension Concept")] "# One\n"- cid <- parseTestConceptId "extensions/one"- assertEqual- [MissingProfileField cid "title" Nothing]- (validateProfile PermissiveConformance compiled [concept])--testStrictProfileRecommendations :: Either Text ()-testStrictProfileRecommendations = do- compiled <- firstShow (compileProfile typeAwareProfileSpec)- concept <-- profileConcept- "owned/one"- [("type", String "Owned Concept"), ("title", String "One"), ("owner", String "Ari")]- "# One\n"- cid <- parseTestConceptId "owned/one"- assertEqual [] (validateProfile PermissiveConformance compiled [concept])- assertEqual- [MissingRecommendedProfileField cid "reviewer" Nothing]- (validateProfile StrictAuthoring compiled [concept])---- | Build an in-memory concept from a raw ID, frontmatter pairs, and a body.-profileConcept :: Text -> [(Text, Value)] -> Text -> Either Text Concept-profileConcept rawId fieldPairs bodyText = do- conceptId <- parseTestConceptId rawId- pure (conceptFromDocument conceptId (OKFDocument (frontmatterFromFields fieldPairs) bodyText))---- | A well-formed @# Schema@ section matching the profile's required columns.-schemaSectionBody :: Text-schemaSectionBody =- Text.unlines- [ "# Schema",- "",- "| Column | Type | Nullable | Description |",- "|--------|--------|----------|-------------|",- "| id | bigint | no | Primary key |"- ]--testProfileConformingTable :: Either Text ()-testProfileConformingTable = do- concept <-- profileConcept- "schemas/sales/tables/orders"- [ ("type", String "PostgreSQL Table"),- ("title", String "Orders"),- ("resource", String "postgresql://warehouse/sales/orders")- ]- schemaSectionBody- assertEqual [] (validateTestProfile testProfileSpec [concept])--testProfileUnknownType :: Either Text ()-testProfileUnknownType = do- concept <-- profileConcept- "schemas/sales/tables/bad"- [("type", String "pg table"), ("resource", String "postgresql://x")]- schemaSectionBody- cid <- parseTestConceptId "schemas/sales/tables/bad"- assertEqual- [TypeNotInProfile cid "pg table", MissingProfileField cid "title" Nothing]- (validateTestProfile testProfileSpec [concept])--testProfileMissingField :: Either Text ()-testProfileMissingField = do- concept <-- profileConcept- "schemas/sales/tables/orders"- [("type", String "PostgreSQL Table"), ("resource", String "postgresql://x")]- schemaSectionBody- cid <- parseTestConceptId "schemas/sales/tables/orders"- assertEqual [MissingProfileField cid "title" Nothing] (validateTestProfile testProfileSpec [concept])--testProfileResourceMismatch :: Either Text ()-testProfileResourceMismatch = do- concept <-- profileConcept- "schemas/sales/tables/orders"- [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "mysql://x")]- schemaSectionBody- cid <- parseTestConceptId "schemas/sales/tables/orders"- assertEqual- [ResourceSchemeMismatch cid "postgresql" "mysql://x"]- (validateTestProfile testProfileSpec [concept])--testProfilePathMismatch :: Either Text ()-testProfilePathMismatch = do- concept <-- profileConcept- "tables/orders"- [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "postgresql://x")]- schemaSectionBody- cid <- parseTestConceptId "tables/orders"- assertEqual- [PathPatternMismatch cid "PostgreSQL Table" "schemas/*/tables/*"]- (validateTestProfile testProfileSpec [concept])--testProfileMissingSchema :: Either Text ()-testProfileMissingSchema = do- concept <-- profileConcept- "schemas/sales/tables/orders"- [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "postgresql://x")]- "# Overview\n\nNo schema section here.\n"- cid <- parseTestConceptId "schemas/sales/tables/orders"- assertEqual- [MissingSchemaSection cid "PostgreSQL Table"]- (validateTestProfile testProfileSpec [concept])--testProfileSchemaColumnsMismatch :: Either Text ()-testProfileSchemaColumnsMismatch = do- let mismatchBody =- Text.unlines- ["# Schema", "", "| Col | Type |", "|-----|------|", "| id | bigint |"]- concept <-- profileConcept- "schemas/sales/tables/orders"- [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "postgresql://x")]- mismatchBody- cid <- parseTestConceptId "schemas/sales/tables/orders"- assertEqual- [SchemaColumnsMismatch cid "PostgreSQL Table" ["Column", "Type", "Nullable", "Description"] ["Col", "Type"]]- (validateTestProfile testProfileSpec [concept])--testProfileConformingDocumentId :: Either Text ()-testProfileConformingDocumentId = do- concept <-- profileConcept- "decisions/one"- [("type", String "Decision Record"), ("title", String "One"), ("docId", String "ADR-1")]- "# One\n"- assertEqual [] (validateTestProfile testDocumentIdProfileSpec [concept])--testProfileMissingDocumentId :: Either Text ()-testProfileMissingDocumentId = do- concept <-- profileConcept- "decisions/one"- [("type", String "Decision Record"), ("title", String "One")]- "# One\n"- cid <- parseTestConceptId "decisions/one"- assertEqual- [MissingDocumentId cid "Decision Record" "ADR"]- (validateTestProfile testDocumentIdProfileSpec [concept])--testProfileMalformedDocumentIds :: Either Text ()-testProfileMalformedDocumentIds = do- leadingZero <-- profileConcept- "decisions/leading-zero"- [("type", String "Decision Record"), ("title", String "Leading zero"), ("docId", String "ADR-007")]- "# Leading zero\n"- wrongPrefix <-- profileConcept- "decisions/wrong-prefix"- [("type", String "Decision Record"), ("title", String "Wrong prefix"), ("docId", String "RFC-1")]- "# Wrong prefix\n"- leadingZeroId <- parseTestConceptId "decisions/leading-zero"- wrongPrefixId <- parseTestConceptId "decisions/wrong-prefix"- assertEqual- [ MalformedDocumentId leadingZeroId "ADR" "ADR-007",- MalformedDocumentId wrongPrefixId "ADR" "RFC-1"- ]- (validateTestProfile testDocumentIdProfileSpec [leadingZero, wrongPrefix])--testProfileDuplicateDocumentIds :: Either Text ()-testProfileDuplicateDocumentIds = do- second <-- profileConcept- "decisions/second"- [("type", String "Decision Record"), ("title", String "Second"), ("docId", String "ADR-1")]- "# Second\n"- firstConcept <-- profileConcept- "decisions/first"- [("type", String "Decision Record"), ("title", String "First"), ("docId", String "ADR-1")]- "# First\n"- firstId <- parseTestConceptId "decisions/first"- secondId <- parseTestConceptId "decisions/second"- assertEqual- [DuplicateDocumentId "ADR-1" firstId secondId]- (validateTestProfile testDocumentIdProfileSpec [second, firstConcept])--testProfileDocumentIdsOffByDefault :: Either Text ()-testProfileDocumentIdsOffByDefault = do- concept <-- profileConcept- "schemas/sales/tables/orders"- [ ("type", String "PostgreSQL Table"),- ("title", String "Orders"),- ("resource", String "postgresql://warehouse/sales/orders"),- ("docId", String "not-a-handle")- ]- schemaSectionBody- assertEqual [] (validateTestProfile testProfileSpec [concept])--testSchemaSectionColumns :: Either Text ()-testSchemaSectionColumns =- assertEqual- (Just ["Column", "Type", "Nullable", "Description"])- (schemaSectionColumns schemaSectionBody)---- | Milestone 5: walking the deviating fixture and validating it against the--- shipped descriptor produces exactly the expected advisory deviations.-testProfileDeviationsFixture :: IO (Either Text ())-testProfileDeviationsFixture = do- descriptorPath <- fixtureFilePath "profiles/postgresql.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "profile-deviations"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load profile: " <> err)- Right spec -> do- badId <- parseTestConceptId "schemas/sales/tables/bad"- ordersId <- parseTestConceptId "schemas/sales/tables/orders"- assertEqual- [TypeNotInProfile badId "pg table", MissingProfileField ordersId "title" Nothing]- (validateTestProfile spec concepts)--testDocumentIdDeviationsFixture :: IO (Either Text ())-testDocumentIdDeviationsFixture = do- descriptorPath <- fixtureFilePath "profiles/decisions.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "doc-id-deviations"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load document ID profile: " <> err)- Right spec -> do- firstId <- parseTestConceptId "decisions/first"- secondId <- parseTestConceptId "decisions/second"- thirdId <- parseTestConceptId "decisions/third"- fourthId <- parseTestConceptId "decisions/fourth"- assertEqual- [ MissingDocumentId fourthId "Decision Record" "ADR",- MalformedDocumentId thirdId "ADR" "ADR-007",- DuplicateDocumentId "ADR-1" firstId secondId- ]- (validateTestProfile spec concepts)--testTypeAwareProfileFixture :: IO (Either Text ())-testTypeAwareProfileFixture = do- descriptorPath <- fixtureFilePath "profiles/type-frontmatter.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "profile-type-frontmatter"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load type-aware profile: " <> err)- Right spec -> do- compiled <- firstShow (compileProfile spec)- ownedId <- parseTestConceptId "owned"- assertEqual [] (validateProfile PermissiveConformance compiled concepts)- assertEqual- [MissingRecommendedProfileField ownedId "reviewer" Nothing]- (validateProfile StrictAuthoring compiled concepts)--testClosedFieldsFixture :: IO (Either Text ())-testClosedFieldsFixture = do- descriptorPath <- fixtureFilePath "profiles/closed-fields.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "profile-closed-fields"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load closed-field profile: " <> err)- Right spec -> do- compiled <- firstShow (compileProfile spec)- typoId <- parseTestConceptId "requests/typo"- assertEqual- [MissingProfileField typoId "status" Nothing, FieldNotInProfile typoId "stauts"]- (validateProfile PermissiveConformance compiled concepts)--testCardinalityFixture :: IO (Either Text ())-testCardinalityFixture = do- descriptorPath <- fixtureFilePath "profiles/cardinality.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "profile-cardinality"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load cardinality profile: " <> err)- Right spec -> do- compiled <- firstShow (compileProfile spec)- badId <- parseTestConceptId "bad"- assertEqual- [ CardinalityMismatch badId (fieldPath "tags") List (String "one"),- CardinalityMismatch badId (fieldPath "title") Scalar (toJSON (["One", "Two"] :: [Text]))- ]- (validateProfile PermissiveConformance compiled concepts)--testFormatsFixture :: IO (Either Text ())-testFormatsFixture = do- descriptorPath <- fixtureFilePath "profiles/formats.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "profile-formats"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load format profile: " <> err)- Right spec -> do- compiled <- firstShow (compileProfile spec)- badId <- parseTestConceptId "bad"- assertEqual- [ ValueFormatMismatch badId (fieldPath "docId") (DocumentHandle "ADR") (String "ADR-007"),- ValueFormatMismatch badId (fieldPath "homepage") (UriWithScheme "https") (String "mailto:owner@example.test"),- ValueFormatMismatch badId (fieldPath "links") Uri (toJSON (["https://example.test/good", "https://example.test/bad%ZZ"] :: [Text])),- ValueFormatMismatch badId (fieldPath "published") Date (String "2026-13-45"),- ValueFormatMismatch badId (fieldPath "timestamp") Rfc3339Utc (String "2026-07-29T17:00:00+01:00")- ]- (validateProfile PermissiveConformance compiled concepts)--testNestedReviewsFixture :: IO (Either Text ())-testNestedReviewsFixture = do- descriptorPath <- fixtureFilePath "profiles/nested-reviews.dhall"- loaded <- loadProfileFile descriptorPath- root <- fixturePath "profile-nested-reviews"- concepts <- readBundle root- pure $ case loaded of- Left err -> Left ("failed to load nested review profile: " <> err)- Right spec -> do- compiled <- firstShow (compileProfile spec)- badId <- parseTestConceptId "bad"- let permissiveExpected =- [ NestedElementNotRecord badId (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),- CardinalityMismatch badId (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),- MissingNestedProfileField badId (nestedTestPath 2 "outcome") Nothing,- ValueFormatMismatch badId (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),- ValueNotInVocabulary badId (nestedTestPath 2 "scope") reviewScopes (String "invalid")- ]- assertEqual permissiveExpected (validateProfile PermissiveConformance compiled concepts)- assertEqual- [ NestedElementNotRecord badId (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),- CardinalityMismatch badId (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),- MissingRecommendedNestedProfileField badId (nestedTestPath 2 "notes") Nothing,- MissingNestedProfileField badId (nestedTestPath 2 "outcome") Nothing,- ValueFormatMismatch badId (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),- ValueNotInVocabulary badId (nestedTestPath 2 "scope") reviewScopes (String "invalid")- ]- (validateProfile StrictAuthoring compiled concepts)--testConditionalFieldsFixture :: IO (Either Text ())-testConditionalFieldsFixture = do- descriptorPath <- fixtureFilePath "profiles/conditional-fields.dhall"- invalidDescriptorPath <- fixtureFilePath "profiles/conditional-fields-invalid.dhall"- loaded <- loadProfileFile descriptorPath- invalidLoaded <- loadProfileFile invalidDescriptorPath- root <- fixturePath "profile-conditions"- concepts <- readBundle root- pure $ do- spec <- first ("failed to load conditional profile: " <>) loaded- compiled <- firstShow (compileProfile spec)- invalidSpec <- first ("failed to load invalid conditional profile: " <>) invalidLoaded- decisionsMissingStatus <- parseTestConceptId "decisions/missing-status"- decisionsSuperseded <- parseTestConceptId "decisions/superseded"- postgresqlOperational <- parseTestConceptId "postgresql/operational"- postgresqlProjection <- parseTestConceptId "postgresql/projection"- reviewsMixed <- parseTestConceptId "reviews/mixed"- assertEqual- ( Left- ( ConditionFieldHasUnreachableValues- Nothing- (fieldPath "supersededBy")- (fieldPath "status")- ["superseded"]- ["active"]- :| []- )- )- (compileProfile invalidSpec)- assertEqual- [ MissingProfileField decisionsMissingStatus "status" Nothing,- MissingProfileField decisionsSuperseded "supersededBy" (Just (FieldCondition "status" ["superseded"])),- MissingProfileField postgresqlProjection "sourceQuery" (Just (FieldCondition "derivationKind" ["projection"])),- MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "effort") (Just (FieldCondition "kind" ["model"])),- MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "model") (Just (FieldCondition "kind" ["model"])),- MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "provider") (Just (FieldCondition "kind" ["model"]))- ]- (validateProfile PermissiveConformance compiled concepts)- assertEqual- [ MissingProfileField decisionsMissingStatus "status" Nothing,- MissingProfileField decisionsSuperseded "supersededBy" (Just (FieldCondition "status" ["superseded"])),- MissingRecommendedProfileField postgresqlOperational "runbook" (Just (FieldCondition "derivationKind" ["operational"])),- MissingProfileField postgresqlProjection "sourceQuery" (Just (FieldCondition "derivationKind" ["projection"])),- MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "effort") (Just (FieldCondition "kind" ["model"])),- MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "model") (Just (FieldCondition "kind" ["model"])),- MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "provider") (Just (FieldCondition "kind" ["model"]))- ]- (validateProfile StrictAuthoring compiled concepts)- where- nestedReviewPath elementIndex key =- FieldPath (FieldName "reviews" :| [ArrayIndex elementIndex, FieldName key])--testDocumentReferencesFixture :: IO (Either Text ())-testDocumentReferencesFixture = do- descriptorPath <- fixtureFilePath "profiles/document-references.dhall"- invalidDescriptorPath <- fixtureFilePath "profiles/document-references-invalid.dhall"- loaded <- loadProfileFile descriptorPath- invalidLoaded <- loadProfileFile invalidDescriptorPath- root <- fixturePath "profile-document-references"- concepts <- readBundle root- pure $ do- spec <- first ("failed to load document-reference profile: " <>) loaded- compiled <- firstShow (compileProfile spec)- invalidSpec <- first ("failed to load invalid document-reference profile: " <>) invalidLoaded- case compileProfile invalidSpec of- Left _ -> Right ()- Right _ -> Left "expected invalid document-reference descriptor to fail compilation"- duplicateAId <- parseTestConceptId "decisions/duplicate-a"- duplicateBId <- parseTestConceptId "decisions/duplicate-b"- sourceId <- parseTestConceptId "decisions/source"- assertEqual- [ DanglingHandleReference sourceId (indexedPath 1) "ADR-99",- ReferenceHandlePrefixMismatch sourceId (indexedPath 2) "PAT-3" "ADR",- MalformedDocumentReference sourceId (indexedPath 3) (String "not a reference"),- ExternalReferenceSchemeNotAllowed sourceId (indexedPath 4) "https" ["mori"],- SelfDocumentReference sourceId (indexedPath 6) "ADR-1",- MalformedDocumentReference sourceId (indexedPath 7) (Number 7),- DuplicateDocumentId "ADR-3" duplicateAId duplicateBId- ]- (validateProfile PermissiveConformance compiled concepts)- where- indexedPath elementIndex = FieldPath (FieldName "references" :| [ArrayIndex elementIndex])--validateTestProfile :: ProfileSpec -> [Concept] -> [ProfileViolation]-validateTestProfile spec concepts =- case compileProfile spec of- Left errors -> error ("test profile failed to compile: " <> show errors)- Right compiled -> validateProfile PermissiveConformance compiled concepts--substringIndex :: Text -> Text -> Maybe Int-substringIndex needle haystack =- let (prefix, match) = Text.breakOn needle haystack- in if Text.null match then Nothing else Just (Text.length prefix)--strictlyIncreasing :: [Int] -> Bool-strictlyIncreasing xs = and (zipWith (<) xs (drop 1 xs))--sampleDocument :: Text-sampleDocument =- Text.unlines- [ "---",- "type: BigQuery Table",- "title: Users",- "description: User records.",- "timestamp: 2026-06-16T00:00:00Z",- "tags: [users]",- "---",- "",- "# Schema",- "",- "Body text."- ]--assertEqual :: (Eq value, Show value) => value -> value -> Either Text ()-assertEqual expected actual- | expected == actual = Right ()- | otherwise =- Left- ( "expected "- <> Text.pack (show expected)- <> ", got "- <> Text.pack (show actual)- )--assertBool :: Text -> Bool -> Either Text ()-assertBool _ True = Right ()-assertBool label False = Left label--firstShow :: (Show err) => Either err value -> Either Text value-firstShow =- either (Left . Text.pack . show) Right--readBundle :: FilePath -> IO [Concept]-readBundle root = do- result <- walkBundle root- case result of- Left bundleError -> fail (show bundleError)- Right concepts -> pure concepts+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Data.Time (fromGregorian)+import Okf.Actor+import Okf.Bundle+import Okf.ConceptId+import Okf.Discovery+import Okf.Document+import Okf.Graph+import Okf.Index+import Okf.Log+import Okf.Markdown+import Okf.Path+-- 'List' and 'Object' are 'Cardinality' constructors; aeson's same-named+-- 'Value' constructors are reached as 'Aeson.Object' and friends.+import Okf.Prelude hiding (List, Object, setField, (.=))+-- 'HumanActor' is both an 'Okf.Actor' constructor and a 'FieldFormat'+-- alternative. This module names the actor far more often, so the format is+-- reached as 'Profile.HumanActor'.+import Okf.Profile hiding (HumanActor)+import Okf.Profile qualified as Profile+import Okf.Profile.Documentation+import Okf.Profile.Registry+import Okf.Trust+import Okf.Validation+import System.Directory+ ( createDirectoryIfMissing,+ doesDirectoryExist,+ doesFileExist,+ getTemporaryDirectory,+ removeDirectoryRecursive,+ )+import System.Exit (exitFailure)+import System.FilePath (normalise, takeDirectory, (</>))+import System.IO.Temp (createTempDirectory)+import "generic-lens" Data.Generics.Labels ()++main :: IO ()+main = do+ results <-+ sequence+ [ test "parseActor classifies the three specification section 7 shapes" testParseActorShapes,+ test "renderActor inverts parseActor on every input" testActorRoundTrip,+ test "parse valid document with YAML frontmatter" testParseValidDocument,+ test "parse document with no frontmatter as empty-frontmatter body" testParseNoFrontmatter,+ test "reject unterminated frontmatter" testRejectUnterminatedFrontmatter,+ test "reject frontmatter that is not a YAML mapping" testRejectNonMappingFrontmatter,+ test "validate permissive profile with only type" testPermissiveValidation,+ test "validate strict profile requiring title description generated" testStrictValidation,+ test "strict validation accepts generated, falls back to timestamp, reports neither" testStrictValidationGeneratedFamily,+ test "validate rejects tags that are not a string list" testRejectInvalidTags,+ test "round-trip preserves semantic frontmatter and body" testRoundTrip,+ test "reject invalid concept id segment" testRejectInvalidConceptId,+ test "convert concept id tables/users to tables/users.md" testConceptIdToFilePath,+ testIO "walkBundle reports a structured IO error for a missing root" testWalkBundleMissingRoot,+ testIO "walkBundle skips index.md and log.md" testWalkBundleSkipsReserved,+ testIO "walkBundle discovers nested concept IDs" testWalkBundleDiscoversNestedConceptIds,+ testIO "walkBundleInventory sees a non-Markdown file that is not a concept" testWalkBundleInventorySeesNonMarkdown,+ testIO "discoverBundleRoots finds a directory holding index.md" testDiscoverIndexMd,+ testIO "discoverBundleRoots finds a directory holding a typed concept" testDiscoverTypedConcept,+ testIO "discoverBundleRoots ignores markdown without a type field" testDiscoverIgnoresPlainMarkdown,+ testIO "discoverBundleRoots does not descend into a bundle it found" testDiscoverPrunesNestedBundles,+ testIO "discoverBundleRoots skips hidden and build directories" testDiscoverSkipsNoise,+ testIO "discoverBundleRoots honours maxDepth" testDiscoverHonoursMaxDepth,+ testIO "discoverBundleRoots reports a fixture bundle as its own root" testDiscoverFixtureBundle,+ test "parseLog/serializeLog round-trips a canonical log" testLogRoundTrip,+ test "validateLog flags a non-ISO date heading" testValidateLogNonIsoDate,+ test "validateLog flags an empty date group" testValidateLogEmptyDay,+ test "validateLog flags out-of-order days" testValidateLogOutOfOrder,+ testIO "walkLogs discovers nested log.md files" testWalkLogsDiscoversNested,+ test "logStaleness flags a concept newer than its nearest log" testLogStalenessFlagsNewerConcept,+ test "logStaleness reads generated.at ahead of the legacy timestamp" testLogStalenessReadsGeneratedAt,+ test "logStaleness prefers the deepest enclosing log" testLogStalenessPrefersDeepestLog,+ test "appendLogEntry inserts newest-first and prepends within a day" testAppendLogEntry,+ testIO "generateIndex groups documents by frontmatter type" testGenerateIndexGroupsByType,+ testIO "extractLinks resolves relative and absolute bundle links" testExtractLinksResolveBundleLinks,+ testIO "extractLinks ignores external markdown URLs" testExtractLinksIgnoresExternalUrls,+ testIO "buildGraph includes only edges to existing concepts" testBuildGraphIncludesKnownEdges,+ testIO "writeBundleIndexes is deterministic" testWriteBundleIndexesDeterministic,+ testIO "readBundleVersion reads a declared, absent, or unparseable okf_version" testReadBundleVersion,+ testIO "readBundleVersion accepts the unquoted YAML number form" testReadBundleVersionUnquoted,+ testIO "writeBundleIndexes preserves an existing okf_version declaration" testWriteBundleIndexesPreservesVersion,+ testIO "writeBundleIndexesWith declares a version and leaves others alone" testWriteBundleIndexesDeclaresVersion,+ testIO "fixture valid bundle validates and graphs expected edges" testFixtureValidBundle,+ testIO "fixture v01 legacy bundle validates strictly through the fallback" testFixtureV01LegacyBundle,+ testIO "fixture graph JSON shape is stable" testFixtureGraphJsonShape,+ testIO "fixture unterminated frontmatter reports parse error" testFixtureUnterminatedFrontmatter,+ testIO "fixture missing type reports validation error" testFixtureMissingType,+ test "frontmatter builder round-trips through serialize and parse" testFrontmatterBuilderRoundTrip,+ test "serializeDocument emits deterministic key order" testSerializeDeterministicKeyOrder,+ test "serializeDocument orders generated before the superseded timestamp" testSerializeGeneratedBeforeTimestamp,+ test "strict validation joins footnote labels to sources ids in both directions" testFootnoteAttributionJoin,+ test "footnote attribution is skipped entirely when a document has no sources" testFootnoteAttributionSkippedWithoutSources,+ test "footnote parsing is enabled, so a definition is not paragraph text" testFootnotesEnabled,+ test "enabling footnotes leaves log parsing and link extraction unchanged" testFootnotesDoNotRegressLogsOrLinks,+ test "extractFootnoteLabels ignores footnote syntax inside code" testExtractFootnoteLabelsIgnoresCode,+ test "extractFootnoteLabels keeps labels the parser erases and never ordinals" testExtractFootnoteLabelsKeepsErasedLabels,+ test "computationBlocks accepts both spellings and bounds its section" testComputationBlocks,+ test "computationBlocks cannot see a block inside an uncited footnote definition" testComputationBlocksFootnoteHazard,+ test "rendered concept link round-trips through extractConceptLinks" testConceptLinkRoundTrip,+ test "over-escaping relative links do not resolve inside bundle" testRejectOverEscapingRelativeLink,+ test "classifyPathReference implements the specification section 6.2 grammar" testClassifyPathReference,+ test "resolvePathReference decides existence against a bundle inventory" testResolvePathReference,+ test "every versioned field name is a core frontmatter field" testVersionedFieldsAreCoreFields,+ test "versionGate applies specification section 12 best-effort reading" testVersionGate,+ test "declared v0.2 reports a legacy timestamp that an undeclared bundle tolerates" testLegacyFieldInDeclaredV2,+ test "an unreadable or unknown declaration is a strict lint, never a refusal" testVersionDeclarationLints,+ test "validateBundle reports a dangling reference" testValidateBundleDanglingReference,+ test "validateBundle accepts a bundle whose links all resolve" testValidateBundleAcceptsResolved,+ test "validateBundle reports a dangling frontmatter path under strict only" testValidateBundleDanglingFrontmatterPath,+ test "a dangling relative path names the bundle-relative spelling that resolves" testDanglingFrontmatterPathAlternative,+ test "duplicateConceptIds finds repeated ids" testDuplicateConceptIds,+ test "conceptFromDocument derives typed fields from frontmatter" testConceptFromDocumentDerivesFields,+ test "conceptGenerated projects the v0.2 generated family" testConceptGeneratedProjection,+ test "readGenerated ignores a generated mapping with no by actor" testReadGeneratedWithoutActor,+ test "readVerified reads a list and normalises a bare mapping to one element" testReadVerifiedShapes,+ test "setVerified always writes a list and round-trips" testVerifiedRoundTrip,+ test "readStatus defaults to stable and preserves an unknown value" testReadStatus,+ test "readStaleAfter reads the date verbatim" testReadStaleAfter,+ test "trustTier derives the three specification section 5.3 tiers" testTrustTier,+ test "latestVerification returns the newest at" testLatestVerification,+ test "staleness compares inclusively against a supplied day" testStaleness,+ test "readSources reads entries and skips one without resource" testReadSources,+ test "usage_window applies at document scope with per-entry override" testUsageWindowOverride,+ test "setSources and setUsageWindow round-trip through serialize and parse" testSourcesRoundTrip,+ test "strict validation reports sources missing resource and duplicate ids" testValidateSources,+ test "the attested computation contract reads from the specification worked example" testReadAttestedComputationContract,+ test "a malformed contract field is not read rather than rejected" testReadAttestedComputationDegenerateShapes,+ test "the specification worked example serializes byte-identically" testAttestedComputationRoundTrip,+ test "readComputationSources joins the body section to the computation key" testReadComputationSources,+ test "strict validation reports an Attested Computation with no runtime" testValidateAttestedComputationRuntime,+ testIO "writeBundle then walkBundle round-trips" testWriteBundleRoundTrip,+ testIO "fixture dangling link reports a bundle validation error" testFixtureDanglingLink,+ testIO "fixture dangling frontmatter path reports exactly one strict problem" testFixtureDanglingFrontmatterPath,+ testIO "fixture attested computation bundle reports one missing runtime and no path problem" testFixtureAttestedComputation,+ testIO "loadProfileFile decodes the postgresql fixture" testLoadProfileFixture,+ testIO "loadProfileFile decodes record-completed document ID rules" testLoadDocumentIdProfileFixture,+ 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,+ testIO "loadProfileFile accepts the frozen EP-3 cardinality schema" testLoadCardinalityCompatibilityFixture,+ testIO "loadProfileFile accepts the frozen EP-4 format schema" testLoadFormatCompatibilityFixture,+ testIO "loadProfileFile decodes bounded nested review rules" testLoadNestedReviewsProfileFixture,+ testIO "loadProfileFile preserves the frozen bounded-nested schema" testLoadNestedCompatibilityFixture,+ testIO "loadProfileFile decodes same-scope conditions" testLoadConditionalFieldsProfileFixture,+ testIO "loadProfileFile preserves the frozen condition-aware schema" testLoadConditionalCompatibilityFixture,+ testIO "loadProfileFile preserves the frozen reference-aware schema" testLoadReferenceCompatibilityFixture,+ testIO "every frozen generation fixture compiles, not merely decodes" testFrozenFixturesCompile,+ testIO "loadProfileFile preserves the frozen pre-bundle-version schema" testLoadPreBundleVersionCompatibilityFixture,+ testIO "loadProfileFile preserves the frozen pre-path schema" testLoadPrePathCompatibilityFixture,+ testIO "loadProfileFile preserves the frozen five-alternative format union" testLoadPreActorCompatibilityFixture,+ testIO "loadProfileFile preserves the frozen optional-presence schema" testLoadPreObjectCompatibilityFixture,+ testIO "loadProfileFile still accepts an okf 0.2.x descriptor" testLoadLegacyProfileFixture,+ testIO "profileFieldDescription finds required and recommended prose" testProfileFieldDescription,+ testIO "profileFieldDescription finds optional prose" testOptionalFieldDescription,+ testIO "profile JSON encoding emits type, not type_" testProfileJsonShape,+ test "field condition JSON encoding is stable" testFieldConditionJsonShape,+ test "handle reference JSON encoding is stable" testHandleReferenceJsonShape,+ test "field format JSON encoding is stable" testFieldFormatJsonShape,+ testIO "loadRegistry enumerates nested profiles and skips non-profiles" testRegistryEnumeratesProfiles,+ testIO "loadRegistry reports a bare profile as a root entry" testRegistryRootProfile,+ testIO "resolveRegistryRef prefers package.dhall inside a directory" testResolveRegistryRef,+ testIO "loadRegistry reports a missing registry as Left" testRegistryLoadFailure,+ test "parseDocumentId accepts only canonical handles" testParseDocumentId,+ testIO "documentIdsInBundle sorts handles by prefix and number" testDocumentIdsInBundle,+ test "nextDocumentId skips gaps and starts unused prefixes at one" testNextDocumentId,+ testIO "findConceptsByDocumentId resolves and reports duplicate handles" testFindConceptsByDocumentId,+ test "compileProfile rejects ambiguous definitions deterministically" testCompileProfileDefinitionErrors,+ test "compiled rules merge profile and type requirements" testCompiledProfileMerge,+ testIO "compiledProfileTypeNames preserves declaration order" testCompiledProfileTypeNames,+ testIO "compiledProfileRulesForType merges profile and type scope" testCompiledProfileRulesMergeTypeScope,+ testIO "compiled optional rules carry no presence clause" testCompiledProfileOptionalPresence,+ test "profileDocumentationSlug normalizes free-text type names" testProfileDocumentationSlug,+ test "duplicate type slugs are disambiguated positionally" testProfileDocumentationSlugCollisions,+ test "profile value display names match the documented vocabulary" testProfileValueDisplayNames,+ testIO "profile documentation renders a root concept" testProfileDocumentationRootConcept,+ test "profile documentation renders object rules" testProfileDocumentationObjectFields,+ test "profile documentation renders a required bundle version" testProfileDocumentationRequiredBundleVersion,+ testIO "profile documentation renders one concept per declared type" testProfileDocumentationTypeConcept,+ testIO "profile documentation renders inherited rules for a bare type" testProfileDocumentationInheritedRules,+ testIO "generated profile documentation round-trips through serialize and parse" testProfileDocumentationRoundTrip,+ testIO "generated profile documentation validates permissively and strictly" testProfileDocumentationValidates,+ testIO "generated profile documentation carries the default generated actor" testProfileDocumentationDefaultGenerated,+ testIO "generated profile documentation honours an explicit generated family" testProfileDocumentationExplicitGenerated,+ testIO "generated profile documentation omits generated on request" testProfileDocumentationOmittedGenerated,+ testIO "generated profile documentation has no dangling references" testProfileDocumentationLinksResolve,+ testIO "generated profile documentation is byte-stable across renders" testProfileDocumentationByteStable,+ testIO "generated profile documentation survives a filesystem round trip" testProfileDocumentationFilesystemRoundTrip,+ test "compiled vocabularies intersect in profile declaration order" testCompiledVocabularyIntersection,+ test "compileProfile rejects disjoint vocabularies" testUnsatisfiableVocabulary,+ test "compiled cardinality uses Any as identity and rejects contradictions" testCompiledCardinality,+ test "profile vocabularies validate strings, lists, and shapes" testVocabularyValidation,+ test "profile cardinality validates all JSON shapes and presence" testCardinalityValidation,+ test "cardinality suppresses redundant vocabulary shape errors" testCardinalityVocabularyInteraction,+ test "compiled formats refine Uri and reject contradictions" testCompiledFieldFormats,+ test "compileProfile rejects invalid format parameters" testInvalidFormatParameters,+ test "named formats validate parser boundaries, lists, and shapes" testNamedFormatValidation,+ test "the actor formats accept the three specification section 7 shapes" testActorFormatValidation,+ test "the numeric and boolean formats reject text and non-integers" testNonTextualFormatValidation,+ test "a non-textual format refines an unspecified cardinality to scalar" testNonTextualFormatRefinesCardinality,+ test "actor and integer narrow across scopes" testNewFormatsNarrowAcrossScopes,+ test "compiled nested rules merge and reject impossible outer cardinality" testCompiledNestedRules,+ test "nested record validation reports indexed paths and strict recommendations" testNestedRecordValidation,+ test "compileProfile rejects objectFields with an explicit scalar or list cardinality" testObjectFieldsRequireObjectShape,+ test "compileProfile refines an object rule to object cardinality" testCompileObjectRule,+ test "compileProfile normalizes a path rule at top-level and nested scope" testCompilePathRule,+ test "a type-scope path rule narrows the profile-scope one" testMergePathRule,+ test "compileProfile rejects incoherent path rules" testPathDefinitionErrors,+ test "compileProfile rejects an unreadable or unknown-major okfVersion" testProfileVersionParsing,+ test "compileProfile clamps a higher okfVersion minor to the supported one" testProfileVersionMinorClamp,+ test "compileProfile rejects an unreadable requireBundleVersion" testRequiredBundleVersionParsing,+ test "validateProfileVersion judges every declaration shape" testValidateProfileVersion,+ test "validateProfileVersion is inert without a requirement" testValidateProfileVersionUnrequired,+ test "compileProfile rejects a superseded field outside the optional list" testProfileVersionSupersededField,+ test "compileProfile rejects the actor formats in a v0.1 profile" testProfileVersionActorFormat,+ test "compileProfile does not judge a profile by its key names" testProfileVersionDoesNotJudgeKeyNames,+ test "validateProfile checks a top-level path-valued field" testValidatePathTopLevel,+ test "validateProfile checks sources[].resource with element indexes" testValidatePathNested,+ test "validateProfile checks a path inside an object-valued field" testValidatePathObjectScope,+ testIO "validateProfileWith resolves a non-Markdown path target" testValidateProfileWithInventory,+ testIO "the documented house profile reports the section 10 contract deviations" testAttestedComputationHouseProfile,+ test "compileProfile keeps a rule declaring both shapes at any cardinality" testCompileRecordOrListRule,+ test "validateProfile requires a member of an object field" testValidateObjectMember,+ test "validateProfile checks a bare mapping and a list against the same member rules" testValidateRecordOrList,+ test "validateProfile reports an object value where only a list is declared" testValidateObjectWrongShape,+ test "an object field with no member rules still requires a mapping" testValidateEmptyObjectRules,+ test "compileProfile rejects invalid same-scope field conditions" testConditionDefinitionErrors,+ test "top-level conditions gate presence without gating value checks" testTopLevelConditionalPresence,+ test "nested conditions use siblings and avoid cascading diagnostics" testNestedConditionalPresence,+ test "compileProfile rejects invalid document reference policies" testReferenceDefinitionErrors,+ test "document references resolve local handles and explicit external URIs" testDocumentReferenceValidation,+ test "optional fields are never missing but are fully value-checked" testOptionalFieldPresence,+ test "optional reference fields resolve handles when present" testOptionalReferenceValidation,+ test "optional nested fields are never missing inside records" testOptionalNestedFieldPresence,+ test "optional fields count as declared under closed field names" testOptionalFieldClosure,+ test "optional at one scope does not cancel the other scope's clause" testOptionalDoesNotCancelOtherScope,+ test "compileProfile rejects optional collisions and dead conditions" testOptionalDefinitionErrors,+ test "closed profiles reject unknown fields and isolate type fields" testClosedFieldValidation,+ test "profile rules apply to unknown concept types" testProfileRulesApplyToUnknownTypes,+ test "strict profile validation checks recommendations" testStrictProfileRecommendations,+ test "validateProfile accepts a conforming table concept" testProfileConformingTable,+ test "validateProfile flags a type not in the vocabulary" testProfileUnknownType,+ test "validateProfile flags a missing required field" testProfileMissingField,+ test "validateProfile flags a resource scheme mismatch" testProfileResourceMismatch,+ test "validateProfile flags a path pattern mismatch" testProfilePathMismatch,+ test "validateProfile flags a missing # Schema section" testProfileMissingSchema,+ test "validateProfile flags mismatched # Schema columns" testProfileSchemaColumnsMismatch,+ test "validateProfile accepts a conforming document ID" testProfileConformingDocumentId,+ test "validateProfile flags a missing document ID" testProfileMissingDocumentId,+ test "validateProfile flags malformed document IDs" testProfileMalformedDocumentIds,+ test "validateProfile flags duplicate document IDs" testProfileDuplicateDocumentIds,+ test "validateProfile document ID checks are off by default" testProfileDocumentIdsOffByDefault,+ test "schemaSectionColumns reads the header row of the Schema table" testSchemaSectionColumns,+ testIO "validateProfile reports the expected deviations for the fixture bundle" testProfileDeviationsFixture,+ testIO "validateProfile reports document ID fixture deviations" testDocumentIdDeviationsFixture,+ testIO "type-aware fixture is permissive but reports one strict recommendation" testTypeAwareProfileFixture,+ testIO "closed-field fixture reports missing and misspelled fields" testClosedFieldsFixture,+ testIO "cardinality fixture reports scalar and list mismatches" testCardinalityFixture,+ testIO "format fixture reports parser-backed mismatches" testFormatsFixture,+ testIO "nested review fixture validates records with indexed diagnostics" testNestedReviewsFixture,+ testIO "conditional fixture covers ADR, PostgreSQL, and review scopes" testConditionalFieldsFixture,+ testIO "document reference fixture covers local, external, self, and duplicate targets" testDocumentReferencesFixture,+ testIO "optional-field fixture reports only the recommendation and bad values" testOptionalFieldsFixture+ ]+ unless (and results) exitFailure++test :: Text -> Either Text () -> IO Bool+test name assertion =+ case assertion of+ Right () -> do+ putStrLn ("PASS " <> Text.unpack name)+ pure True+ Left message -> do+ putStrLn ("FAIL " <> Text.unpack name <> ": " <> Text.unpack message)+ pure False++testIO :: Text -> IO (Either Text ()) -> IO Bool+testIO name assertion = do+ result <- assertion+ test name result++testParseActorShapes :: Either Text ()+testParseActorShapes = do+ assertEqual (HumanActor "ahormati") (parseActor "human:ahormati")+ assertEqual (ProcessActor "finance-nightly") (parseActor "process:finance-nightly")+ assertEqual (ProducerActor "reference_agent" "gemini-2.5-pro") (parseActor "reference_agent/gemini-2.5-pro")+ assertEqual (UnclassifiedActor "something") (parseActor "something")+ -- Section 7 writes the prefixes in lower case and section 5.3 makes the+ -- `human:` test load-bearing, so matching is case-sensitive.+ assertEqual (UnclassifiedActor "Human:ahormati") (parseActor "Human:ahormati")+ assertBool "human actor is human" (isHumanActor (parseActor "human:ahormati"))+ assertBool "producer actor is not human" (not (isHumanActor (parseActor "reference_agent/gemini-2.5-pro")))++testActorRoundTrip :: Either Text ()+testActorRoundTrip =+ for_+ [ "human:ahormati",+ "process:finance-nightly",+ "reference_agent/gemini-2.5-pro",+ "something",+ "Human:ahormati",+ "human:",+ "/version",+ "producer/",+ "a/b/c",+ ""+ ]+ (\raw -> assertEqual raw (renderActor (parseActor raw)))++testParseValidDocument :: Either Text ()+testParseValidDocument = do+ document <- firstShow (parseDocument sampleDocument)+ assertEqual (Just (String "BigQuery Table")) (frontmatterLookup "type" (document ^. #frontmatter))+ assertEqual "# Schema\n\nBody text.\n" (body document)++testParseNoFrontmatter :: Either Text ()+testParseNoFrontmatter = do+ document <- firstShow (parseDocument "# Draft\n")+ assertEqual Nothing (frontmatterLookup "type" (document ^. #frontmatter))+ assertEqual "# Draft\n" (body document)++testRejectUnterminatedFrontmatter :: Either Text ()+testRejectUnterminatedFrontmatter =+ assertEqual (Left UnterminatedFrontmatter) (parseDocument "---\ntype: BigQuery Table\n")++testRejectNonMappingFrontmatter :: Either Text ()+testRejectNonMappingFrontmatter =+ assertEqual (Left FrontmatterNotMapping) (parseDocument "---\n- one\n- two\n---\nBody\n")++testPermissiveValidation :: Either Text ()+testPermissiveValidation = do+ document <- firstShow (parseDocument "---\ntype: BigQuery Table\n---\nBody\n")+ assertEqual [] (validateDocument PermissiveConformance document)++testStrictValidation :: Either Text ()+testStrictValidation = do+ document <- firstShow (parseDocument "---\ntype: BigQuery Table\n---\nBody\n")+ let errors = validateDocument StrictAuthoring document+ assertBool "missing title" (MissingRecommendedField "title" `List.elem` errors)+ assertBool "missing description" (MissingRecommendedField "description" `List.elem` errors)+ assertBool "missing generated" (MissingGeneratedField `List.elem` errors)++-- | Specification section 5.2 satisfies the "when was this last changed"+-- requirement with `generated`, and section 13.1 permits falling back to the+-- superseded v0.1 `timestamp`. Both must pass strict validation; neither+-- present must fail it, naming the v0.2 field.+testStrictValidationGeneratedFamily :: Either Text ()+testStrictValidationGeneratedFamily = do+ let strictErrors source = validateDocument StrictAuthoring <$> firstShow (parseDocument source)+ preamble = "---\ntype: BigQuery Table\ntitle: Orders\ndescription: Order fact table.\n"+ withGenerated <- strictErrors (preamble <> "generated: { by: human:ahormati, at: 2026-06-20T22:53:05Z }\n---\nBody\n")+ assertEqual [] withGenerated+ withLegacyTimestamp <- strictErrors (preamble <> "timestamp: 2026-06-16T00:00:00Z\n---\nBody\n")+ assertEqual [] withLegacyTimestamp+ withNeither <- strictErrors (preamble <> "---\nBody\n")+ assertEqual [MissingGeneratedField] withNeither+ -- `generated` present but without the actor section 5.2 requires within it.+ withoutActor <- strictErrors (preamble <> "generated: { at: 2026-06-20T22:53:05Z }\n---\nBody\n")+ assertEqual [GeneratedMustHaveActor] withoutActor+ -- Section 11 forbids rejecting a bundle for a missing optional field, so+ -- neither diagnostic may fire under PermissiveConformance.+ permissive <- validateDocument PermissiveConformance <$> firstShow (parseDocument (preamble <> "---\nBody\n"))+ assertEqual [] permissive++testRejectInvalidTags :: Either Text ()+testRejectInvalidTags = do+ document <- firstShow (parseDocument "---\ntype: BigQuery Table\ntags: orders\n---\nBody\n")+ assertEqual [FieldMustBeListOfText "tags"] (validateDocument PermissiveConformance document)++testRoundTrip :: Either Text ()+testRoundTrip = do+ document <- firstShow (parseDocument sampleDocument)+ assertEqual [] (validateDocument PermissiveConformance document)+ assertEqual [] (validateDocument StrictAuthoring document)+ reparsed <- firstShow (parseDocument (serializeDocument document))+ assertEqual (document ^. #frontmatter) (reparsed ^. #frontmatter)+ assertEqual (body document) (body reparsed)++testRejectInvalidConceptId :: Either Text ()+testRejectInvalidConceptId =+ assertEqual (Left (InvalidConceptIdSegment "-users")) (parseConceptId "tables/-users")++testConceptIdToFilePath :: Either Text ()+testConceptIdToFilePath = do+ conceptId <- firstShow (parseConceptId "tables/users")+ assertEqual "tables/users.md" (conceptIdToFilePath conceptId)++testWalkBundleMissingRoot :: IO (Either Text ())+testWalkBundleMissingRoot = do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-core-missing-parent"+ let missingRoot = root </> "missing"+ result <- walkBundle missingRoot+ removeDirectoryRecursive root+ pure+ ( case result of+ Left (BundleIoError path message)+ | path == missingRoot && "does not exist" `Text.isInfixOf` message -> Right ()+ other -> Left ("expected missing-root BundleIoError, got " <> Text.pack (show other))+ )++testWalkBundleSkipsReserved :: IO (Either Text ())+testWalkBundleSkipsReserved =+ withFixtureBundle+ ( \root -> do+ concepts <- readBundle root+ pure (assertEqual ["datasets/sales", "tables/customers", "tables/orders"] (renderConceptId . conceptIdOf <$> concepts))+ )++-- | The gap this milestone closes: @references\/attesters\/revenue.py@ is+-- specification §6.3's own example of what an @attester.resource@ points at, and+-- before 'walkBundleInventory' existed nothing in okf could tell whether it was+-- there. It must be visible to the inventory without becoming a concept, since+-- only a @.md@ file carries frontmatter to validate.+testWalkBundleInventorySeesNonMarkdown :: IO (Either Text ())+testWalkBundleInventorySeesNonMarkdown = do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-core-inventory"+ createFixtureBundle root+ createDirectoryIfMissing True (root </> "references" </> "attesters")+ Text.IO.writeFile (root </> "references" </> "attesters" </> "revenue.py") "print('receipt')\n"+ concepts <- readBundle root+ walked <- walkBundleInventory root+ removeDirectoryRecursive root+ pure+ ( do+ inventory <- firstShow walked+ assertBool+ "the .py file is in the inventory"+ (bundleInventoryMember "references/attesters/revenue.py" inventory)+ assertBool+ "the .py file is not a concept"+ (notElem "references/attesters/revenue" (renderConceptId . conceptIdOf <$> concepts))+ assertBool+ "a concept's own file is in the inventory"+ (bundleInventoryMember "tables/orders.md" inventory)+ -- A reserved file is not a concept but is still a file, so a path naming+ -- one names something that exists.+ assertBool "a reserved file is in the inventory" (bundleInventoryMember "index.md" inventory)+ assertBool+ "a file that is not there is not in the inventory"+ (not (bundleInventoryMember "references/attesters/gone.py" inventory))+ -- An in-memory bundle knows its own concepts and honestly cannot know+ -- anything else.+ let inMemory = bundleInventoryOfConcepts concepts+ assertBool+ "the in-memory inventory holds concept paths"+ (bundleInventoryMember "tables/orders.md" inMemory)+ assertBool+ "the in-memory inventory cannot know a non-Markdown file"+ (not (bundleInventoryMember "references/attesters/revenue.py" inMemory))+ )++testWalkBundleDiscoversNestedConceptIds :: IO (Either Text ())+testWalkBundleDiscoversNestedConceptIds =+ withFixtureBundle+ ( \root -> do+ concepts <- readBundle root+ pure+ ( do+ expected <- firstShow (parseConceptId "tables/orders")+ assertBool "nested concept exists" (isJust (findConcept expected concepts))+ )+ )++-- | Build a throwaway directory tree, run an action on it, and clean up.+withDiscoveryTree :: String -> [(FilePath, Text)] -> (FilePath -> IO a) -> IO a+withDiscoveryTree label files action = do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory label+ for_ files $ \(relativePath, content) -> do+ createDirectoryIfMissing True (root </> takeDirectory relativePath)+ Text.IO.writeFile (root </> relativePath) content+ result <- action root+ removeDirectoryRecursive root+ pure result++typedConcept :: Text -> Text+typedConcept titleText =+ Text.unlines ["---", "type: Table", "title: " <> titleText, "---", "", "# " <> titleText]++plainMarkdown :: Text+plainMarkdown = "# Just prose\n\nNo frontmatter here.\n"++testDiscoverIndexMd :: IO (Either Text ())+testDiscoverIndexMd =+ withDiscoveryTree "okf-discovery-index" [("kb/index.md", "# Index\n")] $ \root -> do+ found <- discoverBundleRoots defaultDiscoveryOptions root+ pure (assertEqual [normalise (root </> "kb")] found)++testDiscoverTypedConcept :: IO (Either Text ())+testDiscoverTypedConcept =+ withDiscoveryTree "okf-discovery-typed" [("kb/tables/orders.md", typedConcept "Orders")] $ \root -> do+ found <- discoverBundleRoots defaultDiscoveryOptions root+ pure (assertEqual [normalise (root </> "kb" </> "tables")] found)++testDiscoverIgnoresPlainMarkdown :: IO (Either Text ())+testDiscoverIgnoresPlainMarkdown =+ withDiscoveryTree+ "okf-discovery-plain"+ [("notes/README.md", plainMarkdown), ("notes/CHANGELOG.md", plainMarkdown)]+ $ \root -> do+ found <- discoverBundleRoots defaultDiscoveryOptions root+ pure (assertEqual [] found)++testDiscoverPrunesNestedBundles :: IO (Either Text ())+testDiscoverPrunesNestedBundles =+ withDiscoveryTree+ "okf-discovery-prune"+ [ ("kb/index.md", "# Index\n"),+ ("kb/tables/index.md", "# Tables\n"),+ ("kb/tables/orders.md", typedConcept "Orders")+ ]+ $ \root -> do+ found <- discoverBundleRoots defaultDiscoveryOptions root+ pure (assertEqual [normalise (root </> "kb")] found)++testDiscoverSkipsNoise :: IO (Either Text ())+testDiscoverSkipsNoise =+ withDiscoveryTree+ "okf-discovery-noise"+ [ (".hidden/index.md", "# Hidden\n"),+ ("dist-newstyle/index.md", "# Build output\n"),+ ("kb/index.md", "# Index\n")+ ]+ $ \root -> do+ found <- discoverBundleRoots defaultDiscoveryOptions root+ pure (assertEqual [normalise (root </> "kb")] found)++testDiscoverHonoursMaxDepth :: IO (Either Text ())+testDiscoverHonoursMaxDepth =+ withDiscoveryTree "okf-discovery-depth" [("a/b/c/index.md", "# Deep\n")] $ \root -> do+ shallow <- discoverBundleRoots defaultDiscoveryOptions {maxDepth = 2} root+ deep <- discoverBundleRoots defaultDiscoveryOptions {maxDepth = 3} root+ pure (assertEqual [] shallow >> assertEqual [normalise (root </> "a" </> "b" </> "c")] deep)++testDiscoverFixtureBundle :: IO (Either Text ())+testDiscoverFixtureBundle = do+ bundle <- fixturePath "valid-bundle"+ found <- discoverBundleRoots defaultDiscoveryOptions bundle+ pure (assertEqual [normalise bundle] found)++testLogRoundTrip :: Either Text ()+testLogRoundTrip = do+ let canonicalLog =+ Text.unlines+ [ "# Directory Update Log",+ "",+ "## 2026-06-23",+ "* **Update**: Refreshed [orders](tables/orders.md).",+ "* **Creation**: Added customers.",+ "",+ "## 2026-06-01",+ "* Deprecated a stale note."+ ]+ parsed = parseLog canonicalLog+ reparsed = parseLog (serializeLog parsed)+ assertEqual+ ( Log+ { logTitle = "Directory Update Log",+ logDays =+ [ LogDay+ { logDate = "2026-06-23",+ logEntries =+ [ LogEntry (Just "Update") "Refreshed [orders](tables/orders.md).",+ LogEntry (Just "Creation") "Added customers."+ ]+ },+ LogDay+ { logDate = "2026-06-01",+ logEntries = [LogEntry Nothing "Deprecated a stale note."]+ }+ ]+ }+ )+ parsed+ assertEqual parsed reparsed++testValidateLogNonIsoDate :: Either Text ()+testValidateLogNonIsoDate =+ assertBool+ "expected LogDateNotIso"+ (LogDateNotIso "not-a-date" `List.elem` validateLog (parseLog "# Log\n\n## not-a-date\n* **Update**: oops\n"))++testValidateLogEmptyDay :: Either Text ()+testValidateLogEmptyDay =+ assertBool+ "expected LogEmptyDay"+ (LogEmptyDay "2026-06-23" `List.elem` validateLog (parseLog "# Log\n\n## 2026-06-23\n"))++testValidateLogOutOfOrder :: Either Text ()+testValidateLogOutOfOrder =+ assertBool+ "expected LogDaysOutOfOrder"+ ( LogDaysOutOfOrder "2026-01-01" "2026-06-23"+ `List.elem` validateLog (parseLog "# Log\n\n## 2026-01-01\n* Old.\n\n## 2026-06-23\n* New.\n")+ )++testWalkLogsDiscoversNested :: IO (Either Text ())+testWalkLogsDiscoversNested = do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-core-logs"+ createDirectoryIfMissing True (root </> "tables")+ Text.IO.writeFile (root </> "log.md") "# Root Log\n\n## 2026-06-23\n* Root entry.\n"+ Text.IO.writeFile (root </> "tables" </> "log.md") "# Tables Log\n\n## 2026-06-22\n* Tables entry.\n"+ result <- walkLogs root+ removeDirectoryRecursive root+ pure+ ( case result of+ Right logs -> assertEqual ["log.md", "tables/log.md"] (logSourcePath <$> logs)+ Left bundleError -> Left ("expected logs, got " <> Text.pack (show bundleError))+ )++testLogStalenessFlagsNewerConcept :: Either Text ()+testLogStalenessFlagsNewerConcept = do+ staleId <- parseTestConceptId "stale"+ staleConcept <- testConceptWithTimestamp "stale" "2026-06-23T00:00:00Z"+ currentConcept <- testConceptWithTimestamp "current" "2026-01-01T00:00:00Z"+ let logs = [LogFile "log.md" (parseLog "# Log\n\n## 2026-06-01\n* **Update**: logged.\n")]+ assertEqual+ [ LogStaleness+ { staleConcept = staleId,+ staleConceptDate = "2026-06-23",+ staleLogPath = Just "log.md",+ staleLogDate = Just "2026-06-01"+ }+ ]+ (logStaleness [staleConcept, currentConcept] logs)++-- | Staleness reads the v0.2 `generated.at` (specification section 5.2), and+-- prefers it over the legacy `timestamp` it supersedes when both are present.+testLogStalenessReadsGeneratedAt :: Either Text ()+testLogStalenessReadsGeneratedAt = do+ generatedOnlyId <- parseTestConceptId "generated-only"+ bothId <- parseTestConceptId "both"+ generatedOnly <-+ testConceptWithFrontmatter+ "generated-only"+ "type: Test\ngenerated: { by: human:ahormati, at: 2026-06-23T00:00:00Z }\n"+ -- `generated.at` wins over `timestamp`: the stale date must be the June 24+ -- from `generated`, not the January 1 from the legacy key.+ bothKeys <-+ testConceptWithFrontmatter+ "both"+ "type: Test\ngenerated: { by: human:ahormati, at: 2026-06-24T00:00:00Z }\ntimestamp: 2026-01-01T00:00:00Z\n"+ let logs = [LogFile "log.md" (parseLog "# Log\n\n## 2026-06-01\n* **Update**: logged.\n")]+ assertEqual+ [ LogStaleness bothId "2026-06-24" (Just "log.md") (Just "2026-06-01"),+ LogStaleness generatedOnlyId "2026-06-23" (Just "log.md") (Just "2026-06-01")+ ]+ (logStaleness [bothKeys, generatedOnly] logs)++testLogStalenessPrefersDeepestLog :: Either Text ()+testLogStalenessPrefersDeepestLog = do+ usersId <- parseTestConceptId "tables/users"+ usersConcept <- testConceptWithTimestamp "tables/users" "2026-06-21T00:00:00Z"+ let logs =+ [ LogFile "log.md" (parseLog "# Root Log\n\n## 2026-06-01\n* **Update**: root.\n"),+ LogFile "tables/log.md" (parseLog "# Tables Log\n\n## 2026-06-20\n* **Update**: tables.\n")+ ]+ assertEqual+ [ LogStaleness+ { staleConcept = usersId,+ staleConceptDate = "2026-06-21",+ staleLogPath = Just "tables/log.md",+ staleLogDate = Just "2026-06-20"+ }+ ]+ (logStaleness [usersConcept] logs)++testAppendLogEntry :: Either Text ()+testAppendLogEntry =+ assertEqual+ ( Log+ { logTitle = "Log",+ logDays =+ [ LogDay "2026-06-23" [LogEntry (Just "Update") "new"],+ LogDay "2026-06-01" [LogEntry (Just "Update") "prepended", LogEntry (Just "Creation") "old"]+ ]+ }+ )+ ( appendLogEntry+ "2026-06-01"+ (LogEntry (Just "Update") "prepended")+ ( appendLogEntry+ "2026-06-23"+ (LogEntry (Just "Update") "new")+ (Log "Log" [LogDay "2026-06-01" [LogEntry (Just "Creation") "old"]])+ )+ )++testGenerateIndexGroupsByType :: IO (Either Text ())+testGenerateIndexGroupsByType =+ withFixtureBundle+ ( \root -> do+ concepts <- readBundle root+ pure+ ( do+ orders <- requireConcept "tables/orders" concepts+ customers <- requireConcept "tables/customers" concepts+ let rendered = renderIndex [] [] [orders, customers]+ assertBool "has type heading" ("# BigQuery Table" `Text.isInfixOf` rendered)+ assertBool "has orders bullet" ("[Orders](orders.md) - Order records." `Text.isInfixOf` rendered)+ assertBool "has customers bullet" ("[Customers](customers.md) - Customer records." `Text.isInfixOf` rendered)+ -- §8's progressive disclosure reaches a file with no frontmatter:+ -- a directory holding only an attester used to render one newline.+ let withFiles = renderIndex [] ["revenue.py"] []+ -- The trailing blank line is 'renderIndex''s own, appended after+ -- every section; a concepts-only index ends the same way.+ assertEqual "# Files\n\n- [revenue.py](revenue.py)\n\n" withFiles+ )+ )++testExtractLinksResolveBundleLinks :: IO (Either Text ())+testExtractLinksResolveBundleLinks =+ withFixtureBundle+ ( \root -> do+ concepts <- readBundle root+ pure+ ( do+ orders <- requireConcept "tables/orders" concepts+ customers <- firstShow (parseConceptId "tables/customers")+ sales <- firstShow (parseConceptId "datasets/sales")+ let links = extractConceptLinks orders+ assertBool "absolute or ./ customers link" (customers `List.elem` links)+ assertBool "../ sales link" (sales `List.elem` links)+ )+ )++testExtractLinksIgnoresExternalUrls :: IO (Either Text ())+testExtractLinksIgnoresExternalUrls =+ withFixtureBundle+ ( \root -> do+ concepts <- readBundle root+ pure+ ( do+ orders <- requireConcept "tables/orders" concepts+ assertEqual 4 (length (extractConceptLinks orders))+ )+ )++testBuildGraphIncludesKnownEdges :: IO (Either Text ())+testBuildGraphIncludesKnownEdges =+ withFixtureBundle+ ( \root -> do+ concepts <- readBundle root+ pure+ ( do+ orders <- firstShow (parseConceptId "tables/orders")+ customers <- firstShow (parseConceptId "tables/customers")+ missing <- firstShow (parseConceptId "missing")+ let graph = buildGraph concepts+ assertEqual 3 (length (nodes graph))+ assertBool "known edge exists" (Edge {source = orders, target = customers} `List.elem` edges graph)+ assertBool "broken edge excluded" (Edge {source = orders, target = missing} `notElem` edges graph)+ )+ )++testWriteBundleIndexesDeterministic :: IO (Either Text ())+testWriteBundleIndexesDeterministic =+ withFixtureBundle+ ( \root -> do+ firstResult <- writeBundleIndexes root+ firstIndex <- Text.IO.readFile (root </> "tables" </> "index.md")+ secondResult <- writeBundleIndexes root+ secondIndex <- Text.IO.readFile (root </> "tables" </> "index.md")+ pure+ ( do+ firstShow firstResult+ firstShow secondResult+ assertEqual firstIndex secondIndex+ assertBool "tables index has BigQuery Table section" ("# BigQuery Table" `Text.isInfixOf` secondIndex)+ )+ )++-- | Specification §12 permits a bundle-root @index.md@ to declare the version+-- it targets. Every shape of that declaration is readable and none is fatal.+testReadBundleVersion :: IO (Either Text ())+testReadBundleVersion = do+ declared <- versionOf [("index.md", "---\nokf_version: \"0.2\"\n---\n\n# Root\n")]+ noKey <- versionOf [("index.md", "---\ntitle: Root\n---\n\n# Root\n")]+ noFrontmatter <- versionOf [("index.md", "# Root\n")]+ noIndex <- versionOf [("kb.md", "# Not an index\n")]+ unparseable <- versionOf [("index.md", "---\nokf_version: \"zero point two\"\n---\n\n# Root\n")]+ malformed <- versionOf [("index.md", "---\nokf_version: \"0.2\"\n\n# Root\n")]+ pure $ do+ assertEqual (Right (VersionDeclared (OkfVersion 0 2))) declared+ assertEqual (Right VersionUndeclared) noKey+ assertEqual (Right VersionUndeclared) noFrontmatter+ assertEqual (Right VersionUndeclared) noIndex+ assertEqual (Right (VersionUnparseable "zero point two")) unparseable+ assertEqual (Right VersionUndeclared) malformed++-- | A careless author writes @okf_version: 0.2@ without quotes, which YAML+-- reads as a float. A bundle should not be unreadable over missing quotes.+testReadBundleVersionUnquoted :: IO (Either Text ())+testReadBundleVersionUnquoted = do+ unquoted <- versionOf [("index.md", "---\nokf_version: 0.2\n---\n\n# Root\n")]+ pure (assertEqual (Right (VersionDeclared (OkfVersion 0 2))) unquoted)++versionOf :: [(FilePath, Text)] -> IO (Either BundleError VersionDeclaration)+versionOf files =+ withDiscoveryTree "okf-version" files readBundleVersion++-- | Index generation rewrites the bundle root's @index.md@, so without reading+-- the existing declaration first a single write destroys it. Fails on the code+-- that preceded this test.+testWriteBundleIndexesPreservesVersion :: IO (Either Text ())+testWriteBundleIndexesPreservesVersion =+ withDiscoveryTree+ "okf-version-preserve"+ [ ("index.md", "---\nokf_version: \"0.2\"\n---\n\n# Root\n"),+ ("tables/orders.md", typedConcept "Orders")+ ]+ ( \root -> do+ written <- writeBundleIndexes root+ rootIndex <- Text.IO.readFile (root </> "index.md")+ tablesIndex <- Text.IO.readFile (root </> "tables" </> "index.md")+ declaration <- readBundleVersion root+ pure+ ( do+ firstShow written+ assertEqual (Right (VersionDeclared (OkfVersion 0 2))) declaration+ assertBool "root index keeps the declaration" ("okf_version: \"0.2\"" `Text.isInfixOf` rootIndex)+ assertBool "root index still lists subdirectories" ("[tables/](tables/index.md)" `Text.isInfixOf` rootIndex)+ assertBool "only the root index carries frontmatter" (not ("---" `Text.isInfixOf` tablesIndex))+ )+ )++-- | An explicit declaration is written where there was none; a bundle that+-- declares nothing keeps a frontmatter-free root index.+testWriteBundleIndexesDeclaresVersion :: IO (Either Text ())+testWriteBundleIndexesDeclaresVersion =+ withDiscoveryTree+ "okf-version-declare"+ [("tables/orders.md", typedConcept "Orders")]+ ( \root -> do+ untouched <- writeBundleIndexes root+ withoutDeclaration <- Text.IO.readFile (root </> "index.md")+ declared <- writeBundleIndexesWith (Just (OkfVersion 0 2)) root+ withDeclaration <- Text.IO.readFile (root </> "index.md")+ pure+ ( do+ firstShow untouched+ firstShow declared+ assertBool "undeclared bundle gets no frontmatter" (not ("---" `Text.isInfixOf` withoutDeclaration))+ assertEqual+ ("---\nokf_version: \"0.2\"\n---\n\n" <> withoutDeclaration)+ withDeclaration+ )+ )++testFixtureValidBundle :: IO (Either Text ())+testFixtureValidBundle = do+ root <- fixturePath "valid-bundle"+ concepts <- readBundle root+ inventory <- readBundleInventory root+ declaration <- readBundleVersion root+ pure+ ( do+ orders <- firstShow (parseConceptId "tables/orders")+ customers <- firstShow (parseConceptId "tables/customers")+ sales <- firstShow (parseConceptId "datasets/sales")+ assertEqual 4 (length concepts)+ assertEqual [] (foldMap (validateDocument PermissiveConformance . conceptDocument) concepts)+ -- The primary fixture is a v0.2 bundle: it declares the version and+ -- every concept dates itself with `generated` rather than the+ -- superseded `timestamp`.+ assertEqual (Right (VersionDeclared (OkfVersion 0 2))) declaration+ assertEqual [] (validateBundle StrictAuthoring (VersionDeclared (OkfVersion 0 2)) inventory concepts)+ assertBool+ "every concept carries generated"+ (all (isJust . conceptGenerated) concepts)+ let graph = buildGraph concepts+ assertBool "orders to customers" (Edge {source = orders, target = customers} `List.elem` edges graph)+ assertBool "orders to sales" (Edge {source = orders, target = sales} `List.elem` edges graph)+ )++-- | The v0.1 fallback of @docs\/adr\/7-okf-v0-1-legacy-fallback-policy.md@ needs+-- a bundle in the old shape or it will rot. This fixture is that bundle, and is+-- deliberately never migrated: it dates its concept with the superseded+-- @timestamp@ and declares no @okf_version@, so strict validation must report+-- nothing at all.+testFixtureV01LegacyBundle :: IO (Either Text ())+testFixtureV01LegacyBundle = do+ root <- fixturePath "v01-legacy-bundle"+ concepts <- readBundle root+ inventory <- readBundleInventory root+ declaration <- readBundleVersion root+ pure+ ( do+ assertEqual 1 (length concepts)+ assertEqual (Right VersionUndeclared) declaration+ assertEqual [] (validateBundle StrictAuthoring VersionUndeclared inventory concepts)+ assertBool+ "the concept carries no generated family"+ (all (isNothing . conceptGenerated) concepts)+ -- The date is still read, which is the whole point of the fallback.+ assertEqual ["2026-06-16"] (staleConceptDate <$> logStaleness concepts [])+ )++testFixtureGraphJsonShape :: IO (Either Text ())+testFixtureGraphJsonShape = do+ root <- fixturePath "valid-bundle"+ concepts <- readBundle root+ orders <- requireConceptIO "tables/orders" concepts+ pure+ ( case filter (\Node {id = nodeId} -> nodeId == conceptIdOf orders) (nodes (buildGraph concepts)) of+ [ordersNode] ->+ assertEqual+ ( object+ [ "id" .= ("tables/orders" :: Text),+ "label" .= ("Orders" :: Text),+ "type" .= ("BigQuery Table" :: Text),+ "description" .= Just ("Order fact table." :: Text),+ "resource" .= Just ("bigquery://analytics.tables.orders" :: Text),+ "tags" .= ["orders" :: Text, "sales"]+ ]+ )+ (toJSON ordersNode)+ other -> Left ("expected one orders node, got " <> Text.pack (show (length other)))+ )++testFixtureUnterminatedFrontmatter :: IO (Either Text ())+testFixtureUnterminatedFrontmatter = do+ root <- fixturePath "invalid-unterminated-frontmatter"+ result <- walkBundle root+ pure+ ( case result of+ Left (InvalidConceptDocument "broken.md" UnterminatedFrontmatter) -> Right ()+ other -> Left ("expected unterminated frontmatter error, got " <> Text.pack (show other))+ )++testFixtureMissingType :: IO (Either Text ())+testFixtureMissingType = do+ root <- fixturePath "invalid-missing-type"+ concepts <- readBundle root+ pure+ ( do+ assertEqual 1 (length concepts)+ case foldMap (validateDocument PermissiveConformance . conceptDocument) concepts of+ [MissingRequiredField "type"] -> Right ()+ other -> Left ("expected missing type error, got " <> Text.pack (show other))+ )++testFrontmatterBuilderRoundTrip :: Either Text ()+testFrontmatterBuilderRoundTrip = do+ let generatedValue = Generated (parseActor "reference_agent/gemini-2.5-pro") (Just "2026-06-20T22:53:05Z")+ frontmatterValue =+ setField "version" (String "0.2.0")+ . setTags ["orders", "sales"]+ . setResource "bigquery://analytics.tables.orders"+ . setGenerated generatedValue+ $ okfCommon+ OkfCommon+ { commonType = "BigQuery Table",+ commonTitle = Just "Orders",+ commonDescription = Just "Order fact table.",+ commonTimestamp = Just "2026-06-16T00:00:00Z"+ }+ original = OKFDocument frontmatterValue "# Orders\n\nBody text.\n"+ reparsed <- firstShow (parseDocument (serializeDocument original))+ assertEqual (original ^. #frontmatter) (reparsed ^. #frontmatter)+ assertEqual (body original) (body reparsed)+ -- The v0.2 family survives serialize-then-parse as a typed value, not merely+ -- as equal frontmatter, and a `generated` without `at` round-trips too.+ assertEqual (Just generatedValue) (readGenerated (reparsed ^. #frontmatter))+ let withoutAt = setGenerated (Generated (HumanActor "ahormati") Nothing) emptyFrontmatter+ reparsedWithoutAt <- firstShow (parseDocument (serializeDocument (OKFDocument withoutAt "# Orders\n")))+ assertEqual+ (Just (Generated (HumanActor "ahormati") Nothing))+ (readGenerated (reparsedWithoutAt ^. #frontmatter))++testSerializeDeterministicKeyOrder :: Either Text ()+testSerializeDeterministicKeyOrder = do+ let frontmatterValue =+ setField "zeta" (String "z")+ . setField "alpha" (String "a")+ . setTags ["t"]+ . setResource "res://x"+ . setType "Recipe"+ . setTimestamp "2026-06-16T00:00:00Z"+ . setDescription "Desc"+ . setTitle "Demo"+ $ emptyFrontmatter+ rendered = serializeDocument (OKFDocument frontmatterValue "# Demo\n")+ expectedOrder =+ ["type:", "title:", "description:", "resource:", "tags:", "timestamp:", "alpha:", "zeta:"]+ keyIndices <- traverse (\key -> maybe (Left ("missing key " <> key)) Right (substringIndex key rendered)) expectedOrder+ assertBool ("keys not in deterministic order: " <> Text.pack (show keyIndices)) (strictlyIncreasing keyIndices)++-- | The v0.2 @generated@ family sorts before the v0.1 @timestamp@ it supersedes+-- (specification section 13.1), so a document carrying both reads with the+-- current field first.+testSerializeGeneratedBeforeTimestamp :: Either Text ()+testSerializeGeneratedBeforeTimestamp = do+ let frontmatterValue =+ setTimestamp "2026-06-16T00:00:00Z"+ . setField "generated" (object ["by" .= ("human:ahormati" :: Text), "at" .= ("2026-06-20T22:53:05Z" :: Text)])+ . setType "Recipe"+ $ emptyFrontmatter+ rendered = serializeDocument (OKFDocument frontmatterValue "# Demo\n")+ expectedOrder = ["type:", "generated:", "timestamp:"]+ keyIndices <- traverse (\key -> maybe (Left ("missing key " <> key)) Right (substringIndex key rendered)) expectedOrder+ assertBool ("keys not in deterministic order: " <> Text.pack (show keyIndices)) (strictlyIncreasing keyIndices)++-- | Footnotes are enabled at every parse site, and the observable proof is a+-- link that no longer appears.+--+-- With footnotes disabled a single-token footnote definition such as+-- @[^src]: tables\/orders.md@ is read as a CommonMark /link reference+-- definition/, which turns its citation @[^src]@ into a link whose destination+-- is that token. That phantom link reached 'extractConceptLinks' and was then+-- reported as a dangling reference. Enabling footnotes removes it while leaving+-- an ordinary link in the same body alone.+testFootnotesEnabled :: Either Text ()+testFootnotesEnabled = do+ ordersId <- parseTestConceptId "tables/orders"+ usersId <- parseTestConceptId "tables/users"+ concept <-+ testConcept+ "source"+ ( "See the note.[^src] Also see "+ <> renderConceptLink usersId "users"+ <> ".\n\n[^src]: tables/orders.md\n"+ )+ assertEqual [usersId] (extractConceptLinks concept)+ assertBool+ "footnote definition must not become a link reference definition"+ (ordersId `notElem` extractConceptLinks concept)++-- | Enabling footnotes changes the parse tree every body walker sees, so pin+-- the two walkers that do not care about footnotes at all: log parsing and link+-- extraction. Bracket-heavy prose in a log bullet must still yield one entry+-- with its text intact, and a body whose links sit inside a /cited/ footnote+-- definition must still contribute those links to the graph.+testFootnotesDoNotRegressLogsOrLinks :: Either Text ()+testFootnotesDoNotRegressLogsOrLinks = do+ let parsed = parseLog "# Log\n\n## 2026-06-23\n* **Update**: renamed [orders] to [^orders].\n"+ -- The backslashes are 'Okf.Log.renderInlineNodes' round-tripping inline nodes+ -- through @nodeToCommonmark@, which escapes brackets so the text re-parses to+ -- itself. That predates footnotes and is unchanged by them: an uncited+ -- @[^orders]@ is plain text under either parse configuration.+ assertEqual+ [LogDay "2026-06-23" [LogEntry (Just "Update") "renamed \\[orders\\] to \\[^orders\\]."]]+ (logDays parsed)+ usersId <- parseTestConceptId "tables/users"+ concept <-+ testConcept+ "source"+ ( "Attributed claim.[^cited]\n\n[^cited]: See "+ <> renderConceptLink usersId "users"+ <> " for detail.\n"+ )+ assertEqual [usersId] (extractConceptLinks concept)++-- | Labels come from a real parse, not a naive text scan: footnote syntax+-- inside an inline code span, an indented code block, or a fenced code block+-- yields nothing.+testExtractFootnoteLabelsIgnoresCode :: Either Text ()+testExtractFootnoteLabelsIgnoresCode = do+ let body =+ Text.unlines+ [ "Sharded daily.[^ga4-schema] Not a footnote: `[^inline-code]`.",+ "",+ " [^indented-block]: not a footnote either",+ "",+ "```sql",+ "SELECT 1 -- [^fenced-block]: not a footnote",+ "```",+ "",+ "Undefined citation.[^never-defined]",+ "",+ "[^ga4-schema]: GA4 BigQuery Export schema"+ ]+ labels = extractFootnoteLabels body+ assertEqual ["ga4-schema", "never-defined"] (footnoteReferences labels)+ assertEqual ["ga4-schema"] (footnoteDefinitions labels)+ assertEqual ["ga4-schema", "never-defined"] (footnoteLabelsUsed labels)++-- | The two labels cmark-gfm erases are exactly the two an author most needs+-- reported, so extraction must keep both: a citation with no definition (which+-- the parser reverts to plain text) and a definition nothing cites (which the+-- parser deletes outright). The ordinal guard is the same test's second job —+-- cmark-gfm renumbers a matched reference to @"1"@, so a label that parses as a+-- bare integer would mean extraction had drifted back onto the parse tree.+testExtractFootnoteLabelsKeepsErasedLabels :: Either Text ()+testExtractFootnoteLabelsKeepsErasedLabels = do+ let body =+ Text.unlines+ [ "Matched.[^matched] Undefined.[^undefined]",+ "",+ "[^matched]: a definition that is cited",+ "",+ "[^uncited]: a definition that nothing cites"+ ]+ labels = extractFootnoteLabels body+ assertEqual ["matched", "undefined"] (footnoteReferences labels)+ assertEqual ["matched", "uncited"] (footnoteDefinitions labels)+ assertBool+ "no extracted label may be a bare ordinal"+ (not (any looksLikeInteger (footnoteLabelsUsed labels)))+ where+ looksLikeInteger label =+ not (Text.null label) && Text.all (`Text.elem` "0123456789") label++-- | Specification §10.3's prose says "fenced" and §10.2's own worked example+-- writes an indented block, so both count; and the section is bounded at the+-- next heading of the same or a shallower level, so a fenced block under a later+-- @# Notes@ heading is not a second computation while one under a nested+-- @## Subsection@ still belongs to the section.+testComputationBlocks :: Either Text ()+testComputationBlocks = do+ assertEqual+ ["SELECT 1\n"]+ (computationBlocks (Text.unlines ["# Computation", "", " SELECT 1"]))+ assertEqual+ ["SELECT 1\n"]+ (computationBlocks (Text.unlines ["# Computation", "", "```sql", "SELECT 1", "```"]))+ assertEqual+ ["SELECT 1\n"]+ ( computationBlocks+ ( Text.unlines+ [ "# Computation",+ "",+ " SELECT 1",+ "",+ "# Notes",+ "",+ "```sql",+ "SELECT 2",+ "```"+ ]+ )+ )+ assertEqual+ ["SELECT 1\n", "SELECT 2\n"]+ ( computationBlocks+ ( Text.unlines+ [ "# Computation",+ "",+ " SELECT 1",+ "",+ "## Explanation",+ "",+ "```sql",+ "SELECT 2",+ "```"+ ]+ )+ )+ assertEqual+ ["SELECT 1\n", "SELECT 2\n"]+ ( computationBlocks+ ( Text.unlines+ ["# Computation", "", "```sql", "SELECT 1", "```", "", "```sql", "SELECT 2", "```"]+ )+ )+ assertEqual+ []+ (computationBlocks (Text.unlines ["# Notes", "", "```sql", "SELECT 1", "```"]))+ assertEqual+ ["SELECT 1\n"]+ (computationBlocks (Text.unlines ["## computation ", "", " SELECT 1"]))++-- | The one erasure that reaches 'computationBlocks', pinned so that meeting it+-- later is recognized rather than investigated. Per+-- @docs/adr/9-one-markdown-parse-configuration-and-source-scanned-authoring-checks.md@,+-- okf parses every body with footnotes enabled and cmark-gfm deletes a footnote+-- definition nothing cites, content and all — so a computation hidden inside one+-- is invisible here. The document is malformed in a second, unrelated way, so+-- this is an accepted cost rather than a bug to work around.+testComputationBlocksFootnoteHazard :: Either Text ()+testComputationBlocksFootnoteHazard =+ assertEqual+ []+ ( computationBlocks+ ( Text.unlines+ ["# Computation", "", "[^unused]: a definition nothing cites", "", " SELECT 1"]+ )+ )++testConceptLinkRoundTrip :: Either Text ()+testConceptLinkRoundTrip = do+ sourceId <- parseTestConceptId "recipes/haskell-library-repo"+ let targetStrings = ["orders", "modules/nix-haskell-flake", "refs/source-system.v1"]+ mapM_+ ( \rawTarget -> do+ targetId <- parseTestConceptId rawTarget+ let extracted = extractFromBodyLinkingTo sourceId targetId+ assertEqual [targetId] extracted+ )+ targetStrings++parseTestConceptId :: Text -> Either Text ConceptId+parseTestConceptId rawId =+ first (\err -> "bad concept id " <> rawId <> ": " <> Text.pack (show err)) (parseConceptId rawId)++extractFromBodyLinkingTo :: ConceptId -> ConceptId -> [ConceptId]+extractFromBodyLinkingTo sourceId targetId =+ extractConceptLinks+ (conceptFromDocument sourceId (OKFDocument (setType "Test" emptyFrontmatter) ("See " <> renderConceptLink targetId "link" <> ".\n")))++testRejectOverEscapingRelativeLink :: Either Text ()+testRejectOverEscapingRelativeLink = do+ sourceId <- parseTestConceptId "a/b/source"+ targetId <- parseTestConceptId "tables/orders"+ let concept =+ conceptFromDocument+ sourceId+ (OKFDocument (setType "Test" emptyFrontmatter) "[Escapes](../../../tables/orders.md)\n")+ assertEqual [] (extractConceptLinks concept)+ assertEqual [] (validateInMemoryBundle PermissiveConformance VersionUndeclared [concept, targetConcept targetId])+ where+ targetConcept targetId =+ conceptFromDocument+ targetId+ (OKFDocument (setType "Test" emptyFrontmatter) "# Orders\n")++-- | Specification §6.2: each path-valued field accepts an absolute URL, a+-- bundle-relative path beginning with @/@, or an ordinary relative path.+-- Classification is total, so a value that is none of the three lands on a named+-- alternative rather than being dropped.+testClassifyPathReference :: Either Text ()+testClassifyPathReference = do+ deep <- parseTestConceptId "metrics/finance/revenue"+ shallow <- parseTestConceptId "revenue"+ -- An absolute URL yields its case-folded scheme, whatever the scheme is: §6.2+ -- names no allowed set, so deciding that is the profile's job.+ assertEqual (ExternalUrl "https") (classifyPathReference deep "https://wiki.acme/revenue")+ assertEqual (ExternalUrl "mori") (classifyPathReference deep "mori://shinzui/okf")+ assertEqual (ExternalUrl "https") (classifyPathReference deep "HTTPS://wiki.acme/revenue")+ -- A leading slash resolves from the bundle root regardless of where the+ -- concept carrying the value lives.+ assertEqual (BundlePath "references/policy.md") (classifyPathReference deep "/references/policy.md")+ assertEqual (BundlePath "references/policy.md") (classifyPathReference shallow "/references/policy.md")+ -- A relative path resolves against the source concept's own directory.+ assertEqual+ (BundlePath "metrics/finance/policy.md")+ (classifyPathReference deep "policy.md")+ assertEqual+ (BundlePath "metrics/computations/revenue.md")+ (classifyPathReference deep "../computations/revenue.md")+ assertEqual (BundlePath "policy.md") (classifyPathReference shallow "./policy.md")+ -- A fragment or query suffix names the same target as the bare path.+ assertEqual (BundlePath "references/policy.md") (classifyPathReference deep "/references/policy.md#recognition")+ assertEqual (BundlePath "references/policy.md") (classifyPathReference deep "/references/policy.md?v=2")+ -- Non-Markdown targets classify identically; only the caller cares about the+ -- extension.+ assertEqual+ (BundlePath "references/attesters/revenue.py")+ (classifyPathReference deep "/references/attesters/revenue.py")+ -- Climbing above the bundle root is its own outcome, distinct from malformed:+ -- the author wrote a well-formed relative path that points outside.+ assertEqual EscapesBundle (classifyPathReference shallow "../../etc/passwd")+ assertEqual EscapesBundle (classifyPathReference deep "../../../../elsewhere.md")+ -- Text that names nothing at all.+ assertEqual MalformedPath (classifyPathReference deep "")+ assertEqual MalformedPath (classifyPathReference deep " ")+ assertEqual MalformedPath (classifyPathReference deep "#recognition")+ -- 'collapseBundlePath' is exported for the same reason it is shared: one+ -- spelling of "does this climb out of the bundle".+ assertEqual (Just "references/policy.md") (collapseBundlePath "references/./policy.md")+ assertEqual (Just "policy.md") (collapseBundlePath "references/../policy.md")+ assertEqual Nothing (collapseBundlePath "../policy.md")++-- | The seam 'Okf.Path' deliberately left open: classification says what shape a+-- value has, resolution says whether the thing it names is there. The predicate+-- stands in for a 'BundleInventory' so the two can be tested apart.+testResolvePathReference :: Either Text ()+testResolvePathReference = do+ deep <- parseTestConceptId "metrics/finance/revenue"+ shallow <- parseTestConceptId "revenue"+ let present =+ [ "references/policy.md",+ "references/attesters/revenue.py",+ "metrics/finance/policy.md",+ "sibling.md"+ ]+ exists = (`elem` present)+ resolve = resolvePathReference exists+ -- Every scheme counts, not only the three 'Okf.Graph.isExternalUrl' knows+ -- about, and okf never fetches any of them.+ assertEqual (ResolvedExternal "https") (resolve deep "https://wiki.acme/revenue")+ assertEqual (ResolvedExternal "bigquery") (resolve deep "bigquery://analytics.tables.orders")+ -- A leading slash resolves from the bundle root wherever the concept lives.+ assertEqual (ResolvedInBundle "references/policy.md") (resolve deep "/references/policy.md")+ assertEqual (ResolvedInBundle "references/policy.md") (resolve shallow "/references/policy.md")+ -- A relative path resolves against the carrying concept's own directory.+ assertEqual (ResolvedInBundle "metrics/finance/policy.md") (resolve deep "policy.md")+ assertEqual (ResolvedInBundle "sibling.md") (resolve shallow "./sibling.md")+ -- The case this whole plan exists for: a non-Markdown target resolves, which+ -- is only possible because the inventory sees more than concepts.+ assertEqual+ (ResolvedInBundle "references/attesters/revenue.py")+ (resolve deep "/references/attesters/revenue.py")+ -- Nothing there under that name.+ assertEqual (DanglingInBundle "references/deleted.md") (resolve deep "/references/deleted.md")+ assertEqual (DanglingInBundle "metrics/computations/revenue.md") (resolve deep "../computations/revenue.md")+ -- Distinct outcomes for a value that could never name anything in the bundle.+ assertEqual UnresolvableEscape (resolve shallow "../../etc/passwd")+ assertEqual UnresolvableMalformed (resolve deep "")+ assertEqual UnresolvableMalformed (resolve deep " ")++-- | The version-metadata lists name keys okf actually owns. Nothing else would+-- catch a typo in one of those string literals: a misspelled entry simply never+-- matches a rule, so the check it drives goes quietly missing.+testVersionedFieldsAreCoreFields :: Either Text ()+testVersionedFieldsAreCoreFields = do+ assertEqual [] (filter (`Set.notMember` coreFrontmatterFields) fieldsIntroducedInV02)+ assertEqual [] (filter (`Set.notMember` coreFrontmatterFields) fieldsSupersededInV02)+ -- The two lists are about different versions of the same key set and must not+ -- overlap: a key cannot be both introduced and superseded by v0.2.+ assertEqual [] (filter (`elem` fieldsSupersededInV02) fieldsIntroducedInV02)++-- | Specification §12: a known major with a higher minor is read as the highest+-- version okf understands within that major, because a minor bump is defined as+-- backward-compatible additions. An unknown major is read with no+-- version-specific rules at all.+testVersionGate :: Either Text ()+testVersionGate = do+ assertEqual Nothing (gateEffective (versionGate VersionUndeclared))+ assertEqual Nothing (gateEffective (versionGate (VersionUnparseable "zero point two")))+ assertEqual (Just (OkfVersion 0 1)) (gateEffective (versionGate (VersionDeclared (OkfVersion 0 1))))+ assertEqual (Just (OkfVersion 0 2)) (gateEffective (versionGate (VersionDeclared (OkfVersion 0 2))))+ assertEqual (Just (OkfVersion 0 2)) (gateEffective (versionGate (VersionDeclared (OkfVersion 0 3))))+ assertEqual Nothing (gateEffective (versionGate (VersionDeclared (OkfVersion 1 0))))+ assertEqual (Just "1.0") (gateNotUnderstood (versionGate (VersionDeclared (OkfVersion 1 0))))+ assertEqual Nothing (gateNotUnderstood (versionGate (VersionDeclared (OkfVersion 0 3))))+ assertBool "0.2 satisfies at-least 0.2" (gateDeclaresAtLeast (OkfVersion 0 2) (versionGate (VersionDeclared (OkfVersion 0 2))))+ assertBool "0.1 does not satisfy at-least 0.2" (not (gateDeclaresAtLeast (OkfVersion 0 2) (versionGate (VersionDeclared (OkfVersion 0 1)))))+ assertBool "undeclared satisfies nothing" (not (gateDeclaresAtLeast (OkfVersion 0 2) (versionGate VersionUndeclared)))++-- | The asymmetry this plan exists for: the v0.1 fallback stays unconditional,+-- but a bundle that has declared v0.2 and still carries @timestamp@ is+-- reporting an authoring mistake.+testLegacyFieldInDeclaredV2 :: Either Text ()+testLegacyFieldInDeclaredV2 = do+ conceptId <- parseTestConceptId "tables/orders"+ legacyOnly <-+ testConceptWithFrontmatter+ "tables/orders"+ "type: Test\ntitle: Title\ndescription: Description\ntimestamp: \"2026-06-16T00:00:00Z\"\n"+ migrated <-+ testConceptWithFrontmatter+ "tables/orders"+ "type: Test\ntitle: Title\ndescription: Description\ngenerated:\n by: okf/0.4\n at: \"2026-06-16T00:00:00Z\"\n"+ let declaredV2 = VersionDeclared (OkfVersion 0 2)+ assertEqual [] (validateInMemoryBundle StrictAuthoring VersionUndeclared [legacyOnly])+ assertEqual [] (validateInMemoryBundle PermissiveConformance declaredV2 [legacyOnly])+ assertEqual+ [DocumentInvalid conceptId (LegacyFieldInDeclaredV2 "timestamp")]+ (validateInMemoryBundle StrictAuthoring declaredV2 [legacyOnly])+ assertEqual [] (validateInMemoryBundle StrictAuthoring declaredV2 [migrated])+ -- An unknown major applies no version-specific rule, so the same document is+ -- read the way an undeclared bundle's would be.+ assertEqual+ [BundleVersionNotUnderstood "1.0"]+ (validateInMemoryBundle StrictAuthoring (VersionDeclared (OkfVersion 1 0)) [legacyOnly])++-- | §12: "Consumers that do not understand the declared version SHOULD attempt+-- best-effort consumption rather than refusing the bundle." Neither an+-- unreadable value nor an unknown major stops the bundle being read, and+-- neither is reported outside strict authoring.+testVersionDeclarationLints :: Either Text ()+testVersionDeclarationLints = do+ -- A migrated concept, so the only diagnostics here are version ones.+ concept <-+ testConceptWithFrontmatter+ "a"+ "type: Test\ntitle: Title\ndescription: Description\ngenerated:\n by: okf/0.4\n at: \"2026-06-16T00:00:00Z\"\n"+ assertEqual [] (validateInMemoryBundle PermissiveConformance (VersionUnparseable "0.x") [concept])+ assertEqual [] (validateInMemoryBundle PermissiveConformance (VersionDeclared (OkfVersion 1 0)) [concept])+ assertEqual+ [BundleVersionUnparseable "0.x"]+ (validateInMemoryBundle StrictAuthoring (VersionUnparseable "0.x") [concept])+ assertEqual+ [BundleVersionNotUnderstood "1.0"]+ (validateInMemoryBundle StrictAuthoring (VersionDeclared (OkfVersion 1 0)) [concept])+ -- A higher minor within a known major is a supported case, not a problem.+ assertEqual [] (validateInMemoryBundle StrictAuthoring (VersionDeclared (OkfVersion 0 3)) [concept])++testValidateBundleDanglingReference :: Either Text ()+testValidateBundleDanglingReference = do+ aId <- parseTestConceptId "a"+ bId <- parseTestConceptId "b"+ conceptA <- testConcept "a" ("See " <> renderConceptLink bId "b" <> ".\n")+ assertEqual [DanglingReference aId bId] (validateInMemoryBundle StrictAuthoring VersionUndeclared [conceptA])++testValidateBundleAcceptsResolved :: Either Text ()+testValidateBundleAcceptsResolved = do+ bId <- parseTestConceptId "b"+ conceptA <- testConcept "a" ("See " <> renderConceptLink bId "b" <> ".\n")+ conceptB <- testConcept "b" "Standalone.\n"+ assertEqual [] (validateInMemoryBundle StrictAuthoring VersionUndeclared [conceptA, conceptB])++-- | The §6.2 path check, at the level where its placement decisions live: strict+-- only, dangling only, and never over @sources[].resource@.+testValidateBundleDanglingFrontmatterPath :: Either Text ()+testValidateBundleDanglingFrontmatterPath = do+ aId <- parseTestConceptId "a"+ conceptB <- testConcept "b" "Standalone.\n"+ let withResource value =+ testConceptWithFrontmatter+ "a"+ ( "type: Test\ntitle: Title\ndescription: Description\n"+ <> "generated:\n by: okf/0.4\n at: \"2026-06-16T00:00:00Z\"\n"+ <> "resource: "+ <> value+ <> "\n"+ )+ strict concepts = validateInMemoryBundle StrictAuthoring VersionUndeclared concepts+ permissive concepts = validateInMemoryBundle PermissiveConformance VersionUndeclared concepts+ danglingResource <- withResource "/references/deleted.md"+ resolvedResource <- withResource "/b.md"+ externalResource <- withResource "bigquery://analytics.tables.orders"+ escapingResource <- withResource "../../elsewhere.md"+ assertEqual+ [DanglingFrontmatterPath aId "resource" "references/deleted.md" Nothing]+ (strict [danglingResource, conceptB])+ -- §11 forbids rejecting a bundle over a broken cross-link, so nothing is+ -- reported outside strict authoring.+ assertEqual [] (permissive [danglingResource, conceptB])+ assertEqual [] (strict [resolvedResource, conceptB])+ -- okf has no network access and never fetches, so an absolute URL is as+ -- resolved as it gets — whatever its scheme.+ assertEqual [] (strict [externalResource, conceptB])+ -- Escaping is deliberately unreported: a bare §4.1 URI such as+ -- @analytics.tables.orders@ classifies as a bundle path, so reporting any+ -- outcome other than dangling would fire on correct documents.+ assertEqual [] (strict [escapingResource, conceptB])+ -- The finding that scoped this check: §5.1 sanctions a scope descriptor as a+ -- @sources[].resource@, and @examples\/ddd-ordering@ carries one. Treating it+ -- as a path would report a correct bundle as broken.+ scopeDescriptor <-+ testConceptWithFrontmatter+ "a"+ ( "type: Test\ntitle: Title\ndescription: Description\n"+ <> "generated:\n by: okf/0.4\n at: \"2026-06-16T00:00:00Z\"\n"+ <> "sources:\n"+ <> " - resource: all order-domain terms agreed in the ordering team's glossary reviews\n"+ )+ assertEqual [] (strict [scopeDescriptor, conceptB])++-- | The bundle-relative hint of specification §10.2's own worked example: a+-- relative path that resolves to nothing, where the same text read from the+-- bundle root names a file that is there.+--+-- §6.2 resolution is unchanged — the diagnostic still names what the value+-- actually resolved to. The fourth field only says what the author probably+-- meant.+testDanglingFrontmatterPathAlternative :: Either Text ()+testDanglingFrontmatterPathAlternative = do+ computationId <- parseTestConceptId "computations/revenue"+ rootId <- parseTestConceptId "revenue"+ skill <- testConcept "references/skills/run-on-bq" "Run instructions.\n"+ let withResource rawId value =+ testConceptWithFrontmatter+ rawId+ ( "type: Test\ntitle: Title\ndescription: Description\n"+ <> "generated:\n by: okf/0.4\n at: \"2026-06-16T00:00:00Z\"\n"+ <> "resource: "+ <> value+ <> "\n"+ )+ strict concepts = validateInMemoryBundle StrictAuthoring VersionUndeclared concepts+ -- The specification's own spelling, from a concept in a subdirectory.+ bareReference <- withResource "computations/revenue" "references/skills/run-on-bq.md"+ assertEqual+ [ DanglingFrontmatterPath+ computationId+ "resource"+ "computations/references/skills/run-on-bq.md"+ (Just "references/skills/run-on-bq.md")+ ]+ (strict [bareReference, skill])+ -- No root-anchored reading resolves either, so there is nothing to suggest.+ noTwin <- withResource "computations/revenue" "references/skills/nonexistent.md"+ assertEqual+ [ DanglingFrontmatterPath+ computationId+ "resource"+ "computations/references/skills/nonexistent.md"+ Nothing+ ]+ (strict [noTwin, skill])+ -- A value already written from the bundle root has no alternative reading.+ alreadyAnchored <- withResource "computations/revenue" "/references/skills/deleted.md"+ assertEqual+ [DanglingFrontmatterPath computationId "resource" "references/skills/deleted.md" Nothing]+ (strict [alreadyAnchored, skill])+ -- A concept at the bundle root resolves both readings to the same path, so+ -- there is never a hint: either the file is there and nothing is reported, or+ -- it is not and the alternative names the same missing path. The guard in+ -- 'bundleRelativeAlternative' is what keeps the second case from suggesting+ -- exactly what it just rejected.+ atRoot <- withResource "revenue" "references/skills/run-on-bq.md"+ assertEqual+ [DanglingFrontmatterPath rootId "resource" "references/skills/run-on-bq.md" Nothing]+ (strict [atRoot])+ assertEqual [] (strict [atRoot, skill])++testDuplicateConceptIds :: Either Text ()+testDuplicateConceptIds = do+ aId <- parseTestConceptId "a"+ conceptA <- testConcept "a" "First.\n"+ conceptAAgain <- testConcept "a" "Second.\n"+ assertEqual [aId] (duplicateConceptIds [conceptA, conceptAAgain])++-- | Build an in-memory concept via the public 'conceptFromDocument' constructor,+-- so its typed fields are derived from the frontmatter and cannot diverge.+-- Includes all StrictAuthoring fields so per-document validation passes and+-- bundle-level checks can be isolated.+testConcept :: Text -> Text -> Either Text Concept+testConcept rawId bodyText = do+ conceptId <- parseTestConceptId rawId+ let frontmatterValue =+ okfCommon+ OkfCommon+ { commonType = "Test",+ commonTitle = Just "Title",+ commonDescription = Just "Description",+ commonTimestamp = Just "2026-06-16T00:00:00Z"+ }+ pure (conceptFromDocument conceptId (OKFDocument frontmatterValue bodyText))++-- | Build a concept from raw frontmatter text, for cases where the frontmatter+-- carries families the 'OkfCommon' builder does not cover.+testConceptWithFrontmatter :: Text -> Text -> Either Text Concept+testConceptWithFrontmatter rawId frontmatterText = do+ conceptId <- parseTestConceptId rawId+ document <- firstShow (parseDocument ("---\n" <> frontmatterText <> "---\n\n# Test\n"))+ pure (conceptFromDocument conceptId document)++testConceptWithTimestamp :: Text -> Text -> Either Text Concept+testConceptWithTimestamp rawId timestamp = do+ conceptId <- parseTestConceptId rawId+ let frontmatterValue =+ okfCommon+ OkfCommon+ { commonType = "Test",+ commonTitle = Just "Title",+ commonDescription = Just "Description",+ commonTimestamp = Just timestamp+ }+ pure (conceptFromDocument conceptId (OKFDocument frontmatterValue "# Test\n"))++testConceptGeneratedProjection :: Either Text ()+testConceptGeneratedProjection = do+ conceptId <- parseTestConceptId "tables/orders"+ document <-+ firstShow+ ( parseDocument+ "---\ntype: BigQuery Table\ngenerated: { by: human:ahormati, at: 2026-06-20T22:53:05Z }\n---\n\n# Orders\n"+ )+ let concept = conceptFromDocument conceptId document+ assertEqual+ (Just (Generated (HumanActor "ahormati") (Just "2026-06-20T22:53:05Z")))+ (conceptGenerated concept)++testReadGeneratedWithoutActor :: Either Text ()+testReadGeneratedWithoutActor = do+ -- Section 5.2 makes `by` REQUIRED within `generated`, so a mapping without+ -- one is not a Generated. Reading is silent; reporting is validation's job.+ withoutBy <- firstShow (parseDocument "---\ntype: Recipe\ngenerated: { at: 2026-06-20T22:53:05Z }\n---\nBody\n")+ assertEqual Nothing (readGenerated (withoutBy ^. #frontmatter))+ notAMapping <- firstShow (parseDocument "---\ntype: Recipe\ngenerated: human:ahormati\n---\nBody\n")+ assertEqual Nothing (readGenerated (notAMapping ^. #frontmatter))+ -- `at` is not required within the mapping.+ withoutAt <- firstShow (parseDocument "---\ntype: Recipe\ngenerated: { by: reference_agent/gemini-2.5-pro }\n---\nBody\n")+ assertEqual+ (Just (Generated (ProducerActor "reference_agent" "gemini-2.5-pro") Nothing))+ (readGenerated (withoutAt ^. #frontmatter))++-- | Specification section 5.2 permits `verified` as a list or as a single bare+-- `{ by, at }` mapping, and section 11 makes normalising the bare form to a+-- one-element list a consumer MUST.+testReadVerifiedShapes :: Either Text ()+testReadVerifiedShapes = do+ let verifiedIn source = readVerified . (^. #frontmatter) <$> firstShow (parseDocument source)+ asList <-+ verifiedIn+ "---\ntype: Recipe\nverified:\n - { by: human:ahormati, at: 2026-06-25T09:00:00Z }\n - { by: process:finance-nightly, at: 2026-06-26T02:00:00Z }\n---\nBody\n"+ assertEqual+ [ Verification (HumanActor "ahormati") (Just "2026-06-25T09:00:00Z"),+ Verification (ProcessActor "finance-nightly") (Just "2026-06-26T02:00:00Z")+ ]+ asList+ bareMapping <- verifiedIn "---\ntype: Recipe\nverified: { by: human:ahormati, at: 2026-06-25T09:00:00Z }\n---\nBody\n"+ assertEqual [Verification (HumanActor "ahormati") (Just "2026-06-25T09:00:00Z")] bareMapping+ absent <- verifiedIn "---\ntype: Recipe\n---\nBody\n"+ assertEqual [] absent+ -- An entry without the `by` actor is skipped rather than yielding a partial+ -- Verification, mirroring readGenerated.+ partial <- verifiedIn "---\ntype: Recipe\nverified:\n - { at: 2026-06-25T09:00:00Z }\n - { by: human:ahormati }\n---\nBody\n"+ assertEqual [Verification (HumanActor "ahormati") Nothing] partial+ notAMapping <- verifiedIn "---\ntype: Recipe\nverified: human:ahormati\n---\nBody\n"+ assertEqual [] notAMapping++testVerifiedRoundTrip :: Either Text ()+testVerifiedRoundTrip = do+ let verifications =+ [ Verification (HumanActor "ahormati") (Just "2026-06-25T09:00:00Z"),+ Verification (ProcessActor "finance-nightly") Nothing+ ]+ original = OKFDocument (setVerified verifications (setType "Recipe" emptyFrontmatter)) "# Demo\n"+ rendered = serializeDocument original+ reparsed <- firstShow (parseDocument rendered)+ assertEqual verifications (readVerified (reparsed ^. #frontmatter))+ -- A single entry is still written as a list, not as the bare-mapping form.+ let single = OKFDocument (setVerified [Verification (HumanActor "ahormati") Nothing] emptyFrontmatter) "# Demo\n"+ assertBool+ ("single entry not written as a list: " <> serializeDocument single)+ (isJust (substringIndex "- by:" (serializeDocument single)))++testReadStatus :: Either Text ()+testReadStatus = do+ let statusIn source = readStatus . (^. #frontmatter) <$> firstShow (parseDocument source)+ withStatus value = statusIn ("---\ntype: Recipe\nstatus: " <> value <> "\n---\nBody\n")+ for_+ [("draft", Draft), ("stable", Stable), ("deprecated", Deprecated)]+ (\(text, expected) -> withStatus text >>= assertEqual expected)+ -- Section 5.4: "Absent `status` => `stable`."+ absent <- statusIn "---\ntype: Recipe\n---\nBody\n"+ assertEqual Stable absent+ -- Section 11 forbids rejecting for an unexpected optional value, and the+ -- original text must survive so renderStatus reproduces it.+ unknown <- withStatus "archived"+ assertEqual (UnknownStatus "archived") unknown+ assertEqual "archived" (renderStatus unknown)+ -- Case-sensitive, consistent with the section 7 actor convention.+ wrongCase <- withStatus "Stable"+ assertEqual (UnknownStatus "Stable") wrongCase+ -- renderStatus inverts readStatus on every value it can read back.+ for_+ [Draft, Stable, Deprecated, UnknownStatus "archived"]+ (\value -> withStatus (renderStatus value) >>= assertEqual value)++testReadStaleAfter :: Either Text ()+testReadStaleAfter = do+ let staleAfterIn source = readStaleAfter . (^. #frontmatter) <$> firstShow (parseDocument source)+ present <- staleAfterIn "---\ntype: Recipe\nstale_after: 2026-09-23\n---\nBody\n"+ assertEqual (Just "2026-09-23") present+ absent <- staleAfterIn "---\ntype: Recipe\n---\nBody\n"+ assertEqual Nothing absent+ -- Read verbatim and not parsed here: a malformed date must survive to be+ -- reported by Okf.Trust.staleness rather than vanish on serialization.+ malformed <- staleAfterIn "---\ntype: Recipe\nstale_after: not-a-date\n---\nBody\n"+ assertEqual (Just "not-a-date") malformed+ let built = setStaleAfter "2026-09-23" (setStatus Deprecated emptyFrontmatter)+ reparsed <- firstShow (parseDocument (serializeDocument (OKFDocument built "# Demo\n")))+ assertEqual (Just "2026-09-23") (readStaleAfter (reparsed ^. #frontmatter))+ assertEqual Deprecated (readStatus (reparsed ^. #frontmatter))++testTrustTier :: Either Text ()+testTrustTier = do+ let verifiedBy actor = Verification (parseActor actor) (Just "2026-06-25T09:00:00Z")+ -- Section 5.3: "No `verified` key => unverified."+ assertEqual Unverified (trustTier [])+ -- "`verified` by non-`human:` actors only => machine-confirmed."+ assertEqual MachineConfirmed (trustTier [verifiedBy "process:finance-nightly"])+ assertEqual MachineConfirmed (trustTier [verifiedBy "reference_agent/gemini-2.5-pro"])+ -- An actor matching none of the three section 7 shapes is still not a human.+ assertEqual MachineConfirmed (trustTier [verifiedBy "something"])+ -- "`verified` by a `human:<id>` actor => human-reviewed", including mixed.+ assertEqual HumanReviewed (trustTier [verifiedBy "human:ahormati"])+ assertEqual+ HumanReviewed+ (trustTier [verifiedBy "process:finance-nightly", verifiedBy "human:ahormati"])+ -- The Ord instance runs lowest to highest, as section 5.3 presents them.+ assertBool "tiers ordered lowest to highest" (Unverified < MachineConfirmed && MachineConfirmed < HumanReviewed)++testLatestVerification :: Either Text ()+testLatestVerification = do+ -- Section 5.2: "'How recently' is the latest `at`."+ assertEqual+ (Just "2026-06-26T02:00:00Z")+ ( latestVerification+ [ Verification (HumanActor "ahormati") (Just "2026-06-25T09:00:00Z"),+ Verification (ProcessActor "finance-nightly") (Just "2026-06-26T02:00:00Z")+ ]+ )+ -- Entries without an `at` are skipped rather than losing the whole result.+ assertEqual+ (Just "2026-06-25T09:00:00Z")+ ( latestVerification+ [ Verification (ProcessActor "nightly") Nothing,+ Verification (HumanActor "ahormati") (Just "2026-06-25T09:00:00Z")+ ]+ )+ assertEqual Nothing (latestVerification [])+ assertEqual Nothing (latestVerification [Verification (HumanActor "ahormati") Nothing])++testStaleness :: Either Text ()+testStaleness = do+ let today = fromGregorian 2026 6 15+ assertEqual NoStaleAfter (staleness today Nothing)+ -- Section 5.5: "A concept is stale when `today >= stale_after`."+ assertEqual (Stale (fromGregorian 2026 6 1)) (staleness today (Just "2026-06-01"))+ -- The boundary: equal to today is stale, not fresh. An off-by-one here is a+ -- real bug, so it is asserted explicitly.+ assertEqual (Stale (fromGregorian 2026 6 15)) (staleness today (Just "2026-06-15"))+ assertEqual Fresh (staleness today (Just "2026-06-16"))+ assertEqual Fresh (staleness today (Just "2026-09-23"))+ -- A malformed deadline is surfaced, never silently treated as fresh.+ assertEqual (StaleAfterUnparseable "not-a-date") (staleness today (Just "not-a-date"))+ assertEqual (StaleAfterUnparseable "2026-13-01") (staleness today (Just "2026-13-01"))+ assertEqual "stale since 2026-06-01" (renderStaleness (staleness today (Just "2026-06-01")))+ assertEqual "ok" (renderStaleness (staleness today (Just "2026-09-23")))++-- | A document exercising both usage-window scopes, both credibility-signal+-- shapes, a scope-descriptor resource, and an entry missing the one key+-- section 5.1 requires within an entry.+sourcesFixtureDocument :: Text+sourcesFixtureDocument =+ Text.unlines+ [ "---",+ "type: BigQuery Table",+ "sources:",+ " - id: ga4-schema",+ " resource: https://developers.google.com/analytics/bigquery/export-schema",+ " title: GA4 BigQuery Export schema",+ " author: team:ga4-docs",+ " usage_count: 5000",+ " last_modified: 2026-05-30",+ " - id: exec-dash",+ " resource: dashboards/exec-revenue",+ " usage_count: 12",+ " usage_window: { from: 2026-01-01, to: 2026-01-31 }",+ " - id: broad-scope",+ " resource: all queries in BigQuery project X",+ " usage_count: \"5000\"",+ " - id: no-resource",+ " title: Missing the required key",+ "usage_window: { from: 2026-06-01, to: 2026-06-30 }",+ "---",+ "",+ "# Orders"+ ]++-- | Specification §5.1 makes the footnote label the join key into @sources@, so+-- strict validation checks it in both directions: a label naming no entry is a+-- defect, and an entry whose id nothing cites is a lint. Neither fires in+-- permissive mode, because §11 forbids rejecting a bundle over optional+-- frontmatter.+testFootnoteAttributionJoin :: Either Text ()+testFootnoteAttributionJoin = do+ let document mistyped =+ Text.unlines+ [ "---",+ "type: BigQuery Table",+ "title: Orders",+ "description: Order fact table.",+ "generated: { by: okf-agent/1.0, at: 2026-07-31T00:00:00Z }",+ "sources:",+ " - id: ga4-schema",+ " resource: https://developers.google.com/analytics/bigquery/export-schema",+ " - id: uncited-policy",+ " resource: https://wiki.acme/finance/revenue-recognition",+ "---",+ "",+ "Sharded daily.[^" <> mistyped <> "]",+ "",+ "[^" <> mistyped <> "]: GA4 BigQuery Export schema"+ ]+ mistypedDocument <- firstShow (parseDocument (document "ga4-schmea"))+ assertEqual+ [ FootnoteLabelNotInSources "ga4-schmea",+ SourceIdNotCited "ga4-schema",+ SourceIdNotCited "uncited-policy"+ ]+ (validateDocument StrictAuthoring mistypedDocument)+ assertEqual [] (validateDocument PermissiveConformance mistypedDocument)+ -- Correcting the citation clears the defect and one of the two lints.+ correctedDocument <- firstShow (parseDocument (document "ga4-schema"))+ assertEqual+ [SourceIdNotCited "uncited-policy"]+ (validateDocument StrictAuthoring correctedDocument)++-- | Markdown footnotes are ordinary prose used for ordinary purposes. A document+-- that has not opted into structured provenance is making no attribution claim,+-- so a body full of footnotes must report nothing in either mode.+testFootnoteAttributionSkippedWithoutSources :: Either Text ()+testFootnoteAttributionSkippedWithoutSources = do+ document <-+ firstShow+ ( parseDocument+ ( Text.unlines+ [ "---",+ "type: BigQuery Table",+ "title: Orders",+ "description: Order fact table.",+ "generated: { by: okf-agent/1.0, at: 2026-07-31T00:00:00Z }",+ "---",+ "",+ "An aside.[^aside] Another.[^undefined]",+ "",+ "[^aside]: just a footnote, not an attribution"+ ]+ )+ )+ assertEqual [] (validateDocument StrictAuthoring document)+ assertEqual [] (validateDocument PermissiveConformance document)++testReadSources :: Either Text ()+testReadSources = do+ document <- firstShow (parseDocument sourcesFixtureDocument)+ let sources = readSources (document ^. #frontmatter)+ -- The entry with no `resource` is skipped: section 5.1 makes it REQUIRED+ -- within an entry, and reporting it is validation's job, not the reader's.+ assertEqual [Just "ga4-schema", Just "exec-dash", Just "broad-scope"] (map sourceId sources)+ case sources of+ (first_ : _) -> do+ assertEqual "https://developers.google.com/analytics/bigquery/export-schema" (sourceResource first_)+ assertEqual (Just "GA4 BigQuery Export schema") (sourceTitle first_)+ -- `author` uses the section 7 actor convention; `team:ga4-docs` matches+ -- none of the three shapes, so it stays unclassified rather than failing.+ assertEqual (Just (UnclassifiedActor "team:ga4-docs")) (sourceAuthor first_)+ assertEqual (Just 5000) (sourceUsageCount first_)+ assertEqual (Just "2026-05-30") (sourceLastModified first_)+ [] -> Left "expected sources"+ -- Section 5.1 permits a resource to be a population or scope descriptor no+ -- consumer can follow. It must read cleanly and never be treated as a path.+ assertEqual+ (Just "all queries in BigQuery project X")+ (sourceResource <$> List.find ((== Just "broad-scope") . sourceId) sources)+ -- A numeric string is not an integer. Reading it as one would make the+ -- field's type unpredictable and hide a producer mistake.+ assertEqual+ (Just Nothing)+ (sourceUsageCount <$> List.find ((== Just "broad-scope") . sourceId) sources)+ absent <- firstShow (parseDocument "---\ntype: Recipe\n---\nBody\n")+ assertEqual [] (readSources (absent ^. #frontmatter))++testUsageWindowOverride :: Either Text ()+testUsageWindowOverride = do+ document <- firstShow (parseDocument sourcesFixtureDocument)+ let documentWindow = readUsageWindow (document ^. #frontmatter)+ sources = readSources (document ^. #frontmatter)+ windowFor entryId =+ effectiveUsageWindow documentWindow <$> List.find ((== Just entryId) . sourceId) sources+ assertEqual (Just (UsageWindow (Just "2026-06-01") (Just "2026-06-30"))) documentWindow+ -- An entry with no window of its own inherits the document-scope one...+ assertEqual (Just (Just (UsageWindow (Just "2026-06-01") (Just "2026-06-30")))) (windowFor "ga4-schema")+ -- ...and an entry carrying its own overrides it. Two different windows in one+ -- document is the section 5.1 override rule working.+ assertEqual (Just (Just (UsageWindow (Just "2026-01-01") (Just "2026-01-31")))) (windowFor "exec-dash")+ -- With no window at either scope there is nothing to frame a count with.+ noWindow <- firstShow (parseDocument "---\ntype: Recipe\nsources:\n - resource: https://example.com/a\n---\nBody\n")+ let noWindowSources = readSources (noWindow ^. #frontmatter)+ assertEqual Nothing (readUsageWindow (noWindow ^. #frontmatter))+ assertEqual [Nothing] (effectiveUsageWindow Nothing <$> noWindowSources)++testSourcesRoundTrip :: Either Text ()+testSourcesRoundTrip = do+ let sources =+ [ Source+ { sourceId = Just "ga4-schema",+ sourceResource = "https://developers.google.com/analytics/bigquery/export-schema",+ sourceTitle = Just "GA4 BigQuery Export schema",+ sourceAuthor = Just (parseActor "human:ahormati"),+ sourceUsageCount = Just 5000,+ sourceLastModified = Just "2026-05-30",+ sourceUsageWindow = Nothing+ },+ -- Every optional key absent: these must be omitted on write, not+ -- written as explicit nulls, so the round-trip is lossless.+ Source+ { sourceId = Nothing,+ sourceResource = "all queries in BigQuery project X",+ sourceTitle = Nothing,+ sourceAuthor = Nothing,+ sourceUsageCount = Nothing,+ sourceLastModified = Nothing,+ sourceUsageWindow = Just (UsageWindow (Just "2026-01-01") Nothing)+ }+ ]+ window = UsageWindow (Just "2026-06-01") (Just "2026-06-30")+ built = setUsageWindow window (setSources sources (setType "BigQuery Table" emptyFrontmatter))+ reparsed <- firstShow (parseDocument (serializeDocument (OKFDocument built "# Orders\n")))+ assertEqual sources (readSources (reparsed ^. #frontmatter))+ assertEqual (Just window) (readUsageWindow (reparsed ^. #frontmatter))++-- | Specification §10.2's worked example, verbatim. Flow-style mappings and a+-- flow-style @receipt@ list are exactly how the specification writes it, which+-- is why they are here: a reader that only handles block style would pass a+-- hand-normalized fixture and fail on the document an author copied out of §10.+attestedComputationFixtureDocument :: Text+attestedComputationFixtureDocument =+ Text.unlines+ [ "---",+ "type: Attested Computation",+ "title: Revenue for fiscal year",+ "description: Recognized revenue for a fiscal year, per Finance's definition.",+ "status: stable",+ "runtime: bigquery",+ "parameters:",+ " - { name: year, type: integer, required: true }",+ "executor:",+ " resource: references/skills/run-on-bq.md",+ " receipt: [job_id, executed_sql, result]",+ "attester:",+ " resource: references/attesters/revenue.py",+ "generated: { by: reference_agent/gemini-2.5-pro, at: 2026-06-20T22:53:05Z }",+ "verified: { by: human:ahormati, at: 2026-06-25T09:00:00Z }",+ "stale_after: 2026-09-23",+ "sources:",+ " - id: rev-policy",+ " resource: https://wiki.acme/finance/revenue-recognition",+ " title: Revenue recognition policy",+ "---",+ "",+ "# Computation",+ "",+ " SELECT SUM(amount) AS revenue FROM finance.recognized_revenue WHERE fiscal_year = @year"+ ]++-- | The five §10.2 contract fields read off the specification's own worked+-- example. The trust and provenance families in the same frontmatter block are+-- asserted too: §10.2 puts them there deliberately, and reading the contract+-- must not disturb them.+testReadAttestedComputationContract :: Either Text ()+testReadAttestedComputationContract = do+ document <- firstShow (parseDocument attestedComputationFixtureDocument)+ let frontmatterValue = document ^. #frontmatter+ assertEqual (Just "bigquery") (readRuntime frontmatterValue)+ assertEqual+ [Parameter {parameterName = "year", parameterType = Just "integer", parameterRequired = Just True}]+ (readParameters frontmatterValue)+ -- Absent: §10.3 says an absent `computation` means the body fence is the+ -- computation. Reading the body is a sibling plan's job.+ assertEqual Nothing (readComputation frontmatterValue)+ assertEqual+ ( Just+ Executor+ { executorResource = Just "references/skills/run-on-bq.md",+ executorReceipt = ["job_id", "executed_sql", "result"]+ }+ )+ (readExecutor frontmatterValue)+ assertEqual (Just (Attester (Just "references/attesters/revenue.py"))) (readAttester frontmatterValue)+ -- The §5 families sharing the block still read exactly as before.+ assertEqual+ (Just (Generated (parseActor "reference_agent/gemini-2.5-pro") (Just "2026-06-20T22:53:05Z")))+ (readGenerated frontmatterValue)+ assertEqual [Verification (parseActor "human:ahormati") (Just "2026-06-25T09:00:00Z")] (readVerified frontmatterValue)+ assertEqual Stable (readStatus frontmatterValue)+ assertEqual (Just "2026-09-23") (readStaleAfter frontmatterValue)+ assertEqual [Just "rev-policy"] (map sourceId (readSources frontmatterValue))++-- | Every degenerate contract shape yields a value rather than an error.+-- Specification §11 forbids rejecting a document for a malformed optional+-- field, so the readers are total and reporting is validation's job.+testReadAttestedComputationDegenerateShapes :: Either Text ()+testReadAttestedComputationDegenerateShapes = do+ let readAll source = do+ document <- firstShow (parseDocument source)+ pure (document ^. #frontmatter)+ -- Nothing declared at all.+ bare <- readAll "---\ntype: Attested Computation\n---\nBody\n"+ assertEqual Nothing (readRuntime bare)+ assertEqual [] (readParameters bare)+ assertEqual Nothing (readComputation bare)+ assertEqual Nothing (readExecutor bare)+ assertEqual Nothing (readAttester bare)+ -- `parameters` present but not a list, and `runtime` present but not text.+ wrongShapes <-+ readAll+ ( Text.unlines+ [ "---",+ "type: Attested Computation",+ "runtime: { name: bigquery }",+ "parameters: year",+ "computation: [a, b]",+ "---",+ "Body"+ ]+ )+ assertEqual Nothing (readRuntime wrongShapes)+ assertEqual [] (readParameters wrongShapes)+ assertEqual Nothing (readComputation wrongShapes)+ -- An entry with no `name` names no hole and is dropped, exactly as+ -- `readSources` drops an entry with no `resource`. A `required` written as a+ -- string is not a boolean, mirroring `usage_count`'s refusal of "5000".+ partialEntries <-+ readAll+ ( Text.unlines+ [ "---",+ "type: Attested Computation",+ "parameters:",+ " - { type: integer, required: true }",+ " - { name: year }",+ " - { name: region, type: string, required: \"true\" }",+ " - not-a-mapping",+ "---",+ "Body"+ ]+ )+ assertEqual+ [ Parameter {parameterName = "year", parameterType = Nothing, parameterRequired = Nothing},+ Parameter {parameterName = "region", parameterType = Just "string", parameterRequired = Nothing}+ ]+ (readParameters partialEntries)+ -- `executor` as a scalar is not a mapping and is not read; a `receipt`+ -- written as a bare string is read as a one-element list, mirroring how §5.2+ -- tolerates a bare `verified` mapping where a list is expected.+ scalarExecutor <- readAll "---\ntype: Attested Computation\nexecutor: run-on-bq\nattester: revenue.py\n---\nBody\n"+ assertEqual Nothing (readExecutor scalarExecutor)+ assertEqual Nothing (readAttester scalarExecutor)+ bareReceipt <-+ readAll+ ( Text.unlines+ [ "---",+ "type: Attested Computation",+ "executor: { receipt: job_id }",+ "attester: { note: no resource here }",+ "---",+ "Body"+ ]+ )+ assertEqual+ (Just Executor {executorResource = Nothing, executorReceipt = ["job_id"]})+ (readExecutor bareReceipt)+ -- An `attester` mapping with no `resource` still reads: "declared badly" and+ -- "not declared" are different facts and only the reader can keep them apart.+ assertEqual (Just (Attester Nothing)) (readAttester bareReceipt)++-- | The §10.2 worked example survives serialization losslessly, and the+-- normalized form emits the five contract keys in their fixed+-- 'coreFrontmatterFieldOrder' position — between the lifecycle @status@ and the+-- trust @generated@, which is §10.2's own ordering.+--+-- Byte-identity is asserted against the /normalized/ form rather than against+-- the specification's text, because §10.2 writes flow-style mappings that+-- 'serializeDocument' expands to block style by design. What this pins is that+-- serializing is a fixed point: a bundle regenerated twice yields no diff.+testAttestedComputationRoundTrip :: Either Text ()+testAttestedComputationRoundTrip = do+ document <- firstShow (parseDocument attestedComputationFixtureDocument)+ let normalized = serializeDocument document+ reparsed <- firstShow (parseDocument normalized)+ assertEqual normalized (serializeDocument reparsed)+ -- No contract value was normalized away or rewritten on the way through.+ assertEqual (readRuntime (document ^. #frontmatter)) (readRuntime (reparsed ^. #frontmatter))+ assertEqual (readParameters (document ^. #frontmatter)) (readParameters (reparsed ^. #frontmatter))+ assertEqual (readComputation (document ^. #frontmatter)) (readComputation (reparsed ^. #frontmatter))+ assertEqual (readExecutor (document ^. #frontmatter)) (readExecutor (reparsed ^. #frontmatter))+ assertEqual (readAttester (document ^. #frontmatter)) (readAttester (reparsed ^. #frontmatter))+ assertEqual (body document) (body reparsed)+ assertEqual+ [ "type",+ "title",+ "description",+ "status",+ "runtime",+ "parameters",+ "executor",+ "attester",+ "generated",+ "verified",+ "stale_after",+ "sources"+ ]+ (topLevelKeysInEmissionOrder normalized)++-- | §10.3's two forms, read off one document each and then off a document that+-- wrongly offers both. The reader restates and never enforces, so the+-- both-forms document yields two entries rather than a failure; reporting that+-- is 'validateDocument''s job.+testReadComputationSources :: Either Text ()+testReadComputationSources = do+ -- The specification's own worked example: no `computation` key, one indented+ -- block under `# Computation`.+ inline <- firstShow (parseDocument attestedComputationFixtureDocument)+ assertEqual+ [ComputationInline "SELECT SUM(amount) AS revenue FROM finance.recognized_revenue WHERE fiscal_year = @year\n"]+ (readComputationSources inline)+ byFile <-+ firstShow+ ( parseDocument+ ( Text.unlines+ [ "---",+ "type: Attested Computation",+ "computation: /references/revenue.sql",+ "---",+ "",+ "# Notes",+ "",+ "The computation lives in a file, so this body carries no block."+ ]+ )+ )+ assertEqual [ComputationFile "/references/revenue.sql"] (readComputationSources byFile)+ both <-+ firstShow+ ( parseDocument+ ( Text.unlines+ [ "---",+ "type: Attested Computation",+ "computation: /references/revenue.sql",+ "---",+ "",+ "# Computation",+ "",+ "```sql",+ "SELECT 1",+ "```"+ ]+ )+ )+ assertEqual+ [ComputationFile "/references/revenue.sql", ComputationInline "SELECT 1\n"]+ (readComputationSources both)+ -- Type-agnostic: a `# Computation` section on a `Metric` is still a fact about+ -- that document. Scoping a report to the one type is `Okf.Validation`'s job.+ onMetric <-+ firstShow+ ( parseDocument+ (Text.unlines ["---", "type: Metric", "---", "", "# Computation", "", " SELECT 1"])+ )+ assertEqual [ComputationInline "SELECT 1\n"] (readComputationSources onMetric)++-- | The top-level frontmatter keys of a serialized document, in the order they+-- were emitted. A top-level key is the only thing that starts in column zero+-- inside the frontmatter fence.+topLevelKeysInEmissionOrder :: Text -> [Text]+topLevelKeysInEmissionOrder serialized =+ [ Text.takeWhile (/= ':') line+ | line <- frontmatterLines,+ not (Text.null line),+ Text.isInfixOf ":" line,+ Text.head line /= ' ',+ Text.head line /= '-'+ ]+ where+ frontmatterLines =+ takeWhile (/= "---") (drop 1 (Text.lines serialized))++-- | Specification §10.2 marks @runtime@ REQUIRED for @type: Attested+-- Computation@, and nothing else in the contract. The check is strict-only:+-- §11's conformance list has three items and none is a computation field, and+-- §11 separately forbids rejecting a bundle over an unknown @type@ value, so+-- "REQUIRED for this type" binds the producer rather than licensing a consumer+-- to refuse.+testValidateAttestedComputationRuntime :: Either Text ()+testValidateAttestedComputationRuntime = do+ let errorsFor profile source = validateDocument profile <$> firstShow (parseDocument source)+ concept typeValue extraLines =+ Text.unlines+ ( [ "---",+ "type: " <> typeValue,+ "title: Revenue",+ "description: Recognized revenue for a fiscal year.",+ "generated: { by: human:you, at: 2026-08-01T00:00:00Z }"+ ]+ <> extraLines+ <> ["---", "", "# Computation", "", " SELECT 1"]+ )+ -- A complete contract is clean under both profiles.+ complete <- errorsFor StrictAuthoring (concept "Attested Computation" ["runtime: bigquery"])+ assertEqual [] complete+ -- No runtime: exactly one problem, and only under strict.+ missing <- errorsFor StrictAuthoring (concept "Attested Computation" [])+ assertEqual [AttestedComputationMissingRuntime] missing+ permissive <- errorsFor PermissiveConformance (concept "Attested Computation" [])+ assertEqual [] permissive+ -- No other type is affected, including a near-miss spelling. §4.1 says types+ -- are not registered centrally and consumers must tolerate unknown ones, so+ -- the match is on the one literal §10.1 names, case-sensitively.+ metric <- errorsFor StrictAuthoring (concept "Metric" [])+ assertEqual [] metric+ nearMiss <- errorsFor StrictAuthoring (concept "attested computation" [])+ assertEqual [] nearMiss+ -- An empty or whitespace runtime declares nothing.+ blank <- errorsFor StrictAuthoring (concept "Attested Computation" ["runtime: \" \""])+ assertEqual [AttestedComputationMissingRuntime] blank++testValidateSources :: Either Text ()+testValidateSources = do+ let strictErrors source = validateDocument StrictAuthoring <$> firstShow (parseDocument source)+ permissiveErrors source = validateDocument PermissiveConformance <$> firstShow (parseDocument source)+ preamble =+ Text.unlines+ [ "---",+ "type: BigQuery Table",+ "title: Orders",+ "description: Order fact table.",+ "generated: { by: human:ahormati, at: 2026-06-20T22:53:05Z }"+ ]+ broken =+ preamble+ <> Text.unlines+ [ "sources:",+ " - id: ga4-schema",+ " resource: https://example.com/ga4",+ " - id: no-resource",+ " title: Missing the required key",+ " - id: ga4-schema",+ " resource: https://example.com/ga4-again",+ "---",+ "",+ "# Orders"+ ]+ errors <- strictErrors broken+ -- The index is the position in the raw YAML list, which is what a person sees+ -- in the file, not the position in the list readSources returns.+ assertEqual [SourceMissingResource 1, DuplicateSourceId "ga4-schema"] errors+ -- Section 11 forbids rejecting a bundle over an optional family, so neither+ -- diagnostic may fire permissively.+ permissive <- permissiveErrors broken+ assertEqual [] permissive+ -- A well-formed sources list, including a scope-descriptor resource that no+ -- consumer can follow, is clean. Section 5.1 explicitly permits that shape.+ clean <-+ strictErrors+ ( preamble+ <> Text.unlines+ [ "sources:",+ " - id: ga4-schema",+ " resource: https://example.com/ga4",+ " - resource: all queries in BigQuery project X",+ "---",+ "",+ "# Orders"+ ]+ )+ assertEqual [] clean+ -- A document with no sources at all stays valid in strict mode.+ noSources <- strictErrors (preamble <> "---\n\n# Orders\n")+ assertEqual [] noSources++testConceptFromDocumentDerivesFields :: Either Text ()+testConceptFromDocumentDerivesFields = do+ conceptId <- parseTestConceptId "tables/orders"+ let frontmatterValue =+ okfCommon+ OkfCommon+ { commonType = "BigQuery Table",+ commonTitle = Just "Orders",+ commonDescription = Nothing,+ commonTimestamp = Nothing+ }+ concept = conceptFromDocument conceptId (OKFDocument frontmatterValue "# Orders\n")+ assertEqual "BigQuery Table" (conceptType concept)+ assertEqual (Just "Orders") (conceptTitle concept)+ assertEqual "tables/orders.md" (conceptSourcePath concept)++testWriteBundleRoundTrip :: IO (Either Text ())+testWriteBundleRoundTrip = do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-core-writebundle"+ let buildConcepts = do+ orders <- testConcept "tables/orders" "# Orders\n\nOrder records.\n"+ customers <- testConcept "tables/customers" "# Customers\n\nCustomer records.\n"+ pure [orders, customers]+ case buildConcepts of+ Left message -> do+ removeDirectoryRecursive root+ pure (Left message)+ Right concepts -> do+ writeBundle root concepts+ recovered <- readBundle root+ removeDirectoryRecursive root+ pure+ ( do+ assertEqual+ (List.sort (renderConceptId . conceptIdOf <$> concepts))+ (List.sort (renderConceptId . conceptIdOf <$> recovered))+ assertEqual+ (List.sort ((body . conceptDocument) <$> concepts))+ (List.sort ((body . conceptDocument) <$> recovered))+ )++testFixtureDanglingLink :: IO (Either Text ())+testFixtureDanglingLink = do+ root <- fixturePath "invalid-dangling-link"+ concepts <- readBundle root+ inventory <- readBundleInventory root+ pure+ ( case validateBundle PermissiveConformance VersionUndeclared inventory concepts of+ errs+ | any isDangling errs -> Right ()+ | otherwise -> Left ("expected a DanglingReference, got: " <> Text.pack (show errs))+ )+ where+ isDangling DanglingReference {} = True+ isDangling _ = False++-- | The §6.2 path check against a real directory rather than an in-memory+-- bundle, which is the only way to exercise the half that needs a filesystem:+-- @non-markdown.md@ names @references\/attesters\/revenue.py@, and that resolves+-- only because 'walkBundleInventory' sees files 'walkBundle' filters out.+--+-- @computations\/spec-spelling.md@ is the same text written from a+-- subdirectory, which is specification §10.2's own spelling and the one shape+-- that carries a bundle-relative hint.+testFixtureDanglingFrontmatterPath :: IO (Either Text ())+testFixtureDanglingFrontmatterPath = do+ root <- fixturePath "dangling-frontmatter-path"+ concepts <- readBundle root+ inventory <- readBundleInventory root+ pure+ ( do+ danglingId <- firstShow (parseConceptId "dangling")+ specSpellingId <- firstShow (parseConceptId "computations/spec-spelling")+ assertEqual 4 (length concepts)+ assertEqual+ [ DanglingFrontmatterPath+ specSpellingId+ "resource"+ "computations/references/attesters/revenue.py"+ (Just "references/attesters/revenue.py"),+ DanglingFrontmatterPath danglingId "resource" "references/deleted.txt" Nothing+ ]+ (validateBundle StrictAuthoring (VersionDeclared (OkfVersion 0 2)) inventory concepts)+ assertEqual+ []+ (validateBundle PermissiveConformance (VersionDeclared (OkfVersion 0 2)) inventory concepts)+ )++-- | A whole bundle carrying specification §10.2's contract and §10.3's+-- exactly-one rule, checked end to end.+--+-- Four things this proves that the document-level tests cannot. The §10.2 and+-- §10.3 checks fire on exactly the concepts that get them wrong and leave the+-- @Metric@ and the @references\/@ concept alone — @metrics\/revenue@+-- carries no computation at all and is reported by none of them, which is what+-- proves the checks are keyed on the one @type@. Both of the completed+-- computation's path-valued contract fields resolve, including the non-Markdown+-- @revenue.py@ — which only works because 'walkBundleInventory' records every+-- file rather than only the concepts. @computations\/both-computations@ names a+-- @computation@ path that resolves, so its only diagnostic is the §10.3+-- ambiguity and not a dangling path. And permissive validation reports nothing+-- at all, because §11's conformance list reaches none of this.+testFixtureAttestedComputation :: IO (Either Text ())+testFixtureAttestedComputation = do+ root <- fixturePath "attested-computation"+ concepts <- readBundle root+ inventory <- readBundleInventory root+ pure+ ( do+ marginId <- firstShow (parseConceptId "computations/margin")+ revenueId <- firstShow (parseConceptId "computations/revenue")+ bothId <- firstShow (parseConceptId "computations/both-computations")+ noneId <- firstShow (parseConceptId "computations/no-computation")+ twoBlocksId <- firstShow (parseConceptId "computations/two-blocks")+ -- Six computations, one metric, and the one `references/` file that+ -- `walkBundle` treats as a concept because it is non-reserved Markdown.+ -- The `.py` and `.sql` under `references/` are files and not concepts.+ --+ -- The sixth computation, `computations/churn`, deliberately produces no+ -- diagnostic here. It is core-clean and deviates only from the house+ -- profile in `profiles/attested-computation-house.dhall`, which is what+ -- makes this bundle exercise both layers rather than only this one.+ assertEqual 8 (length concepts)+ assertEqual+ [ DocumentInvalid bothId AttestedComputationHasBothComputations,+ DocumentInvalid marginId AttestedComputationMissingRuntime,+ DocumentInvalid noneId AttestedComputationHasNoComputation,+ DocumentInvalid twoBlocksId (AttestedComputationHasManyBlocks 2)+ ]+ (validateBundle StrictAuthoring (VersionDeclared (OkfVersion 0 2)) inventory concepts)+ assertEqual+ []+ (validateBundle PermissiveConformance (VersionDeclared (OkfVersion 0 2)) inventory concepts)+ -- The contract projected onto the concept, which is what every command+ -- reads rather than reaching back into raw frontmatter.+ revenue <- maybe (Left "expected computations/revenue") Right (findConcept revenueId concepts)+ assertEqual (Just "bigquery") (conceptRuntime revenue)+ assertEqual ["year"] (parameterName <$> conceptParameters revenue)+ assertEqual Nothing (conceptComputation revenue)+ assertEqual+ (Just "/references/skills/run-on-bq.md")+ (executorResource =<< conceptExecutor revenue)+ assertEqual (Just ["job_id", "executed_sql", "result"]) (executorReceipt <$> conceptExecutor revenue)+ assertEqual (Just "/references/attesters/revenue.py") (attesterResource =<< conceptAttester revenue)+ -- The body half of §10.3, projected alongside the frontmatter half: this+ -- concept names no `computation` path, so its one computation is the+ -- indented block under `# Computation`.+ assertEqual+ [ ComputationInline+ "SELECT SUM(amount) AS revenue\nFROM finance.recognized_revenue\nWHERE fiscal_year = @year\n"+ ]+ (conceptComputationSources revenue)+ )++-- | Resolve a fixture file path regardless of whether tests run from the repo+-- root or the package directory (mirrors 'fixturePath' for files).+fixtureFilePath :: FilePath -> IO FilePath+fixtureFilePath name = findExisting candidates+ where+ candidates =+ [ "okf-core" </> "test" </> "fixtures" </> name,+ "test" </> "fixtures" </> name+ ]+ findExisting [] = fail ("fixture file not found: " <> name)+ findExisting (candidate : rest) = do+ exists <- doesFileExist candidate+ if exists then pure candidate else findExisting rest++-- | Milestone 1: the Dhall descriptor round-trips into a 'ProfileSpec'.+testLoadProfileFixture :: IO (Either Text ())+testLoadProfileFixture = do+ path <- fixtureFilePath "profiles/postgresql.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load profile: " <> err)+ Right spec -> do+ assertEqual "shinzui-postgresql" (spec ^. #name)+ assertEqual+ (Just "Conventions for documenting a PostgreSQL database as an OKF bundle.")+ (spec ^. #description)+ assertEqual False (spec ^. #allowUnknownTypes)+ assertEqual ["type", "title"] (map (^. #field) (spec ^. #frontmatter . #required))+ assertEqual+ [ Just "The OKF concept type; must be one of the type rules below.",+ Just "Human-readable name of the object, as a reader would say it."+ ]+ (map (^. #description) (spec ^. #frontmatter . #required))+ -- `timestamp` is written with bare record completion, so it carries no prose.+ assertEqual+ [ Just "One or two sentences on what this object is for.",+ Nothing,+ Just "postgresql:// URI locating the live object."+ ]+ (map (^. #description) (spec ^. #frontmatter . #recommended))+ assertEqual+ ["PostgreSQL Schema", "PostgreSQL Table", "PostgreSQL View"]+ (map (^. #type_) (spec ^. #types))+ assertEqual+ (Just "One physical table in a schema, including its column list.")+ (spec ^. #types . to (!! 1) . #description)++testLoadDocumentIdProfileFixture :: IO (Either Text ())+testLoadDocumentIdProfileFixture = do+ path <- fixtureFilePath "profiles/decisions.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load document ID profile: " <> err)+ Right spec -> do+ assertEqual (Just "docId") (spec ^. #idField)+ assertEqual [Just "ADR"] (map (^. #idPrefix) (spec ^. #types))+ -- Written with the mk/FieldRule.dhall constructors, which normalize to+ -- exactly what record completion produces.+ assertEqual ["type", "title"] (map (^. #field) (spec ^. #frontmatter . #required))+ assertEqual+ [Just "The OKF concept type; must be a type rule below.", Nothing]+ (map (^. #description) (spec ^. #frontmatter . #required))++testLoadDescribedProfileFixture :: IO (Either Text ())+testLoadDescribedProfileFixture = do+ path <- fixtureFilePath "profiles/described.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load described profile: " <> err)+ Right spec -> do+ assertEqual "described" (spec ^. #name)+ assertEqual ["Described Concept"] (map (^. #type_) (spec ^. #types))+ assertEqual [emptyTestFrontmatterRules] (map (^. #frontmatter) (spec ^. #types))+ assertEqual True (spec ^. #allowUnknownFields)+ assertEqual [[]] (map (^. #allowedValues) (spec ^. #frontmatter . #required))+ assertEqual [Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))++testLoadTypeAwareCompatibilityFixture :: IO (Either Text ())+testLoadTypeAwareCompatibilityFixture = do+ path <- fixtureFilePath "profiles/type-aware-ep1.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load EP-1 profile: " <> err)+ Right spec -> do+ assertEqual "type-aware-ep1" (spec ^. #name)+ assertEqual True (spec ^. #allowUnknownFields)+ assertEqual [[]] (map (^. #allowedValues) (spec ^. #frontmatter . #required))+ assertEqual [[[]]] (map (map (^. #allowedValues) . (^. #frontmatter . #required)) (spec ^. #types))+ assertEqual [Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))++testLoadVocabularyCompatibilityFixture :: IO (Either Text ())+testLoadVocabularyCompatibilityFixture = do+ path <- fixtureFilePath "profiles/vocabulary-ep2.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load EP-2 profile: " <> err)+ Right spec -> do+ assertEqual "vocabulary-ep2" (spec ^. #name)+ assertEqual False (spec ^. #allowUnknownFields)+ assertEqual [[], ["draft", "accepted"]] (map (^. #allowedValues) (spec ^. #frontmatter . #required))+ assertEqual [Any, Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))+ assertEqual [Nothing, Nothing] (map (^. #format) (spec ^. #frontmatter . #required))++testLoadCardinalityCompatibilityFixture :: IO (Either Text ())+testLoadCardinalityCompatibilityFixture = do+ path <- fixtureFilePath "profiles/cardinality-ep3.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load EP-3 profile: " <> err)+ Right spec -> do+ assertEqual "cardinality-ep3" (spec ^. #name)+ assertEqual False (spec ^. #allowUnknownFields)+ assertEqual [Any, Scalar] (map (^. #cardinality) (spec ^. #frontmatter . #required))+ assertEqual [Nothing, Nothing] (map (^. #format) (spec ^. #frontmatter . #required))++testLoadFormatCompatibilityFixture :: IO (Either Text ())+testLoadFormatCompatibilityFixture = do+ path <- fixtureFilePath "profiles/formats-ep4.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load EP-4 profile: " <> err)+ Right spec -> do+ assertEqual "formats-ep4" (spec ^. #name)+ assertEqual [Any, Scalar] (map (^. #cardinality) (spec ^. #frontmatter . #required))+ assertEqual [Nothing, Just Rfc3339Utc] (map (^. #format) (spec ^. #frontmatter . #required))+ assertEqual [Nothing, Nothing] (map (^. #elementFields) (spec ^. #frontmatter . #required))++testLoadNestedReviewsProfileFixture :: IO (Either Text ())+testLoadNestedReviewsProfileFixture = do+ path <- fixtureFilePath "profiles/nested-reviews.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load nested review profile: " <> err)+ Right spec ->+ case [rules | rule <- spec ^. #frontmatter . #required, rule ^. #field == "reviews", Just rules <- [rule ^. #elementFields]] of+ [NestedRules {required, recommended}] -> do+ assertEqual ["kind", "reviewer", "reviewed_at", "document_timestamp", "scope", "outcome", "context"] (map (^. #field) required)+ assertEqual ["notes"] (map (^. #field) recommended)+ _ -> Left "expected exactly one reviews rule with elementFields"++testLoadNestedCompatibilityFixture :: IO (Either Text ())+testLoadNestedCompatibilityFixture = do+ path <- fixtureFilePath "profiles/nested-reviews-ep1.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load frozen nested profile: " <> err)+ Right spec ->+ case spec ^. #frontmatter . #required of+ [rule] -> do+ assertEqual Nothing (rule ^. #when)+ case rule ^. #elementFields of+ Just NestedRules {required = [nestedRule]} -> do+ assertEqual "kind" (nestedRule ^. #field)+ assertEqual Nothing (nestedRule ^. #when)+ _ -> Left "expected the frozen nested rule to survive the compatibility upgrade"+ _ -> Left "expected one frozen top-level rule"++testLoadConditionalFieldsProfileFixture :: IO (Either Text ())+testLoadConditionalFieldsProfileFixture = do+ path <- fixtureFilePath "profiles/conditional-fields.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load conditional profile: " <> err)+ Right spec -> do+ assertEqual "conditional-fields" (spec ^. #name)+ compiled <- firstShow (compileProfile spec)+ assertEqual spec (compiledProfileSpec compiled)++testLoadConditionalCompatibilityFixture :: IO (Either Text ())+testLoadConditionalCompatibilityFixture = do+ path <- fixtureFilePath "profiles/conditional-fields-ep2.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load frozen condition-aware profile: " <> err)+ Right spec ->+ case spec ^. #frontmatter . #required of+ [sourceRule, targetRule, reviewsRule] -> do+ assertEqual Nothing (sourceRule ^. #reference)+ assertEqual (Just (FieldCondition "status" ["superseded"])) (targetRule ^. #when)+ assertEqual Nothing (targetRule ^. #reference)+ case reviewsRule ^. #elementFields of+ Just NestedRules {required = [_kindRule, providerRule]} ->+ assertEqual (Just (FieldCondition "kind" ["model"])) (providerRule ^. #when)+ _ -> Left "expected frozen nested conditions to survive the compatibility upgrade"+ _ -> Left "expected three frozen condition-aware top-level rules"++-- | The immediately preceding generation: a descriptor that spells out the+-- reference-aware record types with no @optional@ list anywhere still loads,+-- keeps every field it did declare, and behaves as though each optional list+-- were empty.+testLoadReferenceCompatibilityFixture :: IO (Either Text ())+testLoadReferenceCompatibilityFixture = do+ path <- fixtureFilePath "profiles/document-references-ep3.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load frozen reference-aware profile: " <> err)+ Right spec -> do+ assertEqual [] (spec ^. #frontmatter . #optional)+ assertEqual [[]] (map (^. #frontmatter . #optional) (spec ^. #types))+ case spec ^. #frontmatter . #recommended of+ [referenceRule, conditionRule, reviewsRule] -> do+ assertEqual+ (Just (HandleReferenceRule "ADR" ["mori"] False))+ (referenceRule ^. #reference)+ assertEqual (Just (FieldCondition "status" ["superseded"])) (conditionRule ^. #when)+ case reviewsRule ^. #elementFields of+ Just NestedRules {required = [kindRule], recommended = [notesRule], optional = nestedOptional} -> do+ assertEqual "kind" (kindRule ^. #field)+ assertEqual "notes" (notesRule ^. #field)+ assertEqual [] (map (^. #field) nestedOptional)+ _ -> Left "expected the frozen nested rules to survive with an empty optional list"+ _ -> Left "expected three frozen reference-aware recommended rules"++-- | Every frozen generation fixture must both decode /and/ compile.+--+-- Decoding alone is the weaker property and was, until this test, the only one+-- asserted: eight of the nine generation tests stopped at 'loadProfileFile'. But+-- the guarantee the frozen chain exists to provide is that a descriptor pinned+-- at any released version keeps /working/, and between decoding and working sits+-- 'compileProfile' — where every 'ProfileDefinitionError' is a way for a+-- descriptor that decoded perfectly to stop working.+--+-- The gap was not theoretical. A compile-time version check written for this+-- plan rejected ten fixtures at once, and the failures surfaced in unrelated+-- documentation and optional-field tests rather than here, which is a far worse+-- signal. It also let @path-references-mp8-ep3.dhall@ ship in a state where it+-- decoded and could never compile. See+-- @docs\/adr\/11-growing-the-profile-descriptor-language.md@.+testFrozenFixturesCompile :: IO (Either Text ())+testFrozenFixturesCompile = do+ results <- traverse loadAndCompile frozenGenerationFixtures+ pure (sequence_ results)+ where+ loadAndCompile name = do+ path <- fixtureFilePath ("profiles/" <> name)+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left (Text.pack name <> " failed to load: " <> err)+ Right spec ->+ case compileProfile spec of+ Left definitionErrors ->+ Left (Text.pack name <> " loads but does not compile: " <> Text.pack (show (toList definitionErrors)))+ Right _ -> Right ()++-- | The fixtures that stand for a released descriptor generation, and so must+-- represent something a real pinned descriptor could be.+--+-- The @*-invalid.dhall@ fixtures are deliberately excluded: they exist to prove+-- a definition error fires. @document-references-ep3.dhall@ is excluded for a+-- different and less happy reason — it declares a profile-scope @when@ condition+-- on @status@ while declaring @status@ only at type scope, so it decodes and has+-- never compiled. That is the same latent defect this test exists to prevent,+-- predating it, and repairing it means changing which rules the fixture declares,+-- which its own test asserts. It is recorded in+-- @docs\/plans\/47-enforce-the-profile-declared-okfversion-and-ship-a-v0-2-reference-profile.md@+-- rather than fixed speculatively here.+frozenGenerationFixtures :: [FilePath]+frozenGenerationFixtures =+ [ "legacy-0.2.dhall",+ "described.dhall",+ "type-aware-ep1.dhall",+ "vocabulary-ep2.dhall",+ "cardinality-ep3.dhall",+ "formats-ep4.dhall",+ "nested-reviews-ep1.dhall",+ "conditional-fields-ep2.dhall",+ "object-fields-mp8-ep1.dhall",+ "formats-mp8-ep2.dhall",+ "path-references-mp8-ep3.dhall",+ "pre-bundle-version.dhall",+ -- Not a frozen generation but a *documented* one: this is the descriptor+ -- @docs\/user\/profiles.md@ shows for the specification §10 contract as a+ -- house convention. It is listed here so the documented descriptor cannot+ -- rot into something that no longer compiles.+ "attested-computation-house.dhall"+ ]++-- | The generation frozen immediately before @requireBundleVersion@: a descriptor+-- with no such member still loads, the member arrives as 'Nothing', and every+-- member the frozen descriptor did declare survives the upgrade. The last part is+-- what would catch an upgrade function that dropped a field while adding the new+-- one, which is the failure mode a chain this long invites.+testLoadPreBundleVersionCompatibilityFixture :: IO (Either Text ())+testLoadPreBundleVersionCompatibilityFixture = do+ path <- fixtureFilePath "profiles/pre-bundle-version.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load frozen pre-bundle-version profile: " <> err)+ Right spec -> do+ assertEqual "pre-bundle-version" (spec ^. #name)+ -- The new member, absent from the descriptor, means "demand nothing".+ assertEqual Nothing (spec ^. #requireBundleVersion)+ -- Everything else survived: prose, settings, and rules at both scopes.+ assertEqual (Just "Frozen immediately before requireBundleVersion.") (spec ^. #description)+ assertEqual "0.2" (spec ^. #okfVersion)+ assertEqual False (spec ^. #allowUnknownTypes)+ assertEqual True (spec ^. #allowUnknownFields)+ assertEqual (Just "docId") (spec ^. #idField)+ assertEqual ["type", "generated"] (map (^. #field) (spec ^. #frontmatter . #required))+ assertEqual+ (Just (HandleReferenceRule "ADR" ["mori"] False))+ (case spec ^. #frontmatter . #optional of rule : _ -> rule ^. #reference; [] -> Nothing)+ assertEqual+ [Just Profile.HumanActor]+ (concatMap (map (^. #format) . (^. #frontmatter . #required)) (spec ^. #types))+ assertEqual [Just "ADR"] (map (^. #idPrefix) (spec ^. #types))++-- | The generation frozen immediately before path-valued reference rules: a+-- descriptor with no @path@ member on 'FieldRule' or 'NestedFieldRule' still+-- loads, every member it did declare survives at both levels and at both nested+-- shapes, and the new member arrives as 'Nothing' everywhere. The fixture writes+-- out every published type it names, unions included, so widening one cannot+-- quietly turn this into a test of the current decoder.+testLoadPrePathCompatibilityFixture :: IO (Either Text ())+testLoadPrePathCompatibilityFixture = do+ path <- fixtureFilePath "profiles/path-references-mp8-ep3.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load frozen pre-path profile: " <> err)+ Right spec -> do+ assertEqual "path-references-mp8-ep3" (spec ^. #name)+ -- The new member is absent everywhere it can appear: three top-level+ -- presence lists, one type scope, and both nested shapes.+ assertEqual [Nothing, Nothing, Nothing] (map (^. #path) (spec ^. #frontmatter . #required))+ assertEqual [Nothing] (map (^. #path) (spec ^. #frontmatter . #recommended))+ assertEqual [Nothing] (map (^. #path) (spec ^. #frontmatter . #optional))+ assertEqual+ [Nothing]+ (concatMap (map (^. #path) . (^. #frontmatter . #required)) (spec ^. #types))+ -- Everything the frozen descriptor did declare survives the upgrade.+ assertEqual+ (Just (HandleReferenceRule "ADR" ["mori"] False))+ (case spec ^. #frontmatter . #optional of rule : _ -> rule ^. #reference; [] -> Nothing)+ assertEqual+ [Just Profile.NonNegativeInteger]+ (map (^. #format) (spec ^. #frontmatter . #recommended))+ assertEqual+ [Just Profile.HumanActor]+ (concatMap (map (^. #format) . (^. #frontmatter . #required)) (spec ^. #types))+ case spec ^. #frontmatter . #required of+ [_typeRule, sourcesRule, generatedRule] -> do+ case sourcesRule ^. #elementFields of+ Just NestedRules {required = [resourceRule]} -> do+ assertEqual "resource" (resourceRule ^. #field)+ assertEqual Scalar (resourceRule ^. #cardinality)+ assertEqual Nothing (resourceRule ^. #path)+ _ -> Left "expected the frozen element-field rule to survive"+ case generatedRule ^. #objectFields of+ Just NestedRules {required = [byRule]} -> do+ assertEqual "by" (byRule ^. #field)+ assertEqual (Just Profile.Actor) (byRule ^. #format)+ assertEqual Nothing (byRule ^. #path)+ _ -> Left "expected the frozen object-member rule to survive"+ _ -> Left "expected three frozen required rules"++-- | The generation frozen immediately before the OKF v0.2 value formats: a+-- descriptor whose records match today's shape but whose @format@ members are+-- typed by the five-alternative format union still loads, and every format it+-- declared arrives as the corresponding current 'FieldFormat'. The fixture+-- writes the union out as a literal rather than importing+-- @okf-core\/dhall\/FieldFormat.dhall@, so widening that file cannot quietly+-- turn this into a test of the current decoder.+testLoadPreActorCompatibilityFixture :: IO (Either Text ())+testLoadPreActorCompatibilityFixture = do+ path <- fixtureFilePath "profiles/formats-mp8-ep2.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load frozen five-alternative format profile: " <> err)+ Right spec -> do+ assertEqual "formats-mp8-ep2" (spec ^. #name)+ assertEqual+ [Nothing, Nothing, Nothing]+ (map (^. #format) (spec ^. #frontmatter . #required))+ assertEqual+ [Just Rfc3339Utc, Just Date]+ (map (^. #format) (spec ^. #frontmatter . #recommended))+ assertEqual+ [Just (UriWithScheme "https"), Just (DocumentHandle "ADR"), Just Uri]+ (map (^. #format) (spec ^. #frontmatter . #optional))+ assertEqual+ [Just Date]+ (concatMap (map (^. #format) . (^. #frontmatter . #required)) (spec ^. #types))+ case spec ^. #frontmatter . #required of+ [_typeRule, _titleRule, generatedRule] ->+ case generatedRule ^. #objectFields of+ Just NestedRules {required = [byRule, atRule]} -> do+ assertEqual "by" (byRule ^. #field)+ assertEqual Nothing (byRule ^. #format)+ assertEqual (Just Rfc3339Utc) (atRule ^. #format)+ _ -> Left "expected the frozen object members to survive"+ _ -> Left "expected three frozen required rules"++-- | The generation frozen immediately before object rules: a descriptor that+-- spells out the optional-presence record types with no @objectFields@ member+-- anywhere still loads, keeps every field it did declare — including the+-- @optional@ lists at both scopes and one level of @elementFields@ — and+-- behaves as though every rule declared no object shape.+testLoadPreObjectCompatibilityFixture :: IO (Either Text ())+testLoadPreObjectCompatibilityFixture = do+ path <- fixtureFilePath "profiles/object-fields-mp8-ep1.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load frozen optional-presence profile: " <> err)+ Right spec -> do+ assertEqual "object-fields-mp8-ep1" (spec ^. #name)+ assertEqual+ [Nothing, Nothing]+ (map (^. #objectFields) (spec ^. #frontmatter . #required))+ assertEqual+ [Nothing, Nothing]+ (map (^. #objectFields) (spec ^. #frontmatter . #recommended))+ assertEqual ["supersededBy"] (map (^. #field) (spec ^. #frontmatter . #optional))+ assertEqual [Nothing] (map (^. #objectFields) (spec ^. #frontmatter . #optional))+ case spec ^. #frontmatter . #recommended of+ [referenceRule, reviewsRule] -> do+ assertEqual+ (Just (HandleReferenceRule "ADR" ["mori"] False))+ (referenceRule ^. #reference)+ case reviewsRule ^. #elementFields of+ Just NestedRules {required = [kindRule], recommended = [notesRule], optional = [urlRule]} -> do+ assertEqual "kind" (kindRule ^. #field)+ assertEqual "notes" (notesRule ^. #field)+ assertEqual "url" (urlRule ^. #field)+ _ -> Left "expected the frozen nested rules to survive with all three presence lists"+ _ -> Left "expected two frozen optional-presence recommended rules"++-- | The backwards-compatibility guarantee: a descriptor frozen in the okf 0.2.x+-- shape — bare-string frontmatter keys, no descriptions anywhere — still loads,+-- via the legacy fallback decoder, with every description absent.+testLoadLegacyProfileFixture :: IO (Either Text ())+testLoadLegacyProfileFixture = do+ path <- fixtureFilePath "profiles/legacy-0.2.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load legacy profile: " <> err)+ Right spec -> do+ assertEqual "legacy" (spec ^. #name)+ assertEqual Nothing (spec ^. #description)+ assertEqual ["type", "title"] (map (^. #field) (spec ^. #frontmatter . #required))+ assertEqual [Nothing, Nothing] (map (^. #description) (spec ^. #frontmatter . #required))+ assertEqual ["Legacy Concept"] (map (^. #type_) (spec ^. #types))+ assertEqual [Nothing] (map (^. #description) (spec ^. #types))+ assertEqual True (spec ^. #allowUnknownFields)+ assertEqual [[], []] (map (^. #allowedValues) (spec ^. #frontmatter . #required))+ assertEqual [Any, Any] (map (^. #cardinality) (spec ^. #frontmatter . #required))++testProfileFieldDescription :: IO (Either Text ())+testProfileFieldDescription = do+ path <- fixtureFilePath "profiles/postgresql.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load profile: " <> err)+ Right spec -> do+ assertEqual+ (Just "Human-readable name of the object, as a reader would say it.")+ (profileFieldDescription spec "title")+ assertEqual+ (Just "postgresql:// URI locating the live object.")+ (profileFieldDescription spec "resource")+ assertEqual Nothing (profileFieldDescription spec "timestamp")+ assertEqual Nothing (profileFieldDescription spec "nope")++-- | Prose declared on an optional rule is as discoverable as prose on a required+-- or recommended one; the third list is searched last, after the two that can+-- produce a missing-field diagnostic.+testOptionalFieldDescription :: IO (Either Text ())+testOptionalFieldDescription = do+ path <- fixtureFilePath "profiles/decisions.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load decisions profile: " <> err)+ Right spec ->+ assertEqual+ (Just "The decision this one replaces, when it replaces one.")+ (profileFieldDescription spec "supersedes")++-- | The JSON encoding is pinned field by field, so a future refactor cannot+-- silently rename a key. The @type@ key matters most: the Haskell field is+-- @type_@, and consumers must never see that.+testProfileJsonShape :: IO (Either Text ())+testProfileJsonShape = do+ path <- fixtureFilePath "profiles/decisions.dhall"+ result <- loadProfileFile path+ pure $ case result of+ Left err -> Left ("failed to load document ID profile: " <> err)+ Right spec ->+ assertEqual+ ( object+ [ "name" .= ("decisions" :: Text),+ "description" .= ("How this team records architectural decisions." :: Text),+ "okfVersion" .= ("0.1" :: Text),+ -- Encoded even when absent, so a consumer reads one shape rather+ -- than having to distinguish a missing key from a null one.+ "requireBundleVersion" .= (Nothing :: Maybe Text),+ "allowUnknownTypes" .= False,+ "allowUnknownFields" .= True,+ "idField" .= ("docId" :: Text),+ "frontmatter"+ .= object+ [ "required"+ .= [ object+ [ "field" .= ("type" :: Text),+ "description"+ .= ("The OKF concept type; must be a type rule below." :: Text),+ "allowedValues" .= ([] :: [Text]),+ "cardinality" .= ("any" :: Text),+ "format" .= (Nothing :: Maybe Text),+ "elementFields" .= (Nothing :: Maybe Value),+ "objectFields" .= (Nothing :: Maybe Value),+ "reference" .= (Nothing :: Maybe HandleReferenceRule),+ "path" .= (Nothing :: Maybe PathReferenceRule),+ "when" .= (Nothing :: Maybe FieldCondition)+ ],+ object+ [ "field" .= ("title" :: Text),+ "description" .= (Nothing :: Maybe Text),+ "allowedValues" .= ([] :: [Text]),+ "cardinality" .= ("any" :: Text),+ "format" .= (Nothing :: Maybe Text),+ "elementFields" .= (Nothing :: Maybe Value),+ "objectFields" .= (Nothing :: Maybe Value),+ "reference" .= (Nothing :: Maybe HandleReferenceRule),+ "path" .= (Nothing :: Maybe PathReferenceRule),+ "when" .= (Nothing :: Maybe FieldCondition)+ ]+ ],+ "recommended"+ .= [ object+ [ "field" .= ("status" :: Text),+ "description"+ .= ("One of: proposed, accepted, superseded." :: Text),+ "allowedValues" .= ([] :: [Text]),+ "cardinality" .= ("any" :: Text),+ "format" .= (Nothing :: Maybe Text),+ "elementFields" .= (Nothing :: Maybe Value),+ "objectFields" .= (Nothing :: Maybe Value),+ "reference" .= (Nothing :: Maybe HandleReferenceRule),+ "path" .= (Nothing :: Maybe PathReferenceRule),+ "when" .= (Nothing :: Maybe FieldCondition)+ ]+ ],+ "optional"+ .= [ object+ [ "field" .= ("supersedes" :: Text),+ "description"+ .= ("The decision this one replaces, when it replaces one." :: Text),+ "allowedValues" .= ([] :: [Text]),+ "cardinality" .= ("any" :: Text),+ "format" .= (Nothing :: Maybe Text),+ "elementFields" .= (Nothing :: Maybe Value),+ "objectFields" .= (Nothing :: Maybe Value),+ "reference" .= (Nothing :: Maybe HandleReferenceRule),+ "path" .= (Nothing :: Maybe PathReferenceRule),+ "when" .= (Nothing :: Maybe FieldCondition)+ ]+ ]+ ],+ "types"+ .= [ object+ [ "type" .= ("Decision Record" :: Text),+ "description"+ .= ("One accepted decision, never edited after acceptance." :: Text),+ "frontmatter"+ .= object+ [ "required" .= ([] :: [FieldRule]),+ "recommended" .= ([] :: [FieldRule]),+ "optional" .= ([] :: [FieldRule])+ ],+ "pathPattern" .= ("decisions/*" :: Text),+ "resourceScheme" .= (Nothing :: Maybe Text),+ "requireSchemaSection" .= False,+ "schemaColumns" .= ([] :: [Text]),+ "idPrefix" .= ("ADR" :: Text)+ ]+ ]+ ]+ )+ (toJSON spec)++testFieldFormatJsonShape :: Either Text ()+testFieldFormatJsonShape =+ assertEqual+ [ String "rfc3339-utc",+ String "date",+ String "uri",+ object ["uriWithScheme" .= ("mori" :: Text)],+ object ["documentHandle" .= ("ADR" :: Text)]+ ]+ (map toJSON [Rfc3339Utc, Date, Uri, UriWithScheme "mori", DocumentHandle "ADR"])++testFieldConditionJsonShape :: Either Text ()+testFieldConditionJsonShape =+ assertEqual+ (object ["field" .= ("status" :: Text), "hasValue" .= (["superseded"] :: [Text])])+ (toJSON (FieldCondition "status" ["superseded"]))++testHandleReferenceJsonShape :: Either Text ()+testHandleReferenceJsonShape =+ assertEqual+ ( object+ [ "localPrefix" .= ("ADR" :: Text),+ "externalUriSchemes" .= (["mori", "https"] :: [Text]),+ "allowSelf" .= False+ ]+ )+ (toJSON (HandleReferenceRule "ADR" ["mori", "https"] False))++-- | A registry record enumerates every field that decodes as a profile, one+-- level down as well as at the top, sorted by export path. The @Profile@ schema+-- record and the @note@ string contribute nothing.+testRegistryEnumeratesProfiles :: IO (Either Text ())+testRegistryEnumeratesProfiles = do+ path <- fixtureFilePath "registry/package.dhall"+ loaded <- loadRegistry (RegistryFile path)+ pure $ case loaded of+ Left err -> Left ("failed to load fixture registry: " <> err)+ Right entries -> do+ -- `legacy` is a frozen okf 0.2.x descriptor: it enumerates only because+ -- the registry walk falls back to the legacy decoder.+ assertEqual ["legacy", "nested.decisions", "postgresql"] (map (^. #export) entries)+ case findRegistryEntry "legacy" entries of+ Nothing -> Left "expected an entry at export path legacy"+ Just entry -> do+ assertEqual "legacy" (entry ^. #spec . #name)+ assertEqual Nothing (entry ^. #spec . #description)+ case findRegistryEntry "postgresql" entries of+ Nothing -> Left "expected an entry at export path postgresql"+ Just entry -> assertEqual "shinzui-postgresql" (entry ^. #spec . #name)+ assertEqual Nothing (findRegistryEntry "nope" entries)+ assertBool+ "expected findRegistryEntry to resolve the nested export"+ (isJust (findRegistryEntry "nested.decisions" entries))++-- | A registry reference that is itself a profile yields one entry whose export+-- path is empty.+testRegistryRootProfile :: IO (Either Text ())+testRegistryRootProfile = do+ path <- fixtureFilePath "profiles/decisions.dhall"+ loaded <- loadRegistry (RegistryFile path)+ pure $ case loaded of+ Left err -> Left ("failed to load root profile registry: " <> err)+ Right entries -> do+ assertEqual [""] (map (^. #export) entries)+ assertEqual ["decisions"] (map (^. #spec . #name) entries)++-- | A directory holding @package.dhall@ resolves to that file; anything else+-- is handed to Dhall verbatim.+testResolveRegistryRef :: IO (Either Text ())+testResolveRegistryRef = do+ directory <- fixturePath "registry"+ resolvedDirectory <- resolveRegistryRef (Text.pack directory)+ filePath <- fixtureFilePath "profiles/decisions.dhall"+ resolvedFile <- resolveRegistryRef (Text.pack filePath)+ resolvedExpression <- resolveRegistryRef "./nowhere/at/all.dhall"+ pure $ do+ assertEqual (RegistryFile (directory </> "package.dhall")) resolvedDirectory+ assertEqual (RegistryFile filePath) resolvedFile+ assertEqual (RegistryExpression "./nowhere/at/all.dhall") resolvedExpression++-- | A reference that cannot be evaluated reports an error rather than throwing.+testRegistryLoadFailure :: IO (Either Text ())+testRegistryLoadFailure = do+ loaded <- loadRegistry (RegistryFile "/nonexistent/registry.dhall")+ pure $ case loaded of+ Right entries -> Left ("expected a load failure, got " <> Text.pack (show (length entries)) <> " entries")+ Left message -> assertBool "expected a non-empty error message" (not (Text.null message))++testParseDocumentId :: Either Text ()+testParseDocumentId = do+ assertEqual+ (Just (DocumentId {prefix = "ADR", number = 7}))+ (parseDocumentId "ADR-7")+ mapM_+ (\invalid -> assertEqual Nothing (parseDocumentId invalid))+ ["ADR-007", "ADR-0", "ADR-", "-7", "ADR 7", "ADR-7-extra"]+ assertEqual (Just "ADR-7") (renderDocumentId <$> parseDocumentId "ADR-7")++testDocumentIdsInBundle :: IO (Either Text ())+testDocumentIdsInBundle = do+ descriptorPath <- fixtureFilePath "profiles/decisions.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "doc-ids"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load document ID profile: " <> err)+ Right spec -> do+ useMarkdown <- parseTestConceptId "decisions/use-markdown"+ usePostgres <- parseTestConceptId "decisions/use-postgres"+ adoptOkf <- parseTestConceptId "decisions/adopt-okf"+ assertEqual+ [ (DocumentId "ADR" 1, useMarkdown),+ (DocumentId "ADR" 2, usePostgres),+ (DocumentId "ADR" 3, adoptOkf)+ ]+ (documentIdsInBundle spec concepts)++testNextDocumentId :: Either Text ()+testNextDocumentId = do+ firstConcept <-+ profileConcept+ "decisions/first"+ [("type", String "Decision Record"), ("title", String "First"), ("docId", String "ADR-1")]+ "# First\n"+ thirdConcept <-+ profileConcept+ "decisions/third"+ [("type", String "Decision Record"), ("title", String "Third"), ("docId", String "ADR-3")]+ "# Third\n"+ let concepts = [firstConcept, thirdConcept]+ assertEqual (DocumentId "ADR" 4) (nextDocumentId testDocumentIdProfileSpec concepts "ADR")+ assertEqual (DocumentId "RFC" 1) (nextDocumentId testDocumentIdProfileSpec concepts "RFC")++testFindConceptsByDocumentId :: IO (Either Text ())+testFindConceptsByDocumentId = do+ validRoot <- fixturePath "doc-ids"+ validConcepts <- readBundle validRoot+ deviationRoot <- fixturePath "doc-id-deviations"+ deviationConcepts <- readBundle deviationRoot+ pure $ do+ usePostgres <- parseTestConceptId "decisions/use-postgres"+ firstId <- parseTestConceptId "decisions/first"+ secondId <- parseTestConceptId "decisions/second"+ assertEqual+ [usePostgres]+ (conceptIdOf <$> findConceptsByDocumentId Nothing "ADR-2" validConcepts)+ assertEqual+ [firstId, secondId]+ (conceptIdOf <$> findConceptsByDocumentId (Just "docId") "ADR-1" deviationConcepts)++-- | An undocumented frontmatter key: the validation tests care about names, not+-- prose, and descriptions never affect validation.+requiredField :: Text -> FieldRule+requiredField key = FieldRule {field = key, description = Nothing, allowedValues = [], cardinality = Any, format = Nothing, elementFields = Nothing, objectFields = Nothing, reference = Nothing, path = Nothing, when = Nothing}++-- | Build a 'FieldRule' positionally in the argument order this file used+-- before 'FieldRule' gained @objectFields@, filling that member in as+-- 'Nothing'. The dozens of call sites below constrain lists, formats,+-- conditions, and references rather than object shapes, so spelling out a ninth+-- 'Nothing' at each of them would add noise and no information. A test that+-- does exercise object rules builds its rule with record syntax instead.+fieldRule ::+ Text ->+ Maybe Text ->+ [Text] ->+ Cardinality ->+ Maybe FieldFormat ->+ Maybe NestedRules ->+ Maybe HandleReferenceRule ->+ Maybe FieldCondition ->+ FieldRule+fieldRule key description allowedValues cardinality format elementFields reference condition =+ FieldRule+ { field = key,+ description,+ allowedValues,+ cardinality,+ format,+ elementFields,+ objectFields = Nothing,+ reference,+ path = Nothing,+ when = condition+ }++-- | A standalone profile literal so the validation tests do not depend on the+-- Dhall fixture. One rule: PostgreSQL Table, fully constrained.+testProfileSpec :: ProfileSpec+testProfileSpec =+ ProfileSpec+ { name = "test-postgresql",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required = [requiredField "type", requiredField "title"],+ recommended = [],+ optional = []+ },+ allowUnknownTypes = False,+ allowUnknownFields = True,+ idField = Nothing,+ requireBundleVersion = Nothing,+ types =+ [ TypeRule+ { type_ = "PostgreSQL Table",+ description = Nothing,+ frontmatter = emptyTestFrontmatterRules,+ pathPattern = Just "schemas/*/tables/*",+ resourceScheme = Just "postgresql",+ requireSchemaSection = True,+ schemaColumns = ["Column", "Type", "Nullable", "Description"],+ idPrefix = Nothing+ }+ ]+ }++testDocumentIdProfileSpec :: ProfileSpec+testDocumentIdProfileSpec =+ ProfileSpec+ { name = "test-decisions",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required = [requiredField "type", requiredField "title"],+ recommended = [],+ optional = []+ },+ allowUnknownTypes = False,+ allowUnknownFields = True,+ idField = Just "docId",+ requireBundleVersion = Nothing,+ types =+ [ TypeRule+ { type_ = "Decision Record",+ description = Nothing,+ frontmatter = emptyTestFrontmatterRules,+ pathPattern = Just "decisions/*",+ resourceScheme = Nothing,+ requireSchemaSection = False,+ schemaColumns = [],+ idPrefix = Just "ADR"+ }+ ]+ }++emptyTestFrontmatterRules :: FrontmatterRules+emptyTestFrontmatterRules = FrontmatterRules {required = [], recommended = [], optional = []}++typeAwareProfileSpec :: ProfileSpec+typeAwareProfileSpec =+ ProfileSpec+ { name = "type-aware",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing, fieldRule "title" (Just "Global title.") [] Any Nothing Nothing Nothing Nothing],+ recommended = [fieldRule "owner" (Just "Profile-level owner.") [] Any Nothing Nothing Nothing Nothing],+ optional = []+ },+ allowUnknownTypes = True,+ allowUnknownFields = True,+ idField = Nothing,+ requireBundleVersion = Nothing,+ types =+ [ TypeRule+ { type_ = "Owned Concept",+ description = Nothing,+ frontmatter =+ FrontmatterRules+ { required = [fieldRule "owner" (Just "Responsible person.") [] Any Nothing Nothing Nothing Nothing],+ recommended = [fieldRule "reviewer" (Just "Second pair of eyes.") [] Any Nothing Nothing Nothing Nothing, fieldRule "title" (Just "Type title.") [] Any Nothing Nothing Nothing Nothing],+ optional = []+ },+ pathPattern = Nothing,+ resourceScheme = Nothing,+ requireSchemaSection = False,+ schemaColumns = [],+ idPrefix = Nothing+ }+ ]+ }++testCompileProfileDefinitionErrors :: Either Text ()+testCompileProfileDefinitionErrors = do+ let duplicateField = fieldRule "title" Nothing [] Any Nothing Nothing Nothing Nothing+ invalid =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required = [duplicateField, duplicateField],+ recommended = [duplicateField],+ optional = []+ },+ types = (typeAwareProfileSpec ^. #types) <> (typeAwareProfileSpec ^. #types)+ }+ case compileProfile invalid of+ Right _ -> Left "expected invalid profile definition"+ Left errors ->+ assertEqual+ [ DuplicateFieldRule Nothing "required" "title",+ ConflictingFieldRequirement Nothing "title",+ DuplicateTypeRule "Owned Concept"+ ]+ (toList errors)++testCompiledProfileMerge :: Either Text ()+testCompiledProfileMerge = do+ compiled <- firstShow (compileProfile typeAwareProfileSpec)+ assertEqual (Just "Type title.") (profileFieldDescriptionForType compiled "Owned Concept" "title")+ assertEqual (Just "Responsible person.") (profileFieldDescriptionForType compiled "Owned Concept" "owner")+ assertEqual (Just "Global title.") (profileFieldDescriptionForType compiled "Unknown Concept" "title")+ concept <- profileConcept "owned/one" [("type", String "Owned Concept")] "# One\n"+ cid <- parseTestConceptId "owned/one"+ assertEqual+ [MissingProfileField cid "owner" Nothing, MissingProfileField cid "title" Nothing]+ (validateProfile PermissiveConformance compiled [concept])++vocabularyProfileSpec :: ProfileSpec+vocabularyProfileSpec =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],+ recommended = [fieldRule "status" Nothing ["draft", "approved", "approved"] Any Nothing Nothing Nothing Nothing],+ optional = []+ },+ types =+ [ withTypeFrontmatter+ FrontmatterRules+ { required = [fieldRule "status" Nothing ["approved", "archived"] Any Nothing Nothing Nothing Nothing],+ recommended = [],+ optional = []+ }+ (firstTypeRule typeAwareProfileSpec)+ ]+ }++fieldPath :: Text -> FieldPath+fieldPath key = FieldPath (FieldName key :| [])++testCompiledVocabularyIntersection :: Either Text ()+testCompiledVocabularyIntersection = do+ compiled <- firstShow (compileProfile vocabularyProfileSpec)+ concept <- profileConcept "owned/one" [("type", String "Owned Concept"), ("status", String "draft")] "# One\n"+ cid <- parseTestConceptId "owned/one"+ assertEqual+ [ValueNotInVocabulary cid (fieldPath "status") ["approved"] (String "draft")]+ (validateProfile PermissiveConformance compiled [concept])++testUnsatisfiableVocabulary :: Either Text ()+testUnsatisfiableVocabulary = do+ let disjoint =+ vocabularyProfileSpec+ { types =+ [ withTypeFrontmatter+ FrontmatterRules+ { required = [fieldRule "status" Nothing ["closed"] Any Nothing Nothing Nothing Nothing],+ recommended = [],+ optional = []+ }+ (firstTypeRule vocabularyProfileSpec)+ ]+ }+ assertEqual+ (Left (UnsatisfiableVocabulary (Just "Owned Concept") "status" ["draft", "approved"] ["closed"] :| []))+ (compileProfile disjoint)++testCompiledCardinality :: Either Text ()+testCompiledCardinality = do+ let profileRules =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],+ recommended = [fieldRule "status" Nothing [] Scalar Nothing Nothing Nothing Nothing],+ optional = []+ }+ typeRules cardinality =+ FrontmatterRules+ { required = [fieldRule "status" Nothing [] cardinality Nothing Nothing Nothing Nothing],+ recommended = [],+ optional = []+ }+ baseType = firstTypeRule typeAwareProfileSpec+ compatible =+ typeAwareProfileSpec+ { frontmatter = profileRules,+ types = [withTypeFrontmatter (typeRules Any) baseType]+ }+ contradictory = compatible {types = [withTypeFrontmatter (typeRules List) baseType]}+ compiled <- firstShow (compileProfile compatible)+ valid <- profileConcept "owned/cardinality" [("type", String "Owned Concept"), ("status", Number 3)] "# Valid\n"+ invalid <- profileConcept "owned/cardinality" [("type", String "Owned Concept"), ("status", toJSON (["draft"] :: [Text]))] "# Invalid\n"+ cid <- parseTestConceptId "owned/cardinality"+ assertEqual [] (validateProfile PermissiveConformance compiled [valid])+ assertEqual+ [CardinalityMismatch cid (fieldPath "status") Scalar (toJSON (["draft"] :: [Text]))]+ (validateProfile PermissiveConformance compiled [invalid])+ assertEqual+ (Left (ConflictingCardinality (Just "Owned Concept") "status" Scalar List :| []))+ (compileProfile contradictory)++testVocabularyValidation :: Either Text ()+testVocabularyValidation = do+ let openVocabulary =+ vocabularyProfileSpec+ { frontmatter =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],+ recommended = [fieldRule "status" Nothing ["draft", "approved"] Any Nothing Nothing Nothing Nothing],+ optional = []+ },+ types = []+ }+ compiled <- firstShow (compileProfile openVocabulary)+ validString <- profileConcept "valid-string" [("type", String "Extension"), ("status", String "draft")] "# Valid\n"+ validList <- profileConcept "valid-list" [("type", String "Extension"), ("status", toJSON (["draft", "approved"] :: [Text]))] "# Valid\n"+ absent <- profileConcept "absent" [("type", String "Extension")] "# Absent\n"+ invalidString <- profileConcept "invalid-string" [("type", String "Extension"), ("status", String "banana")] "# Invalid\n"+ invalidList <- profileConcept "invalid-list" [("type", String "Extension"), ("status", toJSON (["draft", "banana"] :: [Text]))] "# Invalid\n"+ invalidShape <- profileConcept "invalid-shape" [("type", String "Extension"), ("status", toJSON (1 :: Int))] "# Invalid\n"+ invalidStringId <- parseTestConceptId "invalid-string"+ invalidListId <- parseTestConceptId "invalid-list"+ invalidShapeId <- parseTestConceptId "invalid-shape"+ assertEqual [] (validateProfile PermissiveConformance compiled [validString, validList, absent])+ assertEqual+ [ValueNotInVocabulary invalidStringId (fieldPath "status") ["draft", "approved"] (String "banana")]+ (validateProfile PermissiveConformance compiled [invalidString])+ assertEqual+ [ValueNotInVocabulary invalidListId (fieldPath "status") ["draft", "approved"] (toJSON (["draft", "banana"] :: [Text]))]+ (validateProfile PermissiveConformance compiled [invalidList])+ assertEqual+ [ValueNotInVocabulary invalidShapeId (fieldPath "status") ["draft", "approved"] (toJSON (1 :: Int))]+ (validateProfile PermissiveConformance compiled [invalidShape])++testCardinalityValidation :: Either Text ()+testCardinalityValidation = do+ cid <- parseTestConceptId "cardinality"+ let check cardinality actual = do+ compiled <- firstShow (compileProfile (singleCardinalityProfile True cardinality []))+ concept <- profileConcept "cardinality" [("type", String "Extension"), ("value", actual)] "# Cardinality\n"+ pure (validateProfile PermissiveConformance compiled [concept])+ mismatch cardinality actual = [CardinalityMismatch cid (fieldPath "value") cardinality actual]+ objectValue = object ["nested" .= (True :: Bool)]+ textList = toJSON (["one"] :: [Text])+ emptyList = toJSON ([] :: [Text])+ for_ [String "one", Number 0, Bool False] $ \actual ->+ check Scalar actual >>= assertEqual []+ for_ [textList, objectValue, Null] $ \actual ->+ check Scalar actual >>= assertEqual (mismatch Scalar actual)+ check List textList >>= assertEqual []+ for_ [String "one", Number 0, Bool False, objectValue, Null] $ \actual ->+ check List actual >>= assertEqual (mismatch List actual)+ check Any (String "one") >>= assertEqual []+ check Any textList >>= assertEqual []+ check Any (Bool False) >>= assertEqual [MissingProfileField cid "value" Nothing]+ check Scalar (String " ") >>= assertEqual [MissingProfileField cid "value" Nothing]+ check List emptyList >>= assertEqual [MissingProfileField cid "value" Nothing]+ optionalCompiled <- firstShow (compileProfile (singleCardinalityProfile False Scalar []))+ optionalConcept <- profileConcept "cardinality" [("type", String "Extension"), ("value", textList)] "# Optional\n"+ assertEqual+ (mismatch Scalar textList)+ (validateProfile PermissiveConformance optionalCompiled [optionalConcept])++testCardinalityVocabularyInteraction :: Either Text ()+testCardinalityVocabularyInteraction = do+ cid <- parseTestConceptId "cardinality"+ scalarCompiled <- firstShow (compileProfile (singleCardinalityProfile True Scalar ["draft"]))+ let objectValue = object ["status" .= ("draft" :: Text)]+ objectConcept <- profileConcept "cardinality" [("type", String "Extension"), ("value", objectValue)] "# Object\n"+ assertEqual+ [CardinalityMismatch cid (fieldPath "value") Scalar objectValue]+ (validateProfile PermissiveConformance scalarCompiled [objectConcept])+ listCompiled <- firstShow (compileProfile (singleCardinalityProfile True List ["draft"]))+ let mixedList = toJSON ([String "draft", Number 1] :: [Value])+ listConcept <- profileConcept "cardinality" [("type", String "Extension"), ("value", mixedList)] "# List\n"+ assertEqual+ [ValueNotInVocabulary cid (fieldPath "value") ["draft"] mixedList]+ (validateProfile PermissiveConformance listCompiled [listConcept])++testCompiledFieldFormats :: Either Text ()+testCompiledFieldFormats = do+ let baseType = firstTypeRule typeAwareProfileSpec+ profileWith profileFormat typeFormat =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing, fieldRule "homepage" Nothing [] Any (Just profileFormat) Nothing Nothing Nothing],+ recommended = [],+ optional = []+ },+ types =+ [ withTypeFrontmatter+ FrontmatterRules+ { required = [fieldRule "homepage" Nothing [] Any (Just typeFormat) Nothing Nothing Nothing],+ recommended = [],+ optional = []+ }+ baseType+ ]+ }+ refined <- firstShow (compileProfile (profileWith Uri (UriWithScheme "https")))+ valid <- profileConcept "format-refinement" [("type", String "Owned Concept"), ("homepage", String "HTTPS://example.test/path")] "# Valid\n"+ invalid <- profileConcept "format-refinement" [("type", String "Owned Concept"), ("homepage", String "http://example.test/path")] "# Invalid\n"+ cid <- parseTestConceptId "format-refinement"+ assertEqual [] (validateProfile PermissiveConformance refined [valid])+ assertEqual+ [ValueFormatMismatch cid (fieldPath "homepage") (UriWithScheme "https") (String "http://example.test/path")]+ (validateProfile PermissiveConformance refined [invalid])+ assertEqual+ (Left (ConflictingFieldFormat (fieldPath "homepage") Date Rfc3339Utc :| []))+ (compileProfile (profileWith Date Rfc3339Utc))++testInvalidFormatParameters :: Either Text ()+testInvalidFormatParameters = do+ let invalid =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required =+ [ fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing,+ fieldRule "handle" Nothing [] Any (Just (DocumentHandle "1ADR")) Nothing Nothing Nothing,+ fieldRule "source" Nothing [] Any (Just (UriWithScheme "https_")) Nothing Nothing Nothing+ ],+ recommended = [],+ optional = []+ },+ types = []+ }+ assertEqual+ ( Left+ ( InvalidFormatParameter (fieldPath "handle") (DocumentHandle "1ADR") "1ADR"+ :| [InvalidFormatParameter (fieldPath "source") (UriWithScheme "https_") "https_"]+ )+ )+ (compileProfile invalid)++testNamedFormatValidation :: Either Text ()+testNamedFormatValidation = do+ cid <- parseTestConceptId "format"+ let check fieldFormat cardinality actual = do+ compiled <- firstShow (compileProfile (singleFormatProfile cardinality fieldFormat))+ concept <-+ profileConcept+ "format"+ ([("type", String "Extension")] <> maybe [] (\value -> [("value", value)]) actual)+ "# Format\n"+ pure (validateProfile PermissiveConformance compiled [concept])+ mismatch fieldFormat actual = [ValueFormatMismatch cid (fieldPath "value") fieldFormat actual]+ for_ ["2024-02-29T23:59:59Z", "2026-07-29T17:00:00.125Z"] $ \value ->+ check Rfc3339Utc Any (Just (String value)) >>= assertEqual []+ for_ ["2026-13-45T99:99:99Z", "2026-07-29T17:00:00+01:00", "2026-07-29 17:00:00Z"] $ \value ->+ check Rfc3339Utc Any (Just (String value)) >>= assertEqual (mismatch Rfc3339Utc (String value))+ check Date Any (Just (String "2024-02-29")) >>= assertEqual []+ for_ ["2023-02-29", "2026-13-01", "2026-07-29T00:00:00Z"] $ \value ->+ check Date Any (Just (String value)) >>= assertEqual (mismatch Date (String value))+ for_ ["https://example.test/path", "urn:example:item"] $ \value ->+ check Uri Any (Just (String value)) >>= assertEqual []+ for_ ["relative/path", "https://example.test/bad%ZZ"] $ \value ->+ check Uri Any (Just (String value)) >>= assertEqual (mismatch Uri (String value))+ check (UriWithScheme "mori") Any (Just (String "MORI://haskell/time")) >>= assertEqual []+ check (UriWithScheme "mori") Any (Just (String "https://example.test"))+ >>= assertEqual (mismatch (UriWithScheme "mori") (String "https://example.test"))+ check (DocumentHandle "ADR") Any (Just (String "ADR-7")) >>= assertEqual []+ for_ ["ADR-007", "IR-7"] $ \value ->+ check (DocumentHandle "ADR") Any (Just (String value))+ >>= assertEqual (mismatch (DocumentHandle "ADR") (String value))+ let validUris = toJSON (["https://example.test", "urn:example:item"] :: [Text])+ mixedUris = toJSON ([String "https://example.test", Number 1] :: [Value])+ check Uri List (Just validUris) >>= assertEqual []+ check Uri List (Just mixedUris) >>= assertEqual (mismatch Uri mixedUris)+ check Uri Scalar (Just validUris)+ >>= assertEqual [CardinalityMismatch cid (fieldPath "value") Scalar validUris]+ check Uri Any (Just (Number 1)) >>= assertEqual (mismatch Uri (Number 1))+ check Uri Any Nothing >>= assertEqual []++-- | The OKF v0.2 actor formats. Specification §7 defines exactly three shapes+-- and 'parseActor' classifies them, so @actor@ accepts those three and reports+-- everything else — including the specification's own illustrative+-- @author: team:ga4-docs@ from §5.1, which §7's convention does not define. The+-- case-sensitivity and empty-component cases come from 'Okf.Actor'.+testActorFormatValidation :: Either Text ()+testActorFormatValidation = do+ cid <- parseTestConceptId "format"+ let check fieldFormat value = do+ compiled <- firstShow (compileProfile (singleFormatProfile Any fieldFormat))+ concept <-+ profileConcept "format" [("type", String "Extension"), ("value", String value)] "# Format\n"+ pure (validateProfile PermissiveConformance compiled [concept])+ mismatch fieldFormat value = [ValueFormatMismatch cid (fieldPath "value") fieldFormat (String value)]+ for_ ["human:ahormati", "process:finance-nightly", "reference_agent/gemini-2.5-pro"] $ \value ->+ check Actor value >>= assertEqual []+ for_ ["nadeem", "team:ga4-docs", "Human:ahormati", "human:", "/version", "producer/"] $ \value ->+ check Actor value >>= assertEqual (mismatch Actor value)+ check Profile.HumanActor "human:ahormati" >>= assertEqual []+ for_ ["process:finance-nightly", "reference_agent/gemini-2.5-pro", "nadeem"] $ \value ->+ check Profile.HumanActor value >>= assertEqual (mismatch Profile.HumanActor value)++-- | The non-textual formats. A numeric /string/ is reported rather than+-- coerced, which is the point of being able to declare the format at all.+testNonTextualFormatValidation :: Either Text ()+testNonTextualFormatValidation = do+ cid <- parseTestConceptId "format"+ let checkWith cardinality fieldFormat actual = do+ compiled <- firstShow (compileProfile (singleFormatProfile cardinality fieldFormat))+ concept <-+ profileConcept "format" [("type", String "Extension"), ("value", actual)] "# Format\n"+ pure (validateProfile PermissiveConformance compiled [concept])+ check = checkWith Any+ mismatch fieldFormat actual = [ValueFormatMismatch cid (fieldPath "value") fieldFormat actual]+ for_ [Number 5000, Number 0, Number (-3)] $ \actual ->+ check Integer actual >>= assertEqual []+ for_ [Number 5000, Number 0] $ \actual ->+ check NonNegativeInteger actual >>= assertEqual []+ check NonNegativeInteger (Number (-3)) >>= assertEqual (mismatch NonNegativeInteger (Number (-3)))+ for_ [Integer, NonNegativeInteger] $ \fieldFormat ->+ for_ [String "5000", Number 5.5, Bool True] $ \actual ->+ check fieldFormat actual >>= assertEqual (mismatch fieldFormat actual)+ for_ [Bool True, Bool False] $ \actual ->+ check Boolean actual >>= assertEqual []+ for_ [String "true", Number 1] $ \actual ->+ check Boolean actual >>= assertEqual (mismatch Boolean actual)+ -- A list is a list of values, so a format constrains each element. The+ -- cardinality has to be declared, because a numeric format alone refines an+ -- unspecified one to scalar.+ let integers = toJSON ([1, 2, 3] :: [Int])+ mixed = toJSON ([Number 1, String "2"] :: [Value])+ checkWith List Integer integers >>= assertEqual []+ checkWith List Integer mixed >>= assertEqual (mismatch Integer mixed)++-- | Declaring a non-textual format and no cardinality refines the rule to+-- 'Scalar'. Without the refinement the 'Any' cardinality routes presence through+-- the legacy predicate, which counts only non-empty text and non-empty arrays,+-- so a present @usage_count: 5000@ is reported /missing/ before its value is+-- ever examined. An explicitly declared cardinality still wins.+testNonTextualFormatRefinesCardinality :: Either Text ()+testNonTextualFormatRefinesCardinality = do+ cid <- parseTestConceptId "counted"+ let specWith cardinality fieldFormat =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required =+ [ fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing,+ fieldRule "usage_count" Nothing [] cardinality (Just fieldFormat) Nothing Nothing Nothing+ ],+ recommended = [],+ optional = []+ },+ allowUnknownTypes = True,+ types = []+ }+ check cardinality fieldFormat actual = do+ compiled <- firstShow (compileProfile (specWith cardinality fieldFormat))+ concept <-+ profileConcept "counted" [("type", String "Extension"), ("usage_count", actual)] "# Counted\n"+ pure (validateProfile PermissiveConformance compiled [concept])+ for_ [NonNegativeInteger, Integer] $ \fieldFormat ->+ check Any fieldFormat (Number 5000) >>= assertEqual []+ check Any Boolean (Bool False) >>= assertEqual []+ -- The gap this closes: a textual format leaves the rule at 'Any', where a+ -- number still does not count as present. The value check runs regardless of+ -- the presence verdict, so both violations are reported.+ check Any Rfc3339Utc (Number 5000)+ >>= assertEqual+ [ MissingProfileField cid "usage_count" Nothing,+ ValueFormatMismatch cid (fieldPath "usage_count") Rfc3339Utc (Number 5000)+ ]+ -- An explicit cardinality wins, and a list of integers stays coherent rather+ -- than becoming an error.+ let integers = toJSON ([1, 2] :: [Int])+ check List NonNegativeInteger integers >>= assertEqual []+ check Scalar NonNegativeInteger integers+ >>= assertEqual [CardinalityMismatch cid (fieldPath "usage_count") Scalar integers]++-- | The two new narrowing pairs, checked the way the 'Uri'/'UriWithScheme' pair+-- is: a profile-scope format and a narrower type-scope one compile to the+-- narrower rule rather than to a 'ConflictingFieldFormat'.+testNewFormatsNarrowAcrossScopes :: Either Text ()+testNewFormatsNarrowAcrossScopes = do+ cid <- parseTestConceptId "narrowing"+ let baseType = firstTypeRule typeAwareProfileSpec+ profileWith profileFormat typeFormat =+ typeAwareProfileSpec+ { -- v0.2, for the same reason 'singleFormatProfile' is: the narrowing+ -- pairs under test include the actor formats.+ okfVersion = "0.2",+ frontmatter =+ FrontmatterRules+ { required =+ [ fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing,+ fieldRule "value" Nothing [] Any (Just profileFormat) Nothing Nothing Nothing+ ],+ recommended = [],+ optional = []+ },+ types =+ [ withTypeFrontmatter+ FrontmatterRules+ { required = [fieldRule "value" Nothing [] Any (Just typeFormat) Nothing Nothing Nothing],+ recommended = [],+ optional = []+ }+ baseType+ ]+ }+ check profileFormat typeFormat actual = do+ compiled <- firstShow (compileProfile (profileWith profileFormat typeFormat))+ concept <-+ profileConcept "narrowing" [("type", String "Owned Concept"), ("value", actual)] "# Narrowing\n"+ pure (validateProfile PermissiveConformance compiled [concept])+ for_ [(Actor, Profile.HumanActor), (Profile.HumanActor, Actor)] $ \(wide, narrow) -> do+ check wide narrow (String "human:ahormati") >>= assertEqual []+ check wide narrow (String "process:nightly")+ >>= assertEqual+ [ValueFormatMismatch cid (fieldPath "value") Profile.HumanActor (String "process:nightly")]+ for_ [(Integer, NonNegativeInteger), (NonNegativeInteger, Integer)] $ \(wide, narrow) -> do+ check wide narrow (Number 5000) >>= assertEqual []+ check wide narrow (Number (-3))+ >>= assertEqual+ [ValueFormatMismatch cid (fieldPath "value") NonNegativeInteger (Number (-3))]+ assertEqual+ (Left (ConflictingFieldFormat (fieldPath "value") Actor Integer :| []))+ (compileProfile (profileWith Actor Integer))++-- | Declares @okfVersion = "0.2"@ rather than inheriting 'typeAwareProfileSpec'\'s+-- @"0.1"@, because the formats under test include the OKF v0.2 actor+-- convention, and 'compileProfile' rejects a v0.2 format under a v0.1+-- declaration.+singleFormatProfile :: Cardinality -> FieldFormat -> ProfileSpec+singleFormatProfile cardinality fieldFormat =+ typeAwareProfileSpec+ { okfVersion = "0.2",+ frontmatter =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],+ recommended = [fieldRule "value" Nothing [] cardinality (Just fieldFormat) Nothing Nothing Nothing],+ optional = []+ },+ allowUnknownTypes = True,+ types = []+ }++singleCardinalityProfile :: Bool -> Cardinality -> [Text] -> ProfileSpec+singleCardinalityProfile isRequired cardinality allowed =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing] <> [rule | isRequired],+ recommended = [rule | not isRequired],+ optional = []+ },+ allowUnknownTypes = True,+ types = []+ }+ where+ rule = fieldRule "value" Nothing allowed cardinality Nothing Nothing Nothing Nothing++testCompiledNestedRules :: Either Text ()+testCompiledNestedRules = do+ let profileRules =+ NestedRules+ { required = [NestedFieldRule "kind" Nothing ["decision", "implementation"] Any Nothing Nothing Nothing],+ recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing Nothing],+ optional = []+ }+ typeRules =+ NestedRules+ { required =+ [ NestedFieldRule "kind" Nothing ["implementation", "operations"] Any Nothing Nothing Nothing,+ NestedFieldRule "outcome" Nothing ["approved", "rejected"] Any Nothing Nothing Nothing+ ],+ recommended = [],+ optional = []+ }+ base = nestedProfileWithRules Any profileRules (Just typeRules)+ compiled <- firstShow (compileProfile base)+ concept <-+ profileConcept+ "reviewed/merge"+ [ ("type", String "Reviewed Concept"),+ ( "reviews",+ toJSON+ [ object+ [ "kind" .= ("decision" :: Text),+ "outcome" .= ("approved" :: Text)+ ]+ ]+ )+ ]+ "# Merge\n"+ cid <- parseTestConceptId "reviewed/merge"+ assertEqual+ [ ValueNotInVocabulary+ cid+ (nestedTestPath 0 "kind")+ ["implementation"]+ (String "decision")+ ]+ (validateProfile PermissiveConformance compiled [concept])+ let impossible = nestedProfileWithRules Scalar profileRules Nothing+ assertEqual+ (Left (ElementFieldsRequireList Nothing (fieldPath "reviews") Scalar :| []))+ (compileProfile impossible)++testNestedRecordValidation :: Either Text ()+testNestedRecordValidation = do+ compiled <- firstShow (compileProfile nestedReviewProfileSpec)+ let firstReview =+ object+ [ "kind" .= ("human" :: Text),+ "reviewer" .= ("Ari" :: Text),+ "reviewed_at" .= ("2026-07-29T16:00:00Z" :: Text),+ "document_timestamp" .= ("2026-07-29T17:00:00Z" :: Text),+ "scope" .= ("content" :: Text),+ "outcome" .= ("approved" :: Text),+ "context" .= ("Complete" :: Text),+ "notes" .= ("No blockers" :: Text),+ "provider" .= ("allowed-extra-key" :: Text)+ ]+ thirdReview =+ object+ [ "kind" .= ("model" :: Text),+ "reviewer" .= ("Bo" :: Text),+ "reviewed_at" .= ("2026-13-45T99:99:99Z" :: Text),+ "document_timestamp" .= ("2026-07-29T17:00:00Z" :: Text),+ "scope" .= ("invalid" :: Text),+ "context" .= (["wrong"] :: [Text])+ ]+ reviewValues = toJSON [firstReview, String "not-a-record", thirdReview]+ concept <-+ profileConcept+ "reviewed/bad"+ [("type", String "Reviewed Concept"), ("reviews", reviewValues)]+ "# Bad\n"+ cid <- parseTestConceptId "reviewed/bad"+ let permissiveExpected =+ [ NestedElementNotRecord cid (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),+ CardinalityMismatch cid (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),+ MissingNestedProfileField cid (nestedTestPath 2 "outcome") Nothing,+ ValueFormatMismatch cid (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),+ ValueNotInVocabulary cid (nestedTestPath 2 "scope") reviewScopes (String "invalid")+ ]+ assertEqual permissiveExpected (validateProfile PermissiveConformance compiled [concept])+ assertEqual+ [ NestedElementNotRecord cid (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),+ CardinalityMismatch cid (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),+ MissingRecommendedNestedProfileField cid (nestedTestPath 2 "notes") Nothing,+ MissingNestedProfileField cid (nestedTestPath 2 "outcome") Nothing,+ ValueFormatMismatch cid (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),+ ValueNotInVocabulary cid (nestedTestPath 2 "scope") reviewScopes (String "invalid")+ ]+ (validateProfile StrictAuthoring compiled [concept])++nestedProfileWithRules :: Cardinality -> NestedRules -> Maybe NestedRules -> ProfileSpec+nestedProfileWithRules outerCardinality profileNested typeNested =+ ProfileSpec+ { name = "nested-merge",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required =+ [ requiredField "type",+ fieldRule "reviews" Nothing [] outerCardinality Nothing (Just profileNested) Nothing Nothing+ ],+ recommended = [],+ optional = []+ },+ allowUnknownTypes = False,+ allowUnknownFields = True,+ idField = Nothing,+ requireBundleVersion = Nothing,+ types =+ [ TypeRule+ { type_ = "Reviewed Concept",+ description = Nothing,+ frontmatter =+ FrontmatterRules+ { required = maybe [] (\rules -> [fieldRule "reviews" Nothing [] Any Nothing (Just rules) Nothing Nothing]) typeNested,+ recommended = [],+ optional = []+ },+ pathPattern = Nothing,+ resourceScheme = Nothing,+ requireSchemaSection = False,+ schemaColumns = [],+ idPrefix = Nothing+ }+ ]+ }++nestedReviewProfileSpec :: ProfileSpec+nestedReviewProfileSpec =+ nestedProfileWithRules Any nestedRules Nothing+ where+ nestedRules =+ NestedRules+ { required =+ [ NestedFieldRule "kind" Nothing ["human", "model"] Any Nothing Nothing Nothing,+ NestedFieldRule "reviewer" Nothing [] Scalar Nothing Nothing Nothing,+ NestedFieldRule "reviewed_at" Nothing [] Any (Just Rfc3339Utc) Nothing Nothing,+ NestedFieldRule "document_timestamp" Nothing [] Any (Just Rfc3339Utc) Nothing Nothing,+ NestedFieldRule "scope" Nothing reviewScopes Any Nothing Nothing Nothing,+ NestedFieldRule "outcome" Nothing ["approved", "changes-requested", "commented"] Any Nothing Nothing Nothing,+ NestedFieldRule "context" Nothing [] Scalar Nothing Nothing Nothing+ ],+ recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing Nothing],+ optional = []+ }++nestedTestPath :: Int -> Text -> FieldPath+nestedTestPath elementIndex key =+ FieldPath (FieldName "reviews" :| [ArrayIndex elementIndex, FieldName key])++-- | A profile whose single object-valued key carries the given member rules and+-- the given declared cardinality. Built with record syntax rather than the+-- positional 'fieldRule' helper precisely because @objectFields@ is the member+-- under test here.+objectProfileWithRules :: Text -> Cardinality -> Maybe NestedRules -> Maybe NestedRules -> ProfileSpec+objectProfileWithRules key declaredCardinality objectRules elementRules =+ ProfileSpec+ { name = "object-rules",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required =+ [ requiredField "type",+ FieldRule+ { field = key,+ description = Nothing,+ allowedValues = [],+ cardinality = declaredCardinality,+ format = Nothing,+ elementFields = elementRules,+ objectFields = objectRules,+ reference = Nothing,+ path = Nothing,+ when = Nothing+ }+ ],+ recommended = [],+ optional = []+ },+ allowUnknownTypes = True,+ allowUnknownFields = True,+ idField = Nothing,+ requireBundleVersion = Nothing,+ types = []+ }++-- | The member rules used by the object-rule tests: @by@ is demanded, @at@ is+-- recommended and must be an RFC3339 UTC timestamp.+provenanceMemberRules :: NestedRules+provenanceMemberRules =+ NestedRules+ { required = [NestedFieldRule "by" (Just "Who or what produced this content.") [] Any Nothing Nothing Nothing],+ recommended = [NestedFieldRule "at" Nothing [] Any (Just Rfc3339Utc) Nothing Nothing],+ optional = []+ }++-- | Declaring @objectFields@ next to an explicit scalar or list cardinality is+-- incoherent — a mapping is neither — and is rejected at compile time in both+-- directions, mirroring 'ElementFieldsRequireList' for the opposite mistake.+testObjectFieldsRequireObjectShape :: Either Text ()+testObjectFieldsRequireObjectShape = do+ assertEqual+ (Left (ObjectFieldsRequireObjectShape Nothing (fieldPath "generated") List :| []))+ (compileProfile (objectProfileWithRules "generated" List (Just provenanceMemberRules) Nothing))+ assertEqual+ (Left (ObjectFieldsRequireObjectShape Nothing (fieldPath "generated") Scalar :| []))+ (compileProfile (objectProfileWithRules "generated" Scalar (Just provenanceMemberRules) Nothing))++-- | A rule that declares object members and no explicit cardinality is refined+-- to 'Object', the compiled-only cardinality that has no Dhall spelling, and its+-- members are reachable through the new accessor.+testCompileObjectRule :: Either Text ()+testCompileObjectRule = do+ compiled <-+ firstShow+ (compileProfile (objectProfileWithRules "generated" Any (Just provenanceMemberRules) Nothing))+ rule <- lookupBaseRule compiled "generated"+ assertEqual Object (fieldRuleCardinality rule)+ assertEqual Nothing (fieldRuleElementFields rule)+ case fieldRuleObjectFields rule of+ Nothing -> Left "expected compiled object member rules"+ Just members -> do+ assertEqual ["at", "by"] (Map.keys members)+ byRule <- maybe (Left "expected a rule for by") Right (Map.lookup "by" members)+ assertEqual+ (Just "Who or what produced this content.")+ (fieldRuleDescription byRule)+ assertEqual [RequiredField] (map presenceClauseRequirement (fieldRulePresenceClauses byRule))+ -- Depth-bounded: a member rule never itself carries a nested shape.+ assertEqual Nothing (fieldRuleObjectFields byRule)+ assertEqual Nothing (fieldRuleElementFields byRule)++-- | A rule declaring both shapes stays at 'Any' cardinality, which is what lets+-- either OKF v0.2 spelling of @verified@ satisfy it, and compiles the same+-- member rules under both accessors.+testCompileRecordOrListRule :: Either Text ()+testCompileRecordOrListRule = do+ compiled <-+ firstShow+ ( compileProfile+ (objectProfileWithRules "verified" Any (Just provenanceMemberRules) (Just provenanceMemberRules))+ )+ rule <- lookupBaseRule compiled "verified"+ assertEqual Any (fieldRuleCardinality rule)+ assertEqual (Just ["at", "by"]) (Map.keys <$> fieldRuleObjectFields rule)+ assertEqual (Just ["at", "by"]) (Map.keys <$> fieldRuleElementFields rule)++-- | A profile with one path rule on a top-level key, plus the given rules on the+-- same key at type scope, so scheme intersection across scopes is exercisable.+pathProfileWith :: Maybe PathReferenceRule -> Maybe FieldFormat -> Maybe HandleReferenceRule -> Maybe PathReferenceRule -> ProfileSpec+pathProfileWith profilePath declaredFormat handlePolicy typePath =+ ProfileSpec+ { name = "path-rules",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required = [requiredField "type"],+ recommended = [],+ -- Optional rather than required, so a concept that simply does not+ -- carry the key reports nothing and the tests below see only the+ -- value checks they are about. An optional rule is fully+ -- value-checked whenever it is present.+ optional =+ [ (requiredField "resource")+ { format = declaredFormat,+ reference = handlePolicy,+ path = profilePath+ }+ ]+ },+ allowUnknownTypes = True,+ allowUnknownFields = True,+ idField = if isJust handlePolicy then Just "docId" else Nothing,+ requireBundleVersion = Nothing,+ types =+ [ TypeRule+ { type_ = "Metric",+ description = Nothing,+ frontmatter =+ FrontmatterRules+ { required = [],+ recommended = [],+ optional = [(requiredField "resource") {path = typePath}]+ },+ pathPattern = Nothing,+ resourceScheme = Nothing,+ requireSchemaSection = False,+ schemaColumns = [],+ idPrefix = if isJust handlePolicy then Just "ADR" else Nothing+ }+ | isJust typePath || isJust handlePolicy+ ]+ }++-- | A profile whose @sources@ key is a list of records, each of which carries a+-- path rule on @resource@ — the OKF v0.2 §6.2 field the rule kind exists for,+-- and the one that was unreachable before nested path checking existed.+sourcesPathProfile :: [Text] -> ProfileSpec+sourcesPathProfile permittedSchemes =+ ProfileSpec+ { name = "sources-paths",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required = [requiredField "type"],+ recommended = [],+ -- Optional for the same reason 'pathProfileWith' is: the tests are+ -- about what a present value resolves to, not about presence.+ optional = [fieldRule "sources" Nothing [] List Nothing (Just memberRules) Nothing Nothing]+ },+ allowUnknownTypes = True,+ allowUnknownFields = True,+ idField = Nothing,+ requireBundleVersion = Nothing,+ types = []+ }+ where+ memberRules =+ NestedRules+ { required =+ [ (NestedFieldRule "resource" Nothing [] Any Nothing Nothing Nothing)+ { path = Just (PathReferenceRule permittedSchemes False)+ }+ ],+ recommended = [],+ optional = []+ }++-- | Compiling a path rule normalizes its scheme list exactly as a handle rule's+-- is — deduplicated case-insensitively and case-folded for storage — and the+-- result is reachable through the new accessor at top-level, nested, and object+-- scope. Nested reachability is the part with no precedent:+-- 'compileOptionalNestedFieldRule' previously hard-coded every reference member+-- to 'Nothing'.+testCompilePathRule :: Either Text ()+testCompilePathRule = do+ compiled <-+ firstShow+ (compileProfile (pathProfileWith (Just (PathReferenceRule ["HTTPS", "https", "mori"] True)) Nothing Nothing Nothing))+ rule <- lookupBaseRule compiled "resource"+ assertEqual (Just (PathReferenceRule ["https", "mori"] True)) (fieldRulePath rule)+ -- A path rule and a handle rule are different things and never both present.+ assertEqual Nothing (fieldRuleReference rule)+ nestedCompiled <- firstShow (compileProfile (sourcesPathProfile ["https"]))+ sourcesRule <- lookupBaseRule nestedCompiled "sources"+ case fieldRuleElementFields sourcesRule of+ Nothing -> Left "expected compiled element member rules for sources"+ Just members -> do+ memberRule <- maybe (Left "expected a rule for resource") Right (Map.lookup "resource" members)+ assertEqual (Just (PathReferenceRule ["https"] False)) (fieldRulePath memberRule)+ -- Nested rules stay depth-bounded and carry no handle policy at all.+ assertEqual Nothing (fieldRuleReference memberRule)++-- | A type-scope path rule narrows the profile-scope one rather than replacing+-- it: schemes intersect and @allowSelf@ combines with logical AND. Unlike+-- 'mergeReferenceRule' the merge is total, because a path policy has no+-- @localPrefix@ for two scopes to disagree about.+testMergePathRule :: Either Text ()+testMergePathRule = do+ compiled <-+ firstShow+ ( compileProfile+ ( pathProfileWith+ (Just (PathReferenceRule ["https", "mori"] True))+ Nothing+ Nothing+ (Just (PathReferenceRule ["mori", "ftp"] False))+ )+ )+ merged <- lookupCompiledRule "resource" (compiledProfileRulesForType compiled "Metric")+ assertEqual (Just (PathReferenceRule ["mori"] False)) (fieldRulePath merged)++-- | The three ways a path rule can be incoherent on its own. Two reuse the+-- handle-reference definition errors because the claim is identical; the third+-- is new, and cannot arise at nested scope where 'NestedFieldRule' carries no+-- handle policy.+testPathDefinitionErrors :: Either Text ()+testPathDefinitionErrors = do+ -- A value cannot be resolved as both a PREFIX-N handle and a §6.2 path.+ assertEqual+ (Left (PathReferenceWithHandleReference Nothing (fieldPath "resource") :| []))+ ( compileProfile+ ( pathProfileWith+ (Just (PathReferenceRule [] False))+ Nothing+ (Just (HandleReferenceRule "ADR" [] False))+ Nothing+ )+ )+ -- A scheme that is not a legal URI scheme, at top-level scope.+ assertEqual+ (Left (InvalidExternalReferenceScheme Nothing (fieldPath "resource") "9nope" :| []))+ (compileProfile (pathProfileWith (Just (PathReferenceRule ["9nope"] False)) Nothing Nothing Nothing))+ -- The same mistake at nested scope, which the reference walk did not reach+ -- before this plan extended it.+ assertEqual+ (Left (InvalidExternalReferenceScheme Nothing (objectMemberPath "sources" "resource") "9nope" :| []))+ (compileProfile (sourcesPathProfile ["9nope"]))+ -- A named format would be checked against text the path rule is already+ -- interpreting structurally, and the two can contradict.+ assertEqual+ (Left (ReferenceWithFormat Nothing (fieldPath "resource") Uri :| []))+ (compileProfile (pathProfileWith (Just (PathReferenceRule [] False)) (Just Uri) Nothing Nothing))++-- | The five outcomes of checking one path value at top-level scope, plus the+-- two shapes that are accepted in silence. Every diagnostic carries the raw text+-- the author wrote rather than the collapsed path okf computed from it.+testValidatePathTopLevel :: Either Text ()+testValidatePathTopLevel = do+ compiled <-+ firstShow+ (compileProfile (pathProfileWith (Just (PathReferenceRule ["https"] False)) Nothing Nothing Nothing))+ cid <- parseTestConceptId "metrics/revenue"+ target <- profileConcept "references/policy" [("type", String "Reference")] "# Policy\n"+ let check value = do+ subject <-+ profileConcept+ "metrics/revenue"+ [("type", String "Metric"), ("resource", value)]+ "# Revenue\n"+ pure (validateProfile PermissiveConformance compiled [subject, target])+ resourcePath = fieldPath "resource"+ -- A bundle path naming a real concept, and a permitted external URL, are both+ -- silent.+ check (String "/references/policy.md") >>= assertEqual []+ check (String "../references/policy.md") >>= assertEqual []+ check (String "https://wiki.acme/revenue") >>= assertEqual []+ -- A path to a non-Markdown file is accepted without a check, because+ -- validateProfile is handed concepts and never looked. §6.3's own example.+ check (String "/references/attesters/revenue.py") >>= assertEqual []+ -- The four failures.+ check (String "/references/gone.md")+ >>= assertEqual [DanglingPathReference cid resourcePath "/references/gone.md"]+ check (String "ftp://files.acme/revenue.csv")+ >>= assertEqual [ExternalReferenceSchemeNotAllowed cid resourcePath "ftp" ["https"]]+ check (String "../../../etc/passwd")+ >>= assertEqual [PathEscapesBundle cid resourcePath "../../../etc/passwd"]+ check (String " ")+ >>= assertEqual [MalformedPathReference cid resourcePath (String " ")]+ -- A value that is not text at all is malformed rather than silently skipped.+ check (Bool True)+ >>= assertEqual [MalformedPathReference cid resourcePath (Bool True)]+ -- A path resolving to the concept that carries it, with allowSelf unset.+ check (String "/metrics/revenue.md")+ >>= assertEqual [SelfDocumentReference cid resourcePath "/metrics/revenue.md"]++-- | The core-versus-profile divergence, closed: 'validateProfileWith' resolves a+-- path naming a file that is not a concept, and 'validateProfile' still cannot+-- and still says nothing.+--+-- Run against a real directory because that is the only way to have an inventory+-- holding a file 'walkBundle' filters out. @dangling-frontmatter-path@ carries+-- both halves already: @non-markdown.md@ names a @.py@ that is there and+-- @dangling.md@ names a @.txt@ that is not.+--+-- Specification §6.3's example is exactly this shape, and+-- @docs\/adr\/13-the-references-convention-and-non-markdown-files.md@ records why+-- the silent answer is preserved rather than tightened: a caller with no+-- directory has not looked, and reporting a target it never checked would be a+-- claim okf cannot back.+testValidateProfileWithInventory :: IO (Either Text ())+testValidateProfileWithInventory = do+ root <- fixturePath "dangling-frontmatter-path"+ concepts <- readBundle root+ inventory <- readBundleInventory root+ pure+ ( do+ compiled <-+ firstShow+ ( compileProfile+ (pathProfileWith (Just (PathReferenceRule ["bigquery"] False)) Nothing Nothing Nothing)+ )+ danglingId <- firstShow (parseConceptId "dangling")+ specSpellingId <- firstShow (parseConceptId "computations/spec-spelling")+ let resourcePath = fieldPath "resource"+ -- With the full inventory: the missing .txt and the relative .py that+ -- resolves outside the bundle are both reported, and the .py that is+ -- really there is silent.+ assertEqual+ [ DanglingPathReference specSpellingId resourcePath "references/attesters/revenue.py",+ DanglingPathReference danglingId resourcePath "/references/deleted.txt"+ ]+ (validateProfileWith inventory PermissiveConformance compiled concepts)+ -- Without it: nothing at all, exactly as before this plan. Neither target+ -- is Markdown, so a concepts-only caller has not looked at either.+ assertEqual [] (validateProfile PermissiveConformance compiled concepts)+ )++-- | The specification §10 contract expressed as a house convention, run against+-- the bundle it was written for.+--+-- This is the descriptor @docs\/user\/profiles.md@ shows, and this test is what+-- stops that document's transcript from rotting: the descriptor must keep+-- compiling ('testFrozenFixturesCompile' lists it) and must keep reporting these+-- deviations and no others.+--+-- What it demonstrates is the boundary this whole initiative rests on. okf's core+-- enforces exactly §10.2's one REQUIRED field and §10.3's exactly-one rule;+-- everything below — that a parameter carry a @type@, that an executor be+-- declared at all — is a team's own policy, reached with @objectFields@ for the+-- mapping-valued keys and @elementFields@ for the list-valued one, and scoped to+-- one @type@ with a 'TypeRule' so no @Metric@ in the bundle is asked for a+-- @runtime@.+--+-- @computations\/churn@ is the concept that makes the point: it produces no core+-- diagnostic at all and one deviation here.+testAttestedComputationHouseProfile :: IO (Either Text ())+testAttestedComputationHouseProfile = do+ descriptorPath <- fixtureFilePath "profiles/attested-computation-house.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "attested-computation"+ concepts <- readBundle root+ inventory <- readBundleInventory root+ pure $ case loaded of+ Left err -> Left ("failed to load the house attested computation profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ bothId <- firstShow (parseConceptId "computations/both-computations")+ churnId <- firstShow (parseConceptId "computations/churn")+ marginId <- firstShow (parseConceptId "computations/margin")+ noneId <- firstShow (parseConceptId "computations/no-computation")+ twoBlocksId <- firstShow (parseConceptId "computations/two-blocks")+ -- Only the required rules fire in permissive mode. `computations/revenue`+ -- carries the whole contract and is absent from both lists.+ assertEqual+ [ MissingProfileField bothId "executor" Nothing,+ MissingProfileField bothId "parameters" Nothing,+ MissingNestedProfileField churnId parameterTypePath Nothing,+ MissingProfileField marginId "executor" Nothing,+ MissingProfileField noneId "executor" Nothing,+ MissingProfileField noneId "parameters" Nothing,+ MissingProfileField twoBlocksId "executor" Nothing,+ MissingProfileField twoBlocksId "parameters" Nothing+ ]+ (validateProfileWith inventory PermissiveConformance compiled concepts)+ -- Under strict the recommended rules join them, including the nested+ -- `executor.receipt` on the one concept that declares an executor without+ -- one. No path deviation appears in either list: every path-valued field in+ -- this bundle resolves.+ assertEqual+ [ MissingRecommendedProfileField bothId "attester" Nothing,+ MissingProfileField bothId "executor" Nothing,+ MissingProfileField bothId "parameters" Nothing,+ MissingRecommendedProfileField churnId "attester" Nothing,+ MissingRecommendedNestedProfileField churnId executorReceiptPath Nothing,+ MissingNestedProfileField churnId parameterTypePath Nothing,+ MissingRecommendedProfileField marginId "attester" Nothing,+ MissingProfileField marginId "executor" Nothing,+ MissingRecommendedProfileField noneId "attester" Nothing,+ MissingProfileField noneId "executor" Nothing,+ MissingProfileField noneId "parameters" Nothing,+ MissingRecommendedProfileField twoBlocksId "attester" Nothing,+ MissingProfileField twoBlocksId "executor" Nothing,+ MissingProfileField twoBlocksId "parameters" Nothing+ ]+ (validateProfileWith inventory StrictAuthoring compiled concepts)+ where+ parameterTypePath = FieldPath (FieldName "parameters" :| [ArrayIndex 0, FieldName "type"])+ executorReceiptPath = FieldPath (FieldName "executor" :| [FieldName "receipt"])++-- | The motivating case: a path rule on @sources[].resource@, reported with the+-- element index so the author can find the entry. Nested path checking did not+-- exist before this plan — 'NestedFieldRule' had no reference member of any kind+-- — so this is the assertion the whole plan is for.+testValidatePathNested :: Either Text ()+testValidatePathNested = do+ compiled <- firstShow (compileProfile (sourcesPathProfile ["https"]))+ cid <- parseTestConceptId "metric"+ target <- profileConcept "references/policy" [("type", String "Reference")] "# Policy\n"+ subject <-+ profileConcept+ "metric"+ [ ("type", String "Metric"),+ ( "sources",+ toJSON+ [ object ["id" .= ("policy" :: Text), "resource" .= ("/references/policy.md" :: Text)],+ object ["id" .= ("gone" :: Text), "resource" .= ("/references/gone.md" :: Text)],+ object ["id" .= ("upstream" :: Text), "resource" .= ("https://wiki.acme/revenue" :: Text)],+ object ["id" .= ("ftp" :: Text), "resource" .= ("ftp://files.acme/revenue.csv" :: Text)],+ object ["id" .= ("escape" :: Text), "resource" .= ("../../etc/passwd" :: Text)],+ object ["id" .= ("script" :: Text), "resource" .= ("references/attesters/revenue.py" :: Text)]+ ]+ )+ ]+ "# Revenue\n"+ assertEqual+ [ DanglingPathReference cid (nestedTestPathIn "sources" 1 "resource") "/references/gone.md",+ ExternalReferenceSchemeNotAllowed cid (nestedTestPathIn "sources" 3 "resource") "ftp" ["https"],+ PathEscapesBundle cid (nestedTestPathIn "sources" 4 "resource") "../../etc/passwd"+ ]+ (validateProfile PermissiveConformance compiled [subject, target])++-- | The same rule kind at object scope, where the member is named without an+-- index: @executor.resource@ rather than @executor[0].resource@.+testValidatePathObjectScope :: Either Text ()+testValidatePathObjectScope = do+ compiled <-+ firstShow+ ( compileProfile+ ( objectProfileWithRules+ "executor"+ Any+ ( Just+ NestedRules+ { required =+ [ (NestedFieldRule "resource" Nothing [] Any Nothing Nothing Nothing)+ { path = Just (PathReferenceRule [] False)+ }+ ],+ recommended = [],+ optional = []+ }+ )+ Nothing+ )+ )+ cid <- parseTestConceptId "thing"+ subject <-+ profileConcept+ "thing"+ [("type", String "Thing"), ("executor", object ["resource" .= ("/computations/gone.md" :: Text)])]+ "# Thing\n"+ assertEqual+ [DanglingPathReference cid (objectMemberPath "executor" "resource") "/computations/gone.md"]+ (validateProfile PermissiveConformance compiled [subject])++nestedTestPathIn :: Text -> Int -> Text -> FieldPath+nestedTestPathIn parent elementIndex key =+ FieldPath (FieldName parent :| [ArrayIndex elementIndex, FieldName key])++-- | A profile declaring the given @okfVersion@, with the given rules in the+-- given presence list, so the version checks can be exercised in one shape.+versionProfileWith :: Text -> Text -> [FieldRule] -> ProfileSpec+versionProfileWith declaredVersion listName rules =+ ProfileSpec+ { name = "versioned",+ description = Nothing,+ okfVersion = declaredVersion,+ frontmatter =+ FrontmatterRules+ { required = [requiredField "type"] <> [rule | rule <- rules, listName == "required"],+ recommended = [rule | rule <- rules, listName == "recommended"],+ optional = [rule | rule <- rules, listName == "optional"]+ },+ allowUnknownTypes = True,+ allowUnknownFields = True,+ idField = Nothing,+ requireBundleVersion = Nothing,+ types = []+ }++-- | An @okfVersion@ okf cannot read at all, and one naming a major version okf+-- does not implement. The second deliberately diverges from the bundle-side rule+-- of specification §12: a bundle may come from a third party and is read+-- best-effort, while a profile is an instruction to okf whose author is present.+testProfileVersionParsing :: Either Text ()+testProfileVersionParsing = do+ assertEqual+ (Left (InvalidProfileOkfVersion "banana" :| []))+ (compileProfile (versionProfileWith "banana" "optional" []))+ assertEqual+ (Left (ProfileOkfVersionNotUnderstood "1.0" :| []))+ (compileProfile (versionProfileWith "1.0" "optional" []))++-- | A higher /minor/ within a known major is clamped rather than rejected,+-- mirroring 'Okf.Validation.versionGate': §12 defines a minor bump as+-- backward-compatible additions, so a v0.9 profile expresses only rules okf+-- already understands. The clamp is observable through the diagnostic, which+-- names the effective version rather than the declared one.+testProfileVersionMinorClamp :: Either Text ()+testProfileVersionMinorClamp = do+ let timestampRule = fieldRule "timestamp" Nothing [] Any (Just Rfc3339Utc) Nothing Nothing Nothing+ assertEqual+ (Left (FieldSupersededInOkfVersion Nothing (fieldPath "timestamp") "0.2" "0.2" :| []))+ (compileProfile (versionProfileWith "0.9" "recommended" [timestampRule]))+ -- And a v0.9 profile that names nothing version-specific simply compiles.+ case compileProfile (versionProfileWith "0.9" "optional" []) of+ Left errs -> Left ("expected a v0.9 profile to compile, got " <> Text.pack (show (toList errs)))+ Right _ -> Right ()++-- | A profile that demands a bundle version okf cannot parse is rejected at+-- compile time, before any bundle is read: no declaration could ever be compared+-- against it, so the descriptor is asking for something unanswerable.+--+-- An unknown /major/ is deliberately accepted here, unlike in @okfVersion@. There+-- the profile asks okf to interpret rules it may not understand; here it states a+-- minimum that a bundle's own declaration is compared against, which stays+-- meaningful whatever the major is.+testRequiredBundleVersionParsing :: Either Text ()+testRequiredBundleVersionParsing = do+ assertEqual+ (Left (InvalidRequiredBundleVersion "banana" :| []))+ (compileProfile (requireBundleVersionProfile (Just "banana")))+ for_ [Just "0.2", Just "1.0", Nothing] $ \required ->+ case compileProfile (requireBundleVersionProfile required) of+ Left errs -> Left ("expected a clean compile, got " <> Text.pack (show (toList errs)))+ Right _ -> Right ()++-- | What each shape of a bundle's §12 declaration means against a profile that+-- requires 0.2. A bundle ahead of the minimum is not a deviation; one behind it,+-- one that says nothing, and one okf cannot parse all are.+testValidateProfileVersion :: Either Text ()+testValidateProfileVersion = do+ compiled <- firstShow (compileProfile (requireBundleVersionProfile (Just "0.2")))+ assertEqual [] (validateProfileVersion (VersionDeclared (OkfVersion 0 2)) compiled)+ assertEqual [] (validateProfileVersion (VersionDeclared (OkfVersion 0 3)) compiled)+ assertEqual [] (validateProfileVersion (VersionDeclared (OkfVersion 1 0)) compiled)+ assertEqual+ [RequiredBundleVersionUnmet "0.2" (Just "0.1")]+ (validateProfileVersion (VersionDeclared (OkfVersion 0 1)) compiled)+ assertEqual+ [RequiredBundleVersionUnmet "0.2" Nothing]+ (validateProfileVersion VersionUndeclared compiled)+ assertEqual+ [RequiredBundleVersionUnmet "0.2" (Just "banana")]+ (validateProfileVersion (VersionUnparseable "banana") compiled)++-- | The default is inert: a profile that requires nothing reports nothing,+-- whatever the bundle declares. Almost every profile is this one.+testValidateProfileVersionUnrequired :: Either Text ()+testValidateProfileVersionUnrequired = do+ compiled <- firstShow (compileProfile (requireBundleVersionProfile Nothing))+ assertEqual Nothing (compiledProfileRequiredBundleVersion compiled)+ for_+ [ VersionDeclared (OkfVersion 0 1),+ VersionDeclared (OkfVersion 0 2),+ VersionUnparseable "banana",+ VersionUndeclared+ ]+ (\declaration -> assertEqual [] (validateProfileVersion declaration compiled))++-- | A minimal v0.2 profile whose only interesting member is the requirement under+-- test.+requireBundleVersionProfile :: Maybe Text -> ProfileSpec+requireBundleVersionProfile required =+ (versionProfileWith "0.2" "optional" []) {requireBundleVersion = required}++-- | A key the declared version supersedes is an error where it is /demanded/ and+-- legal where it is merely documented. The optional list is how a team migrating+-- a corpus says "tolerated but not demanded", and making it an error everywhere+-- would leave no way to describe a migration.+testProfileVersionSupersededField :: Either Text ()+testProfileVersionSupersededField = do+ let timestampRule = fieldRule "timestamp" Nothing [] Any (Just Rfc3339Utc) Nothing Nothing Nothing+ superseded = FieldSupersededInOkfVersion Nothing (fieldPath "timestamp") "0.2" "0.2"+ assertEqual+ (Left (superseded :| []))+ (compileProfile (versionProfileWith "0.2" "required" [timestampRule]))+ assertEqual+ (Left (superseded :| []))+ (compileProfile (versionProfileWith "0.2" "recommended" [timestampRule]))+ -- The migration shape, and the v0.1 profile that has no reason to be told+ -- anything at all.+ for_ [versionProfileWith "0.2" "optional" [timestampRule], versionProfileWith "0.1" "required" [timestampRule]] $ \spec ->+ case compileProfile spec of+ Left errs -> Left ("expected a clean compile, got " <> Text.pack (show (toList errs)))+ Right _ -> Right ()++-- | The actor formats encode the specification §7 convention v0.2 introduced, so+-- naming one under a v0.1 declaration is incoherent. A format is an okf+-- descriptor feature rather than a key name, which is exactly why this check is+-- safe where the mirror check on key names is not — see+-- 'testProfileVersionDoesNotJudgeKeyNames'.+testProfileVersionActorFormat :: Either Text ()+testProfileVersionActorFormat = do+ let actorRule = fieldRule "author" Nothing [] Any (Just Actor) Nothing Nothing Nothing+ humanRule = fieldRule "author" Nothing [] Any (Just Profile.HumanActor) Nothing Nothing Nothing+ assertEqual+ (Left (FormatRequiresOkfVersion Nothing (fieldPath "author") Actor "0.1" "0.2" :| []))+ (compileProfile (versionProfileWith "0.1" "optional" [actorRule]))+ assertEqual+ (Left (FormatRequiresOkfVersion Nothing (fieldPath "author") Profile.HumanActor "0.1" "0.2" :| []))+ (compileProfile (versionProfileWith "0.1" "optional" [humanRule]))+ -- The same rule under a v0.2 declaration is exactly what the format is for.+ case compileProfile (versionProfileWith "0.2" "optional" [actorRule]) of+ Left errs -> Left ("expected a v0.2 actor rule to compile, got " <> Text.pack (show (toList errs)))+ Right _ -> Right ()++-- | The check okf deliberately does /not/ perform, asserted so it is not added+-- back by someone who thinks it was forgotten.+--+-- A v0.1 profile constraining its own @status@, @sources@, or @verified@ key is+-- coherent: per @docs\/adr\/1-profile-declared-document-ids.md@ constraining keys+-- the core format does not own is what profiles are /for/, and these are ordinary+-- words teams were already using before v0.2 claimed them. Rejecting+-- @field.enum "status" ["proposed", "accepted"]@ for naming an ADR lifecycle+-- would be a false positive on a descriptor okf cannot see.+testProfileVersionDoesNotJudgeKeyNames :: Either Text ()+testProfileVersionDoesNotJudgeKeyNames =+ for_ ["status", "sources", "verified", "generated", "stale_after", "usage_window"] $ \key ->+ let houseRule = fieldRule key Nothing ["proposed", "accepted"] Any Nothing Nothing Nothing Nothing+ in case compileProfile (versionProfileWith "0.1" "optional" [houseRule]) of+ Left errs ->+ Left ("a v0.1 profile naming " <> key <> " should compile, got " <> Text.pack (show (toList errs)))+ Right _ -> Right ()++lookupBaseRule :: CompiledProfile -> Text -> Either Text EffectiveFieldRule+lookupBaseRule compiled key =+ maybe+ (Left ("expected a compiled rule for " <> key))+ Right+ (Map.lookup key (compiledProfileBaseRules compiled))++reviewScopes :: [Text]+reviewScopes = ["content", "technical-accuracy", "editorial", "catalog-metadata", "content-and-metadata"]++testConditionDefinitionErrors :: Either Text ()+testConditionDefinitionErrors = do+ let source key values sourceCardinality =+ fieldRule key Nothing values sourceCardinality Nothing Nothing Nothing Nothing+ target key sourceKey values =+ fieldRule key Nothing [] Any Nothing Nothing Nothing (Just (FieldCondition sourceKey values))+ compileWith rules =+ compileProfile+ typeAwareProfileSpec+ { frontmatter = FrontmatterRules {required = rules, recommended = [], optional = []},+ allowUnknownTypes = True,+ types = []+ }+ targetPath key = fieldPath key+ assertEqual+ (Left (EmptyConditionValues Nothing (targetPath "target") (targetPath "status") :| []))+ (compileWith [source "status" ["active"] Scalar, target "target" "status" []])+ assertEqual+ (Left (ConditionFieldNotDeclared Nothing (targetPath "target") (targetPath "missing") :| []))+ (compileWith [target "target" "missing" ["active"]])+ assertEqual+ (Left (ConditionFieldNotScalar Nothing (targetPath "target") (targetPath "status") List :| []))+ (compileWith [source "status" ["active"] List, target "target" "status" ["active"]])+ assertEqual+ (Left (ConditionFieldOpenVocabulary Nothing (targetPath "target") (targetPath "status") :| []))+ (compileWith [source "status" [] Scalar, target "target" "status" ["active"]])+ assertEqual+ (Left (ConditionFieldHasUnreachableValues Nothing (targetPath "target") (targetPath "status") ["superseded"] ["active"] :| []))+ (compileWith [source "status" ["active"] Scalar, target "target" "status" ["superseded"]])+ assertEqual+ (Left (SelfConditionalField Nothing (targetPath "status") :| []))+ (compileWith [fieldRule "status" Nothing ["active"] Scalar Nothing Nothing Nothing (Just (FieldCondition "status" ["active"]))])+ let nestedCrossScope =+ NestedRules+ { required =+ [ NestedFieldRule "kind" Nothing ["human", "model"] Scalar Nothing Nothing Nothing,+ NestedFieldRule "provider" Nothing [] Scalar Nothing Nothing (Just (FieldCondition "status" ["active"]))+ ],+ recommended = [],+ optional = []+ }+ crossScopeProfile =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required =+ [ source "status" ["active"] Scalar,+ fieldRule "reviews" Nothing [] List Nothing (Just nestedCrossScope) Nothing Nothing+ ],+ recommended = [],+ optional = []+ },+ allowUnknownTypes = True,+ types = []+ }+ assertEqual+ (Left (ConditionFieldNotDeclared Nothing (nestedDefinitionTestPath "reviews" "provider") (nestedDefinitionTestPath "reviews" "status") :| []))+ (compileProfile crossScopeProfile)+ where+ nestedDefinitionTestPath parent child =+ FieldPath (FieldName parent :| [FieldName child])++testTopLevelConditionalPresence :: Either Text ()+testTopLevelConditionalPresence = do+ let statusRule = fieldRule "status" Nothing ["active", "superseded"] Scalar Nothing Nothing Nothing Nothing+ recommendedTarget =+ fieldRule "supersededBy" Nothing ["ADR-1"] Scalar Nothing Nothing Nothing (Just (FieldCondition "status" ["active"]))+ requiredTarget =+ fieldRule "supersededBy" Nothing ["ADR-1"] Scalar Nothing Nothing Nothing (Just (FieldCondition "status" ["superseded"]))+ base =+ typeAwareProfileSpec+ { frontmatter = FrontmatterRules {required = [requiredField "type", statusRule], recommended = [recommendedTarget], optional = []},+ allowUnknownTypes = True,+ types =+ [ withTypeFrontmatter+ FrontmatterRules {required = [requiredTarget], recommended = [], optional = []}+ (firstTypeRule typeAwareProfileSpec)+ ]+ }+ compiled <- firstShow (compileProfile base)+ active <- profileConcept "active" [("type", String "Owned Concept"), ("status", String "active")] "# Active\n"+ superseded <- profileConcept "superseded" [("type", String "Owned Concept"), ("status", String "superseded")] "# Superseded\n"+ invalidPresent <- profileConcept "invalid-present" [("type", String "Owned Concept"), ("status", String "active"), ("supersededBy", String "ADR-2")] "# Invalid\n"+ missingSource <- profileConcept "missing-source" [("type", String "Owned Concept")] "# Missing source\n"+ invalidSource <- profileConcept "invalid-source" [("type", String "Owned Concept"), ("status", String "unknown")] "# Invalid source\n"+ wrongShapeSource <- profileConcept "wrong-shape-source" [("type", String "Owned Concept"), ("status", toJSON (["active"] :: [Text]))] "# Wrong shape\n"+ activeId <- parseTestConceptId "active"+ supersededId <- parseTestConceptId "superseded"+ invalidId <- parseTestConceptId "invalid-present"+ missingSourceId <- parseTestConceptId "missing-source"+ invalidSourceId <- parseTestConceptId "invalid-source"+ wrongShapeSourceId <- parseTestConceptId "wrong-shape-source"+ assertEqual [] (validateProfile PermissiveConformance compiled [active])+ assertEqual+ [MissingRecommendedProfileField activeId "supersededBy" (Just (FieldCondition "status" ["active"]))]+ (validateProfile StrictAuthoring compiled [active])+ assertEqual+ [MissingProfileField supersededId "supersededBy" (Just (FieldCondition "status" ["superseded"]))]+ (validateProfile PermissiveConformance compiled [superseded])+ assertEqual+ [ValueNotInVocabulary invalidId (fieldPath "supersededBy") ["ADR-1"] (String "ADR-2")]+ (validateProfile PermissiveConformance compiled [invalidPresent])+ assertEqual+ [MissingProfileField missingSourceId "status" Nothing]+ (validateProfile PermissiveConformance compiled [missingSource])+ assertEqual+ [ValueNotInVocabulary invalidSourceId (fieldPath "status") ["active", "superseded"] (String "unknown")]+ (validateProfile PermissiveConformance compiled [invalidSource])+ assertEqual+ [CardinalityMismatch wrongShapeSourceId (fieldPath "status") Scalar (toJSON (["active"] :: [Text]))]+ (validateProfile PermissiveConformance compiled [wrongShapeSource])++testNestedConditionalPresence :: Either Text ()+testNestedConditionalPresence = do+ let nestedRules =+ NestedRules+ { required =+ [ NestedFieldRule "kind" Nothing ["human", "model"] Scalar Nothing Nothing Nothing,+ NestedFieldRule "provider" Nothing [] Scalar Nothing Nothing (Just (FieldCondition "kind" ["model"]))+ ],+ recommended =+ [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing (Just (FieldCondition "kind" ["human"]))],+ optional = []+ }+ spec = nestedProfileWithRules List nestedRules Nothing+ compiled <- firstShow (compileProfile spec)+ concept <-+ profileConcept+ "conditional-reviews"+ [ ("type", String "Reviewed Concept"),+ ("reviews", toJSON [object ["kind" .= ("model" :: Text)], object ["kind" .= ("human" :: Text)], object []])+ ]+ "# Conditional reviews\n"+ cid <- parseTestConceptId "conditional-reviews"+ assertEqual+ [MissingNestedProfileField cid (nestedTestPath 0 "provider") (Just (FieldCondition "kind" ["model"])), MissingNestedProfileField cid (nestedTestPath 2 "kind") Nothing]+ (validateProfile PermissiveConformance compiled [concept])+ assertEqual+ [ MissingNestedProfileField cid (nestedTestPath 0 "provider") (Just (FieldCondition "kind" ["model"])),+ MissingRecommendedNestedProfileField cid (nestedTestPath 1 "notes") (Just (FieldCondition "kind" ["human"])),+ MissingNestedProfileField cid (nestedTestPath 2 "kind") Nothing+ ]+ (validateProfile StrictAuthoring compiled [concept])++testReferenceDefinitionErrors :: Either Text ()+testReferenceDefinitionErrors = do+ let referenceRule key prefix schemes fieldFormat =+ fieldRule+ key+ Nothing+ []+ Scalar+ fieldFormat+ Nothing+ (Just (HandleReferenceRule prefix schemes False))+ Nothing+ baseType = firstTypeRule testDocumentIdProfileSpec+ specWith profileIdField typeRules profileRules =+ testDocumentIdProfileSpec+ { frontmatter = FrontmatterRules {required = profileRules, recommended = [], optional = []},+ idField = profileIdField,+ requireBundleVersion = Nothing,+ types = typeRules+ }+ path = fieldPath "supersedes"+ invalidPrefixType = baseType {idPrefix = Just "1ADR"}+ invalidPrefixSpec = specWith (Just "docId") [invalidPrefixType] [referenceRule "supersedes" "1ADR" [] Nothing]+ undeclaredPrefixSpec = specWith (Just "docId") [baseType] [referenceRule "supersedes" "PAT" [] Nothing]+ missingIdFieldSpec = specWith Nothing [baseType] [referenceRule "supersedes" "ADR" [] Nothing]+ invalidSchemeSpec = specWith (Just "docId") [baseType] [referenceRule "supersedes" "ADR" ["mori_", "MORI_"] Nothing]+ formatSpec = specWith (Just "docId") [baseType] [referenceRule "supersedes" "ADR" [] (Just (DocumentHandle "ADR"))]+ typeReference = referenceRule "supersedes" "RFC" [] Nothing+ conflictingType :: TypeRule+ conflictingType = baseType & #frontmatter .~ FrontmatterRules {required = [typeReference], recommended = [], optional = []}+ rfcType = baseType {type_ = "RFC", idPrefix = Just "RFC", pathPattern = Nothing}+ conflictSpec = specWith (Just "docId") [conflictingType, rfcType] [referenceRule "supersedes" "ADR" [] Nothing]+ assertEqual+ (Left (InvalidReferencePrefix Nothing path "1ADR" :| []))+ (compileProfile invalidPrefixSpec)+ assertEqual+ (Left (ReferencePrefixNotDeclared Nothing path "PAT" :| []))+ (compileProfile undeclaredPrefixSpec)+ assertEqual+ (Left (ReferenceRequiresIdField Nothing path :| []))+ (compileProfile missingIdFieldSpec)+ assertEqual+ (Left (InvalidExternalReferenceScheme Nothing path "mori_" :| []))+ (compileProfile invalidSchemeSpec)+ assertEqual+ (Left (ReferenceWithFormat Nothing path (DocumentHandle "ADR") :| []))+ (compileProfile formatSpec)+ assertEqual+ (Left (ConflictingReferencePrefix "Decision Record" path "ADR" "RFC" :| []))+ (compileProfile conflictSpec)++testDocumentReferenceValidation :: Either Text ()+testDocumentReferenceValidation = do+ let referencePolicy = HandleReferenceRule "ADR" ["mori", "MORI"] False+ selfPolicy = HandleReferenceRule "ADR" [] True+ referenceRules =+ [ fieldRule "references" Nothing [] List Nothing Nothing (Just referencePolicy) Nothing,+ fieldRule "selfReference" Nothing [] Scalar Nothing Nothing (Just selfPolicy) Nothing+ ]+ spec :: ProfileSpec+ spec =+ testDocumentIdProfileSpec+ & #frontmatter+ .~ FrontmatterRules+ { required = [requiredField "type", requiredField "title"],+ recommended = referenceRules,+ optional = []+ }+ compiled <- firstShow (compileProfile spec)+ duplicateA <- decisionConcept "decisions/duplicate-a" "Duplicate A" "ADR-3" []+ duplicateB <- decisionConcept "decisions/duplicate-b" "Duplicate B" "ADR-3" []+ source <-+ decisionConcept+ "decisions/source"+ "Source"+ "ADR-1"+ [ ( "references",+ toJSON+ [ String "ADR-2",+ String "ADR-99",+ String "PAT-3",+ String "not a reference",+ String "https://example.test/external",+ String "MORI://shinzui/okf/docs/one",+ String "ADR-1",+ Number 7,+ String "ADR-3"+ ]+ ),+ ("selfReference", String "ADR-1")+ ]+ target <- decisionConcept "decisions/target" "Target" "ADR-2" []+ duplicateAId <- parseTestConceptId "decisions/duplicate-a"+ duplicateBId <- parseTestConceptId "decisions/duplicate-b"+ sourceId <- parseTestConceptId "decisions/source"+ assertEqual+ [ DanglingHandleReference sourceId (indexedPath "references" 1) "ADR-99",+ ReferenceHandlePrefixMismatch sourceId (indexedPath "references" 2) "PAT-3" "ADR",+ MalformedDocumentReference sourceId (indexedPath "references" 3) (String "not a reference"),+ ExternalReferenceSchemeNotAllowed sourceId (indexedPath "references" 4) "https" ["mori"],+ SelfDocumentReference sourceId (indexedPath "references" 6) "ADR-1",+ MalformedDocumentReference sourceId (indexedPath "references" 7) (Number 7),+ DuplicateDocumentId "ADR-3" duplicateAId duplicateBId+ ]+ (validateProfile PermissiveConformance compiled [target, source, duplicateB, duplicateA])+ where+ decisionConcept cid title documentId extraFields =+ profileConcept+ cid+ ([("type", String "Decision Record"), ("title", String title), ("docId", String documentId)] <> extraFields)+ ("# " <> title <> "\n")+ indexedPath key elementIndex = FieldPath (FieldName key :| [ArrayIndex elementIndex])++-- | The whole point of the third presence list: absence is silent in both+-- validation modes, while a present value is checked exactly as hard as it would+-- be under @required@.+testOptionalFieldPresence :: Either Text ()+testOptionalFieldPresence = do+ let optionalRules =+ [ fieldRule "supersedes" Nothing ["ADR-1", "ADR-2"] Scalar Nothing Nothing Nothing Nothing,+ fieldRule "reviewedAt" Nothing [] Any (Just Rfc3339Utc) Nothing Nothing Nothing,+ fieldRule "tags" Nothing [] List Nothing Nothing Nothing Nothing+ ]+ spec =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required = [requiredField "type"],+ recommended = [requiredField "owner"],+ optional = optionalRules+ },+ allowUnknownTypes = True,+ types = []+ }+ compiled <- firstShow (compileProfile spec)+ absent <- profileConcept "optional/absent" [("type", String "Extension"), ("owner", String "Ari")] "# Absent\n"+ -- A correctly shaped empty value counts as absent, so it is as silent as a key+ -- that was never written. (A blank value on a field that also declares a+ -- vocabulary or format still fails that check; presence and value are+ -- independent, which is exactly what this feature relies on.)+ emptied <-+ profileConcept+ "optional/emptied"+ [ ("type", String "Extension"),+ ("owner", String "Ari"),+ ("tags", toJSON ([] :: [Text]))+ ]+ "# Emptied\n"+ valid <-+ profileConcept+ "optional/valid"+ [ ("type", String "Extension"),+ ("owner", String "Ari"),+ ("supersedes", String "ADR-1"),+ ("reviewedAt", String "2026-07-30T00:00:00Z"),+ ("tags", toJSON (["profiles"] :: [Text]))+ ]+ "# Valid\n"+ invalid <-+ profileConcept+ "optional/invalid"+ [ ("type", String "Extension"),+ ("owner", String "Ari"),+ ("supersedes", String "ADR-9"),+ ("reviewedAt", String "2026-13-45T99:99:99Z"),+ ("tags", String "profiles")+ ]+ "# Invalid\n"+ invalidId <- parseTestConceptId "optional/invalid"+ for_ [PermissiveConformance, StrictAuthoring] $ \validationProfile -> do+ assertEqual [] (validateProfile validationProfile compiled [absent])+ assertEqual [] (validateProfile validationProfile compiled [emptied])+ assertEqual [] (validateProfile validationProfile compiled [valid])+ assertEqual+ [ ValueFormatMismatch invalidId (fieldPath "reviewedAt") Rfc3339Utc (String "2026-13-45T99:99:99Z"),+ ValueNotInVocabulary invalidId (fieldPath "supersedes") ["ADR-1", "ADR-2"] (String "ADR-9"),+ CardinalityMismatch invalidId (fieldPath "tags") List (String "profiles")+ ]+ (validateProfile validationProfile compiled [invalid])++-- | An optional field carrying a document-reference policy resolves handles the+-- same way a required one does; only the absence check differs.+testOptionalReferenceValidation :: Either Text ()+testOptionalReferenceValidation = do+ let spec :: ProfileSpec+ spec =+ testDocumentIdProfileSpec+ & #frontmatter+ .~ FrontmatterRules+ { required = [requiredField "type", requiredField "title"],+ recommended = [],+ optional = [fieldRule "supersedes" Nothing [] Scalar Nothing Nothing (Just (HandleReferenceRule "ADR" [] False)) Nothing]+ }+ compiled <- firstShow (compileProfile spec)+ target <- decisionTestConcept "decisions/target" "Target" "ADR-1" []+ silent <- decisionTestConcept "decisions/silent" "Silent" "ADR-2" []+ dangling <- decisionTestConcept "decisions/dangling" "Dangling" "ADR-3" [("supersedes", String "ADR-99")]+ danglingId <- parseTestConceptId "decisions/dangling"+ for_ [PermissiveConformance, StrictAuthoring] $ \validationProfile -> do+ assertEqual [] (validateProfile validationProfile compiled [target, silent])+ assertEqual+ [DanglingHandleReference danglingId (fieldPath "supersedes") "ADR-99"]+ (validateProfile validationProfile compiled [target, dangling])++-- | Optional members of a list-element record behave the same way inside every+-- record: never missing, always checked when present.+testOptionalNestedFieldPresence :: Either Text ()+testOptionalNestedFieldPresence = do+ let nestedRules =+ NestedRules+ { required = [NestedFieldRule "kind" Nothing ["human", "model"] Scalar Nothing Nothing Nothing],+ recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing Nothing],+ optional = [NestedFieldRule "model" Nothing ["opus", "sonnet"] Scalar Nothing Nothing Nothing]+ }+ compiled <- firstShow (compileProfile (nestedProfileWithRules Any nestedRules Nothing))+ concept <-+ profileConcept+ "reviewed/optional"+ [ ("type", String "Reviewed Concept"),+ ( "reviews",+ toJSON+ [ object ["kind" .= ("human" :: Text), "notes" .= ("looks good" :: Text)],+ object ["kind" .= ("model" :: Text), "notes" .= ("ran it" :: Text), "model" .= ("opus" :: Text)],+ object ["kind" .= ("model" :: Text), "notes" .= ("ran it" :: Text), "model" .= ("gpt" :: Text)]+ ]+ )+ ]+ "# Optional\n"+ cid <- parseTestConceptId "reviewed/optional"+ for_ [PermissiveConformance, StrictAuthoring] $ \validationProfile ->+ assertEqual+ [ValueNotInVocabulary cid (nestedTestPath 2 "model") ["opus", "sonnet"] (String "gpt")]+ (validateProfile validationProfile compiled [concept])++-- | An optional key is declared for the purposes of field-name closure, so a+-- closed profile accepts it and still catches a misspelling of it.+testOptionalFieldClosure :: Either Text ()+testOptionalFieldClosure = do+ let closed =+ typeAwareProfileSpec+ { frontmatter =+ FrontmatterRules+ { required = [requiredField "type"],+ recommended = [],+ optional = [requiredField "supersedes"]+ },+ allowUnknownTypes = True,+ allowUnknownFields = False,+ types = []+ }+ compiled <- firstShow (compileProfile closed)+ declared <- profileConcept "closed/declared" [("type", String "Extension"), ("supersedes", String "ADR-1")] "# Declared\n"+ typo <- profileConcept "closed/typo" [("type", String "Extension"), ("supersedse", String "ADR-1")] "# Typo\n"+ typoId <- parseTestConceptId "closed/typo"+ assertEqual [] (validateProfile PermissiveConformance compiled [declared])+ assertEqual+ [FieldNotInProfile typoId "supersedse"]+ (validateProfile PermissiveConformance compiled [typo])++-- | Declaring a key optional at one scope does not cancel the other scope's+-- presence clause. Merging accumulates clauses precisely so a type rule can+-- narrow but never silently weaken a profile-wide expectation.+testOptionalDoesNotCancelOtherScope :: Either Text ()+testOptionalDoesNotCancelOtherScope = do+ let ownerRule = requiredField "owner"+ specWith profileRules typeRules =+ typeAwareProfileSpec+ { frontmatter = profileRules,+ allowUnknownTypes = True,+ types = [withTypeFrontmatter typeRules (firstTypeRule typeAwareProfileSpec)]+ }+ recommendedThenOptional =+ specWith+ FrontmatterRules {required = [requiredField "type"], recommended = [ownerRule], optional = []}+ FrontmatterRules {required = [], recommended = [], optional = [ownerRule]}+ optionalThenRecommended =+ specWith+ FrontmatterRules {required = [requiredField "type"], recommended = [], optional = [ownerRule]}+ FrontmatterRules {required = [], recommended = [ownerRule], optional = []}+ concept <- profileConcept "owned/one" [("type", String "Owned Concept")] "# One\n"+ cid <- parseTestConceptId "owned/one"+ for_ [recommendedThenOptional, optionalThenRecommended] $ \spec -> do+ compiled <- firstShow (compileProfile spec)+ assertEqual [] (validateProfile PermissiveConformance compiled [concept])+ assertEqual+ [MissingRecommendedProfileField cid "owner" Nothing]+ (validateProfile StrictAuthoring compiled [concept])++-- | Compilation rejects the two contradictions the third list makes possible: a+-- key classified twice at one scope, and a condition on a rule that has no+-- presence check for it to gate.+testOptionalDefinitionErrors :: Either Text ()+testOptionalDefinitionErrors = do+ let key name = requiredField name+ specWith rules =+ typeAwareProfileSpec {frontmatter = rules, allowUnknownTypes = True, types = []}+ conditioned name sourceKey =+ fieldRule name Nothing [] Any Nothing Nothing Nothing (Just (FieldCondition sourceKey ["active"]))+ statusRule = fieldRule "status" Nothing ["active"] Scalar Nothing Nothing Nothing Nothing+ assertEqual+ (Left (ConflictingFieldRequirement Nothing "owner" :| []))+ (compileProfile (specWith FrontmatterRules {required = [key "type", key "owner"], recommended = [], optional = [key "owner"]}))+ assertEqual+ (Left (ConflictingFieldRequirement Nothing "owner" :| []))+ (compileProfile (specWith FrontmatterRules {required = [key "type"], recommended = [key "owner"], optional = [key "owner"]}))+ assertEqual+ (Left (DuplicateFieldRule Nothing "optional" "owner" :| []))+ (compileProfile (specWith FrontmatterRules {required = [key "type"], recommended = [], optional = [key "owner", key "owner"]}))+ assertEqual+ (Left (OptionalFieldWithCondition Nothing (fieldPath "supersededBy") :| []))+ ( compileProfile+ (specWith FrontmatterRules {required = [key "type", statusRule], recommended = [], optional = [conditioned "supersededBy" "status"]})+ )+ let nestedRules =+ NestedRules+ { required = [NestedFieldRule "kind" Nothing ["model"] Scalar Nothing Nothing Nothing],+ recommended = [],+ optional = [NestedFieldRule "model" Nothing [] Scalar Nothing Nothing (Just (FieldCondition "kind" ["model"]))]+ }+ assertEqual+ (Left (OptionalFieldWithCondition Nothing (FieldPath (FieldName "reviews" :| [FieldName "model"])) :| []))+ (compileProfile (nestedProfileWithRules Any nestedRules Nothing))+ let optionalParent =+ specWith+ FrontmatterRules+ { required = [key "type"],+ recommended = [],+ optional = [fieldRule "reviews" Nothing [] List Nothing (Just nestedRules) Nothing Nothing]+ }+ assertEqual+ (Left (OptionalFieldWithCondition Nothing (FieldPath (FieldName "reviews" :| [FieldName "model"])) :| []))+ (compileProfile optionalParent)++decisionTestConcept :: Text -> Text -> Text -> [(Text, Value)] -> Either Text Concept+decisionTestConcept cid title documentId extraFields =+ profileConcept+ cid+ ([("type", String "Decision Record"), ("title", String title), ("docId", String documentId)] <> extraFields)+ ("# " <> title <> "\n")++-- | The end-to-end proof, run against the fixture bundle a reader can also run+-- from the command line. Absence of the three optional keys is silent in both+-- modes; the one genuine recommendation still fails under strict authoring; the+-- conditional requirement in the same type still fires; and every optional key+-- that /is/ present is checked as hard as a required one.+testOptionalFieldsFixture :: IO (Either Text ())+testOptionalFieldsFixture = do+ descriptorPath <- fixtureFilePath "profiles/optional-fields.dhall"+ conditionPath <- fixtureFilePath "profiles/optional-conditional-invalid.dhall"+ collisionPath <- fixtureFilePath "profiles/optional-collision-invalid.dhall"+ loaded <- loadProfileFile descriptorPath+ conditionLoaded <- loadProfileFile conditionPath+ collisionLoaded <- loadProfileFile collisionPath+ root <- fixturePath "profile-optional-fields"+ concepts <- readBundle root+ pure $ do+ spec <- first ("failed to load optional-fields profile: " <>) loaded+ compiled <- firstShow (compileProfile spec)+ conditionSpec <- first ("failed to load invalid optional-condition profile: " <>) conditionLoaded+ collisionSpec <- first ("failed to load invalid optional-collision profile: " <>) collisionLoaded+ assertEqual+ ( Left+ ( OptionalFieldWithCondition (Just "Decision Record") (FieldPath (FieldName "reviews" :| [FieldName "model"]))+ :| [OptionalFieldWithCondition (Just "Decision Record") (fieldPath "supersededBy")]+ )+ )+ (compileProfile conditionSpec)+ assertEqual+ ( Left+ ( ConflictingFieldRequirement Nothing "reviewedBy"+ :| [ConflictingFieldRequirement (Just "Decision Record") "owner"]+ )+ )+ (compileProfile collisionSpec)+ accepted <- parseTestConceptId "decisions/accepted"+ badSupersedes <- parseTestConceptId "decisions/bad-supersedes"+ superseded <- parseTestConceptId "decisions/superseded"+ let valueViolations =+ [ ValueFormatMismatch badSupersedes (fieldPath "decidedAt") Rfc3339Utc (String "not a timestamp"),+ ValueNotInVocabulary badSupersedes (nestedReviewPath 0 "model") ["opus", "sonnet"] (String "gpt"),+ DanglingHandleReference badSupersedes (fieldPath "supersedes") "ADR-99",+ MissingProfileField superseded "supersededBy" (Just (FieldCondition "status" ["superseded"]))+ ]+ assertEqual valueViolations (validateProfile PermissiveConformance compiled concepts)+ assertEqual+ (MissingRecommendedProfileField accepted "reviewedBy" Nothing : valueViolations)+ (validateProfile StrictAuthoring compiled concepts)+ where+ nestedReviewPath elementIndex key =+ FieldPath (FieldName "reviews" :| [ArrayIndex elementIndex, FieldName key])++testClosedFieldValidation :: Either Text ()+testClosedFieldValidation = do+ let ownedRule :: TypeRule+ ownedRule =+ withTypeFrontmatter+ FrontmatterRules {required = [requiredField "owner"], recommended = [], optional = []}+ (firstTypeRule typeAwareProfileSpec)+ reviewRule =+ withTypeName+ "Review"+ (withTypeFrontmatter FrontmatterRules {required = [requiredField "reviewer"], recommended = [], optional = []} ownedRule)+ closed =+ typeAwareProfileSpec+ { frontmatter = FrontmatterRules {required = [requiredField "type", requiredField "status"], recommended = [], optional = []},+ allowUnknownFields = False,+ idField = Just "requestId",+ requireBundleVersion = Nothing,+ types = [ownedRule, reviewRule]+ }+ compiled <- firstShow (compileProfile closed)+ typo <-+ profileConcept+ "owned/typo"+ [ ("type", String "Owned Concept"),+ ("title", String "Typo"),+ ("description", String "Core"),+ ("timestamp", String "2026-07-29T00:00:00Z"),+ ("resource", String "https://example.test/typo"),+ ("tags", toJSON (["profiles"] :: [Text])),+ ("requestId", String "IR-1"),+ ("owner", String "Ari"),+ ("reviewer", String "Bo"),+ ("stauts", String "draft")+ ]+ "# Typo\n"+ cid <- parseTestConceptId "owned/typo"+ assertEqual+ [ MissingProfileField cid "status" Nothing,+ FieldNotInProfile cid "reviewer",+ FieldNotInProfile cid "stauts"+ ]+ (validateProfile PermissiveConformance compiled [typo])+ let reopened = closed {allowUnknownFields = True}+ reopenedCompiled <- firstShow (compileProfile reopened)+ assertEqual+ [MissingProfileField cid "status" Nothing]+ (validateProfile PermissiveConformance reopenedCompiled [typo])++-- | The behavior this plan exists to deliver: a profile can require a+-- mapping-valued key and can demand a member of that mapping, reported at a path+-- such as @generated.by@.+testValidateObjectMember :: Either Text ()+testValidateObjectMember = do+ compiled <-+ firstShow+ (compileProfile (objectProfileWithRules "generated" Any (Just provenanceMemberRules) Nothing))+ cid <- parseTestConceptId "thing"+ -- A well-formed mapping satisfies the rule and reports nothing at all, which+ -- is the half of the fix that the old `missing profile-required field:+ -- generated` transcript got wrong.+ wellFormed <-+ profileConcept+ "thing"+ [ ("type", String "Thing"),+ ("generated", object ["by" .= ("human:nadeem" :: Text), "at" .= ("2026-06-18T00:00:00Z" :: Text)])+ ]+ "# Thing\n"+ assertEqual [] (validateProfile StrictAuthoring compiled [wellFormed])+ -- A mapping missing the demanded member reports that member, not the parent.+ missingMember <-+ profileConcept+ "thing"+ [ ("type", String "Thing"),+ ("generated", object ["at" .= ("2026-06-18T00:00:00Z" :: Text)])+ ]+ "# Thing\n"+ assertEqual+ [MissingNestedProfileField cid (objectMemberPath "generated" "by") Nothing]+ (validateProfile PermissiveConformance compiled [missingMember])+ -- Members are value-checked exactly as list-element members are, and the+ -- recommended member is reported only under strict authoring.+ badTimestamp <-+ profileConcept+ "thing"+ [("type", String "Thing"), ("generated", object ["by" .= ("human:nadeem" :: Text), "at" .= ("not-a-time" :: Text)])]+ "# Thing\n"+ assertEqual+ [ValueFormatMismatch cid (objectMemberPath "generated" "at") Rfc3339Utc (String "not-a-time")]+ (validateProfile PermissiveConformance compiled [badTimestamp])+ onlyBy <-+ profileConcept+ "thing"+ [("type", String "Thing"), ("generated", object ["by" .= ("human:nadeem" :: Text)])]+ "# Thing\n"+ assertEqual [] (validateProfile PermissiveConformance compiled [onlyBy])+ assertEqual+ [MissingRecommendedNestedProfileField cid (objectMemberPath "generated" "at") Nothing]+ (validateProfile StrictAuthoring compiled [onlyBy])+ -- The key itself is still demanded when it is absent entirely, and an empty+ -- mapping counts as absent for the same reason an empty list does.+ absent <- profileConcept "thing" [("type", String "Thing")] "# Thing\n"+ assertEqual+ [MissingProfileField cid "generated" Nothing]+ (validateProfile PermissiveConformance compiled [absent])+ emptyMapping <-+ profileConcept "thing" [("type", String "Thing"), ("generated", object [])] "# Thing\n"+ assertEqual+ [MissingProfileField cid "generated" Nothing]+ (validateProfile PermissiveConformance compiled [emptyMapping])++-- | OKF v0.2 specification §5.2 permits @verified@ as a list of mappings or as+-- one bare mapping and requires a consumer to treat the bare mapping as a+-- one-element list. A rule declaring both shapes checks them against the same+-- member rules, and neither spelling is a cardinality mismatch.+testValidateRecordOrList :: Either Text ()+testValidateRecordOrList = do+ compiled <-+ firstShow+ ( compileProfile+ (objectProfileWithRules "verified" Any (Just provenanceMemberRules) (Just provenanceMemberRules))+ )+ cid <- parseTestConceptId "thing"+ bareMapping <-+ profileConcept+ "thing"+ [("type", String "Thing"), ("verified", object ["at" .= ("2026-06-20T00:00:00Z" :: Text)])]+ "# Thing\n"+ assertEqual+ [MissingNestedProfileField cid (objectMemberPath "verified" "by") Nothing]+ (validateProfile PermissiveConformance compiled [bareMapping])+ oneElementList <-+ profileConcept+ "thing"+ [("type", String "Thing"), ("verified", toJSON [object ["at" .= ("2026-06-20T00:00:00Z" :: Text)]])]+ "# Thing\n"+ assertEqual+ [ MissingNestedProfileField+ cid+ (FieldPath (FieldName "verified" :| [ArrayIndex 0, FieldName "by"]))+ Nothing+ ]+ (validateProfile PermissiveConformance compiled [oneElementList])+ -- Both spellings are satisfiable, and neither reports a shape error.+ goodMapping <-+ profileConcept+ "thing"+ [("type", String "Thing"), ("verified", object ["by" .= ("human:nadeem" :: Text), "at" .= ("2026-06-20T00:00:00Z" :: Text)])]+ "# Thing\n"+ assertEqual [] (validateProfile StrictAuthoring compiled [goodMapping])++-- | A shape error does not cascade: a value of the wrong shape produces exactly+-- one 'CardinalityMismatch' naming the expected shape, and no member violations+-- from walking a record that is not there.+testValidateObjectWrongShape :: Either Text ()+testValidateObjectWrongShape = do+ compiled <-+ firstShow+ (compileProfile (objectProfileWithRules "generated" Any (Just provenanceMemberRules) Nothing))+ cid <- parseTestConceptId "thing"+ let listValue = toJSON [object ["by" .= ("human:nadeem" :: Text)]]+ concept <-+ profileConcept "thing" [("type", String "Thing"), ("generated", listValue)] "# Thing\n"+ assertEqual+ [CardinalityMismatch cid (fieldPath "generated") Object listValue]+ (validateProfile StrictAuthoring compiled [concept])++-- | Declaring no member rules at all still demands that the value be a mapping.+-- This is how an author says "this key must be an object" and nothing more,+-- which is the alternative to adding an @Object@ alternative to the published+-- Dhall union.+testValidateEmptyObjectRules :: Either Text ()+testValidateEmptyObjectRules = do+ compiled <-+ firstShow+ ( compileProfile+ ( objectProfileWithRules+ "generated"+ Any+ (Just (NestedRules {required = [], recommended = [], optional = []}))+ Nothing+ )+ )+ cid <- parseTestConceptId "thing"+ mapping <-+ profileConcept+ "thing"+ [("type", String "Thing"), ("generated", object ["anything" .= ("at all" :: Text)])]+ "# Thing\n"+ assertEqual [] (validateProfile StrictAuthoring compiled [mapping])+ scalarValue <-+ profileConcept "thing" [("type", String "Thing"), ("generated", String "nope")] "# Thing\n"+ assertEqual+ [CardinalityMismatch cid (fieldPath "generated") Object (String "nope")]+ (validateProfile PermissiveConformance compiled [scalarValue])++objectMemberPath :: Text -> Text -> FieldPath+objectMemberPath parent key = FieldPath (FieldName parent :| [FieldName key])++firstTypeRule :: ProfileSpec -> TypeRule+firstTypeRule spec =+ case spec ^. #types of+ rule : _ -> rule+ [] -> error "test profile unexpectedly has no type rules"++withTypeFrontmatter :: FrontmatterRules -> TypeRule -> TypeRule+withTypeFrontmatter+ replacement+ TypeRule+ { type_,+ description,+ pathPattern,+ resourceScheme,+ requireSchemaSection,+ schemaColumns,+ idPrefix+ } =+ TypeRule+ { type_,+ description,+ frontmatter = replacement,+ pathPattern,+ resourceScheme,+ requireSchemaSection,+ schemaColumns,+ idPrefix+ }++withTypeName :: Text -> TypeRule -> TypeRule+withTypeName+ replacement+ TypeRule+ { description,+ frontmatter,+ pathPattern,+ resourceScheme,+ requireSchemaSection,+ schemaColumns,+ idPrefix+ } =+ TypeRule+ { type_ = replacement,+ description,+ frontmatter,+ pathPattern,+ resourceScheme,+ requireSchemaSection,+ schemaColumns,+ idPrefix+ }++testProfileRulesApplyToUnknownTypes :: Either Text ()+testProfileRulesApplyToUnknownTypes = do+ compiled <- firstShow (compileProfile typeAwareProfileSpec)+ concept <- profileConcept "extensions/one" [("type", String "Extension Concept")] "# One\n"+ cid <- parseTestConceptId "extensions/one"+ assertEqual+ [MissingProfileField cid "title" Nothing]+ (validateProfile PermissiveConformance compiled [concept])++testStrictProfileRecommendations :: Either Text ()+testStrictProfileRecommendations = do+ compiled <- firstShow (compileProfile typeAwareProfileSpec)+ concept <-+ profileConcept+ "owned/one"+ [("type", String "Owned Concept"), ("title", String "One"), ("owner", String "Ari")]+ "# One\n"+ cid <- parseTestConceptId "owned/one"+ assertEqual [] (validateProfile PermissiveConformance compiled [concept])+ assertEqual+ [MissingRecommendedProfileField cid "reviewer" Nothing]+ (validateProfile StrictAuthoring compiled [concept])++-- | Build an in-memory concept from a raw ID, frontmatter pairs, and a body.+profileConcept :: Text -> [(Text, Value)] -> Text -> Either Text Concept+profileConcept rawId fieldPairs bodyText = do+ conceptId <- parseTestConceptId rawId+ pure (conceptFromDocument conceptId (OKFDocument (frontmatterFromFields fieldPairs) bodyText))++-- | A well-formed @# Schema@ section matching the profile's required columns.+schemaSectionBody :: Text+schemaSectionBody =+ Text.unlines+ [ "# Schema",+ "",+ "| Column | Type | Nullable | Description |",+ "|--------|--------|----------|-------------|",+ "| id | bigint | no | Primary key |"+ ]++testProfileConformingTable :: Either Text ()+testProfileConformingTable = do+ concept <-+ profileConcept+ "schemas/sales/tables/orders"+ [ ("type", String "PostgreSQL Table"),+ ("title", String "Orders"),+ ("resource", String "postgresql://warehouse/sales/orders")+ ]+ schemaSectionBody+ assertEqual [] (validateTestProfile testProfileSpec [concept])++testProfileUnknownType :: Either Text ()+testProfileUnknownType = do+ concept <-+ profileConcept+ "schemas/sales/tables/bad"+ [("type", String "pg table"), ("resource", String "postgresql://x")]+ schemaSectionBody+ cid <- parseTestConceptId "schemas/sales/tables/bad"+ assertEqual+ [TypeNotInProfile cid "pg table", MissingProfileField cid "title" Nothing]+ (validateTestProfile testProfileSpec [concept])++testProfileMissingField :: Either Text ()+testProfileMissingField = do+ concept <-+ profileConcept+ "schemas/sales/tables/orders"+ [("type", String "PostgreSQL Table"), ("resource", String "postgresql://x")]+ schemaSectionBody+ cid <- parseTestConceptId "schemas/sales/tables/orders"+ assertEqual [MissingProfileField cid "title" Nothing] (validateTestProfile testProfileSpec [concept])++testProfileResourceMismatch :: Either Text ()+testProfileResourceMismatch = do+ concept <-+ profileConcept+ "schemas/sales/tables/orders"+ [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "mysql://x")]+ schemaSectionBody+ cid <- parseTestConceptId "schemas/sales/tables/orders"+ assertEqual+ [ResourceSchemeMismatch cid "postgresql" "mysql://x"]+ (validateTestProfile testProfileSpec [concept])++testProfilePathMismatch :: Either Text ()+testProfilePathMismatch = do+ concept <-+ profileConcept+ "tables/orders"+ [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "postgresql://x")]+ schemaSectionBody+ cid <- parseTestConceptId "tables/orders"+ assertEqual+ [PathPatternMismatch cid "PostgreSQL Table" "schemas/*/tables/*"]+ (validateTestProfile testProfileSpec [concept])++testProfileMissingSchema :: Either Text ()+testProfileMissingSchema = do+ concept <-+ profileConcept+ "schemas/sales/tables/orders"+ [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "postgresql://x")]+ "# Overview\n\nNo schema section here.\n"+ cid <- parseTestConceptId "schemas/sales/tables/orders"+ assertEqual+ [MissingSchemaSection cid "PostgreSQL Table"]+ (validateTestProfile testProfileSpec [concept])++testProfileSchemaColumnsMismatch :: Either Text ()+testProfileSchemaColumnsMismatch = do+ let mismatchBody =+ Text.unlines+ ["# Schema", "", "| Col | Type |", "|-----|------|", "| id | bigint |"]+ concept <-+ profileConcept+ "schemas/sales/tables/orders"+ [("type", String "PostgreSQL Table"), ("title", String "Orders"), ("resource", String "postgresql://x")]+ mismatchBody+ cid <- parseTestConceptId "schemas/sales/tables/orders"+ assertEqual+ [SchemaColumnsMismatch cid "PostgreSQL Table" ["Column", "Type", "Nullable", "Description"] ["Col", "Type"]]+ (validateTestProfile testProfileSpec [concept])++testProfileConformingDocumentId :: Either Text ()+testProfileConformingDocumentId = do+ concept <-+ profileConcept+ "decisions/one"+ [("type", String "Decision Record"), ("title", String "One"), ("docId", String "ADR-1")]+ "# One\n"+ assertEqual [] (validateTestProfile testDocumentIdProfileSpec [concept])++testProfileMissingDocumentId :: Either Text ()+testProfileMissingDocumentId = do+ concept <-+ profileConcept+ "decisions/one"+ [("type", String "Decision Record"), ("title", String "One")]+ "# One\n"+ cid <- parseTestConceptId "decisions/one"+ assertEqual+ [MissingDocumentId cid "Decision Record" "ADR"]+ (validateTestProfile testDocumentIdProfileSpec [concept])++testProfileMalformedDocumentIds :: Either Text ()+testProfileMalformedDocumentIds = do+ leadingZero <-+ profileConcept+ "decisions/leading-zero"+ [("type", String "Decision Record"), ("title", String "Leading zero"), ("docId", String "ADR-007")]+ "# Leading zero\n"+ wrongPrefix <-+ profileConcept+ "decisions/wrong-prefix"+ [("type", String "Decision Record"), ("title", String "Wrong prefix"), ("docId", String "RFC-1")]+ "# Wrong prefix\n"+ leadingZeroId <- parseTestConceptId "decisions/leading-zero"+ wrongPrefixId <- parseTestConceptId "decisions/wrong-prefix"+ assertEqual+ [ MalformedDocumentId leadingZeroId "ADR" "ADR-007",+ MalformedDocumentId wrongPrefixId "ADR" "RFC-1"+ ]+ (validateTestProfile testDocumentIdProfileSpec [leadingZero, wrongPrefix])++testProfileDuplicateDocumentIds :: Either Text ()+testProfileDuplicateDocumentIds = do+ second <-+ profileConcept+ "decisions/second"+ [("type", String "Decision Record"), ("title", String "Second"), ("docId", String "ADR-1")]+ "# Second\n"+ firstConcept <-+ profileConcept+ "decisions/first"+ [("type", String "Decision Record"), ("title", String "First"), ("docId", String "ADR-1")]+ "# First\n"+ firstId <- parseTestConceptId "decisions/first"+ secondId <- parseTestConceptId "decisions/second"+ assertEqual+ [DuplicateDocumentId "ADR-1" firstId secondId]+ (validateTestProfile testDocumentIdProfileSpec [second, firstConcept])++testProfileDocumentIdsOffByDefault :: Either Text ()+testProfileDocumentIdsOffByDefault = do+ concept <-+ profileConcept+ "schemas/sales/tables/orders"+ [ ("type", String "PostgreSQL Table"),+ ("title", String "Orders"),+ ("resource", String "postgresql://warehouse/sales/orders"),+ ("docId", String "not-a-handle")+ ]+ schemaSectionBody+ assertEqual [] (validateTestProfile testProfileSpec [concept])++testSchemaSectionColumns :: Either Text ()+testSchemaSectionColumns =+ assertEqual+ (Just ["Column", "Type", "Nullable", "Description"])+ (schemaSectionColumns schemaSectionBody)++-- | Milestone 5: walking the deviating fixture and validating it against the+-- shipped descriptor produces exactly the expected advisory deviations.+testProfileDeviationsFixture :: IO (Either Text ())+testProfileDeviationsFixture = do+ descriptorPath <- fixtureFilePath "profiles/postgresql.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "profile-deviations"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load profile: " <> err)+ Right spec -> do+ badId <- parseTestConceptId "schemas/sales/tables/bad"+ ordersId <- parseTestConceptId "schemas/sales/tables/orders"+ assertEqual+ [TypeNotInProfile badId "pg table", MissingProfileField ordersId "title" Nothing]+ (validateTestProfile spec concepts)++testDocumentIdDeviationsFixture :: IO (Either Text ())+testDocumentIdDeviationsFixture = do+ descriptorPath <- fixtureFilePath "profiles/decisions.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "doc-id-deviations"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load document ID profile: " <> err)+ Right spec -> do+ firstId <- parseTestConceptId "decisions/first"+ secondId <- parseTestConceptId "decisions/second"+ thirdId <- parseTestConceptId "decisions/third"+ fourthId <- parseTestConceptId "decisions/fourth"+ assertEqual+ [ MissingDocumentId fourthId "Decision Record" "ADR",+ MalformedDocumentId thirdId "ADR" "ADR-007",+ DuplicateDocumentId "ADR-1" firstId secondId+ ]+ (validateTestProfile spec concepts)++testTypeAwareProfileFixture :: IO (Either Text ())+testTypeAwareProfileFixture = do+ descriptorPath <- fixtureFilePath "profiles/type-frontmatter.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "profile-type-frontmatter"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load type-aware profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ ownedId <- parseTestConceptId "owned"+ assertEqual [] (validateProfile PermissiveConformance compiled concepts)+ assertEqual+ [MissingRecommendedProfileField ownedId "reviewer" Nothing]+ (validateProfile StrictAuthoring compiled concepts)++-- | Declaration order is the author's; 'compiledProfileTypeNames' must not+-- reorder it, because generated documentation follows it.+testCompiledProfileTypeNames :: IO (Either Text ())+testCompiledProfileTypeNames = do+ descriptorPath <- fixtureFilePath "profiles/type-frontmatter.dhall"+ loaded <- loadProfileFile descriptorPath+ pure $ case loaded of+ Left err -> Left ("failed to load type-aware profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ assertEqual ["Owned Concept", "Open Concept"] (compiledProfileTypeNames compiled)++-- | The merged view is what a reader of the profile actually needs: the type+-- rule names two keys, but four apply.+testCompiledProfileRulesMergeTypeScope :: IO (Either Text ())+testCompiledProfileRulesMergeTypeScope = do+ descriptorPath <- fixtureFilePath "profiles/type-frontmatter.dhall"+ loaded <- loadProfileFile descriptorPath+ pure $ case loaded of+ Left err -> Left ("failed to load type-aware profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ let owned = compiledProfileRulesForType compiled "Owned Concept"+ assertEqual ["owner", "reviewer", "title", "type"] (Map.keys owned)+ ownerRule <- lookupCompiledRule "owner" owned+ assertEqual [(RequiredField, Nothing)] (presenceSummary ownerRule)+ reviewerRule <- lookupCompiledRule "reviewer" owned+ assertEqual [(RecommendedField, Nothing)] (presenceSummary reviewerRule)+ titleRule <- lookupCompiledRule "title" owned+ assertEqual (Just "Human-readable concept title.") (fieldRuleDescription titleRule)+ assertEqual ["title", "type"] (Map.keys (compiledProfileRulesForType compiled "Open Concept"))+ assertEqual+ (compiledProfileBaseRules compiled)+ (compiledProfileRulesForType compiled "Not In Profile")++-- | Pins the encoding an outside consumer is most likely to misread: @optional@+-- is an empty presence-clause list, not a constructor.+testCompiledProfileOptionalPresence :: IO (Either Text ())+testCompiledProfileOptionalPresence = do+ descriptorPath <- fixtureFilePath "profiles/optional-fields.dhall"+ loaded <- loadProfileFile descriptorPath+ pure $ case loaded of+ Left err -> Left ("failed to load optional-field profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ let rules = compiledProfileRulesForType compiled "Decision Record"+ for_ ["supersedes", "decidedAt", "reviews", "originatingPlan"] $ \key -> do+ rule <- lookupCompiledRule key rules+ assertEqual [] (presenceSummary rule)+ statusRule <- lookupCompiledRule "status" rules+ assertEqual [(RequiredField, Nothing)] (presenceSummary statusRule)+ assertEqual ["accepted", "superseded"] (fieldRuleAllowedValues statusRule)+ assertEqual Scalar (fieldRuleCardinality statusRule)+ supersededByRule <- lookupCompiledRule "supersededBy" rules+ assertEqual+ [(RequiredField, Just (FieldCondition "status" ["superseded"]))]+ (presenceSummary supersededByRule)+ supersedesRule <- lookupCompiledRule "supersedes" rules+ assertEqual+ (Just (HandleReferenceRule "ADR" [] False))+ (fieldRuleReference supersedesRule)+ reviewsRule <- lookupCompiledRule "reviews" rules+ nested <- maybe (Left "reviews declares no element fields") Right (fieldRuleElementFields reviewsRule)+ assertEqual ["kind", "model"] (Map.keys nested)+ kindRule <- lookupCompiledRule "kind" nested+ assertEqual [(RequiredField, Nothing)] (presenceSummary kindRule)+ assertEqual Nothing (fieldRuleElementFields kindRule)+ modelRule <- lookupCompiledRule "model" nested+ assertEqual [] (presenceSummary modelRule)++lookupCompiledRule :: Text -> Map Text EffectiveFieldRule -> Either Text EffectiveFieldRule+lookupCompiledRule key rules =+ maybe (Left ("no compiled rule for key " <> key)) Right (Map.lookup key rules)++-- | An 'EffectiveFieldRule' is abstract, so summarize its presence clauses+-- through the public accessors into something comparable.+presenceSummary :: EffectiveFieldRule -> [(FieldRequirement, Maybe FieldCondition)]+presenceSummary rule =+ [ (presenceClauseRequirement clause, presenceClauseCondition clause)+ | clause <- fieldRulePresenceClauses rule+ ]++-- * Profile documentation rendering++-- | Load a fixture profile, compile it, render documentation, and hand both the+-- compiled profile and the concepts to an assertion.+withRenderedProfileDocumentation ::+ FilePath ->+ DocumentationOptions ->+ (CompiledProfile -> [Concept] -> Either Text ()) ->+ IO (Either Text ())+withRenderedProfileDocumentation fixture options assertion = do+ descriptorPath <- fixtureFilePath fixture+ loaded <- loadProfileFile descriptorPath+ pure $ case loaded of+ Left err -> Left ("failed to load profile " <> Text.pack fixture <> ": " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ concepts <- firstShow (renderProfileDocumentation options compiled)+ assertion compiled concepts++conceptBodyLines :: Concept -> [Text]+conceptBodyLines concept = Text.lines (conceptDocument concept ^. #body)++-- | Assert on whole lines rather than substrings, so a failure names the line+-- that changed instead of pointing at an opaque haystack.+assertHasLine :: Text -> [Text] -> Either Text ()+assertHasLine expected bodyLines =+ assertBool+ ("expected body line " <> Text.pack (show expected))+ (expected `elem` bodyLines)++conceptAt :: Int -> [Concept] -> Either Text Concept+conceptAt offset concepts =+ case drop offset concepts of+ concept : _ -> Right concept+ [] -> Left ("no concept at position " <> Text.pack (show offset))++testProfileDocumentationSlug :: Either Text ()+testProfileDocumentationSlug = do+ assertEqual "bigquery-table" (profileDocumentationSlug "BigQuery Table")+ assertEqual "decision-record" (profileDocumentationSlug "Decision Record")+ assertEqual "c-header" (profileDocumentationSlug "C++ Header")+ assertEqual "spaced-out" (profileDocumentationSlug " spaced out ")+ assertEqual "adr-7" (profileDocumentationSlug "ADR-7")+ assertEqual "" (profileDocumentationSlug "###")++-- | A profile with no rules at all beyond a required @type@, used to exercise+-- layout concerns without dragging in field rendering.+plainDocumentationTypeRule :: Text -> TypeRule+plainDocumentationTypeRule typeName =+ TypeRule+ { type_ = typeName,+ description = Nothing,+ frontmatter = FrontmatterRules {required = [], recommended = [], optional = []},+ pathPattern = Nothing,+ resourceScheme = Nothing,+ requireSchemaSection = False,+ schemaColumns = [],+ idPrefix = Nothing+ }++-- | Two distinct @type@ strings that slug identically, plus one that slugs to+-- nothing at all. No shipped descriptor has this shape, so the spec is built in+-- Haskell rather than by editing a fixture.+duplicateSlugProfileSpec :: ProfileSpec+duplicateSlugProfileSpec =+ ProfileSpec+ { name = "duplicate-slugs",+ description = Nothing,+ okfVersion = "0.1",+ frontmatter =+ FrontmatterRules+ { required = [fieldRule "type" Nothing [] Any Nothing Nothing Nothing Nothing],+ recommended = [],+ optional = []+ },+ allowUnknownTypes = False,+ allowUnknownFields = True,+ idField = Nothing,+ requireBundleVersion = Nothing,+ types =+ [ plainDocumentationTypeRule "Decision Record",+ plainDocumentationTypeRule "decision record",+ plainDocumentationTypeRule "###"+ ]+ }++testProfileDocumentationSlugCollisions :: Either Text ()+testProfileDocumentationSlugCollisions = do+ compiled <- firstShow (compileProfile duplicateSlugProfileSpec)+ concepts <- firstShow (renderProfileDocumentation defaultDocumentationOptions compiled)+ assertEqual+ ["profile", "types/decision-record", "types/decision-record-2", "types/type-3"]+ (map (renderConceptId . conceptIdOf) concepts)++testProfileValueDisplayNames :: Either Text ()+testProfileValueDisplayNames = do+ assertEqual "any" (renderCardinalityName Any)+ assertEqual "scalar" (renderCardinalityName Scalar)+ assertEqual "list" (renderCardinalityName List)+ assertEqual "rfc3339-utc" (renderFieldFormatName Rfc3339Utc)+ assertEqual "date" (renderFieldFormatName Date)+ assertEqual "uri" (renderFieldFormatName Uri)+ assertEqual "uri-with-scheme(mori)" (renderFieldFormatName (UriWithScheme "mori"))+ assertEqual "document-handle(ADR)" (renderFieldFormatName (DocumentHandle "ADR"))++testProfileDocumentationRootConcept :: IO (Either Text ())+testProfileDocumentationRootConcept =+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ ( \compiled concepts -> do+ assertEqual (1 + length (compiledProfileTypeNames compiled)) (length concepts)+ root <- conceptAt 0 concepts+ assertEqual "profile" (renderConceptId (conceptIdOf root))+ assertEqual profileConceptType (conceptType root)+ assertEqual (Just "optional-fields") (conceptTitle root)+ let bodyLines = conceptBodyLines root+ assertHasLine "# optional-fields" bodyLines+ assertHasLine "- [Decision Record](/types/decision-record.md)" bodyLines+ assertHasLine "- Document ID field: `docId`" bodyLines+ assertHasLine "- Unknown concept types: rejected" bodyLines+ assertHasLine "- Unknown frontmatter keys: rejected" bodyLines+ -- Every profile-level setting has a bullet whether or not the profile+ -- sets it, so a reader learns the setting exists and that this profile+ -- leaves it alone. This fixture declares no bundle-version requirement.+ assertHasLine "- Required bundle version: none" bodyLines+ )++-- | A profile setting the renderer does not print is a silent hole in generated+-- documentation, so @requireBundleVersion@ is rendered in the same change that+-- adds it — the rule stated in+-- @docs\/adr\/11-growing-the-profile-descriptor-language.md@.+testProfileDocumentationRequiredBundleVersion :: Either Text ()+testProfileDocumentationRequiredBundleVersion = do+ compiled <- firstShow (compileProfile (requireBundleVersionProfile (Just "0.2")))+ concepts <- firstShow (renderProfileDocumentation defaultDocumentationOptions compiled)+ root <- conceptAt 0 concepts+ assertHasLine "- Required bundle version: `0.2`" (conceptBodyLines root)++-- | A rule kind the renderer does not know about is a silent hole in generated+-- profile documentation, so object rules are rendered in the same change that+-- creates them. The bullet list is fixed by design, so a key that declares no+-- object shape says so explicitly rather than omitting the bullet.+testProfileDocumentationObjectFields :: Either Text ()+testProfileDocumentationObjectFields = do+ compiled <-+ firstShow+ (compileProfile (objectProfileWithRules "generated" Any (Just provenanceMemberRules) Nothing))+ concepts <- firstShow (renderProfileDocumentation defaultDocumentationOptions compiled)+ root <- conceptAt 0 concepts+ let bodyLines = conceptBodyLines root+ assertHasLine "- Object fields:" bodyLines+ assertHasLine+ " - `by` — required; allowed values: any; cardinality: any; format: none — Who or what produced this content."+ bodyLines+ assertHasLine+ " - `at` — recommended; allowed values: any; cardinality: any; format: rfc3339-utc"+ bodyLines+ assertHasLine "- Element fields: none" bodyLines+ assertHasLine "- Cardinality: object" bodyLines+ -- A key with no object shape still carries the bullet, so the shape of the+ -- list never shifts between rules.+ assertHasLine "- Object fields: none" bodyLines++testProfileDocumentationTypeConcept :: IO (Either Text ())+testProfileDocumentationTypeConcept =+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ ( \_compiled concepts -> do+ typeConcept <- conceptAt 1 concepts+ assertEqual "types/decision-record" (renderConceptId (conceptIdOf typeConcept))+ assertEqual profileTypeConceptType (conceptType typeConcept)+ assertEqual (Just "Decision Record") (conceptTitle typeConcept)+ let bodyLines = conceptBodyLines typeConcept+ assertHasLine "# Decision Record" bodyLines+ assertHasLine "Declared by the [optional-fields](/profile.md) profile." bodyLines+ assertHasLine "- Document ID prefix: `ADR`" bodyLines+ assertHasLine "- Path pattern: `decisions/*`" bodyLines+ assertHasLine "#### `status` — required" bodyLines+ assertHasLine "#### `supersededBy` — required when `status` is `superseded`" bodyLines+ assertHasLine "- Allowed values: `accepted`, `superseded`" bodyLines+ assertHasLine "#### `reviewedBy` — recommended" bodyLines+ assertHasLine "- Checked only under `--strict`" bodyLines+ assertHasLine "- Format: rfc3339-utc" bodyLines+ assertHasLine+ " - `kind` — required; allowed values: `human`, `model`; cardinality: scalar; format: none"+ bodyLines+ assertHasLine+ "- Reference: local handles with prefix `ADR`; external URIs not allowed; self-reference not allowed"+ bodyLines+ -- The profile-scope optional key must appear on the type page, under+ -- Optional: this is the merge being visible, which is the whole point.+ optionalHeading <- lineIndex "### Optional" bodyLines+ inheritedKey <- lineIndex "#### `originatingPlan` — optional" bodyLines+ assertBool+ "profile-scope optional key falls under the Optional heading"+ (optionalHeading < inheritedKey)+ )+ where+ lineIndex needle bodyLines =+ case List.elemIndex needle bodyLines of+ Just found -> Right found+ Nothing -> Left ("expected body line " <> Text.pack (show needle))++testProfileDocumentationInheritedRules :: IO (Either Text ())+testProfileDocumentationInheritedRules =+ withRenderedProfileDocumentation+ "profiles/type-frontmatter.dhall"+ defaultDocumentationOptions+ ( \_compiled concepts -> do+ assertEqual+ ["profile", "types/owned-concept", "types/open-concept"]+ (map (renderConceptId . conceptIdOf) concepts)+ owned <- conceptAt 1 concepts+ assertHasLine "#### `owner` — required" (conceptBodyLines owned)+ assertHasLine "#### `reviewer` — recommended" (conceptBodyLines owned)+ -- "Open Concept" declares no frontmatter of its own, so everything on+ -- its page is inherited from profile scope.+ open <- conceptAt 2 concepts+ let openBody = conceptBodyLines open+ assertEqual (Just "Open Concept") (conceptTitle open)+ assertHasLine "#### `title` — required" openBody+ assertHasLine "#### `type` — required" openBody+ assertHasLine "### Recommended" openBody+ assertHasLine "(none)" openBody+ )++testProfileDocumentationRoundTrip :: IO (Either Text ())+testProfileDocumentationRoundTrip =+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ ( \_compiled concepts ->+ for_ concepts $ \concept -> do+ reparsed <- firstShow (parseDocument (serializeConcept concept))+ assertEqual (conceptDocument concept) reparsed+ )++testProfileDocumentationValidates :: IO (Either Text ())+testProfileDocumentationValidates = do+ permissive <-+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ (\_compiled concepts -> assertEqual [] (validateInMemoryBundle PermissiveConformance VersionUndeclared concepts))+ -- The default options carry a @generated@ family, so strict validation of+ -- default output passes with no extra flag: that is the whole point of the+ -- default. The v0.1 @timestamp@ spelling still satisfies strict authoring too.+ strictResult <-+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ (\_compiled concepts -> assertEqual [] (validateInMemoryBundle StrictAuthoring VersionUndeclared concepts))+ strictLegacyResult <-+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions {generated = Nothing, timestamp = Just "2026-07-31T00:00:00Z"}+ (\_compiled concepts -> assertEqual [] (validateInMemoryBundle StrictAuthoring VersionUndeclared concepts))+ pure (permissive >> strictResult >> strictLegacyResult)++-- | Re-parse the serialized document, so the assertion proves the family+-- survives serialization rather than merely living in the in-memory value.+reparsedGenerated :: Concept -> Either Text (Maybe Generated)+reparsedGenerated concept = do+ reparsed <- firstShow (parseDocument (serializeConcept concept))+ pure (readGenerated (reparsed ^. #frontmatter))++testProfileDocumentationDefaultGenerated :: IO (Either Text ())+testProfileDocumentationDefaultGenerated =+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ ( \_compiled concepts -> for_ concepts $ \concept -> do+ assertEqual+ (Just (Generated (ProcessActor "okf-profile-document") Nothing))+ (conceptGenerated concept)+ roundTripped <- reparsedGenerated concept+ assertEqual+ (Just (Generated (ProcessActor "okf-profile-document") Nothing))+ roundTripped+ )++testProfileDocumentationExplicitGenerated :: IO (Either Text ())+testProfileDocumentationExplicitGenerated =+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ { generated = Just (Generated (HumanActor "nadeem") (Just "2026-08-01T00:00:00Z"))+ }+ ( \_compiled concepts -> for_ concepts $ \concept -> do+ roundTripped <- reparsedGenerated concept+ assertEqual+ (Just (Generated (HumanActor "nadeem") (Just "2026-08-01T00:00:00Z")))+ roundTripped+ )++-- | The escape hatch: a caller who wants no provenance at all gets none.+testProfileDocumentationOmittedGenerated :: IO (Either Text ())+testProfileDocumentationOmittedGenerated =+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions {generated = Nothing}+ ( \_compiled concepts -> for_ concepts $ \concept -> do+ assertEqual Nothing (conceptGenerated concept)+ roundTripped <- reparsedGenerated concept+ assertEqual Nothing roundTripped+ )++testProfileDocumentationLinksResolve :: IO (Either Text ())+testProfileDocumentationLinksResolve =+ withRenderedProfileDocumentation+ "profiles/optional-fields.dhall"+ defaultDocumentationOptions+ ( \_compiled concepts -> do+ assertEqual [] (danglingReferences concepts)+ rootId <- parseTestConceptId "profile"+ typeId <- parseTestConceptId "types/decision-record"+ let graphEdges = buildGraph concepts ^. #edges+ assertBool+ "profile links to the type document"+ (Edge rootId typeId `elem` graphEdges)+ assertBool+ "the type document links back to the profile"+ (Edge typeId rootId `elem` graphEdges)+ )++testProfileDocumentationByteStable :: IO (Either Text ())+testProfileDocumentationByteStable = do+ descriptorPath <- fixtureFilePath "profiles/optional-fields.dhall"+ loaded <- loadProfileFile descriptorPath+ pure $ case loaded of+ Left err -> Left ("failed to load optional-field profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ firstRender <- firstShow (renderProfileDocumentation defaultDocumentationOptions compiled)+ secondRender <- firstShow (renderProfileDocumentation defaultDocumentationOptions compiled)+ assertEqual firstRender secondRender+ -- Compare the serialized text too: serializeDocument sorts frontmatter+ -- keys, so a Concept-only comparison would miss a nondeterministic+ -- serialization.+ assertEqual (map serializeConcept firstRender) (map serializeConcept secondRender)++-- | The generated bundle must survive the round trip the next plan's @--write@+-- mode performs: write it out, generate indexes over it, walk it back.+testProfileDocumentationFilesystemRoundTrip :: IO (Either Text ())+testProfileDocumentationFilesystemRoundTrip = do+ descriptorPath <- fixtureFilePath "profiles/optional-fields.dhall"+ loaded <- loadProfileFile descriptorPath+ case loaded >>= (firstShow . compileProfile) of+ Left err -> pure (Left ("failed to prepare optional-field profile: " <> err))+ Right compiled ->+ case renderProfileDocumentation defaultDocumentationOptions compiled of+ Left err -> pure (Left ("render failed: " <> Text.pack (show err)))+ Right concepts -> do+ temporaryDirectory <- getTemporaryDirectory+ root <- createTempDirectory temporaryDirectory "okf-profile-documentation"+ writeBundle root concepts+ indexResult <- writeBundleIndexes root+ walked <- walkBundle root+ removeDirectoryRecursive root+ pure $ do+ _ <- firstShow indexResult+ walkedConcepts <- firstShow walked+ assertEqual+ (List.sort (map (renderConceptId . conceptIdOf) concepts))+ (List.sort (map (renderConceptId . conceptIdOf) walkedConcepts))++testClosedFieldsFixture :: IO (Either Text ())+testClosedFieldsFixture = do+ descriptorPath <- fixtureFilePath "profiles/closed-fields.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "profile-closed-fields"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load closed-field profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ typoId <- parseTestConceptId "requests/typo"+ assertEqual+ [MissingProfileField typoId "status" Nothing, FieldNotInProfile typoId "stauts"]+ (validateProfile PermissiveConformance compiled concepts)++testCardinalityFixture :: IO (Either Text ())+testCardinalityFixture = do+ descriptorPath <- fixtureFilePath "profiles/cardinality.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "profile-cardinality"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load cardinality profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ badId <- parseTestConceptId "bad"+ assertEqual+ [ CardinalityMismatch badId (fieldPath "tags") List (String "one"),+ CardinalityMismatch badId (fieldPath "title") Scalar (toJSON (["One", "Two"] :: [Text]))+ ]+ (validateProfile PermissiveConformance compiled concepts)++testFormatsFixture :: IO (Either Text ())+testFormatsFixture = do+ descriptorPath <- fixtureFilePath "profiles/formats.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "profile-formats"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load format profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ badId <- parseTestConceptId "bad"+ assertEqual+ [ ValueFormatMismatch badId (fieldPath "docId") (DocumentHandle "ADR") (String "ADR-007"),+ ValueFormatMismatch badId (fieldPath "homepage") (UriWithScheme "https") (String "mailto:owner@example.test"),+ ValueFormatMismatch badId (fieldPath "links") Uri (toJSON (["https://example.test/good", "https://example.test/bad%ZZ"] :: [Text])),+ ValueFormatMismatch badId (fieldPath "published") Date (String "2026-13-45"),+ ValueFormatMismatch badId (fieldPath "timestamp") Rfc3339Utc (String "2026-07-29T17:00:00+01:00")+ ]+ (validateProfile PermissiveConformance compiled concepts)++testNestedReviewsFixture :: IO (Either Text ())+testNestedReviewsFixture = do+ descriptorPath <- fixtureFilePath "profiles/nested-reviews.dhall"+ loaded <- loadProfileFile descriptorPath+ root <- fixturePath "profile-nested-reviews"+ concepts <- readBundle root+ pure $ case loaded of+ Left err -> Left ("failed to load nested review profile: " <> err)+ Right spec -> do+ compiled <- firstShow (compileProfile spec)+ badId <- parseTestConceptId "bad"+ let permissiveExpected =+ [ NestedElementNotRecord badId (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),+ CardinalityMismatch badId (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),+ MissingNestedProfileField badId (nestedTestPath 2 "outcome") Nothing,+ ValueFormatMismatch badId (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),+ ValueNotInVocabulary badId (nestedTestPath 2 "scope") reviewScopes (String "invalid")+ ]+ assertEqual permissiveExpected (validateProfile PermissiveConformance compiled concepts)+ assertEqual+ [ NestedElementNotRecord badId (FieldPath (FieldName "reviews" :| [ArrayIndex 1])) (String "not-a-record"),+ CardinalityMismatch badId (nestedTestPath 2 "context") Scalar (toJSON (["wrong"] :: [Text])),+ MissingRecommendedNestedProfileField badId (nestedTestPath 2 "notes") Nothing,+ MissingNestedProfileField badId (nestedTestPath 2 "outcome") Nothing,+ ValueFormatMismatch badId (nestedTestPath 2 "reviewed_at") Rfc3339Utc (String "2026-13-45T99:99:99Z"),+ ValueNotInVocabulary badId (nestedTestPath 2 "scope") reviewScopes (String "invalid")+ ]+ (validateProfile StrictAuthoring compiled concepts)++testConditionalFieldsFixture :: IO (Either Text ())+testConditionalFieldsFixture = do+ descriptorPath <- fixtureFilePath "profiles/conditional-fields.dhall"+ invalidDescriptorPath <- fixtureFilePath "profiles/conditional-fields-invalid.dhall"+ loaded <- loadProfileFile descriptorPath+ invalidLoaded <- loadProfileFile invalidDescriptorPath+ root <- fixturePath "profile-conditions"+ concepts <- readBundle root+ pure $ do+ spec <- first ("failed to load conditional profile: " <>) loaded+ compiled <- firstShow (compileProfile spec)+ invalidSpec <- first ("failed to load invalid conditional profile: " <>) invalidLoaded+ decisionsMissingStatus <- parseTestConceptId "decisions/missing-status"+ decisionsSuperseded <- parseTestConceptId "decisions/superseded"+ postgresqlOperational <- parseTestConceptId "postgresql/operational"+ postgresqlProjection <- parseTestConceptId "postgresql/projection"+ reviewsMixed <- parseTestConceptId "reviews/mixed"+ assertEqual+ ( Left+ ( ConditionFieldHasUnreachableValues+ Nothing+ (fieldPath "supersededBy")+ (fieldPath "status")+ ["superseded"]+ ["active"]+ :| []+ )+ )+ (compileProfile invalidSpec)+ assertEqual+ [ MissingProfileField decisionsMissingStatus "status" Nothing,+ MissingProfileField decisionsSuperseded "supersededBy" (Just (FieldCondition "status" ["superseded"])),+ MissingProfileField postgresqlProjection "sourceQuery" (Just (FieldCondition "derivationKind" ["projection"])),+ MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "effort") (Just (FieldCondition "kind" ["model"])),+ MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "model") (Just (FieldCondition "kind" ["model"])),+ MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "provider") (Just (FieldCondition "kind" ["model"]))+ ]+ (validateProfile PermissiveConformance compiled concepts)+ assertEqual+ [ MissingProfileField decisionsMissingStatus "status" Nothing,+ MissingProfileField decisionsSuperseded "supersededBy" (Just (FieldCondition "status" ["superseded"])),+ MissingRecommendedProfileField postgresqlOperational "runbook" (Just (FieldCondition "derivationKind" ["operational"])),+ MissingProfileField postgresqlProjection "sourceQuery" (Just (FieldCondition "derivationKind" ["projection"])),+ MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "effort") (Just (FieldCondition "kind" ["model"])),+ MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "model") (Just (FieldCondition "kind" ["model"])),+ MissingNestedProfileField reviewsMixed (nestedReviewPath 0 "provider") (Just (FieldCondition "kind" ["model"]))+ ]+ (validateProfile StrictAuthoring compiled concepts)+ where+ nestedReviewPath elementIndex key =+ FieldPath (FieldName "reviews" :| [ArrayIndex elementIndex, FieldName key])++testDocumentReferencesFixture :: IO (Either Text ())+testDocumentReferencesFixture = do+ descriptorPath <- fixtureFilePath "profiles/document-references.dhall"+ invalidDescriptorPath <- fixtureFilePath "profiles/document-references-invalid.dhall"+ loaded <- loadProfileFile descriptorPath+ invalidLoaded <- loadProfileFile invalidDescriptorPath+ root <- fixturePath "profile-document-references"+ concepts <- readBundle root+ pure $ do+ spec <- first ("failed to load document-reference profile: " <>) loaded+ compiled <- firstShow (compileProfile spec)+ invalidSpec <- first ("failed to load invalid document-reference profile: " <>) invalidLoaded+ case compileProfile invalidSpec of+ Left _ -> Right ()+ Right _ -> Left "expected invalid document-reference descriptor to fail compilation"+ duplicateAId <- parseTestConceptId "decisions/duplicate-a"+ duplicateBId <- parseTestConceptId "decisions/duplicate-b"+ sourceId <- parseTestConceptId "decisions/source"+ assertEqual+ [ DanglingHandleReference sourceId (indexedPath 1) "ADR-99",+ ReferenceHandlePrefixMismatch sourceId (indexedPath 2) "PAT-3" "ADR",+ MalformedDocumentReference sourceId (indexedPath 3) (String "not a reference"),+ ExternalReferenceSchemeNotAllowed sourceId (indexedPath 4) "https" ["mori"],+ SelfDocumentReference sourceId (indexedPath 6) "ADR-1",+ MalformedDocumentReference sourceId (indexedPath 7) (Number 7),+ DuplicateDocumentId "ADR-3" duplicateAId duplicateBId+ ]+ (validateProfile PermissiveConformance compiled concepts)+ where+ indexedPath elementIndex = FieldPath (FieldName "references" :| [ArrayIndex elementIndex])++validateTestProfile :: ProfileSpec -> [Concept] -> [ProfileViolation]+validateTestProfile spec concepts =+ case compileProfile spec of+ Left errors -> error ("test profile failed to compile: " <> show errors)+ Right compiled -> validateProfile PermissiveConformance compiled concepts++substringIndex :: Text -> Text -> Maybe Int+substringIndex needle haystack =+ let (prefix, match) = Text.breakOn needle haystack+ in if Text.null match then Nothing else Just (Text.length prefix)++strictlyIncreasing :: [Int] -> Bool+strictlyIncreasing xs = and (zipWith (<) xs (drop 1 xs))++sampleDocument :: Text+sampleDocument =+ Text.unlines+ [ "---",+ "type: BigQuery Table",+ "title: Users",+ "description: User records.",+ "timestamp: 2026-06-16T00:00:00Z",+ "tags: [users]",+ "---",+ "",+ "# Schema",+ "",+ "Body text."+ ]++assertEqual :: (Eq value, Show value) => value -> value -> Either Text ()+assertEqual expected actual+ | expected == actual = Right ()+ | otherwise =+ Left+ ( "expected "+ <> Text.pack (show expected)+ <> ", got "+ <> Text.pack (show actual)+ )++assertBool :: Text -> Bool -> Either Text ()+assertBool _ True = Right ()+assertBool label False = Left label++firstShow :: (Show err) => Either err value -> Either Text value+firstShow =+ either (Left . Text.pack . show) Right++readBundle :: FilePath -> IO [Concept]+readBundle root = do+ result <- walkBundle root+ case result of+ Left bundleError -> fail (show bundleError)+ Right concepts -> pure concepts++-- | Every file the bundle holds, for the path-valued frontmatter check.+readBundleInventory :: FilePath -> IO BundleInventory+readBundleInventory root = do+ result <- walkBundleInventory root+ case result of+ Left bundleError -> fail (show bundleError)+ Right inventory -> pure inventory++-- | 'validateBundle' over a bundle assembled in memory, whose inventory is+-- exactly the concepts' own source paths. Used wherever a test builds concepts+-- rather than walking a directory; a test that does have a root passes+-- 'readBundleInventory' instead, so the non-Markdown files are seen.+validateInMemoryBundle :: ValidationProfile -> VersionDeclaration -> [Concept] -> [BundleValidationError]+validateInMemoryBundle profile declaration concepts =+ validateBundle profile declaration (bundleInventoryOfConcepts concepts) concepts fixturePath :: FilePath -> IO FilePath fixturePath name = do
+ test/fixtures/attested-computation/computations/both-computations.md view
@@ -0,0 +1,27 @@+---+type: Attested Computation+title: Revenue for fiscal year, twice over+description: Names a computation file and also carries one inline.+status: draft+runtime: bigquery+computation: /references/queries/revenue.sql+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+---++# Computation++```sql+SELECT SUM(amount) AS revenue+FROM finance.recognized_revenue+WHERE fiscal_year = @year+```++# Notes++Offers its computation twice: once by the `computation` path, which resolves,+and once as a body block. Specification section 10.3 permits exactly one, and+two leaves a consumer with no way to know which one the producer sanctioned.+The path is not the problem — it names a real file — so the only diagnostic is+the ambiguity.
+ test/fixtures/attested-computation/computations/churn.md view
@@ -0,0 +1,31 @@+---+type: Attested Computation+title: Customer churn for a fiscal year+description: Share of customers active at the start of a year and not at its end.+status: stable+runtime: bigquery+parameters:+ - name: year+executor:+ resource: /references/skills/run-on-bq.md+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+---++# Computation++ SELECT SAFE_DIVIDE(churned, started) AS churn+ FROM finance.customer_cohorts+ WHERE fiscal_year = @year++The one concept here that OKF itself has nothing to say about. It declares the+`runtime` specification section 10.2 marks REQUIRED, offers exactly one+computation, and every path-valued field it carries resolves, so both permissive+and strict validation report it not at all.++It exists for the *profile* layer. Its `parameters` entry carries no `type`, its+`executor` names no `receipt`, and it declares no `attester` — three things the+format permits and a team might not, which is what+`okf-core/test/fixtures/profiles/attested-computation-house.dhall` demonstrates+and `docs/user/profiles.md` documents.
+ test/fixtures/attested-computation/computations/margin.md view
@@ -0,0 +1,23 @@+---+type: Attested Computation+title: Gross margin for fiscal year+description: Gross margin for a fiscal year, as a fraction of revenue.+status: draft+parameters:+ - name: year+ type: integer+ required: true+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+---++# Computation++ SELECT SAFE_DIVIDE(gross_profit, revenue) AS margin+ FROM finance.income_statement+ WHERE fiscal_year = @year++Declares no `runtime`, which specification section 10.2 marks REQUIRED for this+type. This is the one concept in the bundle strict validation reports, and it+reports nothing in permissive mode.
+ test/fixtures/attested-computation/computations/no-computation.md view
@@ -0,0 +1,17 @@+---+type: Attested Computation+title: Headcount for fiscal year+description: Headcount at the close of a fiscal year.+status: draft+runtime: bigquery+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+---++# Notes++Declares a contract and then carries no computation: no `computation` path, and+no code block under a `# Computation` heading. Specification section 10.3+provides the computation in one of two ways, and this concept takes neither, so+strict validation reports it and permissive validation does not.
+ test/fixtures/attested-computation/computations/revenue.md view
@@ -0,0 +1,35 @@+---+type: Attested Computation+title: Revenue for fiscal year+description: Recognized revenue for a fiscal year, per Finance's definition.+status: stable+runtime: bigquery+parameters:+ - name: year+ type: integer+ required: true+executor:+ resource: /references/skills/run-on-bq.md+ receipt: [job_id, executed_sql, result]+attester:+ resource: /references/attesters/revenue.py+generated:+ by: reference_agent/gemini-2.5-pro+ at: 2026-06-20T22:53:05Z+verified:+ by: human:ahormati+ at: 2026-06-25T09:00:00Z+stale_after: 2026-09-23+---++# Computation++ SELECT SUM(amount) AS revenue+ FROM finance.recognized_revenue+ WHERE fiscal_year = @year++The computation binds only the declared `parameters`. Both path-valued contract+fields are written in specification section 6.2's bundle-relative form, with a+leading slash, because a bare `references/...` on a concept under+`computations/` is a relative path and resolves to+`computations/references/...`.
+ test/fixtures/attested-computation/computations/two-blocks.md view
@@ -0,0 +1,35 @@+---+type: Attested Computation+title: Revenue for fiscal year, in two steps+description: Carries two code blocks in one Computation section.+status: draft+runtime: bigquery+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+---++# Computation++```sql+CREATE TEMP TABLE booked AS+SELECT amount, fiscal_year FROM finance.recognized_revenue+```++```sql+SELECT SUM(amount) AS revenue FROM booked WHERE fiscal_year = @year+```++# Notes++Two code blocks in one `# Computation` section, where specification section+10.3 says "a single fenced code block". Splitting a computation across blocks+is exactly the shape an attester cannot check, because there is no single+statement to compare against what the executor reports having run.++The fenced block below is under a later heading of the same level, so the+section that counts ended above it and this is not a third computation:++```sql+SELECT 1+```
+ test/fixtures/attested-computation/index.md view
@@ -0,0 +1,13 @@+---+okf_version: "0.2"+---++# Attested computation fixture++- [computations/revenue](computations/revenue.md)+- [computations/margin](computations/margin.md)+- [computations/no-computation](computations/no-computation.md)+- [computations/both-computations](computations/both-computations.md)+- [computations/two-blocks](computations/two-blocks.md)+- [computations/churn](computations/churn.md)+- [metrics/revenue](metrics/revenue.md)
+ test/fixtures/attested-computation/metrics/revenue.md view
@@ -0,0 +1,19 @@+---+type: Metric+title: Revenue+description: Recognized revenue for a fiscal year.+tags: [finance, revenue]+status: stable+generated:+ by: reference_agent/gemini-2.5-pro+ at: 2026-06-20T22:53:05Z+---++# Definition++Recognized revenue sums `amount` over rows booked to the fiscal year, computed+by [the revenue computation](/computations/revenue.md).++This concept carries no contract field at all, and declares no `runtime`. It is+what proves the section 10.2 check touches one `type` and no other: were the+check keyed on anything looser, this concept would be reported too.
+ test/fixtures/attested-computation/references/attesters/revenue.py view
@@ -0,0 +1,16 @@+# Specification section 10.2's own example of an attester resource: deterministic+# code, no language model, that takes a receipt and returns a verdict. okf never+# runs this file. It exists so the fixture proves a non-Markdown target of a+# path-valued contract field resolves against the bundle inventory.+++def attest(receipt):+ """Return whether a run produced its value the sanctioned way."""+ return receipt["executed_sql"].strip() == EXPECTED_SQL.strip()+++EXPECTED_SQL = """+SELECT SUM(amount) AS revenue+FROM finance.recognized_revenue+WHERE fiscal_year = @year+"""
+ test/fixtures/attested-computation/references/queries/revenue.sql view
@@ -0,0 +1,3 @@+SELECT SUM(amount) AS revenue+FROM finance.recognized_revenue+WHERE fiscal_year = @year
+ test/fixtures/attested-computation/references/skills/run-on-bq.md view
@@ -0,0 +1,22 @@+---+type: Reference+title: Run on BigQuery+description: Run instructions an executor follows to bind and submit a query.+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+---++# Run on BigQuery++Bind the declared parameters, submit the query, and return `job_id`,+`executed_sql`, and `result`. okf never follows these instructions; specification+section 10.5 places the run and its receipt outside the bundle entirely.++This file carries a `type` because it must. Specification section 11 requires a+non-empty `type` on every non-reserved `.md` file in the tree, with no exemption+for a directory name, so a Markdown file under `references/` is an ordinary+concept. The sibling `references/attesters/revenue.py` is not Markdown and so is+not a concept at all — it is a file the bundle holds, which is exactly what a+path-valued field needs it to be. See+`docs/adr/13-the-references-convention-and-non-markdown-files.md`.
+ test/fixtures/dangling-frontmatter-path/computations/index.md view
@@ -0,0 +1,3 @@+# Reference++- [Specification Spelling](spec-spelling.md) - Its resource is the bare references/ path the specification's own example writes.
+ test/fixtures/dangling-frontmatter-path/computations/spec-spelling.md view
@@ -0,0 +1,26 @@+---+type: Reference+title: Specification Spelling+description: Its resource is the bare references/ path the specification's own example writes.+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+resource: references/attesters/revenue.py+---++# Specification Spelling++Specification section 10.2's worked example writes+`executor.resource: references/skills/run-on-bq.md` and section 10.4 puts+computations in a `computations/` folder, so a bundle assembled from the+specification's own text names a path nobody wrote.++Section 6.2 resolution is unchanged and this concept's `resource` really does+name `computations/references/attesters/revenue.py`, which is not here. What the+diagnostic adds is the spelling that would have worked:+`/references/attesters/revenue.py`.++The sibling `non-markdown.md` writes the same bare text and resolves, because it+sits at the bundle root where the relative and bundle-relative readings are the+same path. That is the whole of the difference, and it is why a concept in a+subdirectory is the only shape that can carry this hint.
+ test/fixtures/dangling-frontmatter-path/dangling.md view
@@ -0,0 +1,14 @@+---+type: Reference+title: Dangling+description: Its resource names a bundle file nothing put there.+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+resource: /references/deleted.txt+---++# Dangling++The only concept in this bundle that specification section 6.2 resolution should+report.
+ test/fixtures/dangling-frontmatter-path/external.md view
@@ -0,0 +1,13 @@+---+type: Reference+title: External+description: Its resource is an absolute URL, which okf never fetches.+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+resource: bigquery://analytics.tables.orders+---++# External++A scheme okf does not know is still a scheme, so this resolves external.
+ test/fixtures/dangling-frontmatter-path/index.md view
@@ -0,0 +1,10 @@+---+okf_version: "0.2"+---++# Path-valued frontmatter fixture++- [computations/](computations/index.md)+- [dangling](dangling.md)+- [external](external.md)+- [non-markdown](non-markdown.md)
+ test/fixtures/dangling-frontmatter-path/non-markdown.md view
@@ -0,0 +1,15 @@+---+type: Reference+title: Non-Markdown+description: Its resource names a file that is not a concept, and does exist.+generated:+ by: human:nadeem+ at: 2026-08-01T00:00:00Z+resource: references/attesters/revenue.py+---++# Non-Markdown++Specification section 6.3's own example of what a references/ path points at.+This concept is the one that proves the bundle inventory sees more than+concepts: before it existed, okf could not tell this file from a deleted one.
+ test/fixtures/dangling-frontmatter-path/references/attesters/revenue.py view
@@ -0,0 +1,1 @@+"""A stand-in attester. okf never runs this; it only checks that it is here."""
+ test/fixtures/profiles/attested-computation-house.dhall view
@@ -0,0 +1,131 @@+-- A *house* profile for the OKF v0.2 attested computation contract+-- (specification §10), and the descriptor `docs/user/profiles.md` documents.+--+-- Everything here is a house convention rather than a v0.2 rule. §10.2 marks+-- exactly one field REQUIRED for this type, `runtime`, and okf's core already+-- reports a missing one under `--strict`. Demanding that every parameter carry+-- a `type`, or that an executor name a resource the bundle actually holds, is a+-- team's own policy — which is why it lives here and not in+-- `docs/profiles/okf-v0-2.dhall`, whose job is the format's own rules.+--+-- This file is exercised by `testFrozenFixturesCompile`, so the descriptor+-- `docs/user/profiles.md` shows cannot rot into something that no longer+-- compiles.+--+-- Two structural points the prose in that document explains, restated where a+-- reader of the descriptor will meet them:+--+-- * The whole contract is scoped to one `type` with a `TypeRule`. Profile+-- scope would demand `runtime` of every `Metric` in the bundle.+-- * `executor` and `attester` are mappings, so their members are reached with+-- `objectFields`, while `parameters` is a list of mappings, so its members+-- are reached with `elementFields`. Getting that pair the wrong way round is+-- the single easiest mistake to make here.+let Profile = ../../../dhall/Profile.dhall++let TypeRule = ../../../dhall/defaults/TypeRule.dhall++let FieldRule = ../../../dhall/defaults/FieldRule.dhall++let NestedRules = ../../../dhall/defaults/NestedRules.dhall++let Cardinality = ../../../dhall/Cardinality.dhall++let field = ../../../dhall/mk/FieldRule.dhall++let nested = ../../../dhall/mk/NestedFieldRule.dhall++-- §10.2. A parameter is a named hole an agent may fill. `name` is what the+-- computation binds; `type` is what this team insists on so an agent knows what+-- kind of value is wanted, and `required` is optional because §10.2 leaves the+-- default to the producer.+let parameterMembers =+ NestedRules::{+ , required =+ [ nested.documented "name" "The bind name the computation uses."+ , nested.documented+ "type"+ "What kind of value the parameter takes. This team requires one so an agent never has to guess."+ // { cardinality = Cardinality.Scalar }+ ]+ , optional = [ nested.boolean "required" ]+ }++-- §10.2. The executor names the run instructions and the receipt fields a run+-- must return. `bundlePath` checks that the resource names a file the bundle+-- actually holds, which is the whole reason a team writes this rule: a contract+-- whose executor cannot be found is a contract that cannot be honoured.+let executorMembers =+ NestedRules::{+ , required =+ [ nested.bundlePath "resource"+ // { description = Some+ "The run instructions, as a path to a file in this bundle."+ }+ ]+ , recommended =+ [ nested.list "receipt"+ // { description = Some+ "The fields a run must return, so an attester knows what to inspect."+ }+ ]+ }++-- §10.2. The attester is deterministic, no-LLM code that inspects a receipt.+-- Same path policy, same reason.+let attesterMembers =+ NestedRules::{+ , required =+ [ nested.bundlePath "resource"+ // { description = Some+ "The attester, as a path to a file in this bundle."+ }+ ]+ }++in { name = "attested-computation-house"+ , description = Some+ "A house convention for the OKF v0.2 attested computation contract."+ , okfVersion = "0.2"+ , frontmatter =+ { required = [ field.plain "type" ]+ , recommended = [] : List FieldRule.Type+ , optional = [] : List FieldRule.Type+ }+ , allowUnknownTypes = True+ , allowUnknownFields = True+ , idField = None Text+ , requireBundleVersion = None Text+ , types =+ [ TypeRule::{+ , type = "Attested Computation"+ , description = Some+ "A sanctioned computation, with the means to check that a value came from running it."+ , frontmatter =+ { required =+ [ field.recordList "parameters" parameterMembers+ // { description = Some+ "The typed named holes an agent may fill. This team requires at least the declaration, so a computation taking none says so with an empty list."+ }+ , field.record "executor" executorMembers+ // { description = Some+ "How a run is performed and what it must return."+ }+ ]+ , recommended =+ [ field.record "attester" attesterMembers+ // { description = Some+ "Deterministic code that inspects a receipt and returns a verdict."+ }+ ]+ , optional =+ [ field.bundlePath "computation"+ // { description = Some+ "The computation, as a path to a file in this bundle, when it is not carried inline."+ }+ ]+ }+ }+ ]+ }+ : Profile
test/fixtures/profiles/cardinality.dhall view
@@ -22,6 +22,7 @@ , allowUnknownTypes = False , allowUnknownFields = True , idField = None Text+ , requireBundleVersion = None Text , types = [ TypeRule::{ , type = "Cardinality Concept"
test/fixtures/profiles/closed-fields.dhall view
@@ -20,6 +20,7 @@ , allowUnknownTypes = False , allowUnknownFields = False , idField = Some "requestId"+ , requireBundleVersion = None Text , types = [ TypeRule::{ , type = "Improvement Request"
test/fixtures/profiles/conditional-fields-ep2.dhall view
@@ -3,7 +3,19 @@ -- preserve both top-level and nested `when` values while adding `None`. let Cardinality = ../../../dhall/Cardinality.dhall -let FieldFormat = ../../../dhall/FieldFormat.dhall+-- The format union is written out rather than imported from+-- `../../../dhall/FieldFormat.dhall`. A Dhall union value carries its full+-- alternative set in its type, so importing the live file would give this+-- frozen fixture whatever alternatives that file later gains and would leave+-- it exercising no frozen decoder at all. These are the five alternatives the+-- published union had when this fixture was frozen.+let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ > let FieldCondition = { field : Text, hasValue : List Text }
test/fixtures/profiles/decisions.dhall view
@@ -30,6 +30,7 @@ , allowUnknownTypes = False , allowUnknownFields = True , idField = Some "docId"+ , requireBundleVersion = None Text , types = [ TypeRule::{ , type = "Decision Record"
test/fixtures/profiles/document-references-ep3.dhall view
@@ -8,7 +8,19 @@ -- keeps typechecking after the published schema moves on. let Cardinality = ../../../dhall/Cardinality.dhall -let FieldFormat = ../../../dhall/FieldFormat.dhall+-- The format union is written out rather than imported from+-- `../../../dhall/FieldFormat.dhall`. A Dhall union value carries its full+-- alternative set in its type, so importing the live file would give this+-- frozen fixture whatever alternatives that file later gains and would leave+-- it exercising no frozen decoder at all. These are the five alternatives the+-- published union had when this fixture was frozen.+let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ > let FieldCondition = { field : Text, hasValue : List Text }
test/fixtures/profiles/document-references-invalid.dhall view
@@ -42,6 +42,7 @@ , allowUnknownTypes = True , allowUnknownFields = True , idField = Some "docId"+ , requireBundleVersion = None Text , types = [ TypeRule::{ , type = "Decision Record"
test/fixtures/profiles/document-references.dhall view
@@ -35,6 +35,7 @@ , allowUnknownTypes = False , allowUnknownFields = True , idField = Some "docId"+ , requireBundleVersion = None Text , types = [ TypeRule::{ , type = "Decision Record"
test/fixtures/profiles/formats-ep4.dhall view
@@ -2,7 +2,19 @@ -- descriptor unannotated and unchanged so it exercises the dedicated decoder. let Cardinality = ../../../dhall/Cardinality.dhall -let FieldFormat = ../../../dhall/FieldFormat.dhall+-- The format union is written out rather than imported from+-- `../../../dhall/FieldFormat.dhall`. A Dhall union value carries its full+-- alternative set in its type, so importing the live file would give this+-- frozen fixture whatever alternatives that file later gains and would leave+-- it exercising no frozen decoder at all. These are the five alternatives the+-- published union had when this fixture was frozen.+let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ > let FieldRule = { field : Text
+ test/fixtures/profiles/formats-mp8-ep2.dhall view
@@ -0,0 +1,182 @@+--| Frozen object-rule descriptor generation from MasterPlan 8 EP-2.+-- This is the published descriptor exactly as it stood immediately before the+-- OKF v0.2 value formats were added to `FieldFormat`: the records match today's+-- shape, `objectFields` and all, and the only difference is the format union,+-- which had exactly the five textual alternatives spelled out below.+--+-- The union is written out as a literal rather than imported from+-- `../../../dhall/FieldFormat.dhall`, which is the whole point of this fixture:+-- a Dhall union value carries its full alternative set in its type, so a+-- fixture that imports the live schema file acquires whatever alternatives that+-- file gains and exercises no frozen decoder at all. `Cardinality` is likewise+-- written out. Every published type this fixture names is spelled out here.+--+-- FROZEN: never edit this file. If a test on it fails, the fault is in the+-- decoder chain in `okf-core/src/Okf/Profile.hs`, not here.+let Cardinality = < Any | List | Scalar >++let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ >++let FieldCondition = { field : Text, hasValue : List Text }++let HandleReferenceRule =+ { localPrefix : Text+ , externalUriSchemes : List Text+ , allowSelf : Bool+ }++let NestedFieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , when : Optional FieldCondition+ }++let NestedRules =+ { required : List NestedFieldRule+ , recommended : List NestedFieldRule+ , optional : List NestedFieldRule+ }++let FieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , elementFields : Optional NestedRules+ , objectFields : Optional NestedRules+ , reference : Optional HandleReferenceRule+ , when : Optional FieldCondition+ }++let FrontmatterRules =+ { required : List FieldRule+ , recommended : List FieldRule+ , optional : List FieldRule+ }++let TypeRule =+ { type : Text+ , description : Optional Text+ , frontmatter : FrontmatterRules+ , pathPattern : Optional Text+ , resourceScheme : Optional Text+ , requireSchemaSection : Bool+ , schemaColumns : List Text+ , idPrefix : Optional Text+ }++let Profile =+ { name : Text+ , description : Optional Text+ , okfVersion : Text+ , frontmatter : FrontmatterRules+ , allowUnknownTypes : Bool+ , allowUnknownFields : Bool+ , idField : Optional Text+ , types : List TypeRule+ }++let plain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , elementFields = None NestedRules+ , objectFields = None NestedRules+ , reference = None HandleReferenceRule+ , when = None FieldCondition+ }++let nestedPlain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , when = None FieldCondition+ }++in { name = "formats-mp8-ep2"+ , description = None Text+ , okfVersion = "0.1"+ , frontmatter =+ { required =+ [ plain "type"+ , plain "title"+ // { cardinality = Cardinality.Scalar }+ , plain "generated"+ // { objectFields = Some+ { required =+ [ nestedPlain "by"+ // { cardinality = Cardinality.Scalar }+ , nestedPlain "at"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.Rfc3339Utc+ }+ ]+ , recommended = [] : List NestedFieldRule+ , optional = [] : List NestedFieldRule+ }+ }+ ]+ , recommended =+ [ plain "timestamp"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.Rfc3339Utc+ }+ , plain "reviewed"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.Date+ }+ ]+ , optional =+ [ plain "homepage"+ // { cardinality = Cardinality.Scalar+ , format = Some (FieldFormat.UriWithScheme "https")+ }+ , plain "supersedes"+ // { cardinality = Cardinality.Scalar+ , format = Some (FieldFormat.DocumentHandle "ADR")+ }+ , plain "seeAlso"+ // { cardinality = Cardinality.Scalar, format = Some FieldFormat.Uri }+ ]+ }+ , allowUnknownTypes = False+ , allowUnknownFields = True+ , idField = None Text+ , types =+ [ { type = "Decision Record"+ , description = None Text+ , frontmatter =+ { required =+ [ plain "decidedOn"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.Date+ }+ ]+ , recommended = [] : List FieldRule+ , optional = [] : List FieldRule+ }+ , pathPattern = None Text+ , resourceScheme = None Text+ , requireSchemaSection = False+ , schemaColumns = [] : List Text+ , idPrefix = None Text+ }+ ]+ }+ : Profile
test/fixtures/profiles/formats.dhall view
@@ -32,6 +32,7 @@ , allowUnknownTypes = False , allowUnknownFields = True , idField = Some "docId"+ , requireBundleVersion = None Text , types = [ TypeRule::{ , type = "Format Concept"
test/fixtures/profiles/nested-reviews-ep1.dhall view
@@ -1,6 +1,18 @@ let Cardinality = ../../../dhall/Cardinality.dhall -let FieldFormat = ../../../dhall/FieldFormat.dhall+-- The format union is written out rather than imported from+-- `../../../dhall/FieldFormat.dhall`. A Dhall union value carries its full+-- alternative set in its type, so importing the live file would give this+-- frozen fixture whatever alternatives that file later gains and would leave+-- it exercising no frozen decoder at all. These are the five alternatives the+-- published union had when this fixture was frozen.+let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ > let NestedFieldRule = { field : Text
test/fixtures/profiles/nested-reviews.dhall view
@@ -61,6 +61,7 @@ , allowUnknownTypes = False , allowUnknownFields = True , idField = None Text+ , requireBundleVersion = None Text , types = [ TypeRule::{ type = "Reviewed Concept" } ] } : Profile
+ test/fixtures/profiles/object-fields-mp8-ep1.dhall view
@@ -0,0 +1,172 @@+--| Frozen optional-presence descriptor generation from MasterPlan 8 EP-1.+-- This is the published descriptor exactly as it stood immediately before+-- `objectFields` was added to `FieldRule`: it has the `optional` presence list+-- at both scopes, `reference`, `when`, and one level of `elementFields`, and it+-- deliberately has no `objectFields` member anywhere. The compatibility decoder+-- must preserve every other field while supplying `objectFields = None`.+--+-- FROZEN: never edit this file. If a test on it fails, the fault is in the+-- decoder chain in `okf-core/src/Okf/Profile.hs`, not here. The file is also+-- deliberately unannotated at the bottom against an imported `Profile` type+-- that is spelled out below rather than imported, precisely so it keeps+-- typechecking after the published schema moves on.+let Cardinality = ../../../dhall/Cardinality.dhall++-- The format union is written out rather than imported from+-- `../../../dhall/FieldFormat.dhall`. A Dhall union value carries its full+-- alternative set in its type, so importing the live file would give this+-- frozen fixture whatever alternatives that file later gains and would leave+-- it exercising no frozen decoder at all. These are the five alternatives the+-- published union had when this fixture was frozen.+let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ >++let FieldCondition = { field : Text, hasValue : List Text }++let HandleReferenceRule =+ { localPrefix : Text+ , externalUriSchemes : List Text+ , allowSelf : Bool+ }++let NestedFieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , when : Optional FieldCondition+ }++let NestedRules =+ { required : List NestedFieldRule+ , recommended : List NestedFieldRule+ , optional : List NestedFieldRule+ }++let FieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , elementFields : Optional NestedRules+ , reference : Optional HandleReferenceRule+ , when : Optional FieldCondition+ }++let FrontmatterRules =+ { required : List FieldRule+ , recommended : List FieldRule+ , optional : List FieldRule+ }++let TypeRule =+ { type : Text+ , description : Optional Text+ , frontmatter : FrontmatterRules+ , pathPattern : Optional Text+ , resourceScheme : Optional Text+ , requireSchemaSection : Bool+ , schemaColumns : List Text+ , idPrefix : Optional Text+ }++let Profile =+ { name : Text+ , description : Optional Text+ , okfVersion : Text+ , frontmatter : FrontmatterRules+ , allowUnknownTypes : Bool+ , allowUnknownFields : Bool+ , idField : Optional Text+ , types : List TypeRule+ }++let plain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , elementFields = None NestedRules+ , reference = None HandleReferenceRule+ , when = None FieldCondition+ }++let nestedPlain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , when = None FieldCondition+ }++in { name = "object-fields-mp8-ep1"+ , description = None Text+ , okfVersion = "0.1"+ , frontmatter =+ { required = [ plain "type", plain "title" ]+ , recommended =+ [ plain "supersedes"+ // { cardinality = Cardinality.Scalar+ , reference = Some+ { localPrefix = "ADR"+ , externalUriSchemes = [ "mori" ]+ , allowSelf = False+ }+ }+ , plain "reviews"+ // { cardinality = Cardinality.List+ , elementFields = Some+ { required =+ [ nestedPlain "kind"+ // { allowedValues = [ "human", "model" ]+ , cardinality = Cardinality.Scalar+ }+ ]+ , recommended = [ nestedPlain "notes" ]+ , optional = [ nestedPlain "url" ]+ }+ }+ ]+ , optional =+ [ plain "supersededBy"+ // { cardinality = Cardinality.Scalar+ , format = Some (FieldFormat.DocumentHandle "ADR")+ }+ ]+ }+ , allowUnknownTypes = False+ , allowUnknownFields = True+ , idField = Some "docId"+ , types =+ [ { type = "Decision Record"+ , description = None Text+ , frontmatter =+ { required =+ [ plain "status"+ // { allowedValues = [ "accepted", "superseded" ]+ , cardinality = Cardinality.Scalar+ }+ ]+ , recommended = [] : List FieldRule+ , optional = [] : List FieldRule+ }+ , pathPattern = Some "decisions/*"+ , resourceScheme = None Text+ , requireSchemaSection = False+ , schemaColumns = [] : List Text+ , idPrefix = Some "ADR"+ }+ ]+ }+ : Profile
+ test/fixtures/profiles/path-references-mp8-ep3.dhall view
@@ -0,0 +1,198 @@+--| Frozen descriptor generation from MasterPlan 8 EP-3.+-- This is the published descriptor exactly as it stood immediately before+-- path-valued reference rules were added: today's shape minus the `path` member+-- on `FieldRule` and on `NestedFieldRule`.+--+-- Every published type this fixture names is written out as a literal rather+-- than imported from `../../../dhall/`. That is the whole point of the fixture:+-- a Dhall union value carries its full alternative set in its type and a record+-- literal carries its full member set, so a fixture that imports a live schema+-- file acquires whatever that file later gains and exercises no frozen decoder+-- at all. `Cardinality` and `FieldFormat` are spelled out for that reason even+-- though this generation changes neither.+--+-- `FieldFormat` here carries the ten alternatives published when this generation+-- was current, not the five of `formats-mp8-ep2.dhall`: that fixture is frozen+-- one generation earlier and the two must not be confused.+--+-- FROZEN: never edit this file. If a test on it fails, the fault is in the+-- decoder chain in `okf-core/src/Okf/Profile.hs`, not here.+--+-- Repaired once, on the day it was written, before any release depended on it:+-- as first committed it declared `okfVersion = "0.1"` while using the v0.2 actor+-- formats, and declared a `reference` rule with no profile `idField` and no type+-- `idPrefix`. Both made it a descriptor that loaded and could never compile, so+-- it was not representative of the pinned descriptor it exists to stand for. No+-- member's presence or absence changed. See+-- `docs/adr/11-growing-the-profile-descriptor-language.md` on why a frozen+-- fixture must compile and not merely decode.+let Cardinality = < Any | List | Scalar >++let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ | Actor+ | HumanActor+ | Integer+ | NonNegativeInteger+ | Boolean+ >++let FieldCondition = { field : Text, hasValue : List Text }++let HandleReferenceRule =+ { localPrefix : Text+ , externalUriSchemes : List Text+ , allowSelf : Bool+ }++let NestedFieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , when : Optional FieldCondition+ }++let NestedRules =+ { required : List NestedFieldRule+ , recommended : List NestedFieldRule+ , optional : List NestedFieldRule+ }++let FieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , elementFields : Optional NestedRules+ , objectFields : Optional NestedRules+ , reference : Optional HandleReferenceRule+ , when : Optional FieldCondition+ }++let FrontmatterRules =+ { required : List FieldRule+ , recommended : List FieldRule+ , optional : List FieldRule+ }++let TypeRule =+ { type : Text+ , description : Optional Text+ , frontmatter : FrontmatterRules+ , pathPattern : Optional Text+ , resourceScheme : Optional Text+ , requireSchemaSection : Bool+ , schemaColumns : List Text+ , idPrefix : Optional Text+ }++let Profile =+ { name : Text+ , description : Optional Text+ , okfVersion : Text+ , frontmatter : FrontmatterRules+ , allowUnknownTypes : Bool+ , allowUnknownFields : Bool+ , idField : Optional Text+ , types : List TypeRule+ }++let plain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , elementFields = None NestedRules+ , objectFields = None NestedRules+ , reference = None HandleReferenceRule+ , when = None FieldCondition+ }++let nestedPlain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , when = None FieldCondition+ }++in { name = "path-references-mp8-ep3"+ , description = None Text+ , okfVersion = "0.2"+ , frontmatter =+ { required =+ [ plain "type"+ , plain "sources"+ // { cardinality = Cardinality.List+ , elementFields = Some+ { required =+ [ nestedPlain "resource"+ // { cardinality = Cardinality.Scalar }+ ]+ , recommended = [] : List NestedFieldRule+ , optional = [] : List NestedFieldRule+ }+ }+ , plain "generated"+ // { objectFields = Some+ { required =+ [ nestedPlain "by"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.Actor+ }+ ]+ , recommended = [] : List NestedFieldRule+ , optional = [] : List NestedFieldRule+ }+ }+ ]+ , recommended =+ [ plain "usage_count"+ // { format = Some FieldFormat.NonNegativeInteger }+ ]+ , optional =+ [ plain "supersededBy"+ // { reference = Some+ { localPrefix = "ADR"+ , externalUriSchemes = [ "mori" ]+ , allowSelf = False+ }+ }+ ]+ }+ , allowUnknownTypes = False+ , allowUnknownFields = True+ , idField = Some "docId"+ , types =+ [ { type = "Metric"+ , description = None Text+ , frontmatter =+ { required =+ [ plain "owner"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.HumanActor+ }+ ]+ , recommended = [] : List FieldRule+ , optional = [] : List FieldRule+ }+ , pathPattern = None Text+ , resourceScheme = None Text+ , requireSchemaSection = False+ , schemaColumns = [] : List Text+ , idPrefix = Some "ADR"+ }+ ]+ }+ : Profile
test/fixtures/profiles/postgresql.dhall view
@@ -48,6 +48,7 @@ , allowUnknownTypes = False , allowUnknownFields = True , idField = None Text+ , requireBundleVersion = None Text , types = [ { type = "PostgreSQL Schema" , description = Some "One namespace grouping tables and views."
+ test/fixtures/profiles/pre-bundle-version.dhall view
@@ -0,0 +1,189 @@+--| Frozen descriptor generation from EP-54.+-- This is the published descriptor exactly as it stood immediately before+-- `requireBundleVersion` was added: today's shape minus that one member on the+-- top-level record. Every other record and every union is identical to today's,+-- which is exactly why this fixture must still spell them all out.+--+-- Every published type this fixture names is written out as a literal rather+-- than imported from `../../../dhall/`. That is the whole point of the fixture:+-- a Dhall union value carries its full alternative set in its type and a record+-- literal carries its full member set, so a fixture that imports a live schema+-- file acquires whatever that file later gains and exercises no frozen decoder+-- at all.+--+-- The descriptor is written to compile as well as decode, per+-- `docs/adr/11-growing-the-profile-descriptor-language.md`: `okfVersion = "0.2"`+-- matches the v0.2 formats it uses, and the `reference` rule has both a profile+-- `idField` and a matching type `idPrefix`.+--+-- FROZEN: never edit this file. If a test on it fails, the fault is in the+-- decoder chain in `okf-core/src/Okf/Profile.hs`, not here.+let Cardinality = < Any | Scalar | List >++let FieldFormat =+ < Rfc3339Utc+ | Date+ | Uri+ | UriWithScheme : Text+ | DocumentHandle : Text+ | Actor+ | HumanActor+ | Integer+ | NonNegativeInteger+ | Boolean+ >++let FieldCondition = { field : Text, hasValue : List Text }++let HandleReferenceRule =+ { localPrefix : Text+ , externalUriSchemes : List Text+ , allowSelf : Bool+ }++let PathReferenceRule = { externalUriSchemes : List Text, allowSelf : Bool }++let NestedFieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , path : Optional PathReferenceRule+ , when : Optional FieldCondition+ }++let NestedRules =+ { required : List NestedFieldRule+ , recommended : List NestedFieldRule+ , optional : List NestedFieldRule+ }++let FieldRule =+ { field : Text+ , description : Optional Text+ , allowedValues : List Text+ , cardinality : Cardinality+ , format : Optional FieldFormat+ , elementFields : Optional NestedRules+ , objectFields : Optional NestedRules+ , reference : Optional HandleReferenceRule+ , path : Optional PathReferenceRule+ , when : Optional FieldCondition+ }++let FrontmatterRules =+ { required : List FieldRule+ , recommended : List FieldRule+ , optional : List FieldRule+ }++let TypeRule =+ { type : Text+ , description : Optional Text+ , frontmatter : FrontmatterRules+ , pathPattern : Optional Text+ , resourceScheme : Optional Text+ , requireSchemaSection : Bool+ , schemaColumns : List Text+ , idPrefix : Optional Text+ }++let Profile =+ { name : Text+ , description : Optional Text+ , okfVersion : Text+ , frontmatter : FrontmatterRules+ , allowUnknownTypes : Bool+ , allowUnknownFields : Bool+ , idField : Optional Text+ , types : List TypeRule+ }++let plain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , elementFields = None NestedRules+ , objectFields = None NestedRules+ , reference = None HandleReferenceRule+ , path = None PathReferenceRule+ , when = None FieldCondition+ }++let nestedPlain =+ \(field : Text) ->+ { field+ , description = None Text+ , allowedValues = [] : List Text+ , cardinality = Cardinality.Any+ , format = None FieldFormat+ , path = None PathReferenceRule+ , when = None FieldCondition+ }++in { name = "pre-bundle-version"+ , description = Some "Frozen immediately before requireBundleVersion."+ , okfVersion = "0.2"+ , frontmatter =+ { required =+ [ plain "type"+ , plain "generated"+ // { objectFields = Some+ { required =+ [ nestedPlain "by"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.Actor+ }+ ]+ , recommended = [] : List NestedFieldRule+ , optional = [] : List NestedFieldRule+ }+ }+ ]+ , recommended =+ [ plain "usage_count"+ // { format = Some FieldFormat.NonNegativeInteger }+ ]+ , optional =+ [ plain "supersededBy"+ // { reference = Some+ { localPrefix = "ADR"+ , externalUriSchemes = [ "mori" ]+ , allowSelf = False+ }+ }+ , plain "runbook"+ // { path = Some+ { externalUriSchemes = [ "https" ], allowSelf = False }+ }+ ]+ }+ , allowUnknownTypes = False+ , allowUnknownFields = True+ , idField = Some "docId"+ , types =+ [ { type = "Metric"+ , description = Some "A measured quantity."+ , frontmatter =+ { required =+ [ plain "owner"+ // { cardinality = Cardinality.Scalar+ , format = Some FieldFormat.HumanActor+ }+ ]+ , recommended = [] : List FieldRule+ , optional = [] : List FieldRule+ }+ , pathPattern = None Text+ , resourceScheme = None Text+ , requireSchemaSection = False+ , schemaColumns = [] : List Text+ , idPrefix = Some "ADR"+ }+ ]+ }+ : Profile
test/fixtures/profiles/type-frontmatter.dhall view
@@ -20,6 +20,7 @@ , allowUnknownTypes = False , allowUnknownFields = True , idField = None Text+ , requireBundleVersion = None Text , types = [ TypeRule::{ , type = "Owned Concept"
+ test/fixtures/v01-legacy-bundle/index.md view
@@ -0,0 +1,10 @@+# OKF v0.1 legacy fixture bundle++A deliberately unmigrated bundle. It uses the OKF v0.1 `timestamp` key that+v0.2 supersedes with `generated.at`, and declares no `okf_version`, so it is+exactly the shape every bundle written before v0.2 existed still has.++It exists to keep the legacy fallback of+`docs/adr/7-okf-v0-1-legacy-fallback-policy.md` under test. Do not migrate it.++- [tables/](tables/index.md)
+ test/fixtures/v01-legacy-bundle/log.md view
@@ -0,0 +1,4 @@+# Bundle Update Log++## 2026-06-16+* **Update**: Created the v0.1 legacy fixture bundle.
+ test/fixtures/v01-legacy-bundle/tables/index.md view
@@ -0,0 +1,3 @@+# BigQuery Table++- [Orders](orders.md) - Order fact table.
+ test/fixtures/v01-legacy-bundle/tables/orders.md view
@@ -0,0 +1,15 @@+---+type: BigQuery Table+title: Orders+description: Order fact table.+timestamp: 2026-06-16T00:00:00Z+resource: bigquery://analytics.tables.orders+tags: [orders, sales]+---++# Orders++A concept whose only date is the OKF v0.1 `timestamp`. OKF v0.2 supersedes that+key with `generated.at`, and okf reads it anyway: `okf validate --strict`+reports nothing here, because the bundle declares no `okf_version` and an+undeclared bundle is exactly what the fallback exists to serve.
test/fixtures/valid-bundle/datasets/sales.md view
@@ -2,7 +2,9 @@ type: Dataset title: Sales Dataset description: Daily sales export used by warehouse tables.-timestamp: 2026-06-16T00:00:00Z+generated:+ by: human:nadeem+ at: 2026-06-16T00:00:00Z tags: [sales, source] ---
test/fixtures/valid-bundle/index.md view
@@ -1,3 +1,7 @@+---+okf_version: "0.2"+---+ # OKF fixture bundle - [datasets/](datasets/index.md)
test/fixtures/valid-bundle/references/source-system.md view
@@ -2,7 +2,9 @@ type: Reference title: Source System description: External system reference.-timestamp: 2026-06-16T00:00:00Z+generated:+ by: human:nadeem+ at: 2026-06-16T00:00:00Z --- # Source System
test/fixtures/valid-bundle/tables/customers.md view
@@ -2,7 +2,9 @@ type: BigQuery Table title: Customers description: Customer dimension table.-timestamp: 2026-06-16T00:00:00Z+generated:+ by: human:nadeem+ at: 2026-06-16T00:00:00Z tags: [customers] ---
test/fixtures/valid-bundle/tables/orders.md view
@@ -2,7 +2,9 @@ type: BigQuery Table title: Orders description: Order fact table.-timestamp: 2026-06-16T00:00:00Z+generated:+ by: human:nadeem+ at: 2026-06-16T00:00:00Z resource: bigquery://analytics.tables.orders tags: [orders, sales] ---