packages feed

keiro-dsl-0.8.0.0: test/Keiro/Dsl/FrontendCompatibility.hs

{-# LANGUAGE ImportQualifiedPost #-}

-- | The executable compatibility oracle for the released keiro-dsl-0.7.0.0
-- language frontend. The checked JSON is deliberately data, not an update mode:
-- a behavior change must produce a reviewed manifest diff.
module Keiro.Dsl.FrontendCompatibility
  ( SourceExpectation (..),
    WorkspaceExpectation (..),
    CompatibilityManifest (..),
    diagnosticGoldens,
    frontendCompatibilitySpec,
    observeSource,
    observeWorkspace,
    readCompatibilityManifest,
    readRepoText,
    releasedFrontendEntryPoints,
    sourceFixturePaths,
    workspaceFixturePaths,
  )
where

import Control.Monad (filterM, forM, forM_)
import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.List (sort)
import Data.List.NonEmpty qualified as NE
import Data.Maybe (listToMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Keiro.Dsl.FrontendPublicApiProbe (apiProbe)
import Keiro.Dsl.LanguageVersion
import Keiro.Dsl.Parser (parseSource)
import Keiro.Dsl.PrettyPrint (renderSource)
import Keiro.Dsl.SemanticContract (checkedSource)
import Keiro.Dsl.Validate (Diagnostic (..), Severity (..), renderDiagnostic, validateService)
import Keiro.Dsl.Workspace
import Numeric.Natural (Natural)
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, takeExtension, (</>))
import Test.Hspec
import Text.Read (readMaybe)

data SourceExpectation = SourceExpectation
  { sourcePath :: !FilePath,
    sourceForm :: !Text,
    sourceDeclaredVersion :: !(Maybe Natural),
    sourceEffectiveVersion :: !(Maybe Natural),
    sourceResult :: !Text,
    sourceFailureClass :: !(Maybe Text),
    sourceDiagnosticCode :: !(Maybe Text)
  }
  deriving stock (Eq, Show)

instance FromJSON SourceExpectation where
  parseJSON = withObject "SourceExpectation" $ \fields ->
    SourceExpectation
      <$> fields .: "path"
      <*> fields .: "sourceForm"
      <*> fields .:? "declaredVersion"
      <*> fields .:? "effectiveVersion"
      <*> fields .: "result"
      <*> fields .:? "failureClass"
      <*> fields .:? "diagnosticCode"

instance ToJSON SourceExpectation where
  toJSON row =
    object
      [ "path" .= sourcePath row,
        "sourceForm" .= sourceForm row,
        "declaredVersion" .= sourceDeclaredVersion row,
        "effectiveVersion" .= sourceEffectiveVersion row,
        "result" .= sourceResult row,
        "failureClass" .= sourceFailureClass row,
        "diagnosticCode" .= sourceDiagnosticCode row
      ]

data WorkspaceExpectation = WorkspaceExpectation
  { workspacePath :: !FilePath,
    workspaceResult :: !Text,
    workspaceFailureClass :: !(Maybe Text),
    workspaceDiagnosticCode :: !(Maybe Text)
  }
  deriving stock (Eq, Show)

instance FromJSON WorkspaceExpectation where
  parseJSON = withObject "WorkspaceExpectation" $ \fields ->
    WorkspaceExpectation
      <$> fields .: "path"
      <*> fields .: "result"
      <*> fields .:? "failureClass"
      <*> fields .:? "diagnosticCode"

instance ToJSON WorkspaceExpectation where
  toJSON row =
    object
      [ "path" .= workspacePath row,
        "result" .= workspaceResult row,
        "failureClass" .= workspaceFailureClass row,
        "diagnosticCode" .= workspaceDiagnosticCode row
      ]

data CompatibilityManifest = CompatibilityManifest
  { manifestSchema :: !Text,
    manifestRelease :: !Text,
    manifestEntryPoints :: ![Text],
    manifestSources :: ![SourceExpectation],
    manifestWorkspaces :: ![WorkspaceExpectation]
  }
  deriving stock (Eq, Show)

instance FromJSON CompatibilityManifest where
  parseJSON = withObject "CompatibilityManifest" $ \fields ->
    CompatibilityManifest
      <$> fields .: "schema"
      <*> fields .: "release"
      <*> fields .: "entryPoints"
      <*> fields .: "sources"
      <*> fields .: "workspaces"

instance ToJSON CompatibilityManifest where
  toJSON manifest =
    object
      [ "schema" .= manifestSchema manifest,
        "release" .= manifestRelease manifest,
        "entryPoints" .= manifestEntryPoints manifest,
        "sources" .= manifestSources manifest,
        "workspaces" .= manifestWorkspaces manifest
      ]

frontendCompatibilitySpec :: SpecWith ()
frontendCompatibilitySpec = describe "frontend 0.7 compatibility" $ do
  it "decodes the released manifest and classifies every checked-in frontend fixture" $ do
    manifest <- readManifest
    manifestSchema manifest `shouldBe` "keiro-dsl/frontend-compatibility/1"
    manifestRelease manifest `shouldBe` "0.7.0.0"
    manifestEntryPoints manifest `shouldBe` releasedFrontendEntryPoints
    sources <- sourceFixturePaths
    workspaces <- workspaceFixturePaths
    map sourcePath (manifestSources manifest) `shouldBe` sources
    map workspacePath (manifestWorkspaces manifest) `shouldBe` workspaces

  it "preserves every source outcome, released contract, and accepted canonical round trip" $ do
    manifest <- readManifest
    forM_ (manifestSources manifest) $ \expected -> do
      actual <- observeSource (sourcePath expected)
      actual `shouldBe` expected
      whenAccepted expected $ do
        source <- readRepoText (sourcePath expected)
        case parseSource (sourcePath expected) source of
          Left failure -> expectationFailure (show failure)
          Right parsed -> case parseSource (sourcePath expected) (renderSource parsed) of
            Left failure -> expectationFailure (show failure)
            Right reparsed -> do
              parsedSpec reparsed `shouldBe` parsedSpec parsed
              effectiveLanguageVersion (parsedSourceLanguage reparsed)
                `shouldBe` effectiveLanguageVersion (parsedSourceLanguage parsed)
              sourceFormText (parsedSourceLanguage reparsed)
                `shouldBe` sourceFormText (parsedSourceLanguage parsed)

  it "preserves every workspace composition outcome and member-attribution code" $ do
    manifest <- readManifest
    forM_ (manifestWorkspaces manifest) $ \expected ->
      observeWorkspace (workspacePath expected) `shouldReturn` expected

  it "keeps direct parsing and representative one-member workspaces semantically identical" $ do
    let examples =
          [ ("legacy.keiro", "context parity\n"),
            ("v1.keiro", "language keiro-dsl 1\ncontext parity\n"),
            ("v2.keiro", "language keiro-dsl 2\ncontext parity\n"),
            ("v3.keiro", "language keiro-dsl 3\ncontext parity\n")
          ]
    forM_ examples $ \(path, source) -> case parseSource path source of
      Left failure -> expectationFailure (show failure)
      Right parsed -> do
        let workspace = oneMemberParsedWorkspace path parsed
        wsMergedSpec workspace `shouldBe` parsedSpec parsed
        checkWorkspace workspace `shouldBe` []

  it "renders curated source, grammar, semantic, and workspace failures byte-for-byte" $
    forM_ diagnosticGoldens $ \(name, renderActual) -> do
      expected <- readRepoText (diagnosticRoot </> name)
      actual <- renderActual
      actual `shouldBe` expected

  it "keeps the released parser, renderer, and representative selector signatures compiling" $
    apiProbe `shouldBe` ()

whenAccepted :: SourceExpectation -> IO () -> IO ()
whenAccepted row action
  | sourceResult row == "accept" = action
  | otherwise = pure ()

observeSource :: FilePath -> IO SourceExpectation
observeSource path = do
  source <- readRepoText path
  let (headerForm, headerVersion) = sourceHeader source
      base result failureClass diagnostic effective =
        SourceExpectation
          { sourcePath = path,
            sourceForm = headerForm,
            sourceDeclaredVersion = headerVersion,
            sourceEffectiveVersion = effective,
            sourceResult = result,
            sourceFailureClass = failureClass,
            sourceDiagnosticCode = diagnostic
          }
  pure $ case parseSource path source of
    Left (SourceLanguageFailure diagnostic) ->
      base
        "reject"
        (Just "source-language")
        (Just (sourceLanguageErrorCodeText (sourceLanguageErrorCode diagnostic)))
        (languageVersionNumber <$> sourceLanguageDeclaredVersion diagnostic)
    Left (BodyGrammarFailure _) ->
      base "reject" (Just "body-grammar") Nothing (supportedHeaderVersion headerVersion)
    Right parsed ->
      let sourceLanguage = parsedSourceLanguage parsed
          effective = Just (languageVersionNumber (effectiveLanguageVersion sourceLanguage))
          errors = filter ((== Error) . severity) (validateService (checkedSource parsed))
       in case errors of
            diagnostic : _ -> base "reject" (Just "semantic") (Just (T.pack (show (code diagnostic)))) effective
            [] -> base "accept" Nothing Nothing effective

observeWorkspace :: FilePath -> IO WorkspaceExpectation
observeWorkspace path = do
  resolved <- resolveRepoPath path
  loaded <- loadWorkspace (fileContentSource (takeDirectory resolved)) path
  pure $ case loaded of
    Left (WorkspaceManifestUnreadable _) -> rejected "manifest-unreadable" Nothing
    Left (WorkspaceManifestUnparseable _) -> rejected "manifest-grammar" Nothing
    Left (WorkspaceRefused diagnostics) ->
      rejected "composition" (Just (T.pack (show (wdCode (NE.head diagnostics)))))
    Right workspace -> case filter ((== Error) . wdSeverity) (checkWorkspace workspace) of
      diagnostic : _ -> rejected "semantic" (Just (T.pack (show (wdCode diagnostic))))
      [] -> WorkspaceExpectation path "accept" Nothing Nothing
  where
    rejected failureClass diagnostic = WorkspaceExpectation path "reject" (Just failureClass) diagnostic

sourceHeader :: Text -> (Text, Maybe Natural)
sourceHeader source = case firstSignificantLine source of
  Just lineText
    | Just token <- T.stripPrefix "language keiro-dsl " lineText ->
        ("declared", readMaybe (T.unpack (T.strip token)))
  _ -> ("legacy-unversioned", Nothing)

firstSignificantLine :: Text -> Maybe Text
firstSignificantLine =
  listToMaybe
    . filter (\lineText -> not (T.null lineText) && not ("#" `T.isPrefixOf` lineText))
    . map T.strip
    . T.lines

supportedHeaderVersion :: Maybe Natural -> Maybe Natural
supportedHeaderVersion raw = do
  number <- raw
  version <- languageVersion number
  _ <- lookupLanguageDefinition version
  pure number

readManifest :: IO CompatibilityManifest
readManifest = do
  path <- resolveRepoPath manifestPath
  decoded <- Aeson.eitherDecodeFileStrict' path
  case decoded of
    Left problem -> expectationFailure problem >> fail "invalid frontend compatibility manifest"
    Right manifest -> pure manifest

readCompatibilityManifest :: IO CompatibilityManifest
readCompatibilityManifest = readManifest

sourceFixturePaths :: IO [FilePath]
sourceFixturePaths = fixturePaths ".keiro"

workspaceFixturePaths :: IO [FilePath]
workspaceFixturePaths = fixturePaths ".keiro-workspace"

fixturePaths :: String -> IO [FilePath]
fixturePaths extension = do
  fixtureRoot <- resolveRepoDirectory "keiro-dsl/test/fixtures"
  relative <- walk fixtureRoot ""
  pure . sort $ ["keiro-dsl/test/fixtures" </> path | path <- relative, takeExtension path == extension]
  where
    walk root relative = do
      entries <- sort <$> listDirectory (root </> relative)
      fmap concat . forM entries $ \entry -> do
        let child = if null relative then entry else relative </> entry
        isDirectory <- doesDirectoryExist (root </> child)
        if isDirectory then walk root child else pure [child]

readRepoText :: FilePath -> IO Text
readRepoText path = resolveRepoPath path >>= TIO.readFile

resolveRepoPath :: FilePath -> IO FilePath
resolveRepoPath path = do
  override <- lookupEnv "KEIRO_DSL_TEST_ROOT"
  let packageRelative = maybe path id (T.unpack <$> T.stripPrefix "keiro-dsl/" (T.pack path))
      candidates = [path, packageRelative] <> maybe [] (\root -> [root </> path, root </> packageRelative]) override
  existing <- filterM doesFileExist candidates
  case existing of
    candidate : _ -> pure candidate
    [] -> fail ("unable to locate repository file " <> show path <> "; tried " <> show candidates)

resolveRepoDirectory :: FilePath -> IO FilePath
resolveRepoDirectory path = do
  override <- lookupEnv "KEIRO_DSL_TEST_ROOT"
  let packageRelative = maybe path id (T.unpack <$> T.stripPrefix "keiro-dsl/" (T.pack path))
      candidates = [path, packageRelative] <> maybe [] (\root -> [root </> path, root </> packageRelative]) override
  existing <- filterM doesDirectoryExist candidates
  case existing of
    candidate : _ -> pure candidate
    [] -> fail ("unable to locate repository directory " <> show path <> "; tried " <> show candidates)

manifestPath :: FilePath
manifestPath = "keiro-dsl/test/frontend-0.7/manifest.json"

diagnosticRoot :: FilePath
diagnosticRoot = "keiro-dsl/test/frontend-0.7/diagnostics"

releasedFrontendEntryPoints :: [Text]
releasedFrontendEntryPoints =
  [ "library.parseSource",
    "library.parseSpec",
    "library.parseSpecText",
    "workspace.parseWorkspaceManifest",
    "workspace.loadWorkspace.members",
    "cli.parse.single",
    "cli.parse.workspace-manifest",
    "cli.check.single",
    "cli.check.workspace",
    "cli.inspect.single",
    "cli.inspect.workspace",
    "cli.behavior-obligations.single",
    "cli.behavior-obligations.workspace",
    "cli.scaffold.single",
    "cli.scaffold.workspace",
    "cli.diff.single-working-tree-and-git-baseline",
    "cli.diff.workspace-working-tree-and-git-baseline"
  ]

diagnosticGoldens :: [(FilePath, IO Text)]
diagnosticGoldens =
  [ ("invalid-preamble.txt", renderParse "invalid-preamble.keiro" "language keiro-dsl nope\ncontext source-fixture\n"),
    ("misplaced-preamble.txt", renderParse "misplaced-preamble.keiro" "context source-fixture\nlanguage keiro-dsl 1\n"),
    ("duplicate-preamble.txt", renderParse "duplicate-preamble.keiro" "language keiro-dsl 1\nlanguage keiro-dsl 1\ncontext source-fixture\n"),
    ("feature-nominal-binding-v1.txt", renderParse "feature-nominal-binding-v1.keiro" "context feature\nid OrderId prefix=ord using {}\n"),
    ("feature-integer-v1.txt", renderParse "feature-integer-v1.keiro" integerFeatureSource),
    ("feature-typed-expression-v1.txt", renderParse "feature-typed-expression-v1.keiro" typedExpressionFeatureSource),
    ("feature-explicit-implementation-v1.txt", renderParse "feature-explicit-implementation-v1.keiro" explicitImplementationFeatureSource),
    ("escaped-string.txt", renderParse "escaped-string.keiro" escapedStringSource),
    ("numeric-overflow.txt", renderParse "numeric-overflow.keiro" numericOverflowSource),
    ("duplicate-clause.txt", renderParse "duplicate-clause.keiro" duplicateClauseSource),
    ("expression-error.txt", renderParse "expression-error.keiro" expressionErrorSource),
    ("semantic-error.txt", renderSemantic "semantic-error.keiro" semanticErrorSource),
    ("workspace-member-parse-failure.txt", renderWorkspaceMemberFailure)
  ]

renderParse :: FilePath -> Text -> IO Text
renderParse path source = pure $ case parseSource path source of
  Left failure -> T.stripEnd (renderParseFailure failure) <> "\n"
  Right _ -> error ("diagnostic source unexpectedly parsed: " <> path)

renderSemantic :: FilePath -> Text -> IO Text
renderSemantic path source = pure $ case parseSource path source of
  Left failure -> error ("semantic diagnostic source failed to parse: " <> show failure)
  Right parsed -> case filter ((== Error) . severity) (validateService (checkedSource parsed)) of
    diagnostic : _ -> renderDiagnostic path diagnostic <> "\n"
    [] -> error ("semantic diagnostic source unexpectedly validated: " <> path)

renderWorkspaceMemberFailure :: IO Text
renderWorkspaceMemberFailure = do
  let path = "keiro-dsl/test/fixtures/workspace-member-parse-failed/service.keiro-workspace"
  resolved <- resolveRepoPath path
  loaded <- loadWorkspace (fileContentSource (takeDirectory resolved)) path
  pure $ case loaded of
    Left failure -> T.stripEnd (T.unlines (renderWorkspaceFailure path failure)) <> "\n"
    Right _ -> error "workspace-member-parse-failed unexpectedly composed"

integerFeatureSource :: Text
integerFeatureSource =
  T.unlines
    [ "context feature",
      "aggregate Counter",
      "  regs",
      "    value Integer = 0",
      "  states Open"
    ]

typedExpressionFeatureSource :: Text
typedExpressionFeatureSource =
  T.unlines
    [ "context feature",
      "aggregate Counter",
      "  regs",
      "    value Int = 0",
      "  states Open",
      "  command Tick { amount:Int }",
      "  Open -- Tick -->",
      "    guard reg.value == cmd.amount",
      "    goto Open"
    ]

explicitImplementationFeatureSource :: Text
explicitImplementationFeatureSource =
  T.unlines
    [ "context feature",
      "aggregate Counter",
      "  regs",
      "  states Open",
      "  command Tick { }",
      "  Open -- Tick -->",
      "    implementation hole",
      "    goto Open"
    ]

escapedStringSource :: Text
escapedStringSource =
  "context svc\n\ncontract c {\n  schemaVersion 1\n  discriminator kind\n  topic events \"bad\\q\"\n}\n"

numericOverflowSource :: Text
numericOverflowSource =
  T.unlines
    [ "context svc",
      "aggregate Thing",
      "  regs",
      "  states Open",
      "  event Changed v18446744073709551617 { }"
    ]

duplicateClauseSource :: Text
duplicateClauseSource =
  T.unlines
    [ "context svc",
      "aggregate Thing",
      "  regs",
      "  states Open Closed",
      "  command Move { }",
      "  Open -- Move -->",
      "    goto Closed",
      "    goto Open"
    ]

expressionErrorSource :: Text
expressionErrorSource =
  T.unlines
    [ "language keiro-dsl 2",
      "context svc",
      "aggregate Thing",
      "  regs",
      "    value Int = 0",
      "  states Open",
      "  command Move { amount:Int }",
      "  Open -- Move -->",
      "    guard cmd.amount +",
      "    goto Open"
    ]

semanticErrorSource :: Text
semanticErrorSource =
  T.unlines
    [ "context svc",
      "aggregate Thing",
      "  regs",
      "  states Open",
      "  Open -- MissingCommand -->",
      "    goto Open"
    ]