packages feed

seihou-cli-0.7.0.0: src/Seihou/CLI/MigrationCohort.hs

-- | Resolving the set of blueprints one @seihou agent migrate@ run needs.
--
-- A blueprint migration edge may declare that crossing it entails crossing an
-- exact edge of another blueprint — that is how a breaking change reaches
-- consumers who depend on the library that absorbed it rather than on the
-- library that shipped it. The set of blueprints reached that way is a
-- /cohort/. It is not an artifact and is recorded nowhere: it is recomputed
-- from declarations on every run, per
-- docs\/adr\/0004-the-manifest-is-the-only-record-of-applied-state.md.
--
-- This module is the filesystem half of that. It resolves blueprints by name
-- through the same search paths the command uses for the blueprint the user
-- typed, validates each one, and classifies each into a portable
-- 'ArtifactOrigin' so its receipts can be keyed by identity rather than by
-- name. The pure half — turning declarations into an ordered list of steps —
-- is 'Seihou.Core.Migration.expandEntailedEdges', which takes what this module
-- found as a lookup function.
module Seihou.CLI.MigrationCohort
  ( CohortBlueprint (..),
    CohortResolutionError (..),
    resolveCohortBlueprint,
    resolveMigrationCohort,
  )
where

import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Seihou.Core.ArtifactOriginDetect (detectArtifactOrigin)
import Seihou.Core.Blueprint (validateBlueprint)
import Seihou.Core.Migration
  ( BlueprintMigration (..),
    BlueprintMigrationStep (..),
    EntailedEdge (..),
  )
import Seihou.Core.Module (discoverRunnable)
import Seihou.Core.Types
  ( ArtifactOrigin,
    Blueprint (..),
    ModuleLoadError (..),
    ModuleName (..),
    Runnable (..),
  )
import Seihou.Prelude

-- | One blueprint this run needs, with everything the run needs to know about
-- it: its declarations, where it lives on disk (so its @files\/@ directory can
-- be mounted for its own steps), and the portable identity its receipts are
-- keyed by.
data CohortBlueprint = CohortBlueprint
  { blueprint :: !Blueprint,
    blueprintDir :: !FilePath,
    origin :: !ArtifactOrigin
  }
  deriving stock (Generic)

-- | Why a named blueprint could not be turned into a 'CohortBlueprint'.
--
-- 'CohortArtifactMissing' is deliberately separate from the other two. A
-- blueprint that is simply not installed is reported by the /expander/, which
-- knows which edge named it and can therefore say what to install and why;
-- resolution just hands the absence back. The other two are genuine problems
-- with an artifact that /is/ present, and stop the command wherever they are
-- found.
data CohortResolutionError
  = -- | The name resolved to something other than a blueprint. Carries the
    -- name and the kind word for the message ("module", "recipe", "prompt").
    CohortArtifactWrongKind !ModuleName !Text
  | -- | Nothing of that name is installed. Carries the name and the
    -- directories searched.
    CohortArtifactMissing !ModuleName ![FilePath]
  | -- | Found, but it could not be evaluated, decoded, or validated.
    CohortArtifactUnusable !ModuleLoadError
  deriving stock (Eq, Show, Generic)

-- | Resolve one blueprint by name: discover it, refuse a same-named artifact
-- of another kind, validate it, and classify its directory into an origin.
--
-- @projectRoot@ is the working directory the command was invoked in; it is
-- what makes a local origin's recorded path relative to the project rather
-- than to the machine, per
-- docs\/adr\/0001-manifest-is-a-checked-in-machine-independent-artifact.md.
resolveCohortBlueprint ::
  -- | project root, for classifying a local blueprint's path
  FilePath ->
  -- | module search paths
  [FilePath] ->
  ModuleName ->
  IO (Either CohortResolutionError CohortBlueprint)
resolveCohortBlueprint projectRoot searchPaths requestedName = do
  runnableResult <- discoverRunnable searchPaths requestedName
  case runnableResult of
    Left (ModuleNotFound name searched) -> pure (Left (CohortArtifactMissing name searched))
    Left err -> pure (Left (CohortArtifactUnusable err))
    Right (RunnableModule _ _) -> pure (Left (CohortArtifactWrongKind requestedName "module"))
    Right (RunnableRecipe _ _) -> pure (Left (CohortArtifactWrongKind requestedName "recipe"))
    Right (RunnableAgentPrompt _ _) -> pure (Left (CohortArtifactWrongKind requestedName "prompt"))
    Right (RunnableBlueprint discovered dir) -> do
      validationResult <- validateBlueprint dir discovered
      case validationResult of
        Left err -> pure (Left (CohortArtifactUnusable err))
        Right validated -> do
          detected <- detectArtifactOrigin projectRoot dir
          pure $
            Right
              CohortBlueprint
                { blueprint = validated,
                  blueprintDir = dir,
                  origin = detected
                }

-- | Load every blueprint reachable by entailment from a planned window, on top
-- of the blueprints already loaded.
--
-- The walk follows /exact edges/ rather than whole blueprints: a reference
-- names one edge, so only that edge's own @entails@ list is followed onward.
-- Loading everything a newly discovered blueprint mentions anywhere would make
-- an unrelated, uninstalled member of some other chain fail a run that never
-- needed it.
--
-- Termination does not depend on the declarations being acyclic. Every
-- reference is expanded at most once, tracked by its @(blueprint, from, to)@
-- triple, so a cycle here simply stops; /reporting/ the cycle is
-- 'Seihou.Core.Migration.expandEntailedEdges''s job, which has the ordering
-- context to name it.
--
-- A blueprint that is not installed is left out of the returned map rather
-- than raised here, so the expander can report it against the edge that named
-- it.
resolveMigrationCohort ::
  -- | project root, for classifying a local blueprint's path
  FilePath ->
  -- | module search paths
  [FilePath] ->
  -- | blueprints already loaded, keyed by name (at least the invoked one)
  Map Text CohortBlueprint ->
  -- | the window-selected steps whose entailments to follow
  [BlueprintMigrationStep] ->
  IO (Either CohortResolutionError (Map Text CohortBlueprint))
resolveMigrationCohort projectRoot searchPaths initial steps =
  walk initial Set.empty (concatMap (\step -> step ^. #edge . #entails) steps)
  where
    walk loaded _seen [] = pure (Right loaded)
    walk loaded seen (reference : rest)
      | referenceKey reference `Set.member` seen = walk loaded seen rest
      | otherwise =
          case Map.lookup (reference ^. #blueprint) loaded of
            Just already -> walk loaded seen' (rest <> onward already reference)
            Nothing -> do
              result <-
                resolveCohortBlueprint
                  projectRoot
                  searchPaths
                  (ModuleName (reference ^. #blueprint))
              case result of
                Left (CohortArtifactMissing _ _) -> walk loaded seen' rest
                Left err -> pure (Left err)
                Right resolved ->
                  walk
                    (Map.insert (reference ^. #blueprint) resolved loaded)
                    seen'
                    (rest <> onward resolved reference)
      where
        seen' = Set.insert (referenceKey reference) seen

    -- What the named edge itself entails. An empty result also covers the
    -- case where the blueprint declares no such edge at all, which the
    -- expander reports against the edge that named it.
    onward resolved reference =
      concat
        [ declared ^. #entails
        | declared <- resolved ^. #blueprint . #migrations,
          declared ^. #from == reference ^. #from,
          declared ^. #to == reference ^. #to
        ]

    referenceKey reference =
      (reference ^. #blueprint, reference ^. #from, reference ^. #to)