seihou-core-0.4.0.0: test/Seihou/Integration/CompositionSpec.hs
module Seihou.Integration.CompositionSpec (tests) where
import Data.Either (isLeft)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Seihou.Composition.Graph (buildGraph, topoSort)
import Seihou.Composition.Instance (ModuleInstance (..), primaryInstance)
import Seihou.Composition.Plan (compileComposedPlan)
import Seihou.Composition.Resolve (loadComposition, resolveComposedVariables)
import Seihou.Core.Types
import System.FilePath ((</>))
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)
tests :: IO TestTree
tests = testSpec "Seihou.Integration.Composition" spec
fixtureDir :: FilePath
fixtureDir = "test/fixtures"
spec :: Spec
spec = do
describe "loadComposition" $ do
it "loads haskell-with-nix with all four modules" $ do
result <- loadComposition [fixtureDir] "haskell-with-nix" []
case result of
Left err -> expectationFailure $ "Expected Right, got: " ++ show err
Right modules -> do
let names = map (\(_, m, _) -> m.name) modules
length names `shouldBe` 4
-- All four modules should be present
elem "nix-base" names `shouldBe` True
elem "haskell-base" names `shouldBe` True
elem "nix-flake" names `shouldBe` True
elem "haskell-with-nix" names `shouldBe` True
it "orders dependencies before dependents" $ do
result <- loadComposition [fixtureDir] "haskell-with-nix" []
case result of
Left err -> expectationFailure $ "Expected Right, got: " ++ show err
Right modules -> do
let names = map (\(_, m, _) -> m.name) modules
indexOf n = case lookup n (zip names [0 :: Int ..]) of
Just i -> i
Nothing -> error $ "Module not found: " ++ show n
-- nix-base before nix-flake
indexOf "nix-base" `shouldSatisfy` (< indexOf "nix-flake")
-- haskell-base before haskell-with-nix
indexOf "haskell-base" `shouldSatisfy` (< indexOf "haskell-with-nix")
-- nix-flake before haskell-with-nix
indexOf "nix-flake" `shouldSatisfy` (< indexOf "haskell-with-nix")
-- haskell-with-nix should be last
indexOf "haskell-with-nix" `shouldBe` 3
it "loads a single module with no dependencies" $ do
result <- loadComposition [fixtureDir] "nix-base" []
case result of
Left err -> expectationFailure $ "Expected Right, got: " ++ show err
Right modules -> do
length modules `shouldBe` 1
case modules of
[(_, m, _)] -> m.name `shouldBe` "nix-base"
_ -> expectationFailure "Expected exactly one module"
it "handles additional modules via --module flag" $ do
result <- loadComposition [fixtureDir] "haskell-base" ["nix-base"]
case result of
Left err -> expectationFailure $ "Expected Right, got: " ++ show err
Right modules -> do
let names = map (\(_, m, _) -> m.name) modules
length names `shouldBe` 2
elem "haskell-base" names `shouldBe` True
elem "nix-base" names `shouldBe` True
it "returns error for nonexistent dependency" $ do
result <- loadComposition [fixtureDir] "haskell-with-nix" ["nonexistent"]
result `shouldSatisfy` isLeft
describe "resolveComposedVariables" $ do
it "flows nix.system from nix-base to nix-flake" $ do
result <- loadComposition [fixtureDir] "nix-flake" []
case result of
Left err -> expectationFailure $ "Load failed: " ++ show err
Right modules -> do
case resolveComposedVariables modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty of
Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
Right resolved -> do
let flakeVars = resolved Map.! primaryInstance "nix-flake"
-- nix-flake should see nix.system from nix-base's export
(.value) (flakeVars Map.! "nix.system") `shouldBe` VText "x86_64-linux"
-- nix-flake should also have its own variable
(.value) (flakeVars Map.! "nix.description") `shouldBe` VText "A Nix project"
it "flows exports through diamond dependency" $ do
result <- loadComposition [fixtureDir] "haskell-with-nix" []
case result of
Left err -> expectationFailure $ "Load failed: " ++ show err
Right modules -> do
let cliOverrides = Map.singleton "project.name" "my-app"
case resolveComposedVariables modules cliOverrides Map.empty "" "" Map.empty Map.empty Map.empty Map.empty of
Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
Right resolved -> do
-- haskell-base should have project.name from CLI
let baseVars = resolved Map.! primaryInstance "haskell-base"
(.value) (baseVars Map.! "project.name") `shouldBe` VText "my-app"
-- haskell-with-nix should inherit project.name via haskell-base's export
let topVars = resolved Map.! primaryInstance "haskell-with-nix"
Map.member "project.name" topVars `shouldBe` True
(.value) (topVars Map.! "project.name") `shouldBe` VText "my-app"
describe "compileComposedPlan" $ do
it "produces operations from all composed modules" $ do
result <- loadComposition [fixtureDir] "haskell-with-nix" []
case result of
Left err -> expectationFailure $ "Load failed: " ++ show err
Right modules -> do
let cliOverrides = Map.singleton "project.name" "my-app"
case resolveComposedVariables modules cliOverrides Map.empty "" "" Map.empty Map.empty Map.empty Map.empty of
Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
Right resolved -> do
let quads =
[ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
| (inst, m, dir) <- modules
]
planResult <- compileComposedPlan quads
case planResult of
Left errs -> expectationFailure $ "Plan failed: " ++ show errs
Right (ops, warnings, _) -> do
-- Should have operations for shell.nix, flake.nix, README.md,
-- src/Lib.hs, LICENSE, *.cabal, cabal.project, Makefile (+ dirs)
let writeOps = [d | WriteFileOp d _ _ <- ops]
elem "shell.nix" writeOps `shouldBe` True
elem "flake.nix" writeOps `shouldBe` True
elem "Makefile" writeOps `shouldBe` True
elem "README.md" writeOps `shouldBe` True
describe "text patching integration" $ do
it "haskell-shared-readme appends section to haskell-base README" $ do
result <- loadComposition [fixtureDir] "haskell-shared-readme" []
case result of
Left err -> expectationFailure $ "Load failed: " ++ show err
Right modules -> do
let cliOverrides = Map.singleton "project.name" "my-app"
case resolveComposedVariables modules cliOverrides Map.empty "" "" Map.empty Map.empty Map.empty Map.empty of
Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
Right resolved -> do
let quads =
[ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
| (inst, m, dir) <- modules
]
planResult <- compileComposedPlan quads
case planResult of
Left errs -> expectationFailure $ "Plan failed: " ++ show errs
Right (ops, warnings, _) -> do
-- Find the merged README.md
let readmeOps = [content | WriteFileOp dest content _ <- ops, dest == "README.md"]
length readmeOps `shouldBe` 1
let readmeContent = head readmeOps
-- Should contain the base content from haskell-base
T.isInfixOf "# my-app" readmeContent `shouldBe` True
-- Should contain the patched section from haskell-shared-readme
T.isInfixOf "Additional Section" readmeContent `shouldBe` True
T.isInfixOf "seihou:haskell-shared-readme" readmeContent `shouldBe` True
-- Should have a ContentMerged warning
any isContentMerged warnings `shouldBe` True
describe "structured merge integration" $ do
it "two modules contributing to same JSON get deep-merged" $ do
result <- loadComposition [fixtureDir] "structured-merge-b" []
case result of
Left err -> expectationFailure $ "Load failed: " ++ show err
Right modules -> do
case resolveComposedVariables modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty of
Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
Right resolved -> do
let quads =
[ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
| (inst, m, dir) <- modules
]
planResult <- compileComposedPlan quads
case planResult of
Left errs -> expectationFailure $ "Plan failed: " ++ show errs
Right (ops, warnings, _) -> do
-- Find the merged config.json
let configOps = [content | WriteFileOp dest content _ <- ops, dest == "config.json"]
length configOps `shouldBe` 1
let configContent = head configOps
-- Should contain keys from module A
T.isInfixOf "name" configContent `shouldBe` True
T.isInfixOf "my-project" configContent `shouldBe` True
-- Should contain keys from module B
T.isInfixOf "debug" configContent `shouldBe` True
T.isInfixOf "logLevel" configContent `shouldBe` True
-- Should have a ContentMerged warning
any isContentMerged warnings `shouldBe` True
describe "multi-instantiation diamond" $ do
-- These three fixtures (multi-instance-helper/leaf/diamond) model the
-- agent-seihou master-plan pattern: a parent depends on a leaf that
-- binds the helper one way, and also directly on the helper bound
-- the other way. Before ExecPlan 10, only one invocation survived.
it "loadComposition produces two helper instances with different bindings" $ do
result <- loadComposition [fixtureDir] "multi-instance-diamond" []
case result of
Left err -> expectationFailure $ "Load failed: " ++ show err
Right modules -> do
-- Expect: helper (skill.name=diamond), helper (skill.name=leaf),
-- leaf, diamond — four entries total.
length modules `shouldBe` 4
let helperInstances =
[ inst
| (inst, m, _) <- modules,
m.name == "multi-instance-helper"
]
length helperInstances `shouldBe` 2
let bindings =
Map.fromList
[ ("diamond" :: Text, True),
("leaf", True)
]
haveSkill vn =
any
( \inst ->
Map.lookup "skill.name" inst.instanceParentVars.unParentVars == Just vn
)
helperInstances
all haveSkill (Map.keys bindings) `shouldBe` True
it "compileComposedPlan produces two helper output files with distinct names" $ do
result <- loadComposition [fixtureDir] "multi-instance-diamond" []
case result of
Left err -> expectationFailure $ "Load failed: " ++ show err
Right modules -> do
case resolveComposedVariables modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty of
Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
Right resolved -> do
let quads =
[ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
| (inst, m, dir) <- modules
]
planResult <- compileComposedPlan quads
case planResult of
Left errs -> expectationFailure $ "Plan failed: " ++ show errs
Right (ops, _, _) -> do
let writeDests = [d | WriteFileOp d _ _ <- ops]
elem "out/leaf.txt" writeDests `shouldBe` True
elem "out/diamond.txt" writeDests `shouldBe` True
describe "cycle detection" $ do
it "detects a circular dependency" $ do
let mkMod name deps =
Module
{ name = name,
version = Nothing,
description = Nothing,
vars = [],
exports = [],
prompts = [],
steps = [],
commands = [],
dependencies = map simpleDep deps,
removal = Nothing,
migrations = []
}
a = mkMod "a" ["b"]
b = mkMod "b" ["c"]
c = mkMod "c" ["a"]
graph = buildGraph [(primaryInstance m.name, m) | m <- [a, b, c]]
topoSort graph `shouldSatisfy` isLeft
isContentMerged :: CompositionWarning -> Bool
isContentMerged (ContentMerged {}) = True
isContentMerged _ = False