packages feed

keiro-dsl-0.7.0.0: src/Keiro/Dsl/WorkspaceRecord.hs

-- | Versioned persistence for one successful __whole-workspace__ scaffold run.
--
-- A workspace record answers three questions a context-keyed
-- "Keiro.Dsl.ScaffoldRecord" cannot: which service produced this output tree,
-- which member files it was composed from, and __which member produced each
-- emitted module__. The last one is what makes moving an aggregate from one member
-- file to another an ownership move rather than a stale/new pair.
--
-- __Coexistence.__ Workspace history is keyed by the service name in a distinct
-- file-name slot, @keiro-dsl-scaffold-record.workspace.\<service\>.txt@, and never
-- by context. A context name is lexed as letters, digits, @_@ and @-@ and can
-- never contain a dot, so this slot provably cannot collide with a legacy
-- context-keyed name even when a service is named after its context. Legacy
-- records and a workspace record may therefore share one output directory: the
-- workspace path never writes a context-keyed name, and an older keiro-dsl binary
-- is structurally incapable of parsing — and therefore of clobbering — workspace
-- history. The one exception is the explicit adoption step, which /appends/ a
-- @superseded-by:@ line to a legacy record; the v1 parser ignores unknown lines,
-- so old binaries still read it.
--
-- The format is line-oriented like the v1 record, with a distinct header so no
-- reader can confuse the schemas:
--
-- @
-- keiro-dsl workspace scaffold record v1
-- service: demo-project
-- manifest: service.keiro-workspace
-- context: demo-project
-- module-root: Demo.Modules.Project
-- layout: collocated
-- member domain/project.keiro
-- module {"kind":"generated","path":"Demo/Project/Generated/StructuralProjections.hs"}
-- module {"kind":"generated","path":"Demo/Project/Project/Generated/Domain.hs","owner":"domain/project.keiro"}
-- mapping {…}
-- binding {…}
-- adopted {"path":"…","evidence":"record","source":"keiro-dsl-scaffold-record.demo-project.txt"}
-- @
--
-- @module@ rows are canonical single-line JSON, following the precedent set for
-- @mapping@ rows. An /absent/ @owner@ means the module is context-level: emitted
-- once for the whole merged graph (the structural projection facade, the
-- replay-audit assembly, or a binding skeleton shared by declarations from several
-- members). Unknown row kinds and unknown JSON keys are ignored so a later tool
-- version can extend the schema; paths that are absolute or contain @..@ are
-- rejected rather than joined to an output root.
module Keiro.Dsl.WorkspaceRecord
  ( WorkspaceRecord (..),
    WorkspaceModuleRow (..),
    WorkspaceSourceLanguageRow (..),
    AdoptedRow (..),
    renderWorkspaceRecord,
    parseWorkspaceRecord,
    workspaceRecordFileName,
    workspaceManifestFileName,
    workspaceMigrationReportFileName,
    supersededByLine,
  )
where

import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as BL
import Data.List (nub, sort)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as Text
import Keiro.Dsl.BehaviorCoverage (BehaviorRecordRow (..))
import Keiro.Dsl.ExplainBindings (BindingHole (..))
import Keiro.Dsl.LanguageVersion (SourceLanguage (..), declaredLanguageVersionMaybe, effectiveLanguageVersion, sourceFormText)
import Keiro.Dsl.MappedConsumer (MappingIdentity (..))
import Keiro.Dsl.Scaffold (ModuleKind (..))
import Keiro.Dsl.SemanticContract (EffectiveLanguageContract, effectiveLanguageContract)
import System.FilePath (isAbsolute, splitDirectories)

-- | One emitted module: what kind it is, where it landed relative to the output
-- directory, and which member file produced it ('Nothing' for context-level
-- modules emitted once from the merged graph).
data WorkspaceModuleRow = WorkspaceModuleRow
  { wrmKind :: !ModuleKind,
    wrmPath :: !FilePath,
    wrmOwner :: !(Maybe FilePath)
  }
  deriving stock (Eq, Show)

instance ToJSON WorkspaceModuleRow where
  toJSON row =
    object $
      [ "kind" .= (case wrmKind row of Generated -> "generated" :: Text; HoleStub -> "hole"),
        "path" .= T.pack (wrmPath row)
      ]
        <> ["owner" .= T.pack owner | Just owner <- [wrmOwner row]]

instance FromJSON WorkspaceModuleRow where
  parseJSON = withObject "WorkspaceModuleRow" $ \fields -> do
    kindLabel <- fields .: "kind"
    moduleKind <- case (kindLabel :: Text) of
      "generated" -> pure Generated
      "hole" -> pure HoleStub
      other -> fail ("unknown module kind: " <> T.unpack other)
    path <- fields .: "path"
    owner <- fields .:? "owner"
    pure
      WorkspaceModuleRow
        { wrmKind = moduleKind,
          wrmPath = T.unpack (path :: Text),
          wrmOwner = T.unpack <$> (owner :: Maybe Text)
        }

-- | One member's source-language provenance in a workspace record.
data WorkspaceSourceLanguageRow = WorkspaceSourceLanguageRow
  { wrslPath :: !FilePath,
    wrslSourceLanguage :: !SourceLanguage
  }
  deriving stock (Eq, Show)

instance ToJSON WorkspaceSourceLanguageRow where
  toJSON row =
    object
      [ "path" .= T.pack (wrslPath row),
        "sourceForm" .= sourceFormText sourceLanguage,
        "declaredLanguageVersion" .= declaredLanguageVersionMaybe sourceLanguage,
        "effectiveLanguageVersion" .= effectiveLanguageVersion sourceLanguage
      ]
    where
      sourceLanguage = wrslSourceLanguage row

instance FromJSON WorkspaceSourceLanguageRow where
  parseJSON value@(Aeson.Object fields) = do
    path <- fields .: "path"
    sourceLanguage <- parseJSON value
    pure
      WorkspaceSourceLanguageRow
        { wrslPath = T.unpack (path :: Text),
          wrslSourceLanguage = sourceLanguage
        }
  parseJSON _ = fail "WorkspaceSourceLanguageRow must be an object"

-- | One file imported into workspace history from pre-workspace scaffold
-- output. @adEvidence@ is @record@ when a legacy per-context scaffold record
-- listed the file, or @banner@ when the file sits at a planned Generated path and
-- carries the @-- \@generated@ banner but no surviving record lists it (the orphan
-- case created when one legacy record overwrote another).
data AdoptedRow = AdoptedRow
  { adPath :: !FilePath,
    adEvidence :: !Text,
    -- | The legacy record's file name, when the evidence is @record@.
    adSource :: !(Maybe Text),
    -- | The legacy record's @spec:@ field, when available.
    adSpec :: !(Maybe Text)
  }
  deriving stock (Eq, Show)

instance ToJSON AdoptedRow where
  toJSON row =
    object $
      ["path" .= T.pack (adPath row), "evidence" .= adEvidence row]
        <> ["source" .= source | Just source <- [adSource row]]
        <> ["spec" .= specPath | Just specPath <- [adSpec row]]

instance FromJSON AdoptedRow where
  parseJSON = withObject "AdoptedRow" $ \fields -> do
    path <- fields .: "path"
    evidence <- fields .: "evidence"
    source <- fields .:? "source"
    specPath <- fields .:? "spec"
    pure
      AdoptedRow
        { adPath = T.unpack (path :: Text),
          adEvidence = evidence,
          adSource = source,
          adSpec = specPath
        }

-- | Everything one successful whole-workspace scaffold produced.
data WorkspaceRecord = WorkspaceRecord
  { -- | The manifest's @service@ name: the workspace's durable identity.
    wrService :: !Text,
    -- | The manifest's __file name__, not a path. Members are relative to its
    --     directory, so the directory is wherever the manifest currently sits;
    --     recording only the name keeps the record independent of the invoking
    --     working directory, which is what makes byte-identical output provable.
    wrManifest :: !Text,
    wrContext :: !Text,
    wrModuleRoot :: !Text,
    wrLayout :: !Text,
    -- | Canonically ordered manifest-relative member paths.
    wrMembers :: ![FilePath],
    wrSourceLanguages :: ![WorkspaceSourceLanguageRow],
    wrLanguageContract :: !EffectiveLanguageContract,
    wrModules :: ![WorkspaceModuleRow],
    wrMappings :: ![MappingIdentity],
    wrIdDomains :: ![Text],
    wrNominalEqualities :: ![Text],
    wrBindingObligations :: ![BindingHole],
    wrBehaviorRequirements :: ![BehaviorRecordRow],
    wrAdopted :: ![AdoptedRow]
  }
  deriving stock (Eq, Show)

workspaceRecordHeader :: Text
workspaceRecordHeader = "keiro-dsl workspace scaffold record v1"

renderWorkspaceRecord :: WorkspaceRecord -> Text
renderWorkspaceRecord record =
  T.unlines $
    [ workspaceRecordHeader,
      "service: " <> wrService record,
      "manifest: " <> wrManifest record,
      "context: " <> wrContext record,
      "module-root: " <> rootLabel,
      "layout: " <> wrLayout record
    ]
      <> ["member " <> T.pack path | path <- wrMembers record]
      <> ["source-language " <> encodeRow row | row <- wrSourceLanguages record]
      <> ["semantic-contract " <> encodeRow (wrLanguageContract record)]
      <> ["module " <> encodeRow row | row <- wrModules record]
      <> [mappingRowPrefix mapping <> encodeRow mapping | mapping <- wrMappings record]
      <> ["id-domain " <> identity | identity <- wrIdDomains record]
      <> ["nominal-equality " <> identity | identity <- wrNominalEqualities record]
      <> ["binding " <> encodeRow obligation | obligation <- wrBindingObligations record]
      <> ["behavior " <> encodeRow requirement | requirement <- wrBehaviorRequirements record]
      <> ["adopted " <> encodeRow adopted | adopted <- wrAdopted record]
  where
    rootLabel = if T.null (wrModuleRoot record) then "(none)" else wrModuleRoot record

encodeRow :: (ToJSON a) => a -> Text
encodeRow = Text.decodeUtf8 . BL.toStrict . Aeson.encode

-- | Parse a workspace record. The header and the five @key: value@ fields must
-- each appear exactly once; unknown lines are ignored for forward compatibility;
-- unsafe paths are rejected rather than joined to an output root.
parseWorkspaceRecord :: Text -> Maybe WorkspaceRecord
parseWorkspaceRecord contents = case T.lines contents of
  header : rows
    | header == workspaceRecordHeader -> do
        service <- exactlyOne "service: " rows
        manifest <- exactlyOne "manifest: " rows
        context <- exactlyOne "context: " rows
        rootLabel <- exactlyOne "module-root: " rows
        layout <- exactlyOne "layout: " rows
        members <- traverse safePath [path | row <- rows, Just path <- [T.stripPrefix "member " row]]
        sourceLanguages <- parseSourceLanguages members rows
        languageContract <- parseLanguageContract sourceLanguages rows
        modules <- traverse (decodeRow "module ") (rowsWith "module " rows)
        checkedModules <- traverse checkedModule modules
        ordinaryMappings <- traverse (decodeRow "mapping ") (rowsWith "mapping " rows)
        nominalMappings <- traverse (decodeRow "nominal-mapping ") (rowsWith "nominal-mapping " rows)
        let mappings = ordinaryMappings <> nominalMappings
        let idDomains = [identity | row <- rows, Just identity <- [T.stripPrefix "id-domain " row]]
        let nominalEqualities = [identity | row <- rows, Just identity <- [T.stripPrefix "nominal-equality " row]]
        obligations <- traverse (decodeRow "binding ") (rowsWith "binding " rows)
        behaviorRequirements <- traverse (decodeRow "behavior ") (rowsWith "behavior " rows)
        adopted <- traverse (decodeRow "adopted ") (rowsWith "adopted " rows)
        checkedAdopted <- traverse checkedAdoption adopted
        if hasDuplicates members
          || hasDuplicates (map wrmPath checkedModules)
          || hasDuplicates (map mappingSpecName mappings)
          || hasDuplicates idDomains
          || hasDuplicates nominalEqualities
          || hasDuplicates (map bindingKey obligations)
          || hasDuplicates (map behaviorRecordKey behaviorRequirements)
          then Nothing
          else
            pure
              WorkspaceRecord
                { wrService = service,
                  wrManifest = manifest,
                  wrContext = context,
                  wrModuleRoot = if rootLabel == "(none)" then "" else rootLabel,
                  wrLayout = layout,
                  wrMembers = members,
                  wrSourceLanguages = sourceLanguages,
                  wrLanguageContract = languageContract,
                  wrModules = checkedModules,
                  wrMappings = mappings,
                  wrIdDomains = idDomains,
                  wrNominalEqualities = nominalEqualities,
                  wrBindingObligations = obligations,
                  wrBehaviorRequirements = behaviorRequirements,
                  wrAdopted = checkedAdopted
                }
  _ -> Nothing
  where
    exactlyOne prefix rows = case [value | row <- rows, Just value <- [T.stripPrefix prefix row]] of
      [value] -> Just value
      _ -> Nothing
    rowsWith prefix rows = [row | row <- rows, prefix `T.isPrefixOf` row]
    decodeRow prefix row = do
      payload <- T.stripPrefix prefix row
      Aeson.decodeStrict' (Text.encodeUtf8 payload)
    checkedModule row = do
      path <- safePath (T.pack (wrmPath row))
      owner <- traverse (safePath . T.pack) (wrmOwner row)
      pure row {wrmPath = path, wrmOwner = owner}
    checkedAdoption row = do
      path <- safePath (T.pack (adPath row))
      pure row {adPath = path}
    parseSourceLanguages members rows = case rowsWith "source-language " rows of
      [] -> Just [WorkspaceSourceLanguageRow path LegacyUnversioned | path <- members]
      sourceRows -> do
        decoded <- traverse (decodeRow "source-language ") sourceRows
        checked <- traverse checkedSourceLanguage decoded
        if hasDuplicates (map wrslPath checked) || sort (map wrslPath checked) /= sort members
          then Nothing
          else Just checked
    parseLanguageContract sourceLanguages rows = do
      let inferred = nub [effectiveLanguageContract (wrslSourceLanguage row) | row <- sourceLanguages]
      common <- case inferred of
        [contract] -> Just contract
        [] -> Just (effectiveLanguageContract LegacyUnversioned)
        _ -> Nothing
      case rowsWith "semantic-contract " rows of
        [] -> Just common
        [row] -> do
          contract <- decodeRow "semantic-contract " row
          if contract == common then Just contract else Nothing
        _ -> Nothing
    checkedSourceLanguage row = do
      path <- safePath (T.pack (wrslPath row))
      pure row {wrslPath = path}
    safePath raw =
      let path = T.unpack raw
       in if null path || isAbsolute path || ".." `elem` splitDirectories path
            then Nothing
            else Just path
    hasDuplicates :: (Eq a) => [a] -> Bool
    hasDuplicates values = length values /= length (nub values)
    bindingKey hole =
      ( holeMappedName hole,
        holeModule hole,
        holeSymbol hole,
        holeKind hole,
        holePath hole
      )

-- | @keiro-dsl-scaffold-record.workspace.\<service\>.txt@ — the workspace
-- history file. See the module header for why the @workspace.@ slot cannot
-- collide with a context-keyed name.
workspaceRecordFileName :: Text -> FilePath
workspaceRecordFileName service = "keiro-dsl-scaffold-record.workspace." <> T.unpack service <> ".txt"

-- | @keiro-dsl-manifest.workspace.\<service\>.txt@ — the Cabal build manifest.
workspaceManifestFileName :: Text -> FilePath
workspaceManifestFileName service = "keiro-dsl-manifest.workspace." <> T.unpack service <> ".txt"

-- | @keiro-dsl-migration-report.workspace.\<service\>.txt@ — the durable review
-- artifact written once, on the run that adopts pre-workspace scaffold output.
workspaceMigrationReportFileName :: Text -> FilePath
workspaceMigrationReportFileName service = "keiro-dsl-migration-report.workspace." <> T.unpack service <> ".txt"

-- | The single line adoption appends to a superseded legacy record. The v1
-- parser ignores unknown lines, so the legacy record keeps parsing for old
-- binaries and stays readable for humans; nothing is renamed or deleted.
supersededByLine :: Text -> Text
supersededByLine service = "superseded-by: " <> T.pack (workspaceRecordFileName service)

mappingRowPrefix :: MappingIdentity -> Text
mappingRowPrefix NominalMapping {} = "nominal-mapping "
mappingRowPrefix _ = "mapping "