packages feed

okf-cli 0.2.0.0 → 0.3.0.0

raw patch · 6 files changed

+1312/−24 lines, 6 filesdep ~okf-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: okf-core

API changes (from Hackage documentation)

+ Okf.Cli: Profile :: ProfileCommand -> Command
+ Okf.Cli: ProfileList :: ProfileListOptions -> ProfileCommand
+ Okf.Cli: ProfileListOptions :: !Maybe Text -> !Bool -> ProfileListOptions
+ Okf.Cli: ProfileShow :: ProfileShowOptions -> ProfileCommand
+ Okf.Cli: ProfileShowOptions :: !Maybe Text -> !Maybe Text -> !Bool -> ProfileShowOptions
+ Okf.Cli: [export] :: ProfileShowOptions -> !Maybe Text
+ Okf.Cli: [registryRef] :: ProfileShowOptions -> !Maybe Text
+ Okf.Cli: data ProfileCommand
+ Okf.Cli: data ProfileListOptions
+ Okf.Cli: data ProfileShowOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileCommand
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileListOptions
+ Okf.Cli: instance GHC.Classes.Eq Okf.Cli.ProfileShowOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileCommand
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileListOptions
+ Okf.Cli: instance GHC.Internal.Show.Show Okf.Cli.ProfileShowOptions
+ Okf.Cli: profileRegistryEnvVar :: String
+ Okf.Cli: renderProfileDetail :: Text -> ProfileSpec -> [Text]
+ Okf.Cli: renderRegistryTable :: [RegistryEntry] -> [Text]
+ Okf.Cli.Config: ProfileSettings :: !Text -> ProfileSettings
+ Okf.Cli.Config: [profiles] :: OkfConfig -> !ProfileSettings
+ Okf.Cli.Config: [registry] :: ProfileSettings -> !Text
+ Okf.Cli.Config: data ProfileSettings
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.LegacyOkfConfig
+ Okf.Cli.Config: instance Dhall.Marshal.Decode.FromDhall Okf.Cli.Config.ProfileSettings
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.LegacyOkfConfig
+ Okf.Cli.Config: instance GHC.Classes.Eq Okf.Cli.Config.ProfileSettings
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.LegacyOkfConfig
+ Okf.Cli.Config: instance GHC.Internal.Generics.Generic Okf.Cli.Config.ProfileSettings
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.LegacyOkfConfig
+ Okf.Cli.Config: instance GHC.Internal.Show.Show Okf.Cli.Config.ProfileSettings
- Okf.Cli: [json] :: GraphOptions -> !Bool
+ Okf.Cli: [json] :: ProfileShowOptions -> !Bool
- Okf.Cli.Config: OkfConfig :: !KitSettings -> !AssistSettings -> OkfConfig
+ Okf.Cli.Config: OkfConfig :: !KitSettings -> !AssistSettings -> !ProfileSettings -> OkfConfig

Files

CHANGELOG.md view
@@ -7,6 +7,77 @@  ## [Unreleased] +## [0.3.0.0] - 2026-07-29++### Added++- `okf profile show` and profile JSON expose local-prefix, external-scheme, and+  self-reference policies. `okf validate` renders path-precise dangling,+  wrong-prefix, malformed, disallowed-external, and self-reference deviations;+  the profiles help topic documents the offline local/external boundary.+- `okf profile show` renders top-level and nested `when` predicates, while+  `okf validate` explains the activating condition on missing-field diagnostics.+  The profiles help topic documents same-scope resolution, compiler checks,+  strict recommendations, and no-cascade runtime behavior.+- `okf profile show` renders bounded nested field rules, and `okf validate`+  reports missing nested fields, non-record list elements, and nested value+  violations with indexed paths such as `reviews[2].outcome`.+- `okf profile show` and profile JSON expose named field formats, and+  `okf validate` renders parser-backed timestamp, date, URI, URI-scheme, and+  document-handle mismatches.+- Profile detail output and validation diagnostics for `Any`, `Scalar`, and+  `List` field cardinality.+- `okf profile show` now displays `allowUnknownFields` and each field's+  `allowedValues`; validation renders value-vocabulary and undeclared-field+  deviations.+- `okf profile show` and `--json` now expose required and recommended+  frontmatter rules beneath each type.+- `okf validate --strict --profile ...` checks profile-recommended fields and+  renders them as `missing profile-recommended field`; `--profile-enforce`+  continues to control whether the deviation fails the command.+- An `UPGRADING FROM 0.1.x` section in the `okf help profiles` topic, summarizing+  how to move a 0.1.x profile descriptor onto the 0.2.0.0 schema.+- Profile descriptions are surfaced everywhere a profile is displayed:+  `okf profile show` prints the profile's description, a `  - key: prose` line+  per frontmatter key, and a description line in each type block; `okf profile+  list` gains a trailing `DESCRIPTION` column reading `-` when absent; and+  `okf validate --profile` appends the key's prose in parentheses to a+  `missing profile-required field` advisory. A `DESCRIPTIONS` section in the+  `okf help profiles` topic covers all of it.++### Changed++- Requires `okf-core ^>=0.3.0.0`. This major bound reflects the breaking+  compiled-profile API and the expanded profile-rule and diagnostic types.+- Profile-definition output now reports invalid reference prefixes and schemes,+  undeclared target prefixes, missing `idField`, profile/type prefix conflicts,+  and reference-plus-format combinations. Mori and other exhaustive consumers+  must handle the five new reference violations and six definition errors, plus+  all previously accumulated profile API changes, before moving the matching+  `cabal.project` and `flake.nix` pins together.+- Profile-definition output now reports every invalid condition category. Mori+  and other exhaustive consumers must handle the new definition errors and the+  condition payload added to missing-field violations before updating their+  `okf-core` pin.+- Profile-definition output now reports invalid format parameters and+  contradictory profile/type formats. Mori and other exhaustive `okf-core`+  consumers must add cases for `InvalidFormatParameter`,+  `ConflictingFieldFormat`, and `ValueFormatMismatch` before updating their+  `okf-core` pin. Mori's renderer is+  `mori-cli/src/Mori/Okf/Advisory.hs`, and its matching `cabal.project` and+  `flake.nix` pins must move together.+- Profile descriptors are compiled before validation. Duplicate type names and+  ambiguous required/recommended declarations are fatal profile-definition+  errors reported once, before any concept is checked.+- Mori and other `okf-core` consumers must call `compileProfile`, handle its+  structured errors, pass `PermissiveConformance` or `StrictAuthoring` to+  `validateProfile`, and handle `MissingRecommendedProfileField` in exhaustive+  violation renderers.+- `okf profile show` prints a non-empty `frontmatter.required` or+  `frontmatter.recommended` as a headed block, one key per line, rather than one+  comma-joined line — per-key prose cannot share a line. An empty list keeps the+  single-line `(none)` form, so every optional field still prints.+ ## [0.2.0.0] - 2026-07-26  ### Added
help/profiles.md view
@@ -20,6 +20,9 @@   --profile-enforce      Make profile deviations fail the command (non-zero                          exit). +  --strict               Also check profile `recommended` fields. Required+                         profile fields are checked in both modes.+ EXIT CODES    - Structural errors always exit non-zero, with or without --profile.@@ -41,8 +44,231 @@   handle, `okf id list BUNDLE --profile PROFILE.dhall` to list allocations, and   `okf show BUNDLE ADR-7` to resolve one. +REGISTRIES++  You do not have to write a descriptor from scratch. A registry is any Dhall+  expression evaluating to a record whose fields -- possibly nested -- are+  profile values. okf finds them structurally: it walks the evaluated record+  and reports every field that decodes as a profile, under the dotted path it+  was found at. That path is the profile's export path.++    okf profile list+    okf profile list --registry /path/to/okf-profiles+    okf profile show postgresql --registry /path/to/okf-profiles++  A bare `okf profile` means `okf profile list`. Both subcommands accept+  --json. The EXPORT column reads "(root)" when the reference is itself a+  profile rather than a record of profiles; the ID FIELD column reads "-" when+  the profile declares no idField.++DESCRIPTIONS++  A profile may document itself: one description for the profile as a whole,+  one per required or recommended frontmatter key, and one per type rule.+  Descriptions are prose for humans -- okf never checks one against a bundle+  and none can produce a deviation.++  `okf profile list` shows the profile's own description in a trailing+  DESCRIPTION column, reading "-" when it has none. `okf profile show` prints+  the profile description under the name, one "  - key: prose" line per+  frontmatter key, and a description line in each type block, all reading+  "(none)" when absent. When a required key is missing from a concept, the+  advisory repeats the key's prose in parentheses:++    profile: schemas/sales/tables/orders: missing profile-required field: title (Human-readable name of the object.)++  Descriptions are optional and additive. A descriptor written before they+  existed loads unchanged and simply shows none; nothing needs migrating. See+  docs/user/profiles.md for how to add them to a descriptor you already have.++TYPE-AWARE FRONTMATTER++  Each type rule may add its own required and recommended frontmatter fields.+  Profile-wide rules apply to every concept; a matching type rule adds to them.+  Value constraints merge by key, while presence declarations remain separate;+  an applicable required clause wins over a strict recommendation. Unknown+  types still receive profile-wide rules.++  `okf profile show` prints `frontmatter.required` and+  `frontmatter.recommended` beneath each type. New descriptors should use+  `okf.defaults.TypeRule::{ ... }`, whose default supplies empty lists.++  Before validating a bundle, okf rejects duplicate type rules, repeated keys+  in one list, and keys placed in both required and recommended at the same+  scope. These are hard profile-definition errors regardless of+  `--profile-enforce`.++VALUE VOCABULARIES AND CLOSED FIELDS++  A FieldRule may set allowedValues to a list of legal text values. An empty+  list means unconstrained. Present strings and lists of strings are checked in+  both permissive and strict modes; a type-level vocabulary narrows a+  profile-wide vocabulary by intersection. Disjoint vocabularies are rejected+  as a profile-definition error before bundle validation.++  Set allowUnknownFields = False to reject undeclared top-level frontmatter+  keys. The allowed names come from the effective rules for that concept's own+  type, plus the core OKF keys and the profile's idField. The default is True,+  so existing profiles continue to allow producer extensions.++    profile: requests/typo: missing profile-required field: status+    profile: requests/typo: frontmatter field not declared by profile: stauts++FIELD CARDINALITY++  Every FieldRule has a cardinality: Any, Scalar, or List. Any is the default+  and preserves the legacy non-empty-text-or-non-empty-list presence behavior.+  Scalar accepts non-blank text, numbers, and booleans. List accepts arrays.+  Objects and null fail an explicit cardinality constraint.++  Use `field.scalar "title"` or `field.list "tags"`. At profile and type scope,+  Any is the identity; contradictory Scalar and List declarations are a hard+  profile-definition error. Wrong-shape values are reported even for a+  recommended field outside --strict, without a duplicate missing-field or+  vocabulary-shape diagnostic.++    profile: bad: frontmatter cardinality at title must be scalar, found list: ["One","Two"]+    profile: bad: frontmatter cardinality at tags must be list, found scalar: "one"++NAMED FIELD FORMATS++  A FieldRule may set format to one of five parser-backed textual contracts:+  Rfc3339Utc, Date, Uri, UriWithScheme Text, or DocumentHandle Text. The default+  is None, so existing fields remain unconstrained. Formats check present strings+  and every string in a list; they do not make an absent field required.++  Rfc3339Utc accepts extended timestamps such as 2026-07-29T17:00:00Z and+  requires uppercase Z rather than a numeric offset. Date accepts exactly+  YYYY-MM-DD and rejects impossible calendar dates. Uri requires an absolute RFC+  3986 URI. UriWithScheme additionally requires the named scheme, compared+  case-insensitively. DocumentHandle requires the canonical PREFIX-N form and+  compares the prefix case-sensitively.++  The FieldRule constructors cover the common forms:++    field.rfc3339Utc "timestamp"+    field.date "published"+    field.uri "source"+    field.uriWithScheme "originPlan" "mori"+    field.documentHandle "decision" "ADR"++  Uri at profile scope may be narrowed to UriWithScheme at type scope. Equal+  formats merge unchanged; other unequal pairs are a hard profile-definition+  error. URI scheme parameters must follow RFC 3986 scheme syntax, and document+  prefixes must follow the same grammar as okf document IDs.++    profile: bad: frontmatter value at timestamp must match format rfc3339-utc, found: "2026-07-29T17:00:00+01:00"+    profile: bad: frontmatter value at originPlan must match format uri-with-scheme(mori), found: "https://example.test"++NESTED RECORD FIELDS++  A top-level FieldRule may set elementFields to required and recommended rules+  for every record in a list. The public schema is intentionally bounded to one+  level: NestedFieldRule has vocabulary, cardinality, and format constraints but+  cannot contain another elementFields value.++  Use field.recordList with NestedFieldRule constructors or record completion.+  Declaring elementFields implies list cardinality; combining it with Scalar is+  a hard profile-definition error. Profile-wide and type-specific nested rules+  merge by sibling key just like top-level rules.++  Each list element must be a record. Required nested keys are always checked;+  recommended nested keys only under --strict. Present nested values are checked+  in both modes, and diagnostics identify the exact index:++    profile: requests/example: missing profile-required field: reviews[2].outcome+    profile: requests/example: frontmatter element at reviews[1] must be a record, found: "not-a-record"++  Extra keys inside a record remain allowed. Nested field-name closure and a+  second nested level are not part of this schema.++CONDITIONAL FIELD PRESENCE++  FieldRule and NestedFieldRule may set `when = Some { field, hasValue }` so a+  required or recommended field applies only when a sibling scalar text field+  has one of the listed values. Top-level rules see top-level siblings; nested+  rules see only siblings in the same list element. There is no cross-scope+  capture.++  The source must be explicitly Scalar and have a non-empty allowedValues+  vocabulary. hasValue must be non-empty and a subset of that vocabulary. Empty,+  self-referential, undeclared, open, non-scalar, and unreachable conditions are+  hard profile-definition errors before any bundle is read.++    FieldRule::{+    , field = "supersededBy"+    , when = Some { field = "status", hasValue = [ "superseded" ] }+    }++  A missing, wrong-shape, or out-of-vocabulary source makes the condition false,+  avoiding a second target-field diagnostic. When the target is present, its+  vocabulary, cardinality, and format are checked regardless of the condition.+  Recommended conditions are evaluated only under --strict.++    profile: decisions/old: missing profile-required field: supersededBy (when status is superseded)++DOCUMENT REFERENCES++  A top-level FieldRule may set reference to a local handle prefix, a list of+  allowed external URI schemes, and an allowSelf policy. The constructors cover+  local-only and explicit external alternatives:++    field.localReference "supersedes" "ADR"+    field.localOrExternalReference "supersededBy" "ADR" [ "mori" ]++  The helpers default allowSelf to False. Use+  okf.defaults.HandleReferenceRule record completion to override it.++  A canonical handle is checked first. A handle with another prefix is a+  category error; one with the declared prefix must belong to a valid,+  profile-governed concept in this bundle. Duplicate owners still produce the+  existing duplicate-ID deviation but count as present, avoiding a false+  dangling-reference message. Lists are checked element-wise with indexed paths.++    profile: decisions/current: supersedes[1] references ADR-99, which does not exist in this bundle++  Text that is not a handle must be an absolute URI whose scheme is listed by+  the policy. Scheme comparison is case-insensitive. okf checks syntax and the+  scheme offline; it never resolves an external URI or consults Mori, a registry,+  DNS, or the network.++  The local prefix must use document-handle grammar, be declared by at least one+  type idPrefix, and have a profile idField. URI schemes must use RFC 3986 scheme+  grammar. A reference field cannot also declare format. Matching profile/type+  policies must use the same local prefix; their external schemes intersect and+  self-reference is allowed only when both permit it. Invalid combinations are+  hard profile-definition errors before any bundle is read.++  A registry reference may be a path to a Dhall file, a directory holding+  package.dhall, or a Dhall expression such as a hash-pinned URL. Without+  --registry, okf uses OKF_PROFILE_REGISTRY, then profiles.registry from+  configuration, then the built-in default: the okf-profiles package pinned by+  tag and sha256 hash. Because it is pinned, Dhall caches it under+  ~/.cache/dhall after the first fetch, so later runs are offline. Pass+  --registry with a local checkout to be offline throughout.++  There is no install step. `okf profile show` closes with the two-line Dhall+  snippet that consumes the profile; save it to a file and pass that file to+  `okf validate --profile`.++UPGRADING FROM 0.1.x++  okf 0.2.0.0 added idField to Profile and idPrefix to TypeRule. Dhall record+  types are closed, so descriptors written against 0.1.x fail to load with+  "Expression doesn't match annotation", listing the missing fields with a "-".++  Set idField = None Text and idPrefix = None Text to keep the old behavior+  (no document-ID checks), or adopt record completion via+  okf.defaults.Profile::{ ... } so later schema additions do not break the+  descriptor again.++  Descriptors pinned to a schema URL must bump the tag and the sha256 hash+  together. Edit the tag, then run `dhall freeze PROFILE.dhall`, which+  rewrites the hash in place even when the old one is stale.+ SEE ALSO    okf help validation   Structural validation and referential integrity. -  The full descriptor schema is documented in docs/user/profiles.md.+  The full descriptor schema, and the upgrade steps above in detail, are+  documented in docs/user/profiles.md.
okf-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               okf-cli-version:            0.2.0.0+version:            0.3.0.0 synopsis:           Command-line interface for Open Knowledge Format bundles description:   okf-cli provides the @okf@ executable for working with Open Knowledge Format@@ -66,7 +66,7 @@     , generic-lens          >=2.2      && <2.4     , githash               ^>=0.1     , lens                  ^>=5.3-    , okf-core              ^>=0.2.0.0+    , okf-core              ^>=0.3.0.0     , optparse-applicative  >=0.18     && <0.20     , process               >=1.6      && <1.7     , text                  ^>=2.1@@ -78,11 +78,11 @@   main-is:        Main.hs   hs-source-dirs: test   build-depends:-    , base                  >=4.20 && <5+    , base                  >=4.20     && <5     , directory     , filepath     , okf-cli-    , okf-core+    , okf-core              ^>=0.3.0.0     , optparse-applicative  >=0.18     , temporary     , text                  ^>=2.1
src/Okf/Cli.hs view
@@ -10,9 +10,15 @@     LogOptions (..),     LogSub (..),     Options (..),+    ProfileCommand (..),+    ProfileListOptions (..),+    ProfileShowOptions (..),     ShowOptions (..),     ValidateOptions (..),     parserInfo,+    profileRegistryEnvVar,+    renderProfileDetail,+    renderRegistryTable,     runCli,     runCommand,     runLogAdd,@@ -24,7 +30,7 @@ import Data.Aeson.Key qualified as AesonKey import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Lazy.Char8 qualified as LazyByteString-import Data.Foldable (traverse_)+import Data.Foldable (toList, traverse_) import Data.List qualified as List import Data.Set qualified as Set import Data.Text qualified as Text@@ -50,21 +56,43 @@ import Okf.Graph (buildGraph) import Okf.Index import Okf.Log qualified as Log-import Okf.Prelude+import Okf.Prelude hiding (List) import Okf.Profile-  ( ProfileSpec (..),+  ( Cardinality (..),+    CompiledProfile,+    FieldCondition (..),+    FieldFormat (..),+    FieldPath (..),+    FieldPathSegment (..),+    FrontmatterRules (..),+    HandleReferenceRule (..),+    NestedRules (..),+    ProfileDefinitionError (..),+    ProfileSpec (..),     ProfileViolation (..),     TypeRule (..),+    compileProfile,     documentIdsInBundle,     loadProfileFile,     nextDocumentId,     parseDocumentId,+    profileFieldDescriptionForType,     renderDocumentId,     validateProfile,   )+import Okf.Profile.Registry+  ( RegistryEntry (..),+    RegistryRef (..),+    findRegistryEntry,+    loadRegistry,+    renderRegistryRef,+    resolveRegistryRef,+    rootExportLabel,+  ) import Okf.Validation import Options.Applicative import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Environment (lookupEnv) import System.Exit (ExitCode (..), exitFailure, exitWith) import System.FilePath ((</>)) import System.FilePath qualified as FilePath@@ -79,6 +107,7 @@   | ShowConcept ShowOptions   | Id IdOptions   | Config ConfigCommand+  | Profile ProfileCommand   | Kit KitCommand   | Assist AssistOptions   | Completions CompletionsShell@@ -152,6 +181,24 @@   | ConfigInit !Bool   deriving stock (Show, Eq) +data ProfileCommand+  = ProfileList ProfileListOptions+  | ProfileShow ProfileShowOptions+  deriving stock (Show, Eq)++data ProfileListOptions = ProfileListOptions+  { registryRef :: !(Maybe Text),+    json :: !Bool+  }+  deriving stock (Show, Eq)++data ProfileShowOptions = ProfileShowOptions+  { registryRef :: !(Maybe Text),+    export :: !(Maybe Text),+    json :: !Bool+  }+  deriving stock (Show, Eq)+ data Options = Options   { cmd :: !Command   }@@ -190,6 +237,7 @@         <> command "show" (info (ShowConcept <$> showOptionsParser <**> helper) (progDesc "Show one concept"))         <> command "id" (info (Id <$> idOptionsParser <**> helper) (progDesc "Allocate and list document IDs"))         <> command "config" (info (Config <$> configCommandParser <**> helper) (progDesc "Show and manage okf configuration"))+        <> command "profile" (info (Profile <$> profileCommandParser <**> helper) (progDesc "List and inspect profiles published by a registry"))         <> command "kit" (info (Kit <$> kitCommandParser <**> helper) (progDesc "Install and manage agent skills and subagents"))         <> command "assist" (info (Assist <$> assistOptionsParser <**> helper) (progDesc "Launch an interactive agent session with installed okf skills"))         <> command "completions" (info (Completions <$> completionsParser <**> helper) (progDesc "Generate a shell completion script (bash, zsh, fish)"))@@ -362,6 +410,55 @@     )     <|> pure ConfigShow +profileCommandParser :: Parser ProfileCommand+profileCommandParser =+  hsubparser+    ( command+        "list"+        ( info+            (ProfileList <$> profileListOptionsParser <**> helper)+            (progDesc "List the profiles a registry publishes")+        )+        <> command+          "show"+          ( info+              (ProfileShow <$> profileShowOptionsParser <**> helper)+              (progDesc "Print one registry profile in full")+          )+    )+    <|> pure (ProfileList (ProfileListOptions Nothing False))++profileListOptionsParser :: Parser ProfileListOptions+profileListOptionsParser =+  ProfileListOptions+    <$> optional registryOption+    <*> jsonSwitch++profileShowOptionsParser :: Parser ProfileShowOptions+profileShowOptionsParser =+  ProfileShowOptions+    <$> optional registryOption+    <*> optional+      ( Text.pack+          <$> strArgument+            ( metavar "EXPORT"+                <> help "Dotted export path of the profile, as printed by `okf profile list`"+            )+      )+    <*> jsonSwitch++registryOption :: Parser Text+registryOption =+  Text.pack+    <$> strOption+      ( long "registry"+          <> metavar "REGISTRY"+          <> help "Dhall file, directory holding package.dhall, or Dhall expression publishing profiles"+      )++jsonSwitch :: Parser Bool+jsonSwitch = switch (long "json" <> help "Emit JSON instead of text")+ bundleArgument :: Parser FilePath bundleArgument =   strArgument (metavar "BUNDLE" <> help "Path to an OKF bundle directory")@@ -375,6 +472,7 @@   ShowConcept options -> runShow options   Id options -> runId options   Config configCommand -> runConfig configCommand+  Profile profileCommand -> runProfile profileCommand   Kit kitCommand -> do     config <- loadConfigOrDie     handleKitCommand config kitCommand@@ -413,6 +511,276 @@     Left err -> dieText ("Failed to load config: " <> err)     Right loaded -> pure loaded +-- | Environment override for the registry @okf profile@ reads.+profileRegistryEnvVar :: String+profileRegistryEnvVar = "OKF_PROFILE_REGISTRY"++runProfile :: ProfileCommand -> IO ()+runProfile = \case+  ProfileList options -> runProfileList options+  ProfileShow options -> runProfileShow options++-- | Registry reference precedence: @--registry@, then 'profileRegistryEnvVar',+-- then configuration (which falls back to the built-in default). Configuration+-- is read only when it is actually needed, so a broken @okf-config.dhall@+-- cannot stop @okf profile list --registry ./somewhere.dhall@.+resolveRegistryReference :: Maybe Text -> IO Text+resolveRegistryReference (Just explicit) = pure explicit+resolveRegistryReference Nothing = do+  fromEnvironment <- lookupEnv profileRegistryEnvVar+  case fromEnvironment of+    Just fromShell | not (null fromShell) -> pure (Text.pack fromShell)+    _ -> do+      OkfConfig {profiles = ProfileSettings {registry}} <- loadConfigOrDie+      pure registry++-- | Resolve, evaluate, and enumerate a registry, or exit 1 explaining why not.+-- The reference is returned in the form the user gave it, for messages, along+-- with the resolved reference, which is what @show@ quotes back as Dhall.+loadRegistryOrDie :: Maybe Text -> IO (Text, RegistryRef, [RegistryEntry])+loadRegistryOrDie explicit = do+  reference <- resolveRegistryReference explicit+  ref <- resolveRegistryRef reference+  loaded <- loadRegistry ref+  case loaded of+    Left err -> dieText (renderRegistryLoadError reference err)+    Right [] -> dieText ("No profiles found in registry " <> reference)+    Right entries -> pure (reference, ref, entries)++-- | A load failure is usually a mistyped path or a missing network, so say what+-- a reference may be and how to work offline.+renderRegistryLoadError :: Text -> Text -> Text+renderRegistryLoadError reference err =+  Text.unlines+    [ "Failed to load profile registry " <> reference <> ": " <> err,+      "A registry reference may be a path to a Dhall file, a directory holding package.dhall, or a",+      "Dhall expression such as a hash-pinned URL. Remote references need network access on first",+      "use; pass --registry with a local checkout to work offline."+    ]++runProfileList :: ProfileListOptions -> IO ()+runProfileList ProfileListOptions {registryRef, json} = do+  (reference, _ref, entries) <- loadRegistryOrDie registryRef+  if json+    then LazyByteString.putStrLn (Aeson.encode (registryListJson reference entries))+    else traverse_ Text.IO.putStrLn (renderRegistryTable entries)++registryListJson :: Text -> [RegistryEntry] -> Aeson.Value+registryListJson reference entries =+  Aeson.object+    [ "registry" Aeson..= reference,+      "profiles"+        Aeson..= [ Aeson.object+                     [ "export" Aeson..= export,+                       "profile" Aeson..= spec+                     ]+                 | RegistryEntry {export, spec} <- entries+                 ]+    ]++-- | An aligned table: a header row plus one row per profile, columns padded to+-- their widest value. Pure so it can be tested without evaluating any Dhall.+--+-- @DESCRIPTION@ comes last so the existing columns keep their positions and a+-- long description cannot push anything off the right edge. Nothing follows it,+-- so it is never padded; an absent description reads @-@, matching @ID FIELD@.+renderRegistryTable :: [RegistryEntry] -> [Text]+renderRegistryTable entries =+  map renderRow rows+  where+    headerRow = ["EXPORT", "NAME", "OKF", "TYPES", "ID FIELD", "DESCRIPTION"]+    entryRow+      RegistryEntry+        { export = exportPath,+          spec = ProfileSpec {name, description, okfVersion, idField, types = typeRules}+        } =+        [ displayExport exportPath,+          name,+          okfVersion,+          Text.pack (show (length typeRules)),+          fromMaybe "-" idField,+          fromMaybe "-" description+        ]++    rows = headerRow : map entryRow entries++    -- One padder per column, in order; the last column is left as it is.+    padders = [padRight, padRight, padLeft, padLeft, padRight, \_ cell -> cell]+    widths = [maximum (0 : map (Text.length . (!! column)) rows) | column <- [0 .. 5]]++    renderRow cells = Text.intercalate "  " (zipWith3 id padders widths cells)++    padRight width cell = cell <> Text.replicate (max 0 (width - Text.length cell)) " "+    padLeft width cell = Text.replicate (max 0 (width - Text.length cell)) " " <> cell++-- | An entry found at the registry root has no export path of its own.+displayExport :: Text -> Text+displayExport exportPath+  | Text.null exportPath = rootExportLabel+  | otherwise = exportPath++runProfileShow :: ProfileShowOptions -> IO ()+runProfileShow ProfileShowOptions {registryRef, export = requestedExport, json} = do+  (reference, ref, entries) <- loadRegistryOrDie registryRef+  RegistryEntry {export = foundExport, spec} <- selectEntry reference entries requestedExport+  if json+    then LazyByteString.putStrLn (Aeson.encode spec)+    else do+      traverse_ Text.IO.putStrLn (renderProfileDetail foundExport spec)+      traverse_ Text.IO.putStrLn (renderProfileUsage ref foundExport)++-- | Pick the profile to show. With no @EXPORT@ argument a single-profile+-- registry needs no disambiguation; otherwise the available exports are listed,+-- which is also what an unknown export reports.+selectEntry :: Text -> [RegistryEntry] -> Maybe Text -> IO RegistryEntry+selectEntry reference entries = \case+  Nothing -> case entries of+    [single] -> pure single+    _ ->+      dieText+        ( "Registry "+            <> reference+            <> " publishes more than one profile; name one.\n"+            <> availableExports entries+        )+  Just requested -> case findRegistryEntry requested entries of+    Just entry -> pure entry+    Nothing ->+      dieText+        ( "No profile named "+            <> requested+            <> " in registry "+            <> reference+            <> "\n"+            <> availableExports entries+        )+  where+    availableExports found =+      "Available exports: "+        <> Text.intercalate ", " [displayExport exportPath | RegistryEntry {export = exportPath} <- found]++-- | One profile's complete rule set. Every optional field prints as @(none)@+-- rather than being omitted, so the output shape does not change between+-- profiles and stays reliable to eyeball or grep. Type rules print in the order+-- the profile declares them, since that order is the author's.+renderProfileDetail :: Text -> ProfileSpec -> [Text]+renderProfileDetail+  exportPath+  ProfileSpec+    { name,+      description,+      okfVersion,+      frontmatter = FrontmatterRules {required, recommended},+      allowUnknownTypes,+      allowUnknownFields,+      idField,+      types = typeRules+    } =+    [ "export: " <> displayExport exportPath,+      "name: " <> name,+      "description: " <> renderOptional description,+      "okfVersion: " <> okfVersion,+      "allowUnknownTypes: " <> renderFlag allowUnknownTypes,+      "allowUnknownFields: " <> renderFlag allowUnknownFields,+      "idField: " <> renderOptional idField+    ]+      <> renderFieldRules "" "frontmatter.required" required+      <> renderFieldRules "" "frontmatter.recommended" recommended+      <> concatMap renderTypeRule typeRules+    where+      -- A field's prose cannot share a comma-joined line with its neighbours, so+      -- a non-empty list becomes a headed block. An empty list keeps the+      -- single-line @(none)@ form the other optional fields use.+      renderFieldRules indent label [] = [indent <> label <> ": " <> renderList []]+      renderFieldRules indent label rules =+        (indent <> label <> ":")+          : concatMap (renderFieldRule indent) rules++      renderFieldRule indent rule =+        [ indent <> "  - " <> rule ^. #field <> ": " <> renderOptional (rule ^. #description),+          indent <> "    allowedValues: " <> renderVocabulary (rule ^. #allowedValues),+          indent <> "    cardinality: " <> renderCardinality (rule ^. #cardinality),+          indent <> "    format: " <> maybe "(none)" renderFieldFormat (rule ^. #format),+          indent <> "    reference: " <> maybe "(none)" renderHandleReferenceRule (rule ^. #reference),+          indent <> "    when: " <> maybe "(none)" renderCondition (rule ^. #when)+        ]+          <> case rule ^. #elementFields of+            Nothing -> [indent <> "    elementFields: (none)"]+            Just NestedRules {required = nestedRequired, recommended = nestedRecommended} ->+              [indent <> "    elementFields:"]+                <> renderNestedFieldRules (indent <> "      ") "required" nestedRequired+                <> renderNestedFieldRules (indent <> "      ") "recommended" nestedRecommended++      renderNestedFieldRules indent label [] = [indent <> label <> ": " <> renderList []]+      renderNestedFieldRules indent label rules =+        (indent <> label <> ":") : concatMap (renderNestedFieldRule indent) rules++      renderNestedFieldRule indent rule =+        [ indent <> "  - " <> rule ^. #field <> ": " <> renderOptional (rule ^. #description),+          indent <> "    allowedValues: " <> renderVocabulary (rule ^. #allowedValues),+          indent <> "    cardinality: " <> renderCardinality (rule ^. #cardinality),+          indent <> "    format: " <> maybe "(none)" renderFieldFormat (rule ^. #format),+          indent <> "    when: " <> maybe "(none)" renderCondition (rule ^. #when)+        ]++      renderTypeRule+        TypeRule+          { type_ = ruleType,+            description = ruleDescription,+            frontmatter = FrontmatterRules {required = typeRequired, recommended = typeRecommended},+            pathPattern,+            resourceScheme,+            requireSchemaSection,+            schemaColumns,+            idPrefix+          } =+          [ "",+            "type: " <> ruleType,+            "  description: " <> renderOptional ruleDescription+          ]+            <> renderFieldRules "  " "frontmatter.required" typeRequired+            <> renderFieldRules "  " "frontmatter.recommended" typeRecommended+            <> [ "  pathPattern: " <> renderOptional pathPattern,+                 "  resourceScheme: " <> renderOptional resourceScheme,+                 "  requireSchemaSection: " <> renderFlag requireSchemaSection,+                 "  schemaColumns: " <> renderList schemaColumns,+                 "  idPrefix: " <> renderOptional idPrefix+               ]++      renderFlag True = "true"+      renderFlag False = "false"+      renderOptional = fromMaybe "(none)"+      renderList [] = "(none)"+      renderList values = Text.intercalate ", " values+      renderVocabulary [] = "(any)"+      renderVocabulary values = Text.intercalate ", " values+      renderCondition FieldCondition {field = sourceField, hasValue} =+        sourceField <> " in [" <> Text.intercalate ", " hasValue <> "]"++-- | The two-line descriptor a user writes to consume the profile with+-- @okf validate --profile@. The reference is quoted in Dhall import syntax, not+-- as the user typed it: Dhall only accepts a path that starts with @.\/@,+-- @..\/@, @~\/@, or @\/@, so a bare relative path is prefixed to stay+-- copy-pasteable.+renderProfileUsage :: RegistryRef -> Text -> [Text]+renderProfileUsage ref exportPath =+  [ "",+    "Use it with:",+    "  let registry = " <> dhallImport ref,+    "  in  registry" <> selector+  ]+  where+    selector+      | Text.null exportPath = ""+      | otherwise = "." <> exportPath++    dhallImport (RegistryExpression expression) = expression+    dhallImport (RegistryFile path)+      | any (`Text.isPrefixOf` rendered) ["./", "../", "~/", "/"] = rendered+      | otherwise = "./" <> rendered+      where+        rendered = renderRegistryRef (RegistryFile path)+ runValidate :: ValidateOptions -> IO () runValidate ValidateOptions {bundlePath, strictMode, profilePath, profileEnforce, logEnforce} = do   concepts <- loadBundleOrExit bundlePath@@ -430,10 +798,19 @@       loaded <- loadProfileFile path       case loaded of         Left err -> dieText ("Failed to load profile " <> Text.pack path <> ": " <> err)-        Right spec -> do-          let violations = validateProfile spec concepts-          mapM_ (Text.IO.hPutStrLn stderr . ("profile: " <>) . renderProfileViolation) violations-          pure violations+        Right spec ->+          case compileProfile spec of+            Left definitionErrors ->+              dieText+                ( "Failed to load profile "+                    <> Text.pack path+                    <> ": invalid profile definition:\n"+                    <> Text.intercalate "\n" (map (("  - " <>) . renderProfileDefinitionError) (toList definitionErrors))+                )+            Right compiled -> do+              let violations = validateProfile coreProfile compiled concepts+              mapM_ (Text.IO.hPutStrLn stderr . ("profile: " <>) . renderProfileViolation compiled concepts) violations+              pure violations    let coreFailed = any bundleValidationErrorIsFailure coreErrors       profileFailed = profileEnforce && not (null profileViolations)@@ -771,12 +1148,104 @@ bundleValidationErrorIsAdvisory :: BundleValidationError -> Bool bundleValidationErrorIsAdvisory = not . bundleValidationErrorIsFailure -renderProfileViolation :: ProfileViolation -> Text-renderProfileViolation = \case+-- | One deviation as one line. The 'ProfileSpec' is here only so a missing+-- required field can carry the profile's own explanation of what that field is+-- for; every other case ignores it.+renderProfileViolation :: CompiledProfile -> [Concept] -> ProfileViolation -> Text+renderProfileViolation compiled concepts = \case   TypeNotInProfile cid ctype ->     renderConceptId cid <> ": type not in profile vocabulary: " <> ctype-  MissingProfileField cid key ->-    renderConceptId cid <> ": missing profile-required field: " <> key+  MissingProfileField cid key condition ->+    renderConceptId cid+      <> ": missing profile-required field: "+      <> key+      <> renderConditionContext condition+      <> renderDescription cid key+  MissingRecommendedProfileField cid key condition ->+    renderConceptId cid+      <> ": missing profile-recommended field: "+      <> key+      <> renderConditionContext condition+      <> renderDescription cid key+  MissingNestedProfileField cid fieldPath condition ->+    renderConceptId cid+      <> ": missing profile-required field: "+      <> renderFieldPath fieldPath+      <> renderConditionContext condition+  MissingRecommendedNestedProfileField cid fieldPath condition ->+    renderConceptId cid+      <> ": missing profile-recommended field: "+      <> renderFieldPath fieldPath+      <> renderConditionContext condition+  ValueNotInVocabulary cid fieldPath allowed actual ->+    renderConceptId cid+      <> ": frontmatter value at "+      <> renderFieldPath fieldPath+      <> " must be one of ["+      <> Text.intercalate ", " allowed+      <> "], found: "+      <> Text.pack (LazyByteString.unpack (Aeson.encode actual))+  CardinalityMismatch cid fieldPath expected actual ->+    renderConceptId cid+      <> ": frontmatter cardinality at "+      <> renderFieldPath fieldPath+      <> " must be "+      <> renderCardinality expected+      <> ", found "+      <> valueCardinalityName actual+      <> ": "+      <> Text.pack (LazyByteString.unpack (Aeson.encode actual))+  ValueFormatMismatch cid fieldPath expected actual ->+    renderConceptId cid+      <> ": frontmatter value at "+      <> renderFieldPath fieldPath+      <> " must match format "+      <> renderFieldFormat expected+      <> ", found: "+      <> Text.pack (LazyByteString.unpack (Aeson.encode actual))+  DanglingHandleReference cid fieldPath handle ->+    renderConceptId cid+      <> ": "+      <> renderFieldPath fieldPath+      <> " references "+      <> handle+      <> ", which does not exist in this bundle"+  ReferenceHandlePrefixMismatch cid fieldPath actual expectedPrefix ->+    renderConceptId cid+      <> ": "+      <> renderFieldPath fieldPath+      <> " references "+      <> actual+      <> ", which must use prefix "+      <> expectedPrefix+  MalformedDocumentReference cid fieldPath actual ->+    renderConceptId cid+      <> ": malformed document reference at "+      <> renderFieldPath fieldPath+      <> ": "+      <> Text.pack (LazyByteString.unpack (Aeson.encode actual))+  ExternalReferenceSchemeNotAllowed cid fieldPath actualScheme allowedSchemes ->+    renderConceptId cid+      <> ": external reference at "+      <> renderFieldPath fieldPath+      <> " uses scheme "+      <> actualScheme+      <> ", allowed schemes: "+      <> renderList allowedSchemes+  SelfDocumentReference cid fieldPath handle ->+    renderConceptId cid+      <> ": self reference at "+      <> renderFieldPath fieldPath+      <> " is not allowed: "+      <> handle+  FieldNotInProfile cid key ->+    renderConceptId cid <> ": frontmatter field not declared by profile: " <> key+  NestedElementNotRecord cid fieldPath actual ->+    renderConceptId cid+      <> ": frontmatter element at "+      <> renderFieldPath fieldPath+      <> " must be a record, found: "+      <> Text.pack (LazyByteString.unpack (Aeson.encode actual))   PathPatternMismatch cid ctype patternText ->     renderConceptId cid <> ": " <> ctype <> " must match path pattern: " <> patternText   MissingResource cid ctype scheme ->@@ -800,7 +1269,163 @@   DuplicateDocumentId handle cid other ->     renderConceptId cid <> ": duplicate document ID " <> handle <> " (also on " <> renderConceptId other <> ")"   where+    renderDescription cid key =+      maybe "" (\prose -> " (" <> prose <> ")") $ do+        ctype <- lookup cid [(conceptIdOf concept, conceptType concept) | concept <- concepts]+        profileFieldDescriptionForType compiled ctype key+    renderConditionContext = maybe "" renderCondition+    renderCondition FieldCondition {field = sourceField, hasValue = [expected]} =+      " (when " <> sourceField <> " is " <> expected <> ")"+    renderCondition FieldCondition {field = sourceField, hasValue} =+      " (when " <> sourceField <> " is one of [" <> Text.intercalate ", " hasValue <> "])"     renderList xs = "[" <> Text.intercalate ", " xs <> "]"++renderProfileDefinitionError :: ProfileDefinitionError -> Text+renderProfileDefinitionError = \case+  DuplicateTypeRule ctype -> "duplicate type rule: " <> ctype+  DuplicateFieldRule scope listName key ->+    renderScope scope <> ": duplicate " <> listName <> " field: " <> key+  ConflictingFieldRequirement scope key ->+    renderScope scope <> ": field appears in required and recommended: " <> key+  UnsatisfiableVocabulary scope key profileValues typeValues ->+    renderScope scope+      <> ": disjoint allowed values for "+      <> key+      <> " (profile: ["+      <> Text.intercalate ", " profileValues+      <> "], type: ["+      <> Text.intercalate ", " typeValues+      <> "])"+  ConflictingCardinality scope key profileCardinality typeCardinality ->+    renderScope scope+      <> ": conflicting cardinality for "+      <> key+      <> " (profile: "+      <> renderCardinality profileCardinality+      <> ", type: "+      <> renderCardinality typeCardinality+      <> ")"+  ElementFieldsRequireList scope fieldPath actualCardinality ->+    renderScope scope+      <> ": elementFields at "+      <> renderFieldPath fieldPath+      <> " requires list cardinality, found: "+      <> renderCardinality actualCardinality+  InvalidFormatParameter fieldPath fieldFormat parameter ->+    "invalid parameter for format "+      <> renderFieldFormat fieldFormat+      <> " at "+      <> renderFieldPath fieldPath+      <> ": "+      <> parameter+  ConflictingFieldFormat fieldPath profileFormat typeFormat ->+    "conflicting formats for "+      <> renderFieldPath fieldPath+      <> " (profile: "+      <> renderFieldFormat profileFormat+      <> ", type: "+      <> renderFieldFormat typeFormat+      <> ")"+  EmptyConditionValues scope target source ->+    renderConditionDefinition scope target source <> " has an empty hasValue list"+  ConditionFieldNotDeclared scope target source ->+    renderConditionDefinition scope target source <> " names an undeclared source field"+  ConditionFieldNotScalar scope target source actualCardinality ->+    renderConditionDefinition scope target source+      <> " requires scalar cardinality, found: "+      <> renderCardinality actualCardinality+  ConditionFieldOpenVocabulary scope target source ->+    renderConditionDefinition scope target source <> " requires a non-empty allowedValues vocabulary"+  ConditionFieldHasUnreachableValues scope target source unreachable allowed ->+    renderConditionDefinition scope target source+      <> " contains unreachable values ["+      <> Text.intercalate ", " unreachable+      <> "]; source allows ["+      <> Text.intercalate ", " allowed+      <> "]"+  SelfConditionalField scope target ->+    renderScope scope <> ": field cannot condition its own presence: " <> renderFieldPath target+  InvalidReferencePrefix scope target prefix ->+    renderScope scope <> ": invalid local reference prefix at " <> renderFieldPath target <> ": " <> prefix+  ReferencePrefixNotDeclared scope target prefix ->+    renderScope scope+      <> ": local reference prefix at "+      <> renderFieldPath target+      <> " is not declared by any type: "+      <> prefix+  ReferenceRequiresIdField scope target ->+    renderScope scope <> ": reference at " <> renderFieldPath target <> " requires profile idField"+  InvalidExternalReferenceScheme scope target scheme ->+    renderScope scope <> ": invalid external reference scheme at " <> renderFieldPath target <> ": " <> scheme+  ConflictingReferencePrefix ctype target profilePrefix typePrefix ->+    "type "+      <> ctype+      <> " frontmatter: conflicting local reference prefixes for "+      <> renderFieldPath target+      <> " (profile: "+      <> profilePrefix+      <> ", type: "+      <> typePrefix+      <> ")"+  ReferenceWithFormat scope target fieldFormat ->+    renderScope scope+      <> ": reference at "+      <> renderFieldPath target+      <> " cannot also declare format "+      <> renderFieldFormat fieldFormat+  where+    renderScope Nothing = "profile frontmatter"+    renderScope (Just ctype) = "type " <> ctype <> " frontmatter"+    renderConditionDefinition scope target source =+      renderScope scope+        <> ": condition for "+        <> renderFieldPath target+        <> " on "+        <> renderFieldPath source++renderCardinality :: Cardinality -> Text+renderCardinality = \case+  Any -> "any"+  Scalar -> "scalar"+  List -> "list"++renderFieldFormat :: FieldFormat -> Text+renderFieldFormat = \case+  Rfc3339Utc -> "rfc3339-utc"+  Date -> "date"+  Uri -> "uri"+  UriWithScheme scheme -> "uri-with-scheme(" <> scheme <> ")"+  DocumentHandle prefix -> "document-handle(" <> prefix <> ")"++renderHandleReferenceRule :: HandleReferenceRule -> Text+renderHandleReferenceRule HandleReferenceRule {localPrefix, externalUriSchemes, allowSelf} =+  "local-prefix("+    <> localPrefix+    <> "), external-uri-schemes("+    <> renderList externalUriSchemes+    <> "), allow-self("+    <> (if allowSelf then "true" else "false")+    <> ")"+  where+    renderList xs = "[" <> Text.intercalate ", " xs <> "]"++valueCardinalityName :: Aeson.Value -> Text+valueCardinalityName = \case+  Aeson.Array _ -> "list"+  Aeson.String _ -> "scalar"+  Aeson.Number _ -> "scalar"+  Aeson.Bool _ -> "scalar"+  Aeson.Object _ -> "object"+  Aeson.Null -> "null"++renderFieldPath :: FieldPath -> Text+renderFieldPath (FieldPath pathSegments) = go (toList pathSegments)+  where+    go [] = ""+    go (FieldName name : rest) = name <> foldMap renderSegment rest+    go (ArrayIndex elementIndex : rest) = Text.pack (show elementIndex) <> foldMap renderSegment rest+    renderSegment (FieldName name) = "." <> name+    renderSegment (ArrayIndex elementIndex) = "[" <> Text.pack (show elementIndex) <> "]"  renderValidationErrorText :: ValidationError -> Text renderValidationErrorText = \case
src/Okf/Cli/Config.hs view
@@ -3,6 +3,7 @@   ( OkfConfig (..),     KitSettings (..),     AssistSettings (..),+    ProfileSettings (..),     OkfProvider (..),     ConfigSource (..),     defaultOkfConfig,@@ -23,6 +24,7 @@ import Dhall (FromDhall (..), auto, genericAutoWith) import Dhall qualified import Okf.Prelude+import Okf.Profile.Registry (defaultRegistryReference) import System.Directory (doesFileExist, getCurrentDirectory, getHomeDirectory) import System.Environment (lookupEnv) import System.FilePath ((</>))@@ -59,9 +61,27 @@   deriving stock (Generic, Eq, Show)   deriving anyclass (FromDhall) +-- | Profile-related settings: which registry @okf profile@ reads by default.+data ProfileSettings = ProfileSettings+  { registry :: !Text+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (FromDhall)+ -- | The whole okf configuration. data OkfConfig = OkfConfig   { kit :: !KitSettings,+    assist :: !AssistSettings,+    profiles :: !ProfileSettings+  }+  deriving stock (Generic, Eq, Show)+  deriving anyclass (FromDhall)++-- | The configuration record as okf 0.2.0.0 defined it, before @profiles@ was+-- added. Dhall decodes records strictly, so without this fallback every config+-- file written for 0.2.0.0 would stop loading the moment a field was added.+data LegacyOkfConfig = LegacyOkfConfig+  { kit :: !KitSettings,     assist :: !AssistSettings   }   deriving stock (Generic, Eq, Show)@@ -89,9 +109,17 @@           { provider = ProviderClaude,             model = Nothing,             systemPrompt = Nothing-          }+          },+      profiles = defaultProfileSettings     } +-- | The profile settings a config file that predates @profiles@ is given.+defaultProfileSettings :: ProfileSettings+defaultProfileSettings =+  ProfileSettings+    { registry = defaultRegistryReference+    }+ okfConfigEnvVar :: String okfConfigEnvVar = "OKF_CONFIG" @@ -132,16 +160,38 @@  -- | Load the effective configuration and report its source. A parse or type -- error in a found file is returned as 'Left'; a missing file yields defaults.+--+-- A file that does not decode against the current record is retried against the+-- 0.2.0.0 shape, which had no @profiles@ field; on success the built-in default+-- registry fills the gap, so upgrading okf does not invalidate a config file the+-- user already wrote. If the retry also fails, the /first/ error is reported,+-- because that message describes the schema the user should be writing against. loadOkfConfig :: IO (Either Text (OkfConfig, ConfigSource)) loadOkfConfig = do   configSource <- findConfigSource   case sourcePath configSource of     Nothing -> pure (Right (defaultOkfConfig, configSource))-    Just path ->-      ( do-          config <- Dhall.inputFile auto path-          pure (Right (config, configSource))-      )+    Just path -> do+      current <- tryDecode (Dhall.inputFile auto path)+      case current of+        Right config -> pure (Right (config, configSource))+        Left currentError -> do+          legacy <- tryDecode (Dhall.inputFile auto path)+          pure $ case legacy of+            Left _legacyError -> Left currentError+            Right LegacyOkfConfig {kit = legacyKit, assist = legacyAssist} ->+              Right+                ( OkfConfig+                    { kit = legacyKit,+                      assist = legacyAssist,+                      profiles = defaultProfileSettings+                    },+                  configSource+                )+  where+    tryDecode :: IO a -> IO (Either Text a)+    tryDecode action =+      (Right <$> action)         `catch` \(exception :: SomeException) ->           pure (Left (Text.pack (show exception))) @@ -166,14 +216,16 @@ renderConfig   OkfConfig     { kit = KitSettings {repoUrl, providers},-      assist = AssistSettings {provider, model, systemPrompt}+      assist = AssistSettings {provider, model, systemPrompt},+      profiles = ProfileSettings {registry}     } =     Text.unlines       [ "kit.repoUrl     = " <> repoUrl,         "kit.providers   = " <> renderProviders providers,         "assist.provider = " <> renderProvider provider,         "assist.model    = " <> fromMaybe "(unset)" model,-        "assist.systemPrompt = " <> fromMaybe "(unset)" systemPrompt+        "assist.systemPrompt = " <> fromMaybe "(unset)" systemPrompt,+        "profiles.registry = " <> registry       ]  renderProviders :: [OkfProvider] -> Text@@ -198,6 +250,9 @@       "        { provider = Provider.Claude",       "        , model = None Text",       "        , systemPrompt = None Text",+      "        }",+      "    , profiles =",+      "        { registry = \"" <> defaultRegistryReference <> "\"",       "        }",       "    }"     ]
test/Main.hs view
@@ -13,6 +13,8 @@ import Okf.Cli.Help (HelpTopic (..), helpTopics) import Okf.ConceptId (parseConceptId) import Okf.Document (parseDocument)+import Okf.Profile (Cardinality (..), FieldCondition (..), FieldFormat (..), FieldRule (..), FrontmatterRules (..), HandleReferenceRule (..), NestedFieldRule (..), NestedRules (..), ProfileSpec (..), TypeRule (..))+import Okf.Profile.Registry (RegistryEntry (..)) import Options.Applicative import System.Directory (createDirectoryIfMissing, getCurrentDirectory, getTemporaryDirectory, removeDirectoryRecursive, withCurrentDirectory) import System.Environment (lookupEnv, setEnv, unsetEnv)@@ -26,6 +28,7 @@   configDefaults <- testConfigDefaults   configProjectPrecedence <- testConfigProjectPrecedence   configEnvPrecedence <- testConfigEnvPrecedence+  configLegacyWithoutProfiles <- testConfigLegacyWithoutProfiles   configInvalidDhall <- testConfigInvalidDhall   assistCommandBuilder <- testAssistCommandBuilder   assistModelOverride <- testAssistModelOverride@@ -175,12 +178,33 @@             == "'/opt/o'\\''kf/okf' show 'b' {2}",           sampleConceptDisplays             == ["tables/orders\tTable\tOrders", "x            \t     \t"],+          parseSucceeds ["profile"],+          parseSucceeds ["profile", "list"],+          parseSucceeds ["profile", "list", "--json"],+          parseSucceeds ["profile", "list", "--registry", "./r.dhall"],+          parseSucceeds ["profile", "show"],+          parseSucceeds ["profile", "show", "postgresql"],+          parseProfileMatches ["profile"] (ProfileList (ProfileListOptions Nothing False)),+          parseProfileMatches+            ["profile", "list", "--registry", "r", "--json"]+            (ProfileList (ProfileListOptions (Just "r") True)),+          parseProfileMatches+            ["profile", "show", "x", "--registry", "r", "--json"]+            (ProfileShow (ProfileShowOptions (Just "r") (Just "x") True)),+          parseProfileMatches+            ["profile", "show"]+            (ProfileShow (ProfileShowOptions Nothing Nothing False)),+          renderRegistryTable sampleRegistryEntries == sampleRegistryTable,+          renderProfileDetail "nested.decisions" sampleDecisionsProfile == sampleProfileDetail,+          renderProfileDetail "" samplePostgresqlProfile == sampleUndocumentedProfileDetail,+          renderProfileDetail "" sampleNestedProfile == sampleNestedProfileDetail,           parseShowsInfo ["--version"],           parseFails ["hello"],           logAddWrites,           configDefaults,           configProjectPrecedence,           configEnvPrecedence,+          configLegacyWithoutProfiles,           configInvalidDhall,           assistCommandBuilder,           assistModelOverride@@ -216,6 +240,265 @@     Success (Options (Validate opts)) -> opts == expected     _ -> False +-- | One root-level entry and one nested entry whose columns differ in width, so+-- the padding in 'renderRegistryTable' is actually exercised, and the @(root)@+-- and @-@ placeholders both appear.+sampleRegistryEntries :: [RegistryEntry]+sampleRegistryEntries =+  [ RegistryEntry {export = "", spec = samplePostgresqlProfile},+    RegistryEntry {export = "nested.decisions", spec = sampleDecisionsProfile}+  ]++-- | @DESCRIPTION@ is last and unpadded; the postgresql sample has none, so the+-- @-@ placeholder appears there as well as in @ID FIELD@.+sampleRegistryTable :: [Text.Text]+sampleRegistryTable =+  [ "EXPORT            NAME                OKF  TYPES  ID FIELD  DESCRIPTION",+    "(root)            shinzui-postgresql  0.1      1  -         -",+    "nested.decisions  decisions           0.1      1  docId     How this team records architectural decisions."+  ]++-- | A profile with no descriptions anywhere — the shape an okf 0.2.x descriptor+-- upgrades into.+samplePostgresqlProfile :: ProfileSpec+samplePostgresqlProfile =+  ProfileSpec+    { name = "shinzui-postgresql",+      description = Nothing,+      okfVersion = "0.1",+      frontmatter =+        FrontmatterRules+          { required = [undocumentedField "type", undocumentedField "title"],+            recommended = []+          },+      allowUnknownTypes = False,+      allowUnknownFields = True,+      idField = Nothing,+      types =+        [ TypeRule+            { type_ = "PostgreSQL Table",+              description = Nothing,+              frontmatter = FrontmatterRules {required = [], recommended = []},+              pathPattern = Just "schemas/*/tables/*",+              resourceScheme = Just "postgresql",+              requireSchemaSection = True,+              schemaColumns = ["Column", "Type"],+              idPrefix = Nothing+            }+        ]+    }++sampleDecisionsProfile :: ProfileSpec+sampleDecisionsProfile =+  ProfileSpec+    { name = "decisions",+      description = Just "How this team records architectural decisions.",+      okfVersion = "0.1",+      frontmatter =+        FrontmatterRules+          { required =+              [ FieldRule+                  { field = "type",+                    description = Just "The OKF concept type; must be a type rule below.",+                    allowedValues = [],+                    cardinality = Any,+                    format = Nothing,+                    elementFields = Nothing,+                    reference = Nothing,+                    when = Nothing+                  },+                undocumentedField "title"+              ],+            recommended = []+          },+      allowUnknownTypes = False,+      allowUnknownFields = True,+      idField = Just "docId",+      types =+        [ TypeRule+            { type_ = "Decision Record",+              description = Just "One accepted decision, never edited after acceptance.",+              frontmatter =+                FrontmatterRules+                  { required = [FieldRule "owner" (Just "Person responsible for the decision.") [] Scalar (Just (DocumentHandle "USR")) Nothing Nothing Nothing],+                    recommended = [FieldRule "reviewer" Nothing ["Ari", "Bo"] List Nothing Nothing (Just (HandleReferenceRule "ADR" ["mori"] False)) Nothing]+                  },+              pathPattern = Just "decisions/*",+              resourceScheme = Nothing,+              requireSchemaSection = False,+              schemaColumns = [],+              idPrefix = Just "ADR"+            }+        ]+    }++undocumentedField :: Text.Text -> FieldRule+undocumentedField key = FieldRule {field = key, description = Nothing, allowedValues = [], cardinality = Any, format = Nothing, elementFields = Nothing, reference = Nothing, when = Nothing}++sampleNestedProfile :: ProfileSpec+sampleNestedProfile =+  ProfileSpec+    { name = "nested",+      description = Nothing,+      okfVersion = "0.1",+      frontmatter =+        FrontmatterRules+          { required =+              [ FieldRule+                  "reviews"+                  Nothing+                  []+                  Any+                  Nothing+                  ( Just+                      NestedRules+                        { required = [NestedFieldRule "outcome" Nothing ["approved", "rejected"] Any Nothing (Just (FieldCondition "kind" ["model"]))],+                          recommended = [NestedFieldRule "notes" Nothing [] Scalar Nothing Nothing]+                        }+                  )+                  Nothing+                  Nothing+              ],+            recommended = []+          },+      allowUnknownTypes = True,+      allowUnknownFields = True,+      idField = Nothing,+      types = []+    }++sampleNestedProfileDetail :: [Text.Text]+sampleNestedProfileDetail =+  [ "export: (root)",+    "name: nested",+    "description: (none)",+    "okfVersion: 0.1",+    "allowUnknownTypes: true",+    "allowUnknownFields: true",+    "idField: (none)",+    "frontmatter.required:",+    "  - reviews: (none)",+    "    allowedValues: (any)",+    "    cardinality: any",+    "    format: (none)",+    "    reference: (none)",+    "    when: (none)",+    "    elementFields:",+    "      required:",+    "        - outcome: (none)",+    "          allowedValues: approved, rejected",+    "          cardinality: any",+    "          format: (none)",+    "          when: kind in [model]",+    "      recommended:",+    "        - notes: (none)",+    "          allowedValues: (any)",+    "          cardinality: scalar",+    "          format: (none)",+    "          when: (none)",+    "frontmatter.recommended: (none)"+  ]++-- | Every optional field prints, as @(none)@ when absent, so the shape does not+-- shift between profiles. A non-empty frontmatter list becomes a headed block,+-- one key per line, since per-field prose cannot share a comma-joined line.+sampleProfileDetail :: [Text.Text]+sampleProfileDetail =+  [ "export: nested.decisions",+    "name: decisions",+    "description: How this team records architectural decisions.",+    "okfVersion: 0.1",+    "allowUnknownTypes: false",+    "allowUnknownFields: true",+    "idField: docId",+    "frontmatter.required:",+    "  - type: The OKF concept type; must be a type rule below.",+    "    allowedValues: (any)",+    "    cardinality: any",+    "    format: (none)",+    "    reference: (none)",+    "    when: (none)",+    "    elementFields: (none)",+    "  - title: (none)",+    "    allowedValues: (any)",+    "    cardinality: any",+    "    format: (none)",+    "    reference: (none)",+    "    when: (none)",+    "    elementFields: (none)",+    "frontmatter.recommended: (none)",+    "",+    "type: Decision Record",+    "  description: One accepted decision, never edited after acceptance.",+    "  frontmatter.required:",+    "    - owner: Person responsible for the decision.",+    "      allowedValues: (any)",+    "      cardinality: scalar",+    "      format: document-handle(USR)",+    "      reference: (none)",+    "      when: (none)",+    "      elementFields: (none)",+    "  frontmatter.recommended:",+    "    - reviewer: (none)",+    "      allowedValues: Ari, Bo",+    "      cardinality: list",+    "      format: (none)",+    "      reference: local-prefix(ADR), external-uri-schemes([mori]), allow-self(false)",+    "      when: (none)",+    "      elementFields: (none)",+    "  pathPattern: decisions/*",+    "  resourceScheme: (none)",+    "  requireSchemaSection: false",+    "  schemaColumns: (none)",+    "  idPrefix: ADR"+  ]++-- | A profile carrying no descriptions at all still prints every line, so the+-- output shape does not shift between an okf 0.2.x descriptor and a documented+-- one.+sampleUndocumentedProfileDetail :: [Text.Text]+sampleUndocumentedProfileDetail =+  [ "export: (root)",+    "name: shinzui-postgresql",+    "description: (none)",+    "okfVersion: 0.1",+    "allowUnknownTypes: false",+    "allowUnknownFields: true",+    "idField: (none)",+    "frontmatter.required:",+    "  - type: (none)",+    "    allowedValues: (any)",+    "    cardinality: any",+    "    format: (none)",+    "    reference: (none)",+    "    when: (none)",+    "    elementFields: (none)",+    "  - title: (none)",+    "    allowedValues: (any)",+    "    cardinality: any",+    "    format: (none)",+    "    reference: (none)",+    "    when: (none)",+    "    elementFields: (none)",+    "frontmatter.recommended: (none)",+    "",+    "type: PostgreSQL Table",+    "  description: (none)",+    "  frontmatter.required: (none)",+    "  frontmatter.recommended: (none)",+    "  pathPattern: schemas/*/tables/*",+    "  resourceScheme: postgresql",+    "  requireSchemaSection: true",+    "  schemaColumns: Column, Type",+    "  idPrefix: (none)"+  ]++parseProfileMatches :: [String] -> ProfileCommand -> Bool+parseProfileMatches args expected =+  case execParserPure defaultPrefs parserInfo args of+    Success (Options (Profile profileCommand)) -> profileCommand == expected+    _ -> False+ parseLogMatches :: [String] -> LogOptions -> Bool parseLogMatches args expected =   case execParserPure defaultPrefs parserInfo args of@@ -300,6 +583,34 @@     configSource <- findConfigSource     loaded <- loadOkfConfig     pure (configSource == SourceEnv envPath && loaded == Right (defaultOkfConfig, SourceEnv envPath))++-- | A config file written for okf 0.2.0.0 has no @profiles@ field. It must+-- still load, with the built-in default registry filled in — otherwise adding a+-- field to the record would break every existing config file.+testConfigLegacyWithoutProfiles :: IO Bool+testConfigLegacyWithoutProfiles =+  withIsolatedConfigEnv "okf-cli-config-legacy" $ do+    projectPath <- projectConfigPath+    Text.IO.writeFile projectPath legacyConfigText+    loaded <- loadOkfConfig+    pure (loaded == Right (defaultOkfConfig, SourceProject projectPath))++-- | Verbatim okf 0.2.0.0 configuration: the record before @profiles@ existed.+legacyConfigText :: Text.Text+legacyConfigText =+  Text.unlines+    [ "let Provider = < Claude | Codex >",+      "in  { kit =",+      "        { repoUrl = \"https://github.com/shinzui/okf-kit.git\"",+      "        , providers = [ Provider.Claude ]",+      "        }",+      "    , assist =",+      "        { provider = Provider.Claude",+      "        , model = None Text",+      "        , systemPrompt = None Text",+      "        }",+      "    }"+    ]  testConfigInvalidDhall :: IO Bool testConfigInvalidDhall =