packages feed

asyncapi3-1.0.0.0: test/SpecConformanceSpec.hs

-- | Repository-level conformance enforcement:
--
--  1. *Doc anchors* — every Haddock spec link in @src/**@ must reference an
--     anchor that actually exists in the vendored spec.
--  2. *Example coverage* — every yaml/json fenced block in the spec must be
--     either tester-annotated (covered by golden tests), in the manual list
--     below, or on the skip list with a reason. The skip list may only
--     contain blocks that are genuinely non-modelable (prose illustrations).
module SpecConformanceSpec (spec) where

import Data.ByteString qualified as BS
import Data.Char (isSpace)
import Data.List (isPrefixOf, isSuffixOf)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath ((</>))
import Test.Hspec
import TestSupport

spec :: Spec
spec = do
  describe "doc anchors" docAnchorSpec
  describe "example coverage" exampleCoverageSpec

-- | Collect all Haskell sources under @src/@ (project-relative paths).
haskellSources :: IO [FilePath]
haskellSources = do
  root <- projectRoot
  let go d = do
        entries <- listDirectory (root </> d)
        concat
          <$> mapM
            ( \e -> do
                let rel = d </> e
                isDir <- doesDirectoryExist (root </> rel)
                if isDir
                  then go rel
                  else pure [rel | ".hs" `isSuffixOf` e]
            )
            entries
  go "src"
  where

-- | All anchors defined in the vendored spec (@<a name="...">@).
specAnchors :: IO (Set.Set Text)
specAnchors = do
  md <- loadSpecMarkdown
  pure . Set.fromList $ concatMap anchorsInLine (T.lines md)
  where
    anchorsInLine l =
      case T.breakOn ("<a name=\"" :: Text) l of
        (_, rest)
          | T.null rest -> []
          | otherwise ->
              let afterAnchor = T.drop (T.length ("<a name=\"" :: Text)) rest
               in [T.takeWhile (/= '"') afterAnchor]

-- | All anchors referenced from Haskell sources via
-- @docs\/reference\/specification\/v3.1.0#<anchor>@.
referencedAnchors :: IO (Set.Set Text)
referencedAnchors = do
  root <- projectRoot
  sources <- haskellSources
  contents <- mapM (fmap (T.unpack . TE.decodeUtf8) . bsRead . (root </>)) sources
  pure . Set.fromList . concatMap refsIn $ contents
  where
    prefix = "docs/reference/specification/v3.1.0#"
    refsIn s = go s
      where
        go [] = []
        go cs
          | prefix `isPrefixOf` cs =
              let rest = drop (length prefix) cs
                  a = takeWhile (`notElem` ("() \t\r\n\"<" :: String)) rest
                  a' = reverse (dropWhile isSpace (reverse a))
               in (if null a' then [] else [T.pack a']) ++ go rest
          | otherwise = go (drop 1 cs)

docAnchorSpec :: Spec
docAnchorSpec =
  it "every referenced spec anchor exists in the vendored spec" $ do
    anchors <- specAnchors
    refs <- referencedAnchors
    let missing = Set.difference refs anchors
    missing `shouldBe` Set.empty

-- | Unannotated yaml/json blocks modeled by targeted tests outside the
-- golden machinery, as @(line, name, pointer)@ rows. The four Identifier
-- Object examples (lines 234, 240, 244, 250) are checked by
-- AsyncAPISpec.identifierExamples.
manualCoverage :: [(Int, Text, Text)]
manualCoverage =
  [ (234, "Identifier Object json urn", "/id"),
    (240, "Identifier Object yaml urn", "/id"),
    (244, "Identifier Object json https", "/id"),
    (250, "Identifier Object yaml https", "/id")
  ]

-- | Unannotated yaml/json blocks that cannot be modeled as data-model
-- golden tests, with reasons. Must shrink to genuinely non-modelable blocks.
skipCoverage :: [(Int, Text)]
skipCoverage =
  [ (21, "prose illustration of a channels object (redacted/commented)"),
    (38, "prose illustration of an operations object (inline-commented narrative)"),
    (50, "prose illustration of channel referencing a message (inline-commented narrative)"),
    (162, "pseudo-syntax fragment of Schema Object fields"),
    (2713, "traits merge mechanism: before-merge prose illustration"),
    (2724, "traits merge mechanism: after-merge prose illustration")
  ]

exampleCoverageSpec :: Spec
exampleCoverageSpec =
  it "partitions all yaml/json fenced blocks into annotated / manual / skip" $ do
    md <- loadSpecMarkdown
    let blocks = allFencedBlocks md
        modelable b = blockFormat b `elem` ["yaml", "json"]
        annotatedLines = map blockLine (annotatedExamples md)
        unannotated =
          [ (blockLine b, b)
          | b <- blocks,
            modelable b,
            blockLine b `notElem` annotatedLines
          ]
        manualLines = map (\(n, _, _) -> n) manualCoverage
        skipLines = map fst skipCoverage
        uncovered =
          [ (n, T.take 60 (T.unwords (T.words (blockBody b))))
          | (n, b) <- unannotated,
            n `notElem` manualLines,
            n `notElem` skipLines
          ]
    -- sanity: every annotation in the file produced exactly one block
    annotationCount <- pure (length (filter (T.isPrefixOf "<!-- asyncapi-example-tester:" . T.strip) (T.lines md)))
    length annotatedLines `shouldBe` annotationCount
    uncovered `shouldBe` []

bsRead :: FilePath -> IO BS.ByteString
bsRead = BS.readFile