packages feed

okf-core 0.1.2.0 → 0.2.0.0

raw patch · 37 files changed

+966/−16 lines, 37 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Okf.Bundle: findConceptsByDocumentId :: Maybe Text -> Text -> [Concept] -> [Concept]
+ Okf.Discovery: DiscoveryOptions :: !Int -> ![FilePath] -> DiscoveryOptions
+ Okf.Discovery: [maxDepth] :: DiscoveryOptions -> !Int
+ Okf.Discovery: [skipDirectories] :: DiscoveryOptions -> ![FilePath]
+ Okf.Discovery: data DiscoveryOptions
+ Okf.Discovery: defaultDiscoveryOptions :: DiscoveryOptions
+ Okf.Discovery: directoryQualifiesAsBundleRoot :: FilePath -> IO Bool
+ Okf.Discovery: discoverBundleRoots :: DiscoveryOptions -> FilePath -> IO [FilePath]
+ Okf.Discovery: instance GHC.Classes.Eq Okf.Discovery.DiscoveryOptions
+ Okf.Discovery: instance GHC.Internal.Generics.Generic Okf.Discovery.DiscoveryOptions
+ Okf.Discovery: instance GHC.Internal.Show.Show Okf.Discovery.DiscoveryOptions
+ Okf.Profile: DocumentId :: !Text -> !Natural -> DocumentId
+ Okf.Profile: DuplicateDocumentId :: Text -> ConceptId -> ConceptId -> ProfileViolation
+ Okf.Profile: MalformedDocumentId :: ConceptId -> Text -> Text -> ProfileViolation
+ Okf.Profile: MissingDocumentId :: ConceptId -> Text -> Text -> ProfileViolation
+ Okf.Profile: [idField] :: ProfileSpec -> !Maybe Text
+ Okf.Profile: [idPrefix] :: TypeRule -> !Maybe Text
+ Okf.Profile: [number] :: DocumentId -> !Natural
+ Okf.Profile: [prefix] :: DocumentId -> !Text
+ Okf.Profile: data DocumentId
+ Okf.Profile: documentIdsInBundle :: ProfileSpec -> [Concept] -> [(DocumentId, ConceptId)]
+ Okf.Profile: instance GHC.Classes.Eq Okf.Profile.DocumentId
+ Okf.Profile: instance GHC.Classes.Ord Okf.Profile.DocumentId
+ Okf.Profile: instance GHC.Internal.Generics.Generic Okf.Profile.DocumentId
+ Okf.Profile: instance GHC.Internal.Show.Show Okf.Profile.DocumentId
+ Okf.Profile: nextDocumentId :: ProfileSpec -> [Concept] -> Text -> DocumentId
+ Okf.Profile: parseDocumentId :: Text -> Maybe DocumentId
+ Okf.Profile: renderDocumentId :: DocumentId -> Text
- Okf.Profile: ProfileSpec :: !Text -> !Text -> !FrontmatterRules -> !Bool -> ![TypeRule] -> ProfileSpec
+ Okf.Profile: ProfileSpec :: !Text -> !Text -> !FrontmatterRules -> !Bool -> !Maybe Text -> ![TypeRule] -> ProfileSpec
- Okf.Profile: TypeRule :: !Text -> !Maybe Text -> !Maybe Text -> !Bool -> ![Text] -> TypeRule
+ Okf.Profile: TypeRule :: !Text -> !Maybe Text -> !Maybe Text -> !Bool -> ![Text] -> !Maybe Text -> TypeRule

Files

CHANGELOG.md view
@@ -7,6 +7,24 @@  ## [Unreleased] +## [0.2.0.0] - 2026-07-26++### Added++- Profile-declared stable document IDs with strict parsing, missing/malformed/+  duplicate validation, allocation helpers, and bundle lookup by handle.+- `Okf.Discovery`, which finds OKF bundle roots in a directory tree: directories+  holding an `index.md` or a concept document with a non-empty `type`, pruned at+  the first match so nested directories of a bundle are not reported separately.++### Changed++- The published profile Dhall schema gained required `idField` and `idPrefix`+  record fields. Existing descriptors, including those in the separate+  `okf-profiles` repository, must add `idField` and `idPrefix` values or adopt+  the new record-completion defaults under `dhall/defaults/`. This is a breaking+  schema change.+ ## [0.1.2.0] - 2026-07-14  ### Added
+ dhall/FrontmatterRules.dhall view
@@ -0,0 +1,6 @@+--| Canonical schema for a profile's frontmatter expectations.+--+-- Mirrors the `FrontmatterRules` decoder in `okf-core/src/Okf/Profile.hs`.+{ required : List Text+, recommended : List Text+}
+ dhall/Profile.dhall view
@@ -0,0 +1,24 @@+--| Canonical schema for a complete OKF profile.+--+-- This record type is the contract that `okf validate --profile` accepts. It is+-- owned and published by okf; okf-profiles and downstream projects import it.+-- It mirrors the `ProfileSpec` decoder in `okf-core/src/Okf/Profile.hs`, kept in+-- sync by the drift guard in `okf-core/test/Main.hs`.+--+-- Profiles are NOT part of the OKF standard. A bundle that deviates from a profile+-- remains fully OKF-conformant; `okf validate --profile` reports deviations as+-- advisory by default.+--+-- `idField = Some "docId"` names the frontmatter key that holds stable document+-- handles.  `None Text` disables every document-ID check.+let TypeRule = ./TypeRule.dhall++let FrontmatterRules = ./FrontmatterRules.dhall++in  { name : Text+    , okfVersion : Text+    , frontmatter : FrontmatterRules+    , allowUnknownTypes : Bool+    , idField : Optional Text+    , types : List TypeRule+    }
+ dhall/TypeRule.dhall view
@@ -0,0 +1,17 @@+--| Canonical schema for one per-`type` rule in an OKF profile.+--+-- This is the single source of truth for the rule shape. It mirrors the+-- `TypeRule` decoder in `okf-core/src/Okf/Profile.hs`; the two are kept in sync by+-- the drift guard in `okf-core/test/Main.hs` (the schema-annotated profile fixture+-- must decode). Other repositories (e.g. okf-profiles) import this type; okf+-- imports nothing remote in return.+--+-- `idPrefix = Some "ADR"` means concepts governed by this rule are expected to+-- carry a handle of the form `ADR-<number>` in the profile's ID field.+{ type : Text+, pathPattern : Optional Text+, resourceScheme : Optional Text+, requireSchemaSection : Bool+, schemaColumns : List Text+, idPrefix : Optional Text+}
+ dhall/defaults/FrontmatterRules.dhall view
@@ -0,0 +1,6 @@+--| Record-completion defaults for profile frontmatter expectations.+let FrontmatterRulesType = ../FrontmatterRules.dhall++in  { Type = FrontmatterRulesType+    , default = { required = [] : List Text, recommended = [] : List Text }+    }
+ dhall/defaults/Profile.dhall view
@@ -0,0 +1,16 @@+--| Record-completion defaults for a complete OKF profile.+let ProfileType = ../Profile.dhall++let FrontmatterRules = ./FrontmatterRules.dhall++let TypeRule = ../TypeRule.dhall++in  { Type = ProfileType+    , default =+      { okfVersion = "0.1"+      , frontmatter = FrontmatterRules.default+      , allowUnknownTypes = True+      , idField = None Text+      , types = [] : List TypeRule+      }+    }
+ dhall/defaults/TypeRule.dhall view
@@ -0,0 +1,12 @@+--| Record-completion defaults for one per-`type` profile rule.+let TypeRuleType = ../TypeRule.dhall++in  { Type = TypeRuleType+    , default =+      { pathPattern = None Text+      , resourceScheme = None Text+      , requireSchemaSection = False+      , schemaColumns = [] : List Text+      , idPrefix = None Text+      }+    }
+ dhall/package.dhall view
@@ -0,0 +1,19 @@+--| Entry point for okf's published profile schema.+--+-- Import this (by relative path within okf, or by pinned URL from another repo) to+-- get the profile schema types and record-completion defaults:+--+--     let okf = https://raw.githubusercontent.com/shinzui/okf/<tag>/okf-core/dhall/package.dhall sha256:<hash>+--     in  ({ name = "acme", okfVersion = "0.1", … } : okf.Profile)+--+-- okf itself imports nothing remote; the relationship with okf-profiles is one-way+-- (okf-profiles imports this).+{ Profile = ./Profile.dhall+, TypeRule = ./TypeRule.dhall+, FrontmatterRules = ./FrontmatterRules.dhall+, defaults =+  { Profile = ./defaults/Profile.dhall+  , TypeRule = ./defaults/TypeRule.dhall+  , FrontmatterRules = ./defaults/FrontmatterRules.dhall+  }+}
okf-core.cabal view
@@ -1,6 +1,6 @@-cabal-version:   3.4-name:            okf-core-version:         0.1.2.0+cabal-version:      3.4+name:               okf-core+version:            0.2.0.0 synopsis:   Read, validate, index, and traverse Open Knowledge Format bundles @@ -10,15 +10,23 @@   index and link graph, validates referential integrity, and writes bundles back   out with a round-trip guarantee. -category:        Data, Text-license:         BSD-3-Clause-license-file:    LICENSE-author:          Nadeem Bitar-maintainer:      nadeem@gmail.com-copyright:       (c) 2026 Nadeem Bitar-build-type:      Simple-extra-doc-files: CHANGELOG.md+category:           Data, Text+license:            BSD-3-Clause+license-file:       LICENSE+author:             Nadeem Bitar+maintainer:         nadeem@gmail.com+copyright:          (c) 2026 Nadeem Bitar+build-type:         Simple+extra-doc-files:    CHANGELOG.md +-- 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.+extra-source-files:+  dhall/**/*.dhall+  test/fixtures/**/*.dhall+  test/fixtures/**/*.md+ common common-options   ghc-options:     -Wall -Wcompat -Widentities -Wincomplete-uni-patterns@@ -39,6 +47,7 @@   exposed-modules:     Okf.Bundle     Okf.ConceptId+    Okf.Discovery     Okf.Document     Okf.Graph     Okf.Index
src/Okf/Bundle.hs view
@@ -13,6 +13,7 @@     conceptTitle,     conceptType,     findConcept,+    findConceptsByDocumentId,     isReservedMarkdownFile,     serializeConcept,     walkBundle,@@ -22,6 +23,7 @@ where  import Control.Exception (IOException, try)+import Data.Aeson.KeyMap qualified as KeyMap import Data.List qualified as List import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO@@ -89,6 +91,23 @@ findConcept :: ConceptId -> [Concept] -> Maybe Concept findConcept conceptId =   List.find (\concept -> conceptIdOf concept == conceptId)++-- | Find concepts whose frontmatter carries the given document handle. When a+-- field is supplied, only that key is examined; otherwise every frontmatter+-- value is searched. All matches are returned so callers can report ambiguity.+findConceptsByDocumentId :: Maybe Text -> Text -> [Concept] -> [Concept]+findConceptsByDocumentId fieldFilter handle =+  filter conceptMatches+  where+    conceptMatches concept =+      case fieldFilter of+        Just fieldName ->+          valueMatches (frontmatterLookup fieldName (documentFrontmatter concept))+        Nothing ->+          any (valueMatches . Just) (KeyMap.elems (fields (documentFrontmatter concept)))+    valueMatches (Just (String value)) = Text.strip value == handle+    valueMatches _ = False+    documentFrontmatter concept = frontmatter (conceptDocument concept)  -- | Extract a concept identifier without colliding with Prelude's `id`. conceptIdOf :: Concept -> ConceptId
+ src/Okf/Discovery.hs view
@@ -0,0 +1,135 @@+-- | Discovery of OKF bundle roots in a directory tree.+--+-- A bundle root is a directory that looks like the top of an OKF bundle: it+-- either holds a reserved @index.md@, or it holds at least one concept+-- document (a non-reserved @.md@ file whose YAML frontmatter carries a+-- non-empty @type@ field). Discovery stops descending as soon as a directory+-- qualifies, so nested subdirectories of a bundle are never reported as+-- bundles of their own.+--+-- Discovery is a convenience for interactive callers, not a validation step:+-- directories that cannot be listed or files that cannot be read are skipped+-- rather than reported as errors.+module Okf.Discovery+  ( DiscoveryOptions (..),+    defaultDiscoveryOptions,+    discoverBundleRoots,+    directoryQualifiesAsBundleRoot,+  )+where++import Control.Exception (IOException, try)+import Control.Monad (filterM)+import Data.List qualified as List+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Okf.Bundle (isReservedMarkdownFile)+import Okf.Document (Frontmatter, OKFDocument (..), frontmatterLookup, parseDocument)+import Okf.Prelude+import System.Directory (doesDirectoryExist, listDirectory, pathIsSymbolicLink)+import System.FilePath ((</>))+import System.FilePath qualified as FilePath++-- | How far and where 'discoverBundleRoots' may look.+data DiscoveryOptions = DiscoveryOptions+  { -- | How many directory levels below the search root to inspect. The search+    -- root itself is depth 0, so @maxDepth = 4@ inspects four levels beneath it.+    maxDepth :: !Int,+    -- | Directory names never entered, regardless of depth. Directories whose+    -- name begins with @.@ are always skipped and need no entry here.+    skipDirectories :: ![FilePath]+  }+  deriving stock (Generic, Eq, Show)++-- | Depth four with the usual build-output directories skipped. Depth four is+-- enough to reach a bundle nested a few levels inside a source repository+-- without walking an entire home directory.+defaultDiscoveryOptions :: DiscoveryOptions+defaultDiscoveryOptions =+  DiscoveryOptions+    { maxDepth = 4,+      skipDirectories =+        [ "dist-newstyle",+          "dist",+          "node_modules",+          "target",+          "vendor",+          "_build"+        ]+    }++-- | Bundle roots under a search root, sorted and normalised. The search root+-- itself is a candidate: pointing this at a bundle returns that bundle.+discoverBundleRoots :: DiscoveryOptions -> FilePath -> IO [FilePath]+discoverBundleRoots DiscoveryOptions {maxDepth, skipDirectories} searchRoot =+  List.sort <$> walk 0 searchRoot+  where+    walk depth directory = do+      qualifies <- directoryQualifiesAsBundleRoot directory+      if qualifies+        then pure [FilePath.normalise directory]+        else+          if depth >= maxDepth+            then pure []+            else do+              entries <- listDirectorySafe directory+              subdirectories <-+                filterM+                  (isSearchableDirectory skipDirectories)+                  [directory </> entry | entry <- List.sort entries, not (isHidden entry)]+              concat <$> traverse (walk (depth + 1)) subdirectories++    isHidden entry = case entry of+      ('.' : _) -> True+      _ -> False++-- | Does this directory look like the top of an OKF bundle?+directoryQualifiesAsBundleRoot :: FilePath -> IO Bool+directoryQualifiesAsBundleRoot directory = do+  entries <- listDirectorySafe directory+  if "index.md" `List.elem` entries+    then pure True+    else anyM isConceptDocument [directory </> entry | entry <- entries, isConceptCandidate entry]+  where+    isConceptCandidate entry =+      FilePath.takeExtension entry == ".md" && not (isReservedMarkdownFile entry)++-- | A file is a concept document when it parses and declares a non-empty @type@.+isConceptDocument :: FilePath -> IO Bool+isConceptDocument path = do+  loaded <- try @IOException (Text.IO.readFile path)+  pure $ case loaded of+    Left _ -> False+    Right content -> case parseDocument content of+      Left _ -> False+      Right OKFDocument {frontmatter} -> hasNonEmptyType frontmatter++hasNonEmptyType :: Frontmatter -> Bool+hasNonEmptyType frontmatter =+  case frontmatterLookup "type" frontmatter of+    Just (String value) -> not (Text.null (Text.strip value))+    _ -> False++-- | A directory we may descend into: a real directory, not a symbolic link+-- (which could form a cycle), and not on the skip list.+isSearchableDirectory :: [FilePath] -> FilePath -> IO Bool+isSearchableDirectory skipDirectories path+  | FilePath.takeFileName path `List.elem` skipDirectories = pure False+  | otherwise = do+      isDirectory <- orFalse (doesDirectoryExist path)+      isSymlink <- orFalse (pathIsSymbolicLink path)+      pure (isDirectory && not isSymlink)++listDirectorySafe :: FilePath -> IO [FilePath]+listDirectorySafe directory = do+  listed <- try @IOException (listDirectory directory)+  pure (either (const []) Prelude.id listed)++orFalse :: IO Bool -> IO Bool+orFalse action = either (const False) Prelude.id <$> try @IOException action++anyM :: (a -> IO Bool) -> [a] -> IO Bool+anyM _ [] = pure False+anyM predicate (x : xs) = do+  matched <- predicate x+  if matched then pure True else anyM predicate xs
src/Okf/Profile.hs view
@@ -17,6 +17,11 @@     loadProfileFile,      -- * Validation+    DocumentId (..),+    parseDocumentId,+    renderDocumentId,+    documentIdsInBundle,+    nextDocumentId,     ProfileViolation (..),     validateProfile, @@ -27,10 +32,13 @@  import CMarkGFM qualified import Control.Exception (SomeException, catch)+import Data.Char (isAsciiLower, isAsciiUpper) import Data.List qualified as List import Data.Text qualified as Text+import Data.Text.Read qualified as Text.Read import Dhall (FromDhall (..), auto, genericAutoWith) import Dhall qualified+import Numeric.Natural (Natural) import Okf.Bundle   ( Concept,     conceptDocument,@@ -49,6 +57,7 @@     okfVersion :: !Text,     frontmatter :: !FrontmatterRules,     allowUnknownTypes :: !Bool,+    idField :: !(Maybe Text),     types :: ![TypeRule]   }   deriving stock (Generic, Eq, Show)@@ -68,7 +77,8 @@     pathPattern :: !(Maybe Text),     resourceScheme :: !(Maybe Text),     requireSchemaSection :: !Bool,-    schemaColumns :: ![Text]+    schemaColumns :: ![Text],+    idPrefix :: !(Maybe Text)   }   deriving stock (Generic, Eq, Show) @@ -90,6 +100,94 @@   (Right <$> Dhall.inputFile auto path)     `catch` \(e :: SomeException) -> pure (Left (Text.pack (show e))) +-- | 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+    isAsciiLetter character =+      isAsciiLower character || isAsciiUpper character+    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+        ]+ -- | 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@@ -106,6 +204,12 @@     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 the profile, returning all deviations. Concepts@@ -113,7 +217,8 @@ -- is no rule to check against) and only produce a 'TypeNotInProfile' violation -- when @allowUnknownTypes@ is @False@. validateProfile :: ProfileSpec -> [Concept] -> [ProfileViolation]-validateProfile spec = concatMap checkConcept+validateProfile spec concepts =+  concatMap checkConcept concepts <> checkDuplicateDocumentIds spec concepts   where     rulesByType = [(rule ^. #type_, rule) | rule <- spec ^. #types] @@ -128,12 +233,55 @@                 <> checkPath cid ctype rule                 <> checkResource cid ctype rule concept                 <> checkSchema cid ctype rule concept+                <> checkDocumentId spec cid ctype rule concept      checkRequiredFields cid concept =       [ MissingProfileField cid key       | key <- spec ^. #frontmatter . #required,         not (hasNonEmptyField key (conceptFrontmatter concept))       ]++-- | 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
test/Main.hs view
@@ -3,11 +3,13 @@ module Main (main) where  import Data.Aeson (object, toJSON, (.=))+import Data.Foldable (for_) 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@@ -23,7 +25,7 @@     removeDirectoryRecursive,   ) import System.Exit (exitFailure)-import System.FilePath ((</>))+import System.FilePath (normalise, takeDirectory, (</>)) import System.IO.Temp (createTempDirectory) import "generic-lens" Data.Generics.Labels () @@ -44,6 +46,13 @@         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,@@ -72,6 +81,11 @@         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,+        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 "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,@@ -79,8 +93,14 @@         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 the expected deviations for the fixture bundle" testProfileDeviationsFixture,+        testIO "validateProfile reports document ID fixture deviations" testDocumentIdDeviationsFixture       ]   unless (and results) exitFailure @@ -189,6 +209,83 @@           )     ) +-- | 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 =@@ -662,6 +759,78 @@         ["PostgreSQL Schema", "PostgreSQL Table", "PostgreSQL View"]         (map (^. #type_) (spec ^. #types)) +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))++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)+ -- | A standalone profile literal so the validation tests do not depend on the -- Dhall fixture. One rule: PostgreSQL Table, fully constrained. testProfileSpec :: ProfileSpec@@ -671,17 +840,39 @@       okfVersion = "0.1",       frontmatter = FrontmatterRules {required = ["type", "title"], recommended = []},       allowUnknownTypes = False,+      idField = Nothing,       types =         [ TypeRule             { type_ = "PostgreSQL Table",               pathPattern = Just "schemas/*/tables/*",               resourceScheme = Just "postgresql",               requireSchemaSection = True,-              schemaColumns = ["Column", "Type", "Nullable", "Description"]+              schemaColumns = ["Column", "Type", "Nullable", "Description"],+              idPrefix = Nothing             }         ]     } +testDocumentIdProfileSpec :: ProfileSpec+testDocumentIdProfileSpec =+  ProfileSpec+    { name = "test-decisions",+      okfVersion = "0.1",+      frontmatter = FrontmatterRules {required = ["type", "title"], recommended = []},+      allowUnknownTypes = False,+      idField = Just "docId",+      types =+        [ TypeRule+            { type_ = "Decision Record",+              pathPattern = Just "decisions/*",+              resourceScheme = Nothing,+              requireSchemaSection = False,+              schemaColumns = [],+              idPrefix = Just "ADR"+            }+        ]+    }+ -- | 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@@ -782,6 +973,78 @@     [SchemaColumnsMismatch cid "PostgreSQL Table" ["Column", "Type", "Nullable", "Description"] ["Col", "Type"]]     (validateProfile 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 [] (validateProfile 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"]+    (validateProfile 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"+    ]+    (validateProfile 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]+    (validateProfile 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 [] (validateProfile testProfileSpec [concept])+ testSchemaSectionColumns :: Either Text () testSchemaSectionColumns =   assertEqual@@ -803,6 +1066,26 @@       ordersId <- parseTestConceptId "schemas/sales/tables/orders"       assertEqual         [TypeNotInProfile badId "pg table", MissingProfileField ordersId "title"]+        (validateProfile 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+        ]         (validateProfile spec concepts)  substringIndex :: Text -> Text -> Maybe Int
+ test/fixtures/doc-id-deviations/decisions/first.md view
@@ -0,0 +1,7 @@+---+type: Decision Record+title: First decision+docId: ADR-1+---++# First decision
+ test/fixtures/doc-id-deviations/decisions/fourth.md view
@@ -0,0 +1,6 @@+---+type: Decision Record+title: Fourth decision+---++# Fourth decision
+ test/fixtures/doc-id-deviations/decisions/second.md view
@@ -0,0 +1,7 @@+---+type: Decision Record+title: Second decision+docId: ADR-1+---++# Second decision
+ test/fixtures/doc-id-deviations/decisions/third.md view
@@ -0,0 +1,7 @@+---+type: Decision Record+title: Third decision+docId: ADR-007+---++# Third decision
+ test/fixtures/doc-ids/decisions/adopt-okf.md view
@@ -0,0 +1,9 @@+---+type: Decision Record+title: Adopt Open Knowledge Format+docId: ADR-3+---++# Adopt Open Knowledge Format++Use OKF bundles for durable project knowledge.
+ test/fixtures/doc-ids/decisions/use-markdown.md view
@@ -0,0 +1,9 @@+---+type: Decision Record+title: Use Markdown for knowledge records+docId: ADR-1+---++# Use Markdown for knowledge records++Store knowledge records as Markdown documents.
+ test/fixtures/doc-ids/decisions/use-postgres.md view
@@ -0,0 +1,9 @@+---+type: Decision Record+title: Use PostgreSQL for the warehouse+docId: ADR-2+---++# Use PostgreSQL for the warehouse++Use PostgreSQL as the warehouse database.
+ test/fixtures/invalid-dangling-link/orders.md view
@@ -0,0 +1,10 @@+---+type: BigQuery Table+title: Orders+description: Order fact table.+timestamp: 2026-06-16T00:00:00Z+---++# Orders++Orders join to [Customers](/customers.md).
+ test/fixtures/invalid-missing-type/missing-type.md view
@@ -0,0 +1,9 @@+---+title: Missing Type+description: This document has frontmatter but no required type.+timestamp: 2026-06-16T00:00:00Z+---++# Missing Type++Permissive validation should reject this as an OKF concept document.
+ test/fixtures/invalid-unterminated-frontmatter/broken.md view
@@ -0,0 +1,6 @@+---+type: BigQuery Table+title: Broken+description: This document never closes frontmatter.++# Broken
+ test/fixtures/profile-deviations/schemas/sales/tables/bad.md view
@@ -0,0 +1,11 @@+---+type: pg table+title: Bad+resource: postgresql://warehouse/sales/public/bad+---++# Schema++| Column   | Type   | Nullable | Description |+|----------|--------|----------|-------------|+| `bad_id` | bigint | no       | Primary key.|
+ test/fixtures/profile-deviations/schemas/sales/tables/customers.md view
@@ -0,0 +1,11 @@+---+type: PostgreSQL Table+title: Customers+resource: postgresql://warehouse/sales/public/customers+---++# Schema++| Column        | Type   | Nullable | Description  |+|---------------|--------|----------|--------------|+| `customer_id` | bigint | no       | Primary key. |
+ test/fixtures/profile-deviations/schemas/sales/tables/orders.md view
@@ -0,0 +1,10 @@+---+type: PostgreSQL Table+resource: postgresql://warehouse/sales/public/orders+---++# Schema++| Column     | Type   | Nullable | Description |+|------------|--------|----------|-------------|+| `order_id` | bigint | no       | Primary key.|
+ test/fixtures/profiles/decisions.dhall view
@@ -0,0 +1,21 @@+-- The annotation and TypeRule record completion jointly guard the canonical+-- schema, its defaults, and the Haskell decoder against drift.+let Profile = ../../../dhall/Profile.dhall++let TypeRule = ../../../dhall/defaults/TypeRule.dhall++in    { name = "decisions"+      , okfVersion = "0.1"+      , frontmatter =+        { required = [ "type", "title" ], recommended = [] : List Text }+      , allowUnknownTypes = False+      , idField = Some "docId"+      , types =+        [ TypeRule::{+          , type = "Decision Record"+          , pathPattern = Some "decisions/*"+          , idPrefix = Some "ADR"+          }+        ]+      }+    : Profile
+ test/fixtures/profiles/postgresql.dhall view
@@ -0,0 +1,38 @@+-- The `: Profile` annotation here is load-bearing: it ties this fixture to the+-- canonical schema, so the `testLoadProfileFixture` round-trip in test/Main.hs+-- fails if okf's published Dhall schema and the Haskell decoder ever drift apart.+let Profile = ../../../dhall/Profile.dhall++in    { name = "shinzui-postgresql"+      , okfVersion = "0.1"+      , frontmatter =+        { required = [ "type", "title" ]+        , recommended = [ "description", "timestamp", "resource" ]+        }+      , allowUnknownTypes = False+      , idField = None Text+      , types =+        [ { type = "PostgreSQL Schema"+          , pathPattern = Some "schemas/*"+          , resourceScheme = Some "postgresql"+          , requireSchemaSection = False+          , schemaColumns = [] : List Text+          , idPrefix = None Text+          }+        , { type = "PostgreSQL Table"+          , pathPattern = Some "schemas/*/tables/*"+          , resourceScheme = Some "postgresql"+          , requireSchemaSection = True+          , schemaColumns = [ "Column", "Type", "Nullable", "Description" ]+          , idPrefix = None Text+          }+        , { type = "PostgreSQL View"+          , pathPattern = Some "schemas/*/views/*"+          , resourceScheme = Some "postgresql"+          , requireSchemaSection = True+          , schemaColumns = [ "Column", "Type", "Description" ]+          , idPrefix = None Text+          }+        ]+      }+    : Profile
+ test/fixtures/valid-bundle/datasets/index.md view
@@ -0,0 +1,3 @@+# Dataset++- [Sales Dataset](sales.md) - Daily sales export used by warehouse tables.
+ test/fixtures/valid-bundle/datasets/sales.md view
@@ -0,0 +1,11 @@+---+type: Dataset+title: Sales Dataset+description: Daily sales export used by warehouse tables.+timestamp: 2026-06-16T00:00:00Z+tags: [sales, source]+---++# Sales Dataset++Source data for the warehouse order tables.
+ test/fixtures/valid-bundle/index.md view
@@ -0,0 +1,5 @@+# OKF fixture bundle++- [datasets/](datasets/index.md)+- [references/](references/index.md)+- [tables/](tables/index.md)
+ test/fixtures/valid-bundle/log.md view
@@ -0,0 +1,4 @@+# Bundle Update Log++## 2026-06-16+* **Update**: Refreshed the valid bundle fixture.
+ test/fixtures/valid-bundle/references/index.md view
@@ -0,0 +1,3 @@+# Reference++- [Source System](source-system.md) - External system reference.
+ test/fixtures/valid-bundle/references/source-system.md view
@@ -0,0 +1,10 @@+---+type: Reference+title: Source System+description: External system reference.+timestamp: 2026-06-16T00:00:00Z+---++# Source System++Reference notes for upstream sales data.
+ test/fixtures/valid-bundle/tables/customers.md view
@@ -0,0 +1,11 @@+---+type: BigQuery Table+title: Customers+description: Customer dimension table.+timestamp: 2026-06-16T00:00:00Z+tags: [customers]+---++# Customers++Customer records used for order attribution.
+ test/fixtures/valid-bundle/tables/index.md view
@@ -0,0 +1,4 @@+# BigQuery Table++- [Customers](customers.md) - Customer dimension table.+- [Orders](orders.md) - Order fact table.
+ test/fixtures/valid-bundle/tables/orders.md view
@@ -0,0 +1,17 @@+---+type: BigQuery Table+title: Orders+description: Order fact table.+timestamp: 2026-06-16T00:00:00Z+resource: bigquery://analytics.tables.orders+tags: [orders, sales]+---++# Orders++Orders join to [Customers](/tables/customers.md), load from the+[Sales Dataset](../datasets/sales.md), and are documented in the+[source reference](../references/source-system.md).++External citations such as [vendor docs](https://example.com/vendor/orders.md)+are useful prose but should not become OKF graph edges.