packages feed

synapse-cc 0.1.0.0 → 0.2.0

raw patch · 17 files changed

+4299/−553 lines, 17 filesdep +aeson-prettydep +base16-bytestringdep +fsnotifydep ~text

Dependencies added: aeson-pretty, base16-bytestring, fsnotify, mtl, plexus-protocol, plexus-synapse, stm, transformers, unordered-containers

Dependency ranges changed: text

Files

app/Main.hs view
@@ -1,16 +1,19 @@ module Main where -import Data.Text (Text)+import Control.Monad (when, forM_) import qualified Data.Text as T import qualified Data.Text.IO as TIO import System.Exit (exitFailure, exitSuccess)-import System.IO (stdout, stderr, hSetEncoding, utf8)+import System.IO (stdout, stderr, hSetEncoding, hSetBuffering, utf8, BufferMode(..))  import SynapseCC.CLI+import SynapseCC.Config (initSynapseConfig, loadSynapseConfig, synapseConfigPath)+import SynapseCC.Detect (ProjectHint(..), defaultDetectors) import SynapseCC.Discover import SynapseCC.Logging import SynapseCC.Pipeline import SynapseCC.Types+import SynapseCC.Watch (runWatch)  -- ============================================================================ -- Main Entry Point@@ -21,45 +24,117 @@   -- Set UTF-8 encoding for stdout/stderr to handle Unicode characters   hSetEncoding stdout utf8   hSetEncoding stderr utf8+  hSetBuffering stdout LineBuffering+  hSetBuffering stderr LineBuffering -  -- Parse command-line arguments-  config <- parseArgs+  cmd <- parseCommand -  let debug = optDebug (cfgOptions config)+  case cmd of+    -- ------------------------------------------------------------------+    -- synapse-cc init+    -- ------------------------------------------------------------------+    CmdInit -> do+      result <- initSynapseConfig synapseConfigPath defaultDetectors+      case result of+        Left err -> do+          TIO.putStrLn $ "[!] " <> err+          exitFailure+        Right hint -> do+          logSuccess $ "Created " <> T.pack synapseConfigPath+          -- If a detector gave a reason, surface it so the user knows+          -- why the generated config has a particular transport.+          case phReason hint of+            Just reason -> logInfo reason+            Nothing     -> pure ()+          TIO.putStrLn "Edit it to configure your targets, then run:"+          TIO.putStrLn "  synapse-cc build"+          exitSuccess -  -- Print version-  when debug $ TIO.putStrLn versionInfo+    -- ------------------------------------------------------------------+    -- synapse-cc build <target> <backend> [opts]+    -- ------------------------------------------------------------------+    CmdBuild config -> do+      let debug = optDebug (cfgOptions config)+      when debug $ TIO.putStrLn versionInfo -  -- Discover tools-  logStep "Discovering tools..."-  toolsResult <- discoverTools debug-  case toolsResult of-    Left err -> do-      TIO.putStrLn $ formatError err-      exitFailure+      logStep "Discovering tools..."+      toolsResult <- discoverTools (cfgOptions config)+      case toolsResult of+        Left err -> do+          TIO.putStrLn $ formatError err+          exitFailure -    Right tools -> do-      logSuccess "Found all required tools"+        Right tools -> do+          logSuccess "Found all required tools"+          logStep $ "Connecting to " <> backendName (cfgBackend config)+                 <> " at ws://" <> cfgHost config <> ":" <> cfgPort config <> "..." -      -- Connect to backend-      logStep $ "Connecting to " <> backendName (cfgBackend config) <> " via registry at " <> cfgHost config <> ":" <> cfgPort config <> "..."+          pipelineResult <- runPipeline config tools+          case pipelineResult of+            Left err -> do+              TIO.putStrLn $ formatError err+              exitFailure -      -- Run the pipeline-      pipelineResult <- runPipeline config tools-      case pipelineResult of+            Right (CompiledPath outputPath) -> do+              TIO.putStrLn ""+              logSuccess $ "Client → " <> T.pack outputPath <> "/"+              exitSuccess++    -- ------------------------------------------------------------------+    -- synapse-cc build   (reads synapse.config.json, all targets)+    -- ------------------------------------------------------------------+    CmdBuildFromConfig opts -> do+      let debug = optDebug opts+      when debug $ TIO.putStrLn versionInfo++      cfgResult <- loadSynapseConfig+      case cfgResult of         Left err -> do-          TIO.putStrLn $ formatError err+          TIO.putStrLn $ "[!] " <> err+          TIO.putStrLn "Hint: run 'synapse-cc init' to create synapse.config.json"+          TIO.putStrLn "      or provide target/backend: synapse-cc build typescript substrate"           exitFailure -        Right (CompiledPath outputPath) -> do-          TIO.putStrLn ""-          logSuccess $ "Client ready at " <> T.pack outputPath-          exitSuccess+        Right sc -> do+          logStep "Discovering tools..."+          toolsResult <- discoverTools opts+          case toolsResult of+            Left err -> do+              TIO.putStrLn $ formatError err+              exitFailure --- ============================================================================--- Helpers--- ============================================================================+            Right tools -> do+              logSuccess "Found all required tools"+              results <- runBuildFromConfig sc opts tools+              forM_ results $ \(targetName, result) ->+                case result of+                  Left err ->+                    -- formatError includes its own "[!] Error: " prefix — strip it+                    -- to avoid "[!] target: [!] Error: ..." doubling+                    let msg = T.strip (formatError err)+                        bare = maybe msg T.strip (T.stripPrefix "[!] Error:" msg+                                               >>= Just . T.stripStart)+                    in logError $ targetName <> ": " <> bare+                  Right (CompiledPath outPath) ->+                    logSuccess $ targetName <> " → " <> T.pack outPath <> "/"+              if all (either (const False) (const True) . snd) results+                then exitSuccess+                else exitFailure -when :: Applicative f => Bool -> f () -> f ()-when True action = action-when False _ = pure ()+    -- ------------------------------------------------------------------+    -- synapse-cc watch <backend> [plugins...] [--target name]+    -- ------------------------------------------------------------------+    CmdWatch watchArgs -> do+      let debug = optDebug (waOptions watchArgs)+      when debug $ TIO.putStrLn versionInfo++      logStep "Discovering tools..."+      toolsResult <- discoverTools (waOptions watchArgs)+      case toolsResult of+        Left err -> do+          TIO.putStrLn $ formatError err+          exitFailure++        Right tools -> do+          logSuccess "Found all required tools"+          runWatch watchArgs tools
+ src/SynapseCC/Benchmark.hs view
@@ -0,0 +1,208 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DerivingStrategies #-}++-- | Pipeline step benchmarking with regression detection.+--+-- After the first successful run, step timings are written to a JSON baseline+-- file inside the cache directory. Every subsequent run compares against that+-- baseline and emits a warning for any step that regresses by more than the+-- configured threshold.+module SynapseCC.Benchmark+  ( -- * Timing+    timeStep++    -- * Baseline management+  , loadBaseline+  , saveBaseline+  , baselinePath++    -- * Regression reporting+  , reportBenchmarks++    -- * Types+  , Baseline(..)+  , StepTiming(..)+  ) where++import Data.Aeson (FromJSON, ToJSON)+import qualified Data.Aeson as Aeson+import qualified Data.ByteString.Lazy as BL+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import Data.Time.Clock+import GHC.Generics (Generic)+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.FilePath ((</>), takeDirectory)++-- ============================================================================+-- Types+-- ============================================================================++data StepTiming = StepTiming+  { stName :: !Text+  , stMs   :: !Int+  } deriving stock (Show, Eq, Generic)+    deriving anyclass (FromJSON, ToJSON)++data Baseline = Baseline+  { blVersion    :: !Text+  , blBackend    :: !Text+  , blTarget     :: !Text+  , blRecordedAt :: !Text+  , blSteps      :: !(Map Text Int)  -- step name -> baseline ms+  , blTotalMs    :: !Int+  } deriving stock (Show, Eq, Generic)+    deriving anyclass (FromJSON, ToJSON)++-- ============================================================================+-- Timing+-- ============================================================================++-- | Run an action and return its result paired with elapsed milliseconds.+timeStep :: IO a -> IO (a, Int)+timeStep action = do+  t0 <- getCurrentTime+  result <- action+  t1 <- getCurrentTime+  let ms = round (diffUTCTime t1 t0 * 1000) :: Int+  pure (result, ms)++-- ============================================================================+-- Baseline I/O+-- ============================================================================++-- | Canonical path for the baseline file.+baselinePath :: FilePath  -- ^ cache dir (optCacheDir)+             -> Text      -- ^ target  (e.g. "typescript")+             -> Text      -- ^ backend (e.g. "substrate")+             -> FilePath+baselinePath cacheDir target backend =+  cacheDir </> "benchmarks" </> T.unpack target </> T.unpack backend <> ".json"++-- | Load a baseline from disk.  Returns Nothing if the file doesn't exist or+-- can't be parsed.+loadBaseline :: FilePath -> IO (Maybe Baseline)+loadBaseline path = do+  exists <- doesFileExist path+  if not exists+    then pure Nothing+    else do+      bytes <- BL.readFile path+      case Aeson.eitherDecode bytes of+        Left  _  -> pure Nothing   -- corrupted — treat as missing+        Right bl -> pure (Just bl)++-- | Write a baseline to disk, creating parent directories as needed.+saveBaseline :: FilePath -> Baseline -> IO ()+saveBaseline path bl = do+  createDirectoryIfMissing True (takeDirectory path)+  BL.writeFile path (Aeson.encode bl)++-- ============================================================================+-- Regression detection+-- ============================================================================++-- | Regression threshold: warn when a step is this many percent slower than+-- baseline AND at least this many milliseconds slower (avoids noise on fast+-- steps).+regressionPct :: Int+regressionPct = 25++regressionMinMs :: Int+regressionMinMs = 200++-- | Compare a run's step timings against a baseline.+-- Returns (regressions, improvements) as lists of human-readable lines.+checkRegressions :: Map Text Int  -- ^ baseline step times+                 -> Map Text Int  -- ^ actual step times+                 -> ([Text], [Text])+checkRegressions baseline actual =+  Map.foldlWithKey' classify ([], []) actual+  where+    classify (regs, imps) step ms =+      case Map.lookup step baseline of+        Nothing -> (regs, imps)  -- new step, no baseline yet+        Just baseMs ->+          let delta    = ms - baseMs+              pct      = (delta * 100) `div` max 1 baseMs+              label    = padStep step <> T.pack (show ms) <> "ms"+                       <> " (baseline " <> T.pack (show baseMs) <> "ms"+                       <> ", " <> showDelta delta <> ")"+          in if delta > regressionMinMs && pct > regressionPct+               then (label : regs, imps)+               else if delta < negate regressionMinMs+                      then (regs, label : imps)+                      else (regs, imps)++    showDelta d+      | d > 0    = "+" <> T.pack (show d) <> "ms"+      | otherwise = T.pack (show d) <> "ms"++    padStep s = s <> T.replicate (max 1 (24 - T.length s)) " "++-- ============================================================================+-- Public reporting+-- ============================================================================++-- | Called at the end of a successful pipeline run.+--+-- Behaviour:+--  * If no baseline exists: save current timings as the new baseline and+--    print a "baseline recorded" notice.+--  * If a baseline exists: compare, print a timing table, warn on regressions,+--    and update the baseline for any step that improved.+reportBenchmarks+  :: FilePath       -- ^ baseline file path+  -> Text           -- ^ backend name+  -> Text           -- ^ target name+  -> Text           -- ^ ISO-8601 timestamp+  -> Map Text Int   -- ^ step timings (ms)+  -> Int            -- ^ total pipeline ms+  -> IO ()+reportBenchmarks path backend target ts steps totalMs = do+  mbBaseline <- loadBaseline path++  case mbBaseline of+    Nothing -> do+      -- First run — record baseline+      let bl = Baseline+            { blVersion    = "1"+            , blBackend    = backend+            , blTarget     = target+            , blRecordedAt = ts+            , blSteps      = steps+            , blTotalMs    = totalMs+            }+      saveBaseline path bl+      putStrLn $ "\n[bench] Baseline recorded (" <> show totalMs <> "ms total)"+      putStrLn   "[bench] Future runs will compare against this baseline."++    Just bl -> do+      let (regs, imps) = checkRegressions (blSteps bl) steps++      -- Always print the timing table+      putStrLn "\n[bench] Step timings:"+      mapM_ (\(k, v) -> putStrLn $ "  " <> T.unpack (padStep k) <> show v <> "ms") (Map.toAscList steps)+      putStrLn $ "  " <> T.unpack (padStep "total") <> show totalMs <> "ms"+      putStrLn $ "  baseline total: " <> show (blTotalMs bl) <> "ms"++      -- Report regressions+      if null regs+        then putStrLn "[bench] No regressions."+        else do+          putStrLn "\n[bench] ⚠  REGRESSIONS DETECTED:"+          mapM_ (\r -> putStrLn $ "  " <> T.unpack r) regs++      -- Report improvements+      mapM_ (\i -> putStrLn $ "[bench] ✓ faster: " <> T.unpack i) imps++      -- Update baseline: keep the minimum (best) time per step, update total+      -- if this run was faster overall.+      let mergedSteps = Map.unionWith min steps (blSteps bl)+          newTotal    = min totalMs (blTotalMs bl)+          bl'         = bl { blSteps = mergedSteps, blTotalMs = newTotal, blRecordedAt = ts }+      saveBaseline path bl'+  where+    padStep s = s <> T.replicate (max 1 (20 - T.length s)) " "
src/SynapseCC/CLI.hs view
@@ -1,29 +1,38 @@ -- | Command-line interface parsing module SynapseCC.CLI-  ( parseArgs+  ( -- * Legacy build-only parser (for tests)+    parseArgs+  , synapseCCParserInfo++    -- * Command parser (with subcommands)+  , parseCommand+  , synapseCCCommandParserInfo++    -- * Version   , versionInfo   ) where  import Data.Text (Text) import qualified Data.Text as T import Options.Applicative-import System.FilePath ((</>))  import SynapseCC.Types  -- ============================================================================--- CLI Parser+-- Legacy Build Parser (backward-compatible, used by tests) -- ============================================================================ --- | Parse command-line arguments into Config+-- | Full parser info (usable with execParserPure for in-process testing)+synapseCCParserInfo :: ParserInfo Config+synapseCCParserInfo = info (configParser <**> helper <**> simpleVersioner (T.unpack versionInfo))+  ( fullDesc+ <> progDesc "Unified compiler toolchain for Plexus backends"+ <> header "synapse-cc - from schema to compiled client in one command"+  )++-- | Parse command-line arguments into Config (legacy build-only) parseArgs :: IO Config-parseArgs = execParser opts-  where-    opts = info (configParser <**> helper)-      ( fullDesc-     <> progDesc "Unified compiler toolchain for Plexus backends"-     <> header "synapse-cc - from schema to compiled client in one command"-      )+parseArgs = execParser synapseCCParserInfo  -- | Main config parser configParser :: Parser Config@@ -34,6 +43,98 @@   <*> portParser   <*> optionsParser +-- ============================================================================+-- Command Parser (with subcommands: build, watch, init)+-- ============================================================================++-- | Full command parser info with subcommands+synapseCCCommandParserInfo :: ParserInfo Command+synapseCCCommandParserInfo = info (commandParser <**> helper <**> simpleVersioner (T.unpack versionInfo))+  ( fullDesc+ <> progDesc "Unified compiler toolchain for Plexus backends"+ <> header "synapse-cc - from schema to compiled client in one command"+  )++-- | Parse a top-level Command+parseCommand :: IO Command+parseCommand = execParser synapseCCCommandParserInfo++commandParser :: Parser Command+commandParser = subparser+  ( command "build" (info buildCommandParser+      (progDesc "Build client from backend schema (default)"))+ <> command "watch" (info watchCommandParser+      (progDesc "Watch backend for changes and incrementally rebuild"))+ <> command "init"  (info initCommandParser+      (progDesc "Scaffold a synapse.config.json in the current directory"))+  )++buildCommandParser :: Parser Command+buildCommandParser = mkCmd+  <$> optional targetParser+  <*> optional backendParser+  <*> hostParser+  <*> portParser+  <*> optionsParser+  where+    mkCmd (Just t) (Just b) host port opts = CmdBuild (Config t b host port opts)+    mkCmd _        _        _    _    opts = CmdBuildFromConfig opts++watchCommandParser :: Parser Command+watchCommandParser = fmap CmdWatch $ WatchArgs+  <$> (T.pack <$> argument str+        ( metavar "BACKEND"+       <> help "Backend identifier (substrate, plexus, etc.)"+        ))+  <*> many (T.pack <$> argument str+        ( metavar "PLUGIN..."+       <> help "Plugin prefix filters (e.g. echo health). Empty = all plugins."+        ))+  <*> optional (T.pack <$> option str+        ( long "target"+       <> short 't'+       <> metavar "NAME"+       <> help "Pin to a single named target from synapse.config.json"+        ))+  <*> optional (option auto+        ( long "interval"+       <> short 'i'+       <> metavar "MS"+       <> help "Poll interval in milliseconds (overrides synapse.config.json)"+        ))+  <*> watchOptionsParser++initCommandParser :: Parser Command+initCommandParser = pure CmdInit++-- | Options relevant to watch mode.+-- host/port come from synapse.config.json (not CLI flags for watch).+watchOptionsParser :: Parser Options+watchOptionsParser = (\cacheDir debug synapse hub ->+  defaultOptions+    { optInstallDeps    = False+    , optBuild          = False+    , optRunTests       = False+    , optCacheDir       = cacheDir+    , optDebug          = debug+    , optSynapsePath    = synapse+    , optHubCodegenPath = hub+    })+  <$> option str+      ( long "cache-dir"+     <> metavar "DIR"+     <> value (optCacheDir defaultOptions)+     <> showDefault+     <> help "Cache directory"+      )+  <*> switch (long "debug" <> help "Enable debug logging")+  <*> optional (option str (long "synapse"     <> metavar "PATH" <> help "synapse binary path"))+  <*> optional (option str (long "hub-codegen" <> metavar "PATH" <> help "hub-codegen binary path"))++-- ============================================================================+-- Shared Parsers+-- ============================================================================+ -- | Parse target language targetParser :: Parser Target targetParser = argument readTarget@@ -79,6 +180,11 @@  <> help "Registry/discovery port"   ) +parseTransport :: String -> Either String TransportType+parseTransport "ws"      = Right WsTransport+parseTransport "browser" = Right BrowserTransport+parseTransport s         = Left $ "Unknown transport: " <> s+ -- | Parse options optionsParser :: Parser Options optionsParser = Options@@ -90,12 +196,11 @@      <> showDefault      <> help "Output directory"       )-  <*> option auto-      ( long "bundle-transport"-     <> metavar "BOOL"-     <> value (optBundleTransport defaultOptions)-     <> showDefault-     <> help "Bundle transport code (true/false)"+  <*> option (eitherReader parseTransport)+      ( long "transport"+     <> metavar "ws|browser"+     <> value WsTransport+     <> help "Transport: ws (Node.js, default) or browser (native WebSocket, for Tauri/WebView)"       )   <*> flag True False       ( long "no-install"@@ -121,13 +226,19 @@      <> help "Force regeneration (ignore cache)"       )   <*> switch-      ( long "watch"-     <> help "Watch backend and regenerate on changes"-      )-  <*> switch       ( long "debug"      <> help "Enable debug logging"       )+  <*> optional (option str+      ( long "synapse"+     <> metavar "PATH"+     <> help "Path to synapse binary (overrides discovery)"+      ))+  <*> optional (option str+      ( long "hub-codegen"+     <> metavar "PATH"+     <> help "Path to hub-codegen binary (overrides discovery)"+      ))  -- ============================================================================ -- Version Info@@ -135,4 +246,4 @@  -- | Version information versionInfo :: Text-versionInfo = "synapse-cc v0.1.0"+versionInfo = "synapse-cc v" <> synapseCCVersion
src/SynapseCC/Cache.hs view
@@ -11,6 +11,9 @@   , getCacheDir   , clearCache +    -- * Shared utilities+  , expandTilde+     -- * V2 Validation (internal, exposed for testing)   , validatePluginCaches   , validatePluginCache@@ -18,7 +21,7 @@  import Control.Exception (catch, SomeException) import Control.Monad (when, forM_)-import Data.Aeson (encode, eitherDecodeFileStrict)+import Data.Aeson (FromJSON, encode, eitherDecodeFileStrict) import qualified Data.ByteString.Lazy as BL import Data.List (isPrefixOf) import Data.Map.Strict (Map)@@ -32,6 +35,7 @@ import System.Directory (createDirectoryIfMissing, doesFileExist, removeDirectoryRecursive, getHomeDirectory) import System.FilePath ((</>)) +import SynapseCC.Logging (logDebug) import SynapseCC.Types import qualified SynapseCC.Dependency as Dep @@ -76,43 +80,36 @@ getCodeCacheDir :: Options -> Backend -> Target -> IO FilePath getCodeCacheDir opts backend target = do   baseDir <- getCacheDir opts-  pure $ baseDir </> "hub-codegen" </> targetName target </> T.unpack (backendName backend)-  where-    targetName TypeScript = "typescript"-    targetName Python = "python"-    targetName Rust = "rust"+  pure $ baseDir </> "synapse-cc" </> "code" </> T.unpack (targetToText target) </> T.unpack (backendName backend)  -- ============================================================================ -- Cache Reading -- ============================================================================ --- | Read IR cache manifest-readIRCacheManifest :: Options -> Backend -> IO (Either SynapseCCError IRCacheManifest)-readIRCacheManifest opts backend = do-  cacheDir <- getIRCacheDir opts backend+-- | Generic manifest reader: look for manifest.json in cacheDir and decode it.+readManifest :: FromJSON a => FilePath -> String -> IO (Either SynapseCCError a)+readManifest cacheDir label = do   let manifestPath = cacheDir </> "manifest.json"   exists <- doesFileExist manifestPath   if not exists-    then pure $ Left $ CacheError "IR cache manifest not found"+    then pure $ Left $ CacheError $ T.pack label <> " manifest not found"     else do       result <- eitherDecodeFileStrict manifestPath       case result of-        Left err -> pure $ Left $ CacheError $ "Failed to parse IR cache manifest: " <> T.pack err+        Left err     -> pure $ Left $ CacheError $ "Failed to parse " <> T.pack label <> " manifest: " <> T.pack err         Right manifest -> pure $ Right manifest +-- | Read IR cache manifest+readIRCacheManifest :: Options -> Backend -> IO (Either SynapseCCError IRCacheManifest)+readIRCacheManifest opts backend = do+  cacheDir <- getIRCacheDir opts backend+  readManifest cacheDir "IR cache"+ -- | Read code cache manifest readCodeCacheManifest :: Options -> Backend -> Target -> IO (Either SynapseCCError CodeCacheManifest) readCodeCacheManifest opts backend target = do   cacheDir <- getCodeCacheDir opts backend target-  let manifestPath = cacheDir </> "manifest.json"-  exists <- doesFileExist manifestPath-  if not exists-    then pure $ Left $ CacheError "Code cache manifest not found"-    else do-      result <- eitherDecodeFileStrict manifestPath-      case result of-        Left err -> pure $ Left $ CacheError $ "Failed to parse code cache manifest: " <> T.pack err-        Right manifest -> pure $ Right manifest+  readManifest cacheDir "code cache"  -- ============================================================================ -- Cache Validation@@ -120,40 +117,42 @@  -- | Validate cache with version-aware checking -- Returns CacheResult indicating what needs regeneration-validateCache :: Config -> IO CacheResult-validateCache config = do+validateCache :: Config -> ToolLocations -> IO CacheResult+validateCache config tools = do   let opts = cfgOptions config       backend = cfgBackend config       target = cfgTarget config       debug = optDebug opts+      synapseVer    = toolSynapseVersion tools+      hubCodegenVer = toolHubCodegenVersion tools    -- If --force flag is set, skip cache entirely   if optForce opts     then do-      when debug $ putStrLn "[!] --force flag set, skipping cache"+      logDebug debug "--force flag set, skipping cache"       pure $ CacheMiss ManifestNotFound     else do       -- Try to read IR cache manifest       irManifestResult <- readIRCacheManifest opts backend       case irManifestResult of         Left err -> do-          when debug $ putStrLn $ "[!] IR cache miss: " ++ T.unpack (formatError err)+          logDebug debug $ "IR cache miss: " <> formatError err           pure $ CacheMiss ManifestNotFound          Right irManifest -> do           -- Check tool versions against IR cache           let irToolchain = ircmToolchain irManifest           if tvSynapseCC irToolchain /= synapseCCVersion ||-             tvSynapse irToolchain /= "0.2.0.0"  -- TODO: Get from synapse somehow+             tvSynapse irToolchain /= synapseVer             then do-              when debug $ putStrLn "[!] Tool versions changed, invalidating IR cache"+              logDebug debug "Tool versions changed, invalidating IR cache"               pure $ CacheMiss ToolVersionChanged             else do               -- IR cache valid, check code cache               codeManifestResult <- readCodeCacheManifest opts backend target               case codeManifestResult of                 Left err -> do-                  when debug $ putStrLn $ "[!] Code cache miss: " ++ T.unpack (formatError err)+                  logDebug debug $ "Code cache miss: " <> formatError err                   -- IR is cached, but code is not                   pure $ CacheMiss ManifestNotFound @@ -161,14 +160,14 @@                   -- Check tool versions against code cache                   let codeToolchain = ccmToolchain codeManifest                   if tvSynapseCC codeToolchain /= synapseCCVersion ||-                     tvSynapse codeToolchain /= "0.2.0.0" ||  -- TODO: Get from synapse-                     isJust (tvHubCodegen codeToolchain) && tvHubCodegen codeToolchain /= Just "0.1.0"  -- TODO: Get from hub-codegen+                     tvSynapse codeToolchain /= synapseVer ||+                     isJust (tvHubCodegen codeToolchain) && tvHubCodegen codeToolchain /= Just hubCodegenVer                     then do-                      when debug $ putStrLn "[!] Tool versions changed, invalidating code cache"+                      logDebug debug "Tool versions changed, invalidating code cache"                       pure $ CacheMiss ToolVersionChanged                     else do                       -- V2: Check plugin hashes for granular invalidation-                      when debug $ putStrLn "[*] Checking plugin hashes for granular invalidation..."+                      logDebug debug "Checking plugin hashes for granular invalidation..."                       validatePluginCaches irManifest codeManifest debug  -- ============================================================================@@ -180,44 +179,54 @@ validatePluginCaches irManifest codeManifest debug = do   let irPlugins = ircmPlugins irManifest       codePlugins = ccmPlugins codeManifest-      allPluginNames = Set.toList $ Set.union (Map.keysSet irPlugins) (Map.keysSet codePlugins) -  -- Check each plugin for direct invalidation (schema/IR hash changes)-  results <- mapM (validatePluginCache irManifest codeManifest debug) allPluginNames+  -- Monolithic code cache: a single "default" entry means the last run was a+  -- full-output pass with no per-plugin breakdown.  Tool versions already match+  -- (checked before we get here), so treat this as a full cache hit.+  -- Output-directory existence is verified by runPipeline's FullCacheHit handler.+  if Map.size codePlugins == 1 && Map.member "default" codePlugins+    then do+      logDebug debug $ "Full cache hit (" <> T.pack (show (Map.size irPlugins)) <> " plugins)"+      pure FullCacheHit+    else do+      let allPluginNames = Set.toList $ Set.union (Map.keysSet irPlugins) (Map.keysSet codePlugins) -  -- Collect directly invalid plugins-  let directlyInvalidPlugins = Set.fromList [name | (name, False) <- results]-      directlyValidPlugins = [name | (name, True) <- results]+      -- Check each plugin for direct invalidation (schema/IR hash changes)+      results <- mapM (validatePluginCache irManifest codeManifest debug) allPluginNames -  -- Build dependency graph from IR cache-  let depGraph = Map.map ipcDependencies irPlugins+      -- Collect directly invalid plugins+      let directlyInvalidPlugins = Set.fromList [name | (name, False) <- results]+          directlyValidPlugins = [name | (name, True) <- results] -  -- Find transitively invalid plugins using dependency resolution-  let allInvalidPlugins = Dep.findInvalidPlugins directlyInvalidPlugins depGraph-      invalidPluginsList = Set.toList allInvalidPlugins-      validPluginsList = filter (`Set.notMember` allInvalidPlugins) directlyValidPlugins+      -- Build dependency graph from IR cache+      let depGraph = Map.map ipcDependencies irPlugins -  -- Report transitive invalidation-  when debug $ do-    let transitivelyInvalid = Set.difference allInvalidPlugins directlyInvalidPlugins-    when (not $ Set.null transitivelyInvalid) $ do-      putStrLn "[!] Transitive invalidation due to dependencies:"-      forM_ (Set.toList transitivelyInvalid) $ \name ->-        putStrLn $ "    - " ++ T.unpack name ++ " (depends on invalid plugin)"+      -- Find transitively invalid plugins using dependency resolution+      let allInvalidPlugins = Dep.findInvalidPlugins directlyInvalidPlugins depGraph+          invalidPluginsList = Set.toList allInvalidPlugins+          validPluginsList = filter (`Set.notMember` allInvalidPlugins) directlyValidPlugins -  if null invalidPluginsList-    then do-      when debug $ putStrLn $ "[+] Full cache hit - all " ++ show (length validPluginsList) ++ " plugins valid"-      pure FullCacheHit-    else do+      -- Report transitive invalidation       when debug $ do-        putStrLn $ "[!] Partial cache hit:"-        putStrLn $ "    Valid: " ++ show (length validPluginsList) ++ " plugins"-        putStrLn $ "    Invalid: " ++ show (length invalidPluginsList) ++ " plugins (including transitive)"-        forM_ invalidPluginsList $ \name ->-          putStrLn $ "      - " ++ T.unpack name-      pure $ PartialCacheHit validPluginsList invalidPluginsList+        let transitivelyInvalid = Set.difference allInvalidPlugins directlyInvalidPlugins+        when (not $ Set.null transitivelyInvalid) $ do+          logDebug debug "Transitive invalidation due to dependencies:"+          forM_ (Set.toList transitivelyInvalid) $ \name ->+            logDebug debug $ "    - " <> name <> " (depends on invalid plugin)" +      if null invalidPluginsList+        then do+          logDebug debug $ "Full cache hit - all " <> T.pack (show (length validPluginsList)) <> " plugins valid"+          pure FullCacheHit+        else do+          when debug $ do+            logDebug debug "Partial cache hit:"+            logDebug debug $ "    Valid: " <> T.pack (show (length validPluginsList)) <> " plugins"+            logDebug debug $ "    Invalid: " <> T.pack (show (length invalidPluginsList)) <> " plugins (including transitive)"+            forM_ invalidPluginsList $ \name ->+              logDebug debug $ "      - " <> name+          pure $ PartialCacheHit validPluginsList invalidPluginsList+ -- | Validate a single plugin's cache -- Returns (plugin name, is valid) validatePluginCache :: IRCacheManifest -> CodeCacheManifest -> Bool -> Text -> IO (Text, Bool)@@ -228,17 +237,17 @@   case (Map.lookup pluginName irPlugins, Map.lookup pluginName codePlugins) of     (Nothing, Nothing) -> do       -- Plugin not in either cache - invalid-      when debug $ putStrLn $ "[!] Plugin '" ++ T.unpack pluginName ++ "' not found in cache"+      logDebug debug $ "Plugin '" <> pluginName <> "' not found in cache"       pure (pluginName, False)      (Just _irCache, Nothing) -> do       -- IR cached but code not generated - invalid-      when debug $ putStrLn $ "[!] Plugin '" ++ T.unpack pluginName ++ "' has IR cache but no code cache"+      logDebug debug $ "Plugin '" <> pluginName <> "' has IR cache but no code cache"       pure (pluginName, False)      (Nothing, Just _codeCache) -> do       -- Code exists but no IR cache - invalid (shouldn't happen)-      when debug $ putStrLn $ "[!] Plugin '" ++ T.unpack pluginName ++ "' has code cache but no IR cache"+      logDebug debug $ "Plugin '" <> pluginName <> "' has code cache but no IR cache"       pure (pluginName, False)      (Just irCache, Just codeCache) -> do@@ -272,19 +281,17 @@ -- | Report cache hit with V2 hash details reportCacheHit :: Text -> Text -> Text -> Bool -> IO () reportCacheHit pluginName selfHash childrenHash debug = do-  when debug $ do-    putStrLn $ "[+] Cache hit: '" ++ T.unpack pluginName ++ "'"-    when (not $ T.null selfHash) $-      putStrLn $ "    self_hash:     " ++ T.unpack (T.take 8 selfHash) ++ "..."-    when (not $ T.null childrenHash) $-      putStrLn $ "    children_hash: " ++ T.unpack (T.take 8 childrenHash) ++ "..."+  logDebug debug $ "Cache hit: '" <> pluginName <> "'"+  when (not $ T.null selfHash) $+    logDebug debug $ "    self_hash:     " <> T.take 8 selfHash <> "..."+  when (not $ T.null childrenHash) $+    logDebug debug $ "    children_hash: " <> T.take 8 childrenHash <> "..."  -- | Report cache invalidation with detailed reason reportInvalidation :: Text -> String -> Bool -> IO () reportInvalidation pluginName reason debug = do-  when debug $ do-    putStrLn $ "[!] Cache miss: '" ++ T.unpack pluginName ++ "'"-    putStrLn $ "    Reason: " ++ reason+  logDebug debug $ "Cache miss: '" <> pluginName <> "'"+  logDebug debug $ "    Reason: " <> T.pack reason  -- | Analyze what changed using V2 granular hashes analyzeHashChange :: Text -> IRPluginCache -> Text -> Bool -> IO ()@@ -293,33 +300,32 @@       cachedSelfHash = ipcSelfHash irCache       cachedChildrenHash = ipcChildrenHash irCache -  when debug $ do-    putStrLn $ "[!] Cache miss: '" ++ T.unpack pluginName ++ "'"-    putStrLn $ "    IR hash changed:"-    putStrLn $ "      cached: " ++ T.unpack (T.take 8 cachedIRHash) ++ "..."-    putStrLn $ "      new:    " ++ T.unpack (T.take 8 newCodeHash) ++ "..."+  logDebug debug $ "Cache miss: '" <> pluginName <> "'"+  logDebug debug "    IR hash changed:"+  logDebug debug $ "      cached: " <> T.take 8 cachedIRHash <> "..."+  logDebug debug $ "      new:    " <> T.take 8 newCodeHash <> "..." -    -- V2 granular analysis (when fresh hashes are available)-    if T.null cachedSelfHash && T.null cachedChildrenHash-      then do-        putStrLn "    V2 hashes unavailable - using V1 validation"-        putStrLn "    Reason: Schema or IR changed (full regeneration needed)"-      else do-        putStrLn "    V2 granular analysis:"-        when (not $ T.null cachedSelfHash) $-          putStrLn $ "      self_hash:     " ++ T.unpack (T.take 8 cachedSelfHash) ++ "... (methods)"-        when (not $ T.null cachedChildrenHash) $-          putStrLn $ "      children_hash: " ++ T.unpack (T.take 8 cachedChildrenHash) ++ "... (dependencies)"-        putStrLn "    Note: Fresh schema comparison needed for granular invalidation"-        putStrLn "          (requires fetching current schema from backend)"+  -- V2 granular analysis (when fresh hashes are available)+  if T.null cachedSelfHash && T.null cachedChildrenHash+    then do+      logDebug debug "    V2 hashes unavailable - using V1 validation"+      logDebug debug "    Reason: Schema or IR changed (full regeneration needed)"+    else do+      logDebug debug "    V2 granular analysis:"+      when (not $ T.null cachedSelfHash) $+        logDebug debug $ "      self_hash:     " <> T.take 8 cachedSelfHash <> "... (methods)"+      when (not $ T.null cachedChildrenHash) $+        logDebug debug $ "      children_hash: " <> T.take 8 cachedChildrenHash <> "... (dependencies)"+      logDebug debug "    Note: Fresh schema comparison needed for granular invalidation"+      logDebug debug "          (requires fetching current schema from backend)"  -- ============================================================================ -- Cache Writing -- ============================================================================  -- | Write IR cache manifest-writeIRCacheManifest :: Options -> Backend -> Map Text IRPluginCache -> IO ()-writeIRCacheManifest opts backend plugins = do+writeIRCacheManifest :: Options -> Backend -> Map Text IRPluginCache -> Text -> IO ()+writeIRCacheManifest opts backend plugins synapseVer = do   cacheDir <- getIRCacheDir opts backend   createDirectoryIfMissing True cacheDir @@ -331,7 +337,7 @@         , ircmIRVersion = "2.0"         , ircmToolchain = ToolchainVersions             { tvSynapseCC = synapseCCVersion-            , tvSynapse = "0.2.0.0"  -- TODO: Get from synapse+            , tvSynapse = synapseVer             , tvHubCodegen = Nothing             }         , ircmUpdatedAt = timestamp@@ -342,8 +348,8 @@   BL.writeFile manifestPath (encode manifest)  -- | Write code cache manifest-writeCodeCacheManifest :: Options -> Backend -> Target -> Map Text CodePluginCache -> IO ()-writeCodeCacheManifest opts backend target plugins = do+writeCodeCacheManifest :: Options -> Backend -> Target -> Map Text CodePluginCache -> Text -> Text -> IO ()+writeCodeCacheManifest opts backend target plugins synapseVer hubCodegenVer = do   cacheDir <- getCodeCacheDir opts backend target   createDirectoryIfMissing True cacheDir @@ -352,14 +358,11 @@    let manifest = CodeCacheManifest         { ccmVersion = "1.0"-        , ccmTarget = T.pack $ case target of-            TypeScript -> "typescript"-            Python -> "python"-            Rust -> "rust"+        , ccmTarget = targetToText target         , ccmToolchain = ToolchainVersions             { tvSynapseCC = synapseCCVersion-            , tvSynapse = "0.2.0.0"  -- TODO: Get from synapse-            , tvHubCodegen = Just "0.1.0"  -- TODO: Get from hub-codegen+            , tvSynapse = synapseVer+            , tvHubCodegen = Just hubCodegenVer             }         , ccmUpdatedAt = timestamp         , ccmPlugins = plugins@@ -380,4 +383,4 @@   when exists $ do     catch       (removeDirectoryRecursive cacheDir)-      (\(e :: SomeException) -> putStrLn $ "Warning: Failed to clear cache: " ++ show e)+      (\(e :: SomeException) -> logDebug True $ "Warning: Failed to clear cache: " <> T.pack (show e))
+ src/SynapseCC/Config.hs view
@@ -0,0 +1,155 @@+{-# LANGUAGE OverloadedStrings #-}++-- | synapse.config.json loader and validator+module SynapseCC.Config+  ( loadSynapseConfig+  , validateSynapseConfig+  , initSynapseConfig+  , synapseConfigPath++    -- * Shared config-to-runtime conversions+  , parseWsUrl+  , buildConfigFromTarget+  ) where++import Data.Aeson (eitherDecodeFileStrict)+import qualified Data.Aeson.Encode.Pretty as Pretty+import qualified Data.ByteString.Lazy as BL+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as T+import System.Directory (doesFileExist)++import SynapseCC.Detect (Detector, ProjectHint(..), runDetectors)+import SynapseCC.Types++-- | Standard config file name (looked up from CWD)+synapseConfigPath :: FilePath+synapseConfigPath = "synapse.config.json"++-- | Load synapse.config.json from the current directory.+-- Returns Left if the file doesn't exist or fails to parse.+loadSynapseConfig :: IO (Either Text SynapseConfig)+loadSynapseConfig = loadSynapseConfigFrom synapseConfigPath++-- | Load from an explicit path (for hot-reload)+loadSynapseConfigFrom :: FilePath -> IO (Either Text SynapseConfig)+loadSynapseConfigFrom path = do+  exists <- doesFileExist path+  if not exists+    then pure $ Left $ "synapse.config.json not found at " <> T.pack path+                    <> " — run 'synapse-cc init' to create one"+    else do+      result <- eitherDecodeFileStrict path+      case result of+        Left err  -> pure $ Left $ T.pack err+        Right cfg -> case validateSynapseConfig cfg of+          Left verr -> pure $ Left verr+          Right ()  -> pure $ Right cfg++-- | Validate a loaded SynapseConfig, returning the first error found.+validateSynapseConfig :: SynapseConfig -> Either Text ()+validateSynapseConfig cfg = do+  requireNonEmpty "language" (scLanguage cfg)+  -- backend and url are required only when at least one target needs a backend.+  -- A target with generate: ["transport"] is backend-free.+  let needsBackend = any ((/= ["transport"]) . tcGenerate) (Map.elems (scTargets cfg))+  if needsBackend+    then case scBackend cfg of+           Nothing -> Left "\"backend\" is required when targets need a backend connection"+           Just b  -> requireNonEmpty "backend" b+    else Right ()+  if Map.null (scTargets cfg)+    then Left "At least one entry under \"targets\" is required"+    else mapM_ (validateTargetConfig (scTargets cfg)) (Map.toList (scTargets cfg))+  where+    requireNonEmpty field val =+      if T.null (T.strip val)+        then Left $ "\"" <> field <> "\" must not be empty"+        else Right ()++validateTargetConfig :: Map Text TargetConfig -> (Text, TargetConfig) -> Either Text ()+validateTargetConfig _all (name, tc) = do+  if null (tcGenerate tc)+    then Left $ "target \"" <> name <> "\": \"generate\" list must not be empty"+    else Right ()+  if null (tcOutputDir tc)+    then Left $ "target \"" <> name <> "\": \"outputDir\" must not be empty"+    else Right ()++-- ============================================================================+-- Config-to-Runtime Conversions+-- ============================================================================++-- | Parse host and port from a ws:// or wss:// URL.+-- Defaults port to "4444" when absent.+parseWsUrl :: Text -> (Text, Text)+parseWsUrl url =+  let stripped = case T.stripPrefix "wss://" url of+        Just s  -> s+        Nothing -> case T.stripPrefix "ws://" url of+          Just s  -> s+          Nothing -> url+      (host, rest) = T.breakOn ":" stripped+      port         = T.takeWhile (/= '/') (T.drop 1 rest)+  in (host, if T.null port then "4444" else port)++-- | Build a runtime Config from a set of CLI options, a SynapseConfig, and one target.+-- The target's outputDir and transport override the options; all other options+-- (--debug, --force, --no-install, etc.) are inherited as-is.+buildConfigFromTarget :: Options -> SynapseConfig -> TargetConfig -> Config+buildConfigFromTarget opts sc tc =+  let url            = fromMaybe "ws://127.0.0.1:4444" (scUrl sc)+      (host, port)   = parseWsUrl url+      backendName    = fromMaybe "" (scBackend sc)+  in Config+       { cfgTarget  = parseLanguage (scLanguage sc)+       , cfgBackend = Backend backendName+       , cfgHost    = host+       , cfgPort    = port+       , cfgOptions = opts+           { optOutput    = tcOutputDir tc+           , optTransport = tcTransport tc+           }+       }++-- | Scaffold a synapse.config.json, using 'detectors' to infer the right+-- transport and other settings for the current project.+--+-- Returns @Left@ if the file already exists.+-- Returns @Right hint@ on success — the caller can inspect 'phReason' to+-- print a message explaining what was detected.+--+-- Pass 'SynapseCC.Detect.defaultDetectors' for the standard set, or prepend+-- additional detectors for project-specific overrides.+initSynapseConfig :: FilePath -> [Detector] -> IO (Either Text ProjectHint)+initSynapseConfig path detectors = do+  exists <- doesFileExist path+  if exists+    then pure $ Left $ T.pack path <> " already exists"+    else do+      hint <- runDetectors detectors+      let config = applyHint hint defaultSynapseConfig+      BL.writeFile path (Pretty.encodePretty' prettyConfig config)+      pure $ Right hint++-- | Apply detected hints to the default config.+applyHint :: ProjectHint -> SynapseConfig -> SynapseConfig+applyHint hint cfg =+  case phTransport hint of+    Nothing -> cfg+    Just t  -> cfg+      { scTargets = Map.map (\tc -> tc { tcTransport = t }) (scTargets cfg)+      }++-- | aeson-pretty config: 2-space indent, semantic field ordering.+prettyConfig :: Pretty.Config+prettyConfig = Pretty.defConfig+  { Pretty.confCompare = Pretty.keyOrder+      [ "schema", "language", "backend", "url", "packageManager"+      , "watch", "pollInterval", "hotReload"+      , "targets", "generate", "transport", "outputDir", "smokePath"+      ]+  }
+ src/SynapseCC/Detect.hs view
@@ -0,0 +1,194 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Project detection layer for synapse-cc init.+--+-- Inspects the current working directory to infer appropriate config values+-- (transport mode, output directory, etc.) so that generated synapse.config.json+-- is correct out of the box for common project structures.+--+-- Detectors are plain values — extend by prepending to the list passed to+-- 'runDetectors'. Earlier detectors win on a per-field basis.+module SynapseCC.Detect+  ( -- * Types+    ProjectHint(..)+  , Detector(..)+  , emptyHint++    -- * Running detectors+  , runDetectors++    -- * Default detector set+  , defaultDetectors++    -- * Individual detectors (for composition and testing)+  , detectTauri+  , detectVite+  , detectNextJS+  , detectNodeProject+  ) where++import Control.Applicative ((<|>))+import Control.Monad (foldM)+import qualified Data.Text as T+import System.Directory (doesDirectoryExist, doesFileExist)++import SynapseCC.Types (TransportType(..))++-- ============================================================================+-- Hint type+-- ============================================================================++-- | Inferred preferences for a new synapse.config.json.+-- 'Nothing' means "no opinion" — another detector or the hardcoded default wins.+data ProjectHint = ProjectHint+  { phTransport :: Maybe TransportType+    -- ^ 'WsTransport' (Node.js) or 'BrowserTransport' (native WebSocket)+  , phReason    :: Maybe T.Text+    -- ^ Human-readable explanation of why transport was chosen+  } deriving (Show, Eq)++-- | Hint with no opinions on any field.+emptyHint :: ProjectHint+emptyHint = ProjectHint Nothing Nothing++-- | Merge two hints: earlier/left values win per field.+-- Uses 'Control.Applicative.<|>' which picks the first 'Just'.+mergeHints :: ProjectHint -> ProjectHint -> ProjectHint+mergeHints a b = ProjectHint+  { phTransport = phTransport a <|> phTransport b+  , phReason    = phReason a    <|> phReason b+  }++-- ============================================================================+-- Detector type+-- ============================================================================++-- | A named detector that inspects the project and returns a hint.+--+-- Detectors are run in order; the first non-Nothing value wins per field.+-- Add custom detectors by prepending them to the list passed to 'runDetectors'.+data Detector = Detector+  { detectorName :: T.Text+    -- ^ Short label for debug output (e.g. "tauri", "vite")+  , runDetector  :: IO ProjectHint+    -- ^ IO action: inspect CWD, return hints (or 'emptyHint' if not applicable)+  }++-- ============================================================================+-- Running detectors+-- ============================================================================++-- | Run all detectors in order, merging results.+-- The first non-Nothing value per field wins (leftmost detector takes priority).+runDetectors :: [Detector] -> IO ProjectHint+runDetectors = foldM step emptyHint+  where+    step acc det = mergeHints acc <$> runDetector det++-- ============================================================================+-- Default detector set+-- ============================================================================++-- | Default detector list, in priority order:+--+-- @+-- detectTauri      -- src-tauri/          → browser+-- detectVite       -- vite.config.*       → browser+-- detectNextJS     -- next.config.*       → browser+-- detectNodeProject-- package.json        → ws (fallback)+-- @+--+-- Inject additional detectors by prepending to this list:+--+-- @+-- myDetector : defaultDetectors+-- @+defaultDetectors :: [Detector]+defaultDetectors =+  [ detectTauri+  , detectVite+  , detectNextJS+  , detectNodeProject+  ]++-- ============================================================================+-- Individual detectors+-- ============================================================================++-- | Tauri desktop-app projects.+-- Looks for 'src-tauri/' directory or 'tauri.conf.json' (v1 or v2).+-- Sets transport to 'BrowserTransport' — Tauri's WebView exposes a native+-- 'WebSocket' global, so the @ws@ npm package must not be imported.+detectTauri :: Detector+detectTauri = Detector "tauri" $ do+  hasSrcTauri  <- doesDirectoryExist "src-tauri"+  hasTauriConf <- anyExist+    [ "tauri.conf.json"+    , "src-tauri/tauri.conf.json"+    , "src-tauri/tauri.conf.json5"+    ]+  if hasSrcTauri || hasTauriConf+    then pure emptyHint+      { phTransport = Just BrowserTransport+      , phReason    = Just "Tauri project detected (src-tauri/) — using browser WebSocket"+      }+    else pure emptyHint++-- | Vite projects (browser-native bundler context).+-- Looks for 'vite.config.ts', 'vite.config.js', or 'vite.config.mts'.+-- Sets transport to 'BrowserTransport'.+detectVite :: Detector+detectVite = Detector "vite" $ do+  found <- anyExist+    [ "vite.config.ts"+    , "vite.config.js"+    , "vite.config.mts"+    , "vite.config.mjs"+    ]+  if found+    then pure emptyHint+      { phTransport = Just BrowserTransport+      , phReason    = Just "Vite project detected — using browser WebSocket"+      }+    else pure emptyHint++-- | Next.js projects.+-- Looks for 'next.config.js', 'next.config.ts', or 'next.config.mjs'.+-- Sets transport to 'BrowserTransport' (client components use native WebSocket).+detectNextJS :: Detector+detectNextJS = Detector "nextjs" $ do+  found <- anyExist+    [ "next.config.js"+    , "next.config.ts"+    , "next.config.mjs"+    ]+  if found+    then pure emptyHint+      { phTransport = Just BrowserTransport+      , phReason    = Just "Next.js project detected — using browser WebSocket"+      }+    else pure emptyHint++-- | Bare Node.js projects (package.json present, no framework marker).+-- Sets transport to 'WsTransport' — the @ws@ npm package is available in Node.+-- This is the fallback: runs last, so framework detectors override it.+detectNodeProject :: Detector+detectNodeProject = Detector "node" $ do+  found <- doesFileExist "package.json"+  if found+    then pure emptyHint+      { phTransport = Just WsTransport+      , phReason    = Just "Node.js project detected — using ws WebSocket"+      }+    else pure emptyHint++-- ============================================================================+-- Helpers+-- ============================================================================++-- | Return True if any of the given paths exists as a file.+anyExist :: [FilePath] -> IO Bool+anyExist paths = foldM step False paths+  where+    step True  _    = pure True+    step False path = doesFileExist path
src/SynapseCC/Discover.hs view
@@ -3,67 +3,91 @@   ( discoverTools   , findTool   , toolPathToFilePath+  , findInDistNewstyle   ) where -import Control.Monad (filterM)+import Control.Exception (catch, SomeException, try)+import System.Exit (ExitCode)+import Control.Monad (forM, when)+import Data.List (sortBy, maximumBy)+import Data.Maybe (catMaybes)+import Data.Ord (comparing, Down(..)) import Data.Text (Text) import qualified Data.Text as T-import System.Directory (doesFileExist, findExecutable, getHomeDirectory)+import System.Directory (doesFileExist, doesDirectoryExist, findExecutable, getHomeDirectory, listDirectory, getModificationTime) import System.FilePath ((</>))+import System.Process (readProcessWithExitCode) +import SynapseCC.Logging (logDebug, logInfo) import SynapseCC.Types  -- ============================================================================ -- Tool Discovery -- ============================================================================ --- | Discover all required tools-discoverTools :: Bool -> IO (Either SynapseCCError ToolLocations)-discoverTools debug = do-  when debug $ putStrLn "[*] Discovering tools..."+-- | Discover all required tools, respecting any explicit paths in Options+discoverTools :: Options -> IO (Either SynapseCCError ToolLocations)+discoverTools opts = do+  let debug = optDebug opts -  synapsePath <- findTool debug "synapse" synapsePaths+  synapsePath <- case optSynapsePath opts of+    Just explicit -> resolveExplicit "synapse" explicit debug+    Nothing       -> findTool debug "synapse" synapseFallbackPaths+   case synapsePath of     Nothing -> pure $ Left $ ToolNotFound "synapse" synapseSuggestions     Just synapseToolPath -> do-      when debug $ putStrLn $ "  [+] Found synapse at " ++ toolPathToFilePath synapseToolPath+      hubCodegenPath <- case optHubCodegenPath opts of+        Just explicit -> resolveExplicit "hub-codegen" explicit debug+        Nothing       -> findTool debug "hub-codegen" hubCodegenFallbackPaths -      hubCodegenPath <- findTool debug "hub-codegen" hubCodegenPaths       case hubCodegenPath of         Nothing -> pure $ Left $ ToolNotFound "hub-codegen" hubCodegenSuggestions         Just hubCodegenToolPath -> do-          when debug $ putStrLn $ "  [+] Found hub-codegen at " ++ toolPathToFilePath hubCodegenToolPath+          -- Run --version for each discovered tool+          synapseVer    <- getToolVersion (toolPathToFilePath synapseToolPath)+          hubCodegenVer <- getToolVersion (toolPathToFilePath hubCodegenToolPath) +          logDebug debug $ "  synapse     " <> T.pack (toolPathToFilePath synapseToolPath) <> "  (" <> synapseVer <> ")"+          logDebug debug $ "  hub-codegen " <> T.pack (toolPathToFilePath hubCodegenToolPath) <> "  (" <> hubCodegenVer <> ")"+           pure $ Right $ ToolLocations-            { toolSynapse = synapseToolPath-            , toolHubCodegen = hubCodegenToolPath+            { toolSynapse           = synapseToolPath+            , toolHubCodegen        = hubCodegenToolPath+            , toolSynapseVersion    = synapseVer+            , toolHubCodegenVersion = hubCodegenVer             } --- | Find a tool by checking multiple locations-findTool :: Bool -> String -> (FilePath -> [FilePath]) -> IO (Maybe ToolPath)-findTool debug name pathsF = do-  home <- getHomeDirectory-  let paths = pathsF home--  when debug $ putStrLn $ "  Searching for " ++ name ++ "..."+-- | Resolve an explicitly-provided path, failing clearly if it doesn't exist+resolveExplicit :: String -> FilePath -> Bool -> IO (Maybe ToolPath)+resolveExplicit name path debug = do+  exists <- doesFileExist path+  if exists+    then pure $ Just $ classifyPath path+    else do+      logDebug debug $ "  [!] " <> T.pack name <> ": explicit path not found: " <> T.pack path+      pure Nothing -  -- Try to find the tool in priority order-  tryPaths paths+-- | Find a tool: check PATH first, then fallback paths+findTool :: Bool -> String -> IO [FilePath] -> IO (Maybe ToolPath)+findTool debug name fallbacksIO = do+  -- Check PATH first (i.e. `which <name>`)+  mbWhich <- findExecutable name+  case mbWhich of+    Just path -> do+      logDebug debug $ "  " <> T.pack name <> ": found via PATH at " <> T.pack path+      pure $ Just $ SystemPath path+    Nothing -> do+      logDebug debug $ "  " <> T.pack name <> ": not in PATH, searching fallback locations..."+      paths <- fallbacksIO+      tryPaths paths   where-    tryPaths [] = do-      -- Last resort: check system PATH-      mbSystemPath <- findExecutable name-      case mbSystemPath of-        Just path -> do-          when debug $ putStrLn $ "    Found in PATH: " ++ path-          pure $ Just $ SystemPath path-        Nothing -> pure Nothing-+    tryPaths [] = pure Nothing     tryPaths (path:rest) = do       exists <- doesFileExist path       if exists         then do-          when debug $ putStrLn $ "    Found: " ++ path+          logDebug debug $ "    Found: " <> T.pack path           pure $ Just $ classifyPath path         else tryPaths rest @@ -71,9 +95,9 @@ classifyPath :: FilePath -> ToolPath classifyPath path   | "dist-newstyle" `elem` splitPath path = LocalDev path-  | "target" `elem` splitPath path = LocalDev path-  | ".plexus/bin" `elem` splitPath path = PlexusBin path-  | otherwise = SystemPath path+  | "target"        `elem` splitPath path = LocalDev path+  | ".plexus/bin"   `elem` splitPath path = PlexusBin path+  | otherwise                             = SystemPath path   where     splitPath = filter (not . null) . wordsBy (== '/')     wordsBy p s = case dropWhile p s of@@ -87,62 +111,133 @@   SystemPath path -> path   PlexusBin path  -> path +-- | Run a tool with @--version@ and return the version string.+-- Strips a leading "v" and takes only the first line.+-- Returns @"unknown"@ if the call fails for any reason.+getToolVersion :: FilePath -> IO Text+getToolVersion exe = do+  result <- try (readProcessWithExitCode exe ["--version"] "") :: IO (Either SomeException (ExitCode, String, String))+  case result of+    Left _             -> pure "unknown"+    Right (_, out, _) ->+      let firstLine = T.strip $ T.pack $ head (lines out ++ [""])+          -- Take last word (handles "hub-codegen 0.1.0" and "3.5.0")+          lastWord  = case reverse (T.words firstLine) of+                        (w:_) -> w+                        []    -> ""+          ver = case T.unpack lastWord of+                  ('v':rest) -> T.pack rest+                  _          -> lastWord+      in pure $ if T.null ver then "unknown" else ver+ -- ============================================================================--- Search Paths+-- dist-newstyle Glob Search -- ============================================================================ --- | Search paths for synapse (in priority order)-synapsePaths :: FilePath -> [FilePath]-synapsePaths home =-  [ -- Local development builds-    "../synapse/dist-newstyle/build/aarch64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"-  , "../synapse/dist-newstyle/build/x86_64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"-  , "../../synapse/dist-newstyle/build/aarch64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"-  , "../../synapse/dist-newstyle/build/x86_64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"-    -- Plexus bin directory-  , home </> ".plexus/bin/synapse"-    -- Cabal install location-  , home </> ".cabal/bin/synapse"-  ]+-- | Search dist-newstyle for a built executable, returning the most recently+-- modified match (newest build wins).+--+-- Walks the pattern:+--   <root>/dist-newstyle/build/*\/ghc-*\/<pkg>-*\/x\/<exe>\/build\/<exe>\/<exe>+findInDistNewstyle :: FilePath  -- ^ project root (e.g. "../synapse")+                   -> String    -- ^ executable name (e.g. "synapse")+                   -> IO (Maybe FilePath)+findInDistNewstyle root exe = do+  let buildDir = root </> "dist-newstyle" </> "build"+  exists <- doesDirectoryExist buildDir+  if not exists+    then pure Nothing+    else do+      -- Level 1: arch dirs (e.g. aarch64-linux, x86_64-darwin)+      archDirs <- safeListDirectory buildDir+      candidates <- fmap concat $ forM archDirs $ \arch -> do+        let archPath = buildDir </> arch+        isDir <- doesDirectoryExist archPath+        if not isDir then pure [] else do --- | Search paths for hub-codegen (in priority order)-hubCodegenPaths :: FilePath -> [FilePath]-hubCodegenPaths home =-  [ -- Local development builds-    "../hub-codegen/target/release/hub-codegen"-  , "../hub-codegen/target/debug/hub-codegen"-  , "../../hub-codegen/target/release/hub-codegen"-  , "../../hub-codegen/target/debug/hub-codegen"-    -- Plexus bin directory-  , home </> ".plexus/bin/hub-codegen"-    -- Cargo install location-  , home </> ".cargo/bin/hub-codegen"-  ]+          -- Level 2: ghc-* dirs+          ghcDirs <- safeListDirectory archPath+          fmap concat $ forM ghcDirs $ \ghcDir -> do+            if not ("ghc-" `isPrefixOfStr` ghcDir) then pure [] else do+              let ghcPath = archPath </> ghcDir +              -- Level 3: <pkg>-* dirs+              pkgDirs <- safeListDirectory ghcPath+              fmap concat $ forM pkgDirs $ \pkgDir -> do+                let pkgPath = ghcPath </> pkgDir+                isDir2 <- doesDirectoryExist pkgPath+                if not isDir2 then pure [] else do++                  -- Level 4: x/<exe>/build/<exe>/<exe>+                  let candidate = pkgPath </> "x" </> exe </> "build" </> exe </> exe+                  exists2 <- doesFileExist candidate+                  if exists2 then pure [candidate] else pure []++      case candidates of+        []  -> pure Nothing+        [c] -> pure $ Just c+        cs  -> do+          -- Pick the most recently modified binary+          withTimes <- forM cs $ \c -> do+            t <- getModificationTime c+            pure (t, c)+          pure $ Just $ snd $ maximumBy (comparing fst) withTimes+  where+    isPrefixOfStr prefix str = take (length prefix) str == prefix++    safeListDirectory dir =+      listDirectory dir `catch` \(_ :: SomeException) -> pure []+ -- ============================================================================+-- Fallback Search Paths (used only when not found in PATH)+-- ============================================================================++-- | Fallback paths for synapse (checked if not in PATH).+-- Uses glob-based dist-newstyle search to handle any arch/GHC version.+synapseFallbackPaths :: IO [FilePath]+synapseFallbackPaths = do+  home <- getHomeDirectory+  -- Search dist-newstyle trees relative to common project layouts+  mb1 <- findInDistNewstyle "../synapse" "synapse"+  mb2 <- findInDistNewstyle "../../synapse" "synapse"+  let devPaths = catMaybes [mb1, mb2]+  let installPaths =+        [ home </> ".plexus/bin/synapse"+        , home </> ".local/bin/synapse"+        , home </> ".cabal/bin/synapse"+        ]+  pure $ devPaths ++ installPaths++-- | Fallback paths for hub-codegen (checked if not in PATH)+hubCodegenFallbackPaths :: IO [FilePath]+hubCodegenFallbackPaths = do+  home <- getHomeDirectory+  let devPaths =+        [ "../hub-codegen/target/release/hub-codegen"+        , "../hub-codegen/target/debug/hub-codegen"+        , "../../hub-codegen/target/release/hub-codegen"+        , "../../hub-codegen/target/debug/hub-codegen"+        ]+      installPaths =+        [ home </> ".plexus/bin/hub-codegen"+        , home </> ".cargo/bin/hub-codegen"+        ]+  pure $ devPaths ++ installPaths++-- ============================================================================ -- Suggestions -- ============================================================================ --- | Suggestions for installing synapse synapseSuggestions :: [Text] synapseSuggestions =-  [ "Build synapse: cd ../synapse && cabal build"-  , "Install synapse: cabal install synapse"-  , "Add to PATH: export PATH=\"$PATH:~/.plexus/bin\""+  [ "Install synapse: cd ../synapse && cabal install exe:synapse"+  , "Or specify path: --synapse /path/to/synapse"+  , "Add to PATH: export PATH=\"$HOME/.local/bin:$PATH\""   ] --- | Suggestions for installing hub-codegen hubCodegenSuggestions :: [Text] hubCodegenSuggestions =-  [ "Build hub-codegen: cd ../hub-codegen && cargo build --release"-  , "Install hub-codegen: cargo install --path ../hub-codegen"-  , "Add to PATH: export PATH=\"$PATH:~/.plexus/bin\""+  [ "Install hub-codegen: cd ../hub-codegen && cargo install --path ."+  , "Or specify path: --hub-codegen /path/to/hub-codegen"+  , "Add to PATH: export PATH=\"$HOME/.cargo/bin:$PATH\""   ]---- ============================================================================--- Helpers--- ============================================================================--when :: Applicative f => Bool -> f () -> f ()-when True action = action-when False _ = pure ()
src/SynapseCC/Language.hs view
@@ -1,15 +1,20 @@ -- | Language-specific integrations (dependency install, build, etc.) module SynapseCC.Language   ( installDependencies+  , addDependencies   , buildProject   , runTests   ) where  import Control.Monad (when)+import Data.Aeson (eitherDecodeFileStrict, Value(..))+import qualified Data.Aeson.Key as AKey+import qualified Data.Aeson.KeyMap as KM+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T-import qualified Data.Text.IO as TIO import System.Directory (doesFileExist, findExecutable) import System.Exit (ExitCode(..)) import System.FilePath ((</>))@@ -29,7 +34,7 @@   | Bun   deriving (Show, Eq) --- | Get command name for package manager+-- | Get install command name for package manager packageManagerCommand :: PackageManager -> String packageManagerCommand = \case   Npm  -> "npm"@@ -37,56 +42,75 @@   Yarn -> "yarn"   Bun  -> "bun" +-- | Get (command, args-prefix) for running an exec via the package manager+-- e.g. bun x tsc, pnpm exec tsc, npx tsc+packageManagerExec :: PackageManager -> (String, [String])+packageManagerExec = \case+  Bun  -> ("bun", ["x"])+  Pnpm -> ("pnpm", ["exec"])+  Yarn -> ("yarn", [])+  Npm  -> ("npx", [])++-- | Get (command, args) for running tests via the package manager+packageManagerTestCmd :: PackageManager -> (String, [String])+packageManagerTestCmd = \case+  Bun  -> ("bun", ["test"])+  Pnpm -> ("pnpm", ["test"])+  Yarn -> ("yarn", ["test"])+  Npm  -> ("npm", ["test"])+ -- | Detect which package manager to use for TypeScript project detectPackageManager :: GeneratedPath -> Bool -> IO PackageManager detectPackageManager (GeneratedPath path) debug = do   when debug $ putStrLn "[*] Detecting package manager..." -  -- Priority 1: Check for lockfiles-  hasPnpmLock <- doesFileExist (path </> "pnpm-lock.yaml")-  if hasPnpmLock+  -- Priority 1: Check for lockfiles (most reliable signal)+  hasBunLock  <- (||) <$> doesFileExist (path </> "bun.lock")+                          <*> doesFileExist (path </> "bun.lockb")+  if hasBunLock     then do-      when debug $ putStrLn "  [+] Found pnpm-lock.yaml, using pnpm"-      pure Pnpm+      when debug $ putStrLn "  [+] Found bun.lock, using bun"+      pure Bun     else do-      hasYarnLock <- doesFileExist (path </> "yarn.lock")-      if hasYarnLock+      hasPnpmLock <- doesFileExist (path </> "pnpm-lock.yaml")+      if hasPnpmLock         then do-          when debug $ putStrLn "  [+] Found yarn.lock, using yarn"-          pure Yarn+          when debug $ putStrLn "  [+] Found pnpm-lock.yaml, using pnpm"+          pure Pnpm         else do-          -- Priority 2: Check available commands (prefer pnpm > yarn > bun > npm)-          hasPnpm <- isJust <$> findExecutable "pnpm"-          if hasPnpm+          hasYarnLock <- doesFileExist (path </> "yarn.lock")+          if hasYarnLock             then do-              when debug $ putStrLn "  [+] pnpm available, using pnpm"-              pure Pnpm+              when debug $ putStrLn "  [+] Found yarn.lock, using yarn"+              pure Yarn             else do-              hasYarn <- isJust <$> findExecutable "yarn"-              if hasYarn+              hasNpmLock <- doesFileExist (path </> "package-lock.json")+              if hasNpmLock                 then do-                  when debug $ putStrLn "  [+] yarn available, using yarn"-                  pure Yarn+                  when debug $ putStrLn "  [+] Found package-lock.json, using npm"+                  pure Npm                 else do+                  -- Priority 2: Check available commands (prefer bun > pnpm > yarn > npm)                   hasBun <- isJust <$> findExecutable "bun"                   if hasBun                     then do                       when debug $ putStrLn "  [+] bun available, using bun"                       pure Bun                     else do-                      when debug $ putStrLn "  [+] Defaulting to npm"-                      pure Npm---- | Check if package.json contains workspace protocol-checkForWorkspaceProtocol :: GeneratedPath -> IO Bool-checkForWorkspaceProtocol (GeneratedPath path) = do-  let packageJson = path </> "package.json"-  exists <- doesFileExist packageJson-  if exists-    then do-      content <- TIO.readFile packageJson-      pure $ "workspace:" `T.isInfixOf` content-    else pure False+                      hasPnpm <- isJust <$> findExecutable "pnpm"+                      if hasPnpm+                        then do+                          when debug $ putStrLn "  [+] pnpm available, using pnpm"+                          pure Pnpm+                        else do+                          hasYarn <- isJust <$> findExecutable "yarn"+                          if hasYarn+                            then do+                              when debug $ putStrLn "  [+] yarn available, using yarn"+                              pure Yarn+                            else do+                              when debug $ putStrLn "  [+] Defaulting to npm"+                              pure Npm  -- | Provide helpful suggestions based on error patterns checkInstallError :: Text -> [Text]@@ -97,10 +121,6 @@       ["Network error - check your internet connection"]   | "ENETUNREACH" `T.isInfixOf` stderr =       ["Network unreachable - check firewall/proxy settings"]-  | "workspace:" `T.isInfixOf` stderr =-      ["npm doesn't support workspace: protocol"-      ,"Use pnpm, yarn, or bun: npm install -g pnpm && pnpm install"-      ,"Or enable bundle-transport: --bundle-transport=true"]   | otherwise = []  -- ============================================================================@@ -116,17 +136,6 @@   pm <- detectPackageManager genPath debug   let pmCmd = packageManagerCommand pm -  -- Check for workspace protocol with npm (warn user)-  when (pm == Npm) $ do-    hasWorkspace <- checkForWorkspaceProtocol genPath-    when hasWorkspace $ do-      putStrLn "\nWarning: This project uses the 'workspace:*' protocol"-      putStrLn "npm does not support workspace protocol."-      putStrLn "\nOptions:"-      putStrLn "  1. Use pnpm: npm install -g pnpm && pnpm install"-      putStrLn "  2. Use yarn: npm install -g yarn && yarn install"-      putStrLn "  3. Set --bundle-transport=true to avoid workspace dependency"-   -- Verify package manager is actually available   mbPmPath <- findExecutable pmCmd   case mbPmPath of@@ -159,6 +168,64 @@   when debug $ putStrLn "[*] Rust dependency installation not yet implemented (Phase 2 TODO)"   pure $ Right () +-- | Add specific packages to the project using the detected package manager.+-- Calls @pm add \<pkgs\>@ for runtime deps and @pm add -D \<pkgs\>@ for dev deps.+-- Skips packages already present in package.json (no-op on subsequent builds).+-- Returns True if packages were actually added, False if all were already present.+addDependencies+  :: GeneratedPath+  -> Map Text Text   -- ^ Runtime dependencies (name -> version)+  -> Map Text Text   -- ^ Dev dependencies (name -> version)+  -> Bool            -- ^ Debug+  -> IO (Either SynapseCCError Bool)+addDependencies _       deps devDeps _ | Map.null deps && Map.null devDeps = pure (Right False)+addDependencies genPath deps devDeps debug = do+  existing <- readExistingDeps (unGeneratedPath genPath)+  let newDeps    = Map.filterWithKey (\k _ -> k `Map.notMember` existing) deps+      newDevDeps = Map.filterWithKey (\k _ -> k `Map.notMember` existing) devDeps+  if Map.null newDeps && Map.null newDevDeps+    then pure (Right False)+    else do+      pm <- detectPackageManager genPath debug+      let pmCmd = packageManagerCommand pm+          dir   = Just (unGeneratedPath genPath)+      -- Add runtime deps+      rtResult <- if Map.null newDeps then pure (Right ()) else do+        let pkgs = Map.keys newDeps+        when debug $ putStrLn $ "  [+] " <> pmCmd <> " add " <> unwords (map T.unpack pkgs)+        r <- runProcess pmCmd ("add" : map T.unpack pkgs) dir debug+        pure $ case prExitCode r of+          ExitSuccess   -> Right ()+          ExitFailure c -> Left $ LanguageToolError (T.pack pmCmd <> " add") (prStderr r) c+      -- Add dev deps+      devResult <- if Map.null newDevDeps then pure (Right ()) else do+        let pkgs = Map.keys newDevDeps+        when debug $ putStrLn $ "  [+] " <> pmCmd <> " add -D " <> unwords (map T.unpack pkgs)+        r <- runProcess pmCmd (["add", "-D"] <> map T.unpack pkgs) dir debug+        pure $ case prExitCode r of+          ExitSuccess   -> Right ()+          ExitFailure c -> Left $ LanguageToolError (T.pack pmCmd <> " add -D") (prStderr r) c+      pure $ fmap (const True) (rtResult >> devResult)++-- | Read all declared dependencies (runtime + dev) from an existing package.json.+-- Returns an empty map if the file doesn't exist or can't be parsed.+readExistingDeps :: FilePath -> IO (Map Text Text)+readExistingDeps dir = do+  let pkgPath = dir </> "package.json"+  exists <- doesFileExist pkgPath+  if not exists then pure Map.empty else do+    result <- eitherDecodeFileStrict pkgPath :: IO (Either String Value)+    case result of+      Left _           -> pure Map.empty+      Right (Object o) ->+        let extract key = case KM.lookup (AKey.fromText key) o of+              Just (Object deps) -> Map.fromList+                [ (AKey.toText k, v')+                | (k, String v') <- KM.toList deps ]+              _ -> Map.empty+        in pure $ Map.union (extract "dependencies") (extract "devDependencies")+      Right _ -> pure Map.empty+ -- ============================================================================ -- Building/Type-checking -- ============================================================================@@ -168,15 +235,19 @@ buildProject TypeScript genPath debug = do   when debug $ putStrLn $ "[*] Type-checking TypeScript in " ++ unGeneratedPath genPath -  -- Run tsc --noEmit for type-checking-  result <- runProcess "npx" ["tsc", "--noEmit"] (Just $ unGeneratedPath genPath) debug+  pm <- detectPackageManager genPath debug+  let (cmd, prefix) = packageManagerExec pm+      args = prefix ++ ["tsc", "--noEmit"] +  result <- runProcess cmd args (Just $ unGeneratedPath genPath) debug+   case prExitCode result of     ExitSuccess -> do       when debug $ putStrLn "  [+] TypeScript type-check passed"       pure $ Right $ CompiledPath $ unGeneratedPath genPath     ExitFailure code -> do-      pure $ Left $ LanguageToolError "tsc" (prStderr result) code+      let combined = T.unlines $ filter (not . T.null . T.strip) [prStdout result, prStderr result]+      pure $ Left $ LanguageToolError "tsc" combined code  buildProject Python genPath debug = do   when debug $ putStrLn "[*] Python build not yet implemented (Phase 2 TODO)"@@ -197,18 +268,21 @@   Python -> pure $ Right ()  -- TODO: Implement Python tests   Rust -> pure $ Right ()  -- TODO: Implement Rust tests --- | Run TypeScript smoke tests using npm+-- | Run TypeScript smoke tests runTypeScriptTests :: GeneratedPath -> Bool -> IO (Either SynapseCCError ())-runTypeScriptTests (GeneratedPath path) debug = do-  when debug $ putStrLn $ "[*] Running smoke tests in " ++ path+runTypeScriptTests genPath debug = do+  when debug $ putStrLn $ "[*] Running smoke tests in " ++ unGeneratedPath genPath -  -- Check if npm is available-  result <- runProcess "npm" ["test"] (Just path) debug+  pm <- detectPackageManager genPath debug+  let (cmd, args) = packageManagerTestCmd pm+      toolLabel = T.pack cmd <> " test" +  result <- runProcess cmd args (Just $ unGeneratedPath genPath) debug+   case prExitCode result of     ExitSuccess -> do       when debug $ putStrLn "  [+] Tests passed"       pure $ Right ()     ExitFailure code -> do-      let stderr = prStderr result-      pure $ Left $ LanguageToolError "npm test" stderr code+      let combined = T.unlines $ filter (not . T.null . T.strip) [prStdout result, prStderr result]+      pure $ Left $ LanguageToolError toolLabel combined code
+ src/SynapseCC/Lock.hs view
@@ -0,0 +1,127 @@+{-# LANGUAGE OverloadedStrings #-}++-- | synapse.lock — project-level lockfile for reproducible builds.+-- Committed to git; makes backend changes visible as a diff.+module SynapseCC.Lock+  ( SynapseLock(..), LockTarget(..)+  , readSynapseLock, writeSynapseLock+  , lookupLockTarget, synapseLockPath+  , emptySynapseLock+  ) where++import Data.Aeson (FromJSON, ToJSON, eitherDecodeFileStrict)+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Encode.Pretty as Pretty+import qualified Data.ByteString.Lazy as BL+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import System.Directory (doesFileExist)++import SynapseCC.Types (synapseCCVersion)++-- | Standard lock file name (lives next to synapse.config.json in CWD)+synapseLockPath :: FilePath+synapseLockPath = "synapse.lock"++-- ============================================================================+-- Types+-- ============================================================================++-- | Per-target entry in synapse.lock, keyed by outputDir+data LockTarget = LockTarget+  { ltBackend   :: !Text+  , ltIrHash    :: !Text+  , ltTransport :: !Text+  , ltFiles     :: !(Map Text Text)  -- ^ relPath → content hash+  } deriving (Show, Eq)++instance FromJSON LockTarget where+  parseJSON = Aeson.withObject "LockTarget" $ \o -> LockTarget+    <$> o Aeson..: "backend"+    <*> o Aeson..: "irHash"+    <*> o Aeson..: "transport"+    <*> o Aeson..: "files"++instance ToJSON LockTarget where+  toJSON lt = Aeson.object+    [ "backend"   Aeson..= ltBackend lt+    , "irHash"    Aeson..= ltIrHash lt+    , "transport" Aeson..= ltTransport lt+    , "files"     Aeson..= ltFiles lt+    ]++-- | Top-level synapse.lock structure+data SynapseLock = SynapseLock+  { slVersion    :: !Text+  , slSynapseCC  :: !Text+  , slHubCodegen :: !Text+  , slUpdatedAt  :: !Text+  , slTargets    :: !(Map Text LockTarget)  -- ^ outputDir → LockTarget+  } deriving (Show, Eq)++instance FromJSON SynapseLock where+  parseJSON = Aeson.withObject "SynapseLock" $ \o -> SynapseLock+    <$> o Aeson..: "version"+    <*> o Aeson..: "synapseCC"+    <*> o Aeson..: "hubCodegen"+    <*> o Aeson..: "updatedAt"+    <*> o Aeson..: "targets"++instance ToJSON SynapseLock where+  toJSON sl = Aeson.object+    [ "version"    Aeson..= slVersion sl+    , "synapseCC"  Aeson..= slSynapseCC sl+    , "hubCodegen" Aeson..= slHubCodegen sl+    , "updatedAt"  Aeson..= slUpdatedAt sl+    , "targets"    Aeson..= slTargets sl+    ]++-- | Empty lock, used as a base when creating a fresh synapse.lock+emptySynapseLock :: SynapseLock+emptySynapseLock = SynapseLock+  { slVersion    = "1"+  , slSynapseCC  = synapseCCVersion+  , slHubCodegen = ""+  , slUpdatedAt  = ""+  , slTargets    = Map.empty+  }++-- ============================================================================+-- Pretty-print config+-- ============================================================================++lockPrettyConfig :: Pretty.Config+lockPrettyConfig = Pretty.defConfig+  { Pretty.confCompare = Pretty.keyOrder+      [ "version", "synapseCC", "hubCodegen", "updatedAt", "targets"+      , "backend", "irHash", "transport", "files"+      ]+  }++-- ============================================================================+-- Read / Write+-- ============================================================================++-- | Read synapse.lock from CWD. Returns Nothing on missing or parse error.+readSynapseLock :: IO (Maybe SynapseLock)+readSynapseLock = do+  exists <- doesFileExist synapseLockPath+  if not exists+    then pure Nothing+    else do+      result <- eitherDecodeFileStrict synapseLockPath+      case result of+        Left  _ -> pure Nothing+        Right sl -> pure (Just sl)++-- | Write synapse.lock to CWD (pretty-printed JSON).+writeSynapseLock :: SynapseLock -> IO ()+writeSynapseLock sl =+  BL.writeFile synapseLockPath (Pretty.encodePretty' lockPrettyConfig sl)++-- | Look up the LockTarget for a given outputDir.+lookupLockTarget :: FilePath -> SynapseLock -> Maybe LockTarget+lookupLockTarget outputDir sl =+  Map.lookup (T.pack outputDir) (slTargets sl)
src/SynapseCC/Logging.hs view
@@ -5,6 +5,7 @@   , logError   , logDebug   , logStep+  , logSubStep   ) where  import Data.Text (Text)@@ -48,11 +49,20 @@   setSGR [Reset] logDebug False _ = pure () --- | Log a pipeline step+-- | Log a top-level pipeline step (blank line before, bold yellow ==>) logStep :: Text -> IO () logStep msg = do   TIO.putStrLn ""   setSGR [SetColor Foreground Vivid Yellow, SetConsoleIntensity BoldIntensity]   TIO.putStr "==> "+  TIO.putStrLn msg+  setSGR [Reset]++-- | Log a sub-step within a target build (no leading blank line, indented)+-- Used for steps that are visually nested under a logStep target header.+logSubStep :: Text -> IO ()+logSubStep msg = do+  setSGR [SetColor Foreground Vivid Yellow, SetConsoleIntensity BoldIntensity]+  TIO.putStr "    ==> "   TIO.putStrLn msg   setSGR [Reset]
+ src/SynapseCC/Merge.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Three-way merge for generated files.+-- Preserves user modifications while applying generator updates safely.+module SynapseCC.Merge+  ( computeFileHash+  , applyMerge+  , cleanRemovedFiles+  , MergeResult(..)+  ) where++import qualified Crypto.Hash.SHA256 as SHA256+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base16 as Base16+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.Encoding.Error as TEE+import Control.Exception (try, SomeException)+import Data.Maybe (catMaybes)+import System.Directory ( createDirectoryIfMissing, doesFileExist+                        , removeFile, listDirectory, removeDirectory )+import System.FilePath ((</>), takeDirectory)++-- | Compute SHA-256 hash of text content, returning first 16 hex characters.+-- Algorithm matches hub-codegen hash.rs: SHA-256(utf8_bytes)[..16] as lowercase hex.+computeFileHash :: Text -> Text+computeFileHash content =+  T.take 16 $ TE.decodeUtf8 $ Base16.encode $ SHA256.hash $ TE.encodeUtf8 content++data FileStatus+  = NewFile+  | SafeToUpdate+  | Unchanged+  | UserModified+  deriving (Show, Eq)++-- | Three-way merge decision table:+--+--   cached  | current | new  | status+--   --------+---------+------+---------------+--   ∅       | ∅       | any  | NewFile+--   any     | ∅       | any  | SafeToUpdate (restore deleted file)+--   ∅       | H       | H    | Unchanged+--   ∅       | H       | H2   | NewFile+--   H1==H1  | same    | same | Unchanged+--   H1==H1  | same    | H2   | SafeToUpdate+--   H1      | H2≠H1   | any  | UserModified (skip)+determineStatus :: Maybe Text -> Maybe Text -> Text -> FileStatus+determineStatus Nothing   Nothing      _new = NewFile+determineStatus (Just _)  Nothing      _new = SafeToUpdate+determineStatus Nothing   (Just curr)  new+  | curr == new = Unchanged+  | otherwise   = NewFile+determineStatus (Just cached) (Just curr) new+  | cached == curr = if curr == new then Unchanged else SafeToUpdate+  | otherwise      = UserModified++-- | Result of applying a merge+data MergeResult = MergeResult+  { mrNew       :: [Text]  -- ^ New files written+  , mrUpdated   :: [Text]  -- ^ Existing files safely updated+  , mrUnchanged :: [Text]  -- ^ Files unchanged (write skipped)+  , mrSkipped   :: [Text]  -- ^ Files skipped due to user modifications+  , mrDeleted   :: [Text]  -- ^ Stale generated files removed+  } deriving (Show, Eq)++-- | Apply three-way merge: write generated files to disk, skipping user-modified ones.+applyMerge+  :: Map Text Text  -- ^ Generated file contents (relPath -> content)+  -> Map Text Text  -- ^ Generated file hashes   (relPath -> hash, unused but kept for API symmetry)+  -> Map Text Text  -- ^ Cached file hashes       (relPath -> hash)+  -> FilePath       -- ^ Output directory+  -> IO MergeResult+applyMerge generatedFiles _generatedHashes cachedHashes outputDir = do+  results <- mapM processFile (Map.toList generatedFiles)+  pure $ foldr addResult emptyResult results+  where+    emptyResult = MergeResult [] [] [] [] []++    processFile (relPath, content) = do+      let fullPath   = outputDir </> T.unpack relPath+          newHash    = computeFileHash content+          cachedHash = Map.lookup relPath cachedHashes+      currentHash <- readCurrentHash fullPath+      let status = determineStatus cachedHash currentHash newHash+      case status of+        NewFile      -> writeFile' fullPath content >> pure (relPath, NewFile)+        SafeToUpdate -> writeFile' fullPath content >> pure (relPath, SafeToUpdate)+        Unchanged    -> pure (relPath, Unchanged)+        UserModified -> pure (relPath, UserModified)++    readCurrentHash path = do+      exists <- doesFileExist path+      if exists+        then do+          bytes <- BS.readFile path+          pure $ Just $ computeFileHash $ TE.decodeUtf8With TEE.lenientDecode bytes+        else pure Nothing++    writeFile' path content = do+      createDirectoryIfMissing True (takeDirectory path)+      BS.writeFile path (TE.encodeUtf8 content)++    addResult (p, NewFile)      mr = mr { mrNew       = p : mrNew       mr }+    addResult (p, SafeToUpdate) mr = mr { mrUpdated   = p : mrUpdated   mr }+    addResult (p, Unchanged)    mr = mr { mrUnchanged = p : mrUnchanged mr }+    addResult (p, UserModified) mr = mr { mrSkipped   = p : mrSkipped   mr }++-- | Delete stale generated files: those present in the previous generation's+-- cached hashes but absent from the new generation.+--+-- A file is only deleted if it hasn't been modified since the last generation+-- (current hash == cached hash). User-modified files are left untouched.+--+-- After file deletion, empty parent directories are pruned up to (but not+-- including) the output root.+cleanRemovedFiles+  :: Map Text Text  -- ^ Old cached file hashes (previous generation)+  -> Map Text Text  -- ^ New generated file hashes (current generation)+  -> FilePath       -- ^ Output directory+  -> IO [Text]      -- ^ Paths of deleted files (relative)+cleanRemovedFiles oldHashes newHashes outputDir = do+  let stale = Map.keys $ Map.difference oldHashes newHashes+  deleted <- mapM tryDelete stale+  pure (catMaybes deleted)+  where+    tryDelete relPath = do+      let fullPath   = outputDir </> T.unpack relPath+          cachedHash = Map.findWithDefault "" relPath oldHashes+      exists <- doesFileExist fullPath+      if not exists+        then pure Nothing  -- already gone+        else do+          bytes <- BS.readFile fullPath+          let current = computeFileHash $ TE.decodeUtf8With TEE.lenientDecode bytes+          if current /= cachedHash+            then pure Nothing  -- user-modified: leave it+            else do+              removeFile fullPath+              pruneEmptyDirs (takeDirectory fullPath) outputDir+              pure (Just relPath)++-- | Remove empty directories walking upward, stopping at the output root.+pruneEmptyDirs :: FilePath -> FilePath -> IO ()+pruneEmptyDirs dir root+  | dir == root || length dir <= length root = pure ()+  | otherwise = do+      contentsResult <- try (listDirectory dir) :: IO (Either SomeException [FilePath])+      case contentsResult of+        Left _         -> pure ()+        Right []       -> do+          _ <- try (removeDirectory dir) :: IO (Either SomeException ())+          pruneEmptyDirs (takeDirectory dir) root+        Right _nonempty -> pure ()
src/SynapseCC/Pipeline.hs view
@@ -1,30 +1,45 @@ -- | Pipeline orchestration - running the full toolchain module SynapseCC.Pipeline   ( runPipeline+  , runBuildFromConfig   , generateIR   , generateCode+  , formatSynapseError   ) where -import Control.Monad (unless, when)+import Control.Exception (try, SomeException)+import Control.Monad (when, unless, forM_) import Data.Aeson (FromJSON, eitherDecodeStrict, eitherDecodeFileStrict) import qualified Data.Aeson as Aeson import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as BL import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe)-import Data.Monoid (mempty) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO+import Data.Time.Clock (UTCTime, getCurrentTime, diffUTCTime)+import Data.Time.Format (formatTime, defaultTimeLocale) import GHC.Generics (Generic)-import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, listDirectory) import System.Exit (ExitCode(..))-import System.FilePath ((</>), takeDirectory)+import System.FilePath ((</>)) +import Synapse.Monad (initEnv, runSynapseM, SynapseError(..))+import Synapse.IR.Builder (buildIR)++import SynapseCC.Benchmark (timeStep, baselinePath, reportBenchmarks)+import SynapseCC.Config (buildConfigFromTarget)+import SynapseCC.Logging (logDebug, logInfo, logStep, logSuccess) import SynapseCC.Types import SynapseCC.Process import SynapseCC.Discover import qualified SynapseCC.Language as Language import qualified SynapseCC.Cache as Cache+import SynapseCC.Cache (getCacheDir)+import qualified SynapseCC.Merge as Merge+import qualified SynapseCC.Lock as Lock  -- ============================================================================ -- Pipeline Orchestration@@ -36,152 +51,396 @@   let debug = optDebug (cfgOptions config)    -- Step 0: Check cache (unless --force is set)-  cacheResult <- Cache.validateCache config+  cacheResult <- Cache.validateCache config tools   case cacheResult of     FullCacheHit -> do-      when debug $ putStrLn "\n[+] Full cache hit (versions match)"-      when debug $ putStrLn "  [*] Using cached output"-      -- TODO: Copy cached code to output directory if needed+      logDebug debug "Full cache hit (versions match)"       let outputPath = optOutput (cfgOptions config)-      pure $ Right $ CompiledPath outputPath+      -- If synapse.lock has an entry for this target, verify the live irHash+      -- before skipping.  This catches dynamic backends (e.g. fidget-spinner)+      -- where the schema can change without a toolchain version bump.+      lockResult <- Lock.readSynapseLock+      case lockResult >>= Lock.lookupLockTarget outputPath of+        Nothing ->+          -- No lock entry — fall through to directory existence check+          useCachedOutput debug outputPath+        Just lt -> do+          logDebug debug "  Checking live irHash against synapse.lock..."+          irCheckResult <- generateIR config tools+          case irCheckResult of+            Left _ -> do+              -- Backend unreachable — use cached output gracefully (offline mode)+              logDebug debug "  Cannot reach backend — using cached output"+              useCachedOutput debug outputPath+            Right (IRPath irFile) -> do+              irBytes <- BS.readFile irFile+              -- Use the IR's own irHash field (content-stable, excludes timestamps).+              let liveHash = case eitherDecodeStrict irBytes of+                    Right (irData :: IRData) -> fromMaybe "" (irdIrHash irData)+                    Left _                   -> Merge.computeFileHash (TE.decodeUtf8 irBytes)+              if liveHash == Lock.ltIrHash lt+                then do+                  logDebug debug $ "  irHash unchanged (" <> T.take 8 liveHash <> "...) — using cache"+                  useCachedOutput debug outputPath+                else do+                  logDebug debug $ "  irHash changed: " <> T.take 8 (Lock.ltIrHash lt)+                                <> " → " <> T.take 8 liveHash+                  runFullPipeline config tools+      where+        useCachedOutput dbg outPath = do+          dirExists <- doesDirectoryExist outPath+          files <- if dirExists then listDirectory outPath else pure []+          if dirExists && not (null files)+            then do+              logDebug dbg "  Using cached output"+              pure $ Right $ CompiledPath outPath+            else do+              logDebug dbg "  Cache hit but output directory missing or empty — regenerating"+              runFullPipeline config tools      CacheMiss reason -> do-      when debug $ putStrLn $ "\n[!] Cache miss: " ++ show reason-      when debug $ putStrLn "  [*] Regenerating..."+      logDebug debug $ "Cache miss: " <> T.pack (show reason)+      logDebug debug "  Regenerating..."       runFullPipeline config tools      PartialCacheHit valid invalid -> do-      when debug $ putStrLn "\n[*] Partial cache hit"-      when debug $ putStrLn $ "  [+] Valid plugins: " ++ show valid-      when debug $ putStrLn $ "  [!] Invalid plugins: " ++ show invalid-      when debug $ putStrLn "  [*] Regenerating invalid plugins..."-      -- TODO: Implement partial regeneration-      -- For now, do full regeneration+      let totalPlugins = length valid + length invalid+      logInfo $ T.pack (show (length invalid)) <> " of " <> T.pack (show totalPlugins)+              <> " plugins changed — regenerating"       runFullPipeline config tools +-- | Minimal starter package.json written when none exists in the output directory.+-- Includes standard scripts for the generated client; users own the name/version.+-- Dependencies are NOT listed here — they are added via `pm add` by addDependencies.+-- | Marker field written into the starter package.json.+-- Presence of this field in the cwd package.json tells synapse-cc that IT+-- created the file (standalone mode), so scaffolding files (tsconfig, test/)+-- continue to be regenerated on subsequent runs.  Users who remove this field+-- opt in to integration mode — synapse-cc will stop managing those files.+synapseCCMarker :: Text+synapseCCMarker = "\"_generatedBy\": \"synapse-cc\""++-- | Marker present in package.json files generated by hub-codegen.+-- Used alongside synapseCCMarker to detect generated (standalone) output directories.+generatedByMarker :: Text+generatedByMarker = "\"_generatedBy\""++-- | Generate tsconfig.json for the synapse-cc managed output directory.+-- Excludes test/ so bun:test imports don't cause tsc errors — bun handles+-- test files natively and doesn't need tsconfig to know about bun:test.+generateTsconfig :: TransportType -> Text+generateTsconfig transport =+  let typeConfig = case transport of+        BrowserTransport -> "\"lib\": [\"ES2022\", \"DOM\"]"+        WsTransport      -> "\"types\": [\"node\"]"+  in T.unlines+    [ "{"+    , "  \"compilerOptions\": {"+    , "    \"target\": \"ES2022\","+    , "    \"module\": \"ESNext\","+    , "    \"moduleResolution\": \"bundler\","+    , "    \"strict\": true,"+    , "    \"skipLibCheck\": true,"+    , "    \"noEmit\": true,"+    , "    " <> typeConfig+    , "  },"+    , "  \"include\": [\"*.ts\"]"+    , "}"+    ]+ -- | Run the full pipeline without cache runFullPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath) runFullPipeline config tools = do-  let debug = optDebug (cfgOptions config)+  let debug  = optDebug (cfgOptions config)+      opts   = cfgOptions config+      Backend backendName = cfgBackend config+      targetName = targetToText (cfgTarget config)+      outputDir = optOutput opts -  -- Step 1: Generate IR-  when debug $ putStrLn "\n[*] Generating IR..."-  irResult <- generateIR config tools+  pipelineStart <- getCurrentTime+  cacheDir <- getCacheDir opts++  -- Step 1: Read schema from backend+  logStep "Reading schema..."+  (irResult, irMs) <- timeStep $ generateIR config tools   case irResult of     Left err -> pure $ Left err     Right irPath -> do-      when debug $ putStrLn $ "  [+] IR generated at " ++ unIRPath irPath+      irBytes <- BS.readFile (unIRPath irPath)+      let pluginCount = case eitherDecodeStrict irBytes of+            Right (ir :: IRData) -> Map.size (irdPlugins ir)+            Left _               -> 0+      logSuccess $ "Schema ready (" <> T.pack (show pluginCount) <> " plugins)"+      logDebug debug $ "  IR at " <> T.pack (unIRPath irPath)        -- Step 2: Generate code-      when debug $ putStrLn "\n[*] Generating code..."-      codeResult <- generateCode config tools irPath+      logStep "Generating code..."+      (codeResult, codeMs) <- timeStep $ generateCode config tools irPath Nothing       case codeResult of         Left err -> pure $ Left err-        Right genPath -> do-          when debug $ putStrLn $ "  [+] Code generated at " ++ unGeneratedPath genPath+        Right out -> do+          logSuccess $ "Code generated (" <> T.pack (show (Map.size (coFiles out))) <> " files)" +          -- Detect standalone vs integration mode.+          --+          -- Check the OUTPUT directory first: if it already has a package.json with a+          -- "_generatedBy" field, hub-codegen (or a prior synapse-cc run) wrote it+          -- → standalone, regardless of what the CWD contains.+          --+          -- If outputDir has no package.json, fall back to checking the CWD: if the CWD+          -- has a package.json without our marker, the user ran synapse-cc from inside a+          -- host project → integration mode (Tauri, monorepo, etc.).+          --+          -- Integration mode strips test/ scaffolding, skips devDeps, and skips+          -- build/test steps — the host project owns those concerns.+          cwd <- getCurrentDirectory+          let pmPath    = GeneratedPath cwd+              cwdPkgPath = cwd </> "package.json"+              outPkgJsonPath = outputDir </> "package.json"+          outPkgExists <- doesFileExist outPkgJsonPath+          isIntegration <-+            if outPkgExists+              then do+                -- outputDir has a package.json — if it has _generatedBy it's ours (standalone)+                content <- TIO.readFile outPkgJsonPath+                pure $ not (generatedByMarker `T.isInfixOf` content)+              else do+                -- outputDir has no package.json — check CWD for host project+                cwdPkgExists <- doesFileExist cwdPkgPath+                if not cwdPkgExists+                  then pure False+                  else do+                    cwdContent <- TIO.readFile cwdPkgPath+                    pure $ not (synapseCCMarker `T.isInfixOf` cwdContent)+          when isIntegration $+            logInfo "  Integration mode — build and test steps skipped"+          let scaffolding k _ = "test/" `T.isPrefixOf` k++          -- Apply three-way merge: write safe files, skip user-modified ones.+          -- tsconfig.json is always excluded: synapse-cc writes its own (see below).+          -- package.json is excluded in integration mode (host project owns it);+          --   in standalone mode it goes through the merge so script changes are applied.+          -- In integration mode, also exclude test/* (host project owns those).+          -- With --force, skip cached hashes so all files are written fresh.+          cachedHashes <- if optForce opts then pure Map.empty else getCachedFileHashes config+          let filesToMerge = (if isIntegration then Map.filterWithKey (fmap not . scaffolding) else id)+                           $ (if isIntegration then Map.delete "package.json" else id)+                           $ Map.delete "tsconfig.json"+                           $ coFiles out+          mergeResult  <- Merge.applyMerge filesToMerge (coFileHashes out) cachedHashes outputDir++          -- Remove stale generated files (plugins removed from backend).+          -- Only deletes files whose on-disk hash still matches the cached hash+          -- (i.e. the user hasn't modified them). Empty directories are pruned.+          deleted <- Merge.cleanRemovedFiles cachedHashes (coFileHashes out) outputDir+          unless (null deleted) $ do+            logInfo $ "  🗑  " <> T.pack (show (length deleted))+                    <> " stale file(s) removed (plugin removed from backend)"+            when debug $ forM_ deleted $ \f ->+              logDebug debug $ "      - " <> f++          -- Write ir.json to output dir as a reference artifact+          irContent <- BS.readFile (unIRPath irPath)+          BS.writeFile (outputDir </> "ir.json") irContent++          -- Log skipped files (user modifications preserved)+          let skipped = Merge.mrSkipped mergeResult+          unless (null skipped) $ do+            logInfo $ "  ⚠  " <> T.pack (show (length skipped))+                    <> " file(s) skipped (user modifications preserved)"+            when debug $ forM_ skipped $ \f ->+              logDebug debug $ "      - " <> f++          let genPath = GeneratedPath outputDir++          -- Write synapse-cc's tsconfig to the output dir (standalone mode only).+          -- Integration mode: host project owns tsconfig at its root; we don't touch it.+          -- The tsconfig only covers *.ts (not test/) so tsc never sees bun:test imports.+          unless isIntegration $ do+            logDebug debug "  Writing tsconfig.json"+            TIO.writeFile (outputDir </> "tsconfig.json") (generateTsconfig (optTransport opts))+           -- Step 3: Install dependencies (if enabled)-          installResult <- if optInstallDeps (cfgOptions config)+          -- Standalone: deps go into the generated dir (genPath) — it owns its own package.json.+          -- Integration: deps go into the host project root (pmPath) since there's no generated package.json.+          let depsToAdd    = coDependencies out+              devDepsToAdd = if isIntegration then Map.empty else coDevDependencies out+              depPath      = if isIntegration then pmPath else genPath+          (installResult, installMs) <- if optInstallDeps opts             then do-              when debug $ putStrLn "\n[*] Installing dependencies..."-              Language.installDependencies (cfgTarget config) genPath debug+              (addResult, addMs) <- timeStep $ Language.addDependencies+                depPath+                depsToAdd+                devDepsToAdd+                debug+              case addResult of+                Left err -> pure (Left err, addMs)+                Right added -> do+                  -- Run installDependencies if new packages were added OR node_modules is absent+                  nodeModulesExists <- doesDirectoryExist+                    (unGeneratedPath depPath </> "node_modules")+                  if not added && nodeModulesExists+                    then pure (Right (), addMs)+                    else do+                      when added $ logSuccess "Dependencies updated"+                      logStep "Installing dependencies..."+                      (result, ms) <- timeStep $ Language.installDependencies (cfgTarget config) depPath debug+                      case result of+                        Right () -> logSuccess "Dependencies installed"+                        Left _   -> pure ()+                      pure (result, addMs + ms)             else do-              when debug $ putStrLn "\n[*] Skipping dependency installation (--no-install)"-              pure $ Right ()+              logDebug debug "Skipping dependency installation (--no-install)"+              pure (Right (), 0)            case installResult of             Left err -> pure $ Left err             Right () -> do                -- Step 4: Build project (if enabled)-              buildResult <- if optBuild (cfgOptions config)+              -- In integration mode tsconfig.json is not written to the output dir;+              -- the host project's own build system handles compilation.+              (buildResult, buildMs) <- if optBuild opts && not isIntegration                 then do-                  when debug $ putStrLn "\n[*] Building project..."-                  Language.buildProject (cfgTarget config) genPath debug+                  logStep "Building..."+                  (result, ms) <- timeStep $ Language.buildProject (cfgTarget config) genPath debug+                  case result of+                    Right _ -> logSuccess "Build passed"+                    Left _  -> pure ()+                  pure (result, ms)                 else do-                  when debug $ putStrLn "\n[*] Skipping build (--no-build)"-                  pure $ Right $ CompiledPath $ unGeneratedPath genPath+                  if isIntegration+                    then logDebug debug "Skipping build (integration mode — compile via your project's build system)"+                    else logDebug debug "Skipping build (--no-build)"+                  pure (Right $ CompiledPath $ unGeneratedPath genPath, 0)                case buildResult of                 Left err -> pure $ Left err                 Right compiledPath -> do                    -- Step 5: Run tests (if enabled)-                  if optRunTests (cfgOptions config)+                  -- In integration mode test/ is not written to the output dir.+                  (testResult, testMs) <- if optRunTests opts && not isIntegration                     then do-                      when debug $ putStrLn "\n[*] Running smoke tests..."-                      testResult <- Language.runTests (cfgTarget config) genPath debug-                      case testResult of-                        Left err -> pure $ Left err-                        Right () -> do-                          when debug $ putStrLn "  [+] All tests passed"-                          writeCache config compiledPath-                          pure $ Right compiledPath-                    else do-                      writeCache config compiledPath+                      logStep "Running tests..."+                      (result, ms) <- timeStep $ Language.runTests (cfgTarget config) genPath debug+                      case result of+                        Right () -> logSuccess "Tests passed"+                        Left _   -> pure ()+                      pure (result, ms)+                    else pure (Right (), 0)++                  case testResult of+                    Left err -> pure $ Left err+                    Right () -> do+                      writeCache config tools irPath compiledPath out++                      -- Benchmarks+                      pipelineEnd <- getCurrentTime+                      let totalMs  = round (pipelineEnd `diffUTCTime'` pipelineStart * 1000) :: Int+                          steps    = Map.fromList $ filter (\(_, v) -> v > 0)+                                       [ ("ir_generation",       irMs)+                                       , ("code_generation",     codeMs)+                                       , ("install_dependencies", installMs)+                                       , ("build",               buildMs)+                                       , ("tests",               testMs)+                                       ]+                          ts       = T.pack $ formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" pipelineEnd+                          bPath    = baselinePath cacheDir targetName backendName++                      when debug $ reportBenchmarks bPath backendName targetName ts steps totalMs+                       pure $ Right compiledPath --- | Write cache manifests after successful generation-writeCache :: Config -> CompiledPath -> IO ()-writeCache config _compiledPath = do-  let debug = optDebug (cfgOptions config)-      outputDir = optOutput (cfgOptions config)-  when debug $ putStrLn "\n[*] Writing cache manifests..."+diffUTCTime' :: UTCTime -> UTCTime -> Double+diffUTCTime' t1 t0 = realToFrac (t1 `diffUTCTime` t0) -  -- Read file hashes from .codegen-metadata.json-  fileHashesMap <- readFileHashes outputDir debug+-- | Write cache manifests after successful generation+writeCache :: Config -> ToolLocations -> IRPath -> CompiledPath -> CodegenOutput -> IO ()+writeCache config tools irPath _compiledPath codegenOutput = do+  let debug         = optDebug (cfgOptions config)+      synapseVer    = toolSynapseVersion tools+      hubCodegenVer = coHubCodegenVersion codegenOutput+  logDebug debug "Writing cache manifests..."    -- Read IR to extract plugin hashes-  irPluginCaches <- readIRPluginHashes outputDir debug+  irPluginCaches <- readIRPluginHashes irPath debug    -- Write IR cache manifest with plugin hashes-  Cache.writeIRCacheManifest (cfgOptions config) (cfgBackend config) irPluginCaches+  Cache.writeIRCacheManifest (cfgOptions config) (cfgBackend config) irPluginCaches synapseVer -  -- Write code cache with file hashes-  -- Convert flat file hash map to per-plugin structure-  let pluginCaches = buildPluginCaches fileHashesMap-  Cache.writeCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config) pluginCaches+  -- Write code cache manifest using file hashes from CodegenOutput+  let pluginCaches = Map.singleton "default" CodePluginCache+        { cpcIRHash     = ""+        , cpcFileHashes = coFileHashes codegenOutput+        , cpcCachedAt   = ""+        }+  Cache.writeCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config)+    pluginCaches synapseVer hubCodegenVer -  when debug $ putStrLn "  [+] Cache manifests written"+  -- Write synapse.lock (project-level, committed to git)+  irBytes <- BS.readFile (unIRPath irPath)+  -- Use the IR's own irHash field (content-stable, no timestamps).+  -- Fall back to hashing the raw bytes only if the field is absent.+  let parsedIrHash = case eitherDecodeStrict irBytes of+        Right (irData :: IRData) -> fromMaybe "" (irdIrHash irData)+        Left _                   -> ""+      irHash    = if T.null parsedIrHash+                    then Merge.computeFileHash (TE.decodeUtf8 irBytes)+                    else parsedIrHash+      outputDir = optOutput (cfgOptions config)+      transport = case optTransport (cfgOptions config) of+                    WsTransport      -> "ws"+                    BrowserTransport -> "browser"+      Backend bkName = cfgBackend config+      lt = Lock.LockTarget+             { Lock.ltBackend   = bkName+             , Lock.ltIrHash    = irHash+             , Lock.ltTransport = transport+             , Lock.ltFiles     = coFileHashes codegenOutput+             }+  lock <- fromMaybe Lock.emptySynapseLock <$> Lock.readSynapseLock+  now  <- getCurrentTime+  Lock.writeSynapseLock $ lock+    { Lock.slTargets    = Map.insert (T.pack outputDir) lt (Lock.slTargets lock)+    , Lock.slUpdatedAt  = T.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" now)+    , Lock.slHubCodegen = hubCodegenVer+    } --- | Read file hashes from .codegen-metadata.json-readFileHashes :: FilePath -> Bool -> IO (Map.Map Text Text)-readFileHashes outputDir debug = do-  let metadataPath = outputDir </> ".codegen-metadata.json"-  exists <- doesFileExist metadataPath-  if not exists-    then do-      when debug $ putStrLn "  [!] .codegen-metadata.json not found, skipping file hashes"-      pure Map.empty-    else do-      result <- eitherDecodeFileStrict metadataPath-      case result of-        Left err -> do-          when debug $ putStrLn $ "  [!] Failed to parse metadata: " ++ err-          pure Map.empty-        Right (metadata :: CodegenMetadata) -> do-          when debug $ putStrLn $ "  [+] Read " ++ show (Map.size (ciFileHashes (cmCache metadata))) ++ " file hashes"-          pure $ ciFileHashes (cmCache metadata)+  logDebug debug "  Cache manifests written" --- | Read IR plugin hashes from ir.json-readIRPluginHashes :: FilePath -> Bool -> IO (Map.Map Text IRPluginCache)-readIRPluginHashes outputDir debug = do-  let irPath = outputDir </> "ir.json"-  exists <- doesFileExist irPath+-- | Read the cached file hashes for three-way merge.+-- synapse.lock (project-level, committed to git) takes priority over ~/.cache.+getCachedFileHashes :: Config -> IO (Map.Map Text Text)+getCachedFileHashes config = do+  let outputDir = optOutput (cfgOptions config)+  lockResult <- Lock.readSynapseLock+  case lockResult >>= Lock.lookupLockTarget outputDir of+    Just lt -> pure (Lock.ltFiles lt)+    Nothing -> do+      result <- Cache.readCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config)+      pure $ case result of+        Left _         -> Map.empty+        Right manifest -> maybe Map.empty cpcFileHashes $ Map.lookup "default" (ccmPlugins manifest)++-- | Read IR plugin hashes from ir.json (now at cache path)+readIRPluginHashes :: IRPath -> Bool -> IO (Map.Map Text IRPluginCache)+readIRPluginHashes (IRPath irFilePath) debug = do+  exists <- doesFileExist irFilePath   if not exists     then do-      when debug $ putStrLn "  [!] ir.json not found, skipping IR plugin hashes"+      logDebug debug "  ir.json not found, skipping IR plugin hashes"       pure Map.empty     else do-      result <- eitherDecodeFileStrict irPath+      result <- eitherDecodeFileStrict irFilePath       case result of         Left err -> do-          when debug $ putStrLn $ "  [!] Failed to parse IR: " ++ err+          logDebug debug $ "  Failed to parse IR: " <> T.pack err           pure Map.empty         Right (irData :: IRData) -> do           let pluginHashes = fromMaybe Map.empty (irdPluginHashes irData)               pluginCaches = Map.mapWithKey (buildIRPluginCache pluginHashes) (irdPlugins irData)-          when debug $ putStrLn $ "  [+] Extracted hashes for " ++ show (Map.size pluginCaches) ++ " plugins"+          logDebug debug $ "  Extracted hashes for " <> T.pack (show (Map.size pluginCaches)) <> " plugins"           pure pluginCaches  -- | Build an IRPluginCache entry from plugin info and hashes@@ -189,83 +448,179 @@ buildIRPluginCache hashMap pluginName _methods =   case Map.lookup pluginName hashMap of     Just hashes -> IRPluginCache-      { ipcIRHash = ""  -- TODO: Compute IR hash (WS2)-      , ipcSchemaHash = phiHash hashes-      , ipcSelfHash = phiSelfHash hashes+      { ipcIRHash       = ""+      , ipcSchemaHash   = phiHash hashes+      , ipcSelfHash     = phiSelfHash hashes       , ipcChildrenHash = phiChildrenHash hashes-      , ipcDependencies = []  -- TODO: Extract dependencies from IR-      , ipcCachedAt = ""  -- Will be set by writeIRCacheManifest+      , ipcDependencies = []+      , ipcCachedAt     = ""       }     Nothing -> IRPluginCache-      { ipcIRHash = ""-      , ipcSchemaHash = ""  -- No hash info available-      , ipcSelfHash = ""+      { ipcIRHash       = ""+      , ipcSchemaHash   = ""+      , ipcSelfHash     = ""       , ipcChildrenHash = ""       , ipcDependencies = []-      , ipcCachedAt = ""+      , ipcCachedAt     = ""       } --- | Build per-plugin cache entries from flat file hash map--- For now, group all files into a single "default" plugin entry--- This will be improved when we implement per-plugin hash tracking-buildPluginCaches :: Map.Map Text Text -> Map.Map Text CodePluginCache-buildPluginCaches fileHashes =-  if Map.null fileHashes-    then Map.empty-    else Map.singleton "default" CodePluginCache-      { cpcIRHash = ""  -- Will be populated when WS1 is complete-      , cpcFileHashes = fileHashes-      , cpcCachedAt = ""  -- Will be set by writeCodeCacheManifest-      }+-- ============================================================================+-- Config-file build+-- ============================================================================ +-- | Run a build pass for every target in a SynapseConfig.+-- CLI options override per-target transport/outputDir only when explicitly+-- different from their defaults; targets own those fields.+-- Returns one result per target (name, outcome).+runBuildFromConfig+  :: SynapseConfig+  -> Options       -- ^ CLI flags (--debug, --force, --no-install, etc.)+  -> ToolLocations+  -> IO [(T.Text, Either SynapseCCError CompiledPath)]+runBuildFromConfig sc opts tools =+  mapM buildTarget (Map.toList (scTargets sc))+  where+    buildTarget (name, tc) = do+      let config = buildConfigFromTarget opts sc tc+      logInfo $ "Building \"" <> name <> "\" → " <> T.pack (optOutput (cfgOptions config))+      logDebug (optDebug opts) $ "  target \"" <> name <> "\" is defined in synapse.config.json under .targets"+      result <- if tcGenerate tc == ["transport"]+                   then runTransportOnlyPipeline config tools+                   else runPipeline config tools+      pure (name, result)+ -- ============================================================================+-- Transport-Only Pipeline (no backend connection required)+-- ============================================================================++-- | Run the transport-only pipeline.+-- transport.ts is a static template parameterised only by --transport mode,+-- so no backend connection or IR generation is needed.+runTransportOnlyPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath)+runTransportOnlyPipeline config tools = do+  let opts      = cfgOptions config+      debug     = optDebug opts+      outputDir = optOutput opts++  createDirectoryIfMissing True outputDir++  logStep "Generating transport layer..."+  codeResult <- generateTransportCode config tools+  case codeResult of+    Left err -> pure $ Left err+    Right out -> do+      logSuccess "Transport generated"+      -- Three-way merge (transport.ts is static — merge handles unchanged correctly+      -- even without a cache entry: if content matches what's on disk, skip write).+      cachedHashes <- if optForce opts then pure Map.empty else getCachedFileHashes config+      mergeResult  <- Merge.applyMerge (coFiles out) (coFileHashes out) cachedHashes outputDir+      let skipped = Merge.mrSkipped mergeResult+      unless (null skipped) $+        logInfo $ "  ⚠  " <> T.pack (show (length skipped))+                <> " file(s) skipped (user modifications preserved)"+      logDebug debug "  No backend, deps, build, or tests for transport-only target"+      pure $ Right $ CompiledPath outputDir++-- | Call hub-codegen --generate transport (no IR argument needed).+generateTransportCode :: Config -> ToolLocations -> IO (Either SynapseCCError CodegenOutput)+generateTransportCode config tools = do+  let debug          = optDebug (cfgOptions config)+      hubCodegenPath = toolPathToFilePath (toolHubCodegen tools)+      opts           = cfgOptions config+      targetArg      = T.unpack (targetToText (cfgTarget config))+      args =+        [ "--target",        targetArg+        , "--output-format", "json"+        , "--generate",      "transport"+        , "--transport",     case optTransport opts of+                               WsTransport      -> "ws"+                               BrowserTransport -> "browser"+        ]+  result <- runProcess hubCodegenPath args Nothing debug+  case prExitCode result of+    ExitSuccess ->+      case eitherDecodeStrict (TE.encodeUtf8 (prStdout result)) of+        Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0+        Right out     -> pure $ Right out+    ExitFailure code ->+      pure $ Left $ HubCodegenError (prStderr result) code++-- ============================================================================ -- IR Generation -- ============================================================================ --- | Generate IR using synapse+-- | Generate IR using plexus-synapse library (replaces subprocess call) generateIR :: Config -> ToolLocations -> IO (Either SynapseCCError IRPath)-generateIR config tools = do-  let debug = optDebug (cfgOptions config)-      synapsePath = toolPathToFilePath (toolSynapse tools)-      Backend backendName = cfgBackend config+generateIR config _tools = do+  let debug  = optDebug (cfgOptions config)+      Backend bkName = cfgBackend config       outputDir = optOutput (cfgOptions config)-      irFile = outputDir </> "ir.json"+      opts = cfgOptions config+      host = cfgHost config+      port = read (T.unpack (cfgPort config)) :: Int+      generatorInfo = ["synapse-cc:" <> synapseCCVersion] -  -- Ensure output directory exists+  -- Compute IR file path in the cache directory:+  --   <cacheDir>/synapse/ir/<backend>/ir.json+  cacheDir <- getCacheDir opts+  let irDir  = cacheDir </> "synapse" </> "ir" </> T.unpack bkName+      irFile = irDir </> "ir.json"++  -- Ensure output and IR cache directories exist   createDirectoryIfMissing True outputDir+  createDirectoryIfMissing True irDir -  -- Build synapse command: synapse -H <host> -P <port> -i <backend> --generator-info synapse-cc:version-  let host = cfgHost config-      port = cfgPort config-      generatorInfo = "synapse-cc:" <> synapseCCVersion-      args = [ "-H", T.unpack host-             , "-P", T.unpack port-             , "--generator-info", T.unpack generatorInfo-             , "-i"-             , T.unpack backendName-             ]+  -- Call plexus-synapse library directly (no subprocess).+  -- Pass [] as path: the backend is encoded in the env; [] = walk from root.+  logDebug debug $ "  Connecting to " <> host <> ":" <> T.pack (show port)+  env <- initEnv host port bkName+  -- Wrap in try to catch hard exceptions from child-plugin fetch errors+  rawResult <- try (runSynapseM env (buildIR generatorInfo []))+  result <- case rawResult of+    Left (ex :: SomeException) ->+      let raw = T.pack (show ex)+          msg = if "Connection refused" `T.isInfixOf` raw || "does not exist" `T.isInfixOf` raw+                  then "Cannot connect to backend — is it running?"+                  else T.takeWhile (/= '\n') raw+      in pure $ Left msg+    Right (Left synapseErr)    -> pure $ Left $ formatSynapseError synapseErr+    Right (Right ir)           -> pure $ Right ir -  -- Run synapse-  result <- runProcess synapsePath args Nothing debug+  case result of+    Left msg -> do+      logDebug debug $ "  Synapse error: " <> msg+      pure $ Left $ SynapseError msg 1 -  case prExitCode result of-    ExitSuccess -> do-      -- Write IR to file-      BS.writeFile irFile (TE.encodeUtf8 $ prStdout result)+    Right ir -> do+      -- Encode IR to JSON and write to cache path+      let irBytes = BL.toStrict (Aeson.encode ir)+      BS.writeFile irFile irBytes -      -- Validate IR by trying to parse it-      irBytes <- BS.readFile irFile+      -- Validate by round-tripping (ensures we can parse what we wrote)       case eitherDecodeStrict irBytes of         Left parseErr -> pure $ Left $ InvalidIR $ T.pack parseErr         Right (_ :: IRData) -> pure $ Right $ IRPath irFile -    ExitFailure code -> do-      pure $ Left $ SynapseError (prStderr result) code+-- | Format a SynapseError (from plexus-synapse) for display+formatSynapseError :: SynapseError -> Text+formatSynapseError err = case err of+  NavError ne ->+    let raw = T.pack (show ne)+    in if "Connection refused" `T.isInfixOf` raw || "does not exist" `T.isInfixOf` raw+         then "Cannot connect to backend — is it running?"+         else "Connection error: " <> T.takeWhile (/= '\n') raw+  TransportError t  -> t+  TransportErrorContext ctx ->+    "Cannot connect to " <> T.pack (show ctx)+  ParseError t      -> "Parse error: " <> t+  ValidationError t -> "Validation error: " <> t+  BackendError bt _ -> "Backend error: " <> T.pack (show bt)  -- | IR structure for reading plugin hashes -- We only need the fields relevant for caching data IRData = IRData   { irdVersion      :: !Text+  , irdIrHash       :: !(Maybe Text)   -- ^ Content-stable hash of schema (no timestamps)   , irdPlugins      :: !(Map.Map Text [Text])   , irdPluginHashes :: !(Maybe (Map.Map Text PluginHashInfo))   } deriving stock (Show, Generic)@@ -273,6 +628,7 @@ instance FromJSON IRData where   parseJSON = Aeson.withObject "IRData" $ \o -> IRData     <$> o Aeson..: "irVersion"+    <*> o Aeson..:? "irHash"     <*> o Aeson..: "irPlugins"     <*> o Aeson..:? "irPluginHashes" @@ -288,34 +644,39 @@ -- Code Generation -- ============================================================================ --- | Generate code using hub-codegen-generateCode :: Config -> ToolLocations -> IRPath -> IO (Either SynapseCCError GeneratedPath)-generateCode config tools irPath = do-  let debug = optDebug (cfgOptions config)+-- | Generate code using hub-codegen, returning parsed JSON output.+-- Pass @Just namespaces@ to restrict generation to specific plugin namespaces+-- (equivalent to @--generate plugins --plugins ns1,ns2@).+-- Pass @Nothing@ for a full generation pass.+generateCode :: Config -> ToolLocations -> IRPath -> Maybe [Text] -> IO (Either SynapseCCError CodegenOutput)+generateCode config tools irPath nsFilter = do+  let debug          = optDebug (cfgOptions config)       hubCodegenPath = toolPathToFilePath (toolHubCodegen tools)-      outputDir = optOutput (cfgOptions config)-      target = cfgTarget config-      opts = cfgOptions config--  -- Build hub-codegen command-  let targetArg = case target of-        TypeScript -> "typescript"-        Python -> "python"-        Rust -> "rust"-+      opts           = cfgOptions config+      target         = cfgTarget config+      targetArg      = T.unpack (targetToText target)+      backendUrl     = "ws://" <> T.unpack (cfgHost config) <> ":" <> T.unpack (cfgPort config)+      filterArgs = case nsFilter of+        Nothing  -> []+        Just nss -> ["--generate", "plugins", "--plugins", T.unpack (T.intercalate "," nss)]       args =-        [ "--target", targetArg-        , "--output", outputDir-        , unIRPath irPath+        [ "--target",           targetArg+        , "--output-format",    "json"+        , "--transport", case optTransport opts of+            WsTransport      -> "ws"+            BrowserTransport -> "browser"+        , "--backend-url",      backendUrl         ]+        ++ filterArgs+        ++ [ unIRPath irPath ] -  -- Run hub-codegen   result <- runProcess hubCodegenPath args Nothing debug    case prExitCode result of     ExitSuccess -> do-      pure $ Right $ GeneratedPath outputDir--    ExitFailure code -> do+      let stdoutBytes = TE.encodeUtf8 (prStdout result)+      case eitherDecodeStrict stdoutBytes of+        Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0+        Right out     -> pure $ Right out+    ExitFailure code ->       pure $ Left $ HubCodegenError (prStderr result) code-
src/SynapseCC/Process.hs view
@@ -5,16 +5,18 @@   , ProcessResult(..)   ) where +import Control.Monad (unless, when) import qualified Data.ByteString as BS import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding.Error as TE-import qualified Data.Text.IO as TIO import System.Exit (ExitCode(..)) import System.IO (Handle, hClose, hGetContents, hPutStr) import System.Process hiding (runProcess) +import SynapseCC.Logging (logDebug)+ -- ============================================================================ -- Process Execution -- ============================================================================@@ -34,26 +36,14 @@   -> Bool          -- ^ Debug mode   -> IO ProcessResult runProcess exe args mbCwd debug = do-  when debug $ do-    putStrLn $ "Running: " ++ exe ++ " " ++ unwords args-    case mbCwd of-      Just cwd -> putStrLn $ "  Working directory: " ++ cwd-      Nothing -> pure ()+  logDebug debug $ "Running: " <> T.pack exe <> " " <> T.pack (unwords args)+  case mbCwd of+    Just cwd -> logDebug debug $ "  Working directory: " <> T.pack cwd+    Nothing  -> pure ()    (exitCode, stdout, stderr) <- readProcessWithExitCode' exe args mbCwd "" -  when debug $ do-    unless (T.null stdout) $ do-      let len = T.length stdout-      if len > 1000-        then putStrLn $ "  Stdout: <large output, " ++ show len ++ " chars, truncated>"-        else do-          putStrLn "  Stdout:"-          putStrLn $ "    " ++ T.unpack stdout-    unless (T.null stderr) $ do-      putStrLn "  Stderr:"-      putStrLn $ "    " ++ T.unpack stderr-+  logProcessOutput debug stdout stderr   pure $ ProcessResult exitCode stdout stderr  -- | Run a process with stdin input@@ -65,33 +55,35 @@   -> Bool          -- ^ Debug mode   -> IO ProcessResult runProcessWithInput exe args mbCwd input debug = do-  when debug $ do-    putStrLn $ "Running: " ++ exe ++ " " ++ unwords args-    case mbCwd of-      Just cwd -> putStrLn $ "  Working directory: " ++ cwd-      Nothing -> pure ()-    putStrLn $ "  With input: " ++ T.unpack (T.take 100 input) ++ "..."+  logDebug debug $ "Running: " <> T.pack exe <> " " <> T.pack (unwords args)+  case mbCwd of+    Just cwd -> logDebug debug $ "  Working directory: " <> T.pack cwd+    Nothing  -> pure ()+  logDebug debug $ "  With input: " <> T.take 100 input <> "..."    (exitCode, stdout, stderr) <- readProcessWithExitCode' exe args mbCwd (T.unpack input) -  when debug $ do-    unless (T.null stdout) $ do-      let len = T.length stdout-      if len > 1000-        then putStrLn $ "  Stdout: <large output, " ++ show len ++ " chars, truncated>"-        else do-          putStrLn "  Stdout:"-          putStrLn $ "    " ++ T.unpack stdout-    unless (T.null stderr) $ do-      putStrLn "  Stderr:"-      putStrLn $ "    " ++ T.unpack stderr-+  logProcessOutput debug stdout stderr   pure $ ProcessResult exitCode stdout stderr  -- ============================================================================ -- Helpers -- ============================================================================ +-- | Log process stdout/stderr when in debug mode.+logProcessOutput :: Bool -> Text -> Text -> IO ()+logProcessOutput debug stdout stderr = when debug $ do+  unless (T.null stdout) $ do+    let len = T.length stdout+    if len > 5000+      then logDebug debug $ "  Stdout: <large output, " <> T.pack (show len) <> " chars, truncated>"+      else do+        logDebug debug "  Stdout:"+        logDebug debug $ "    " <> stdout+  unless (T.null stderr) $ do+    logDebug debug "  Stderr:"+    logDebug debug $ "    " <> stderr+ -- | Like readProcessWithExitCode but with working directory support readProcessWithExitCode'   :: FilePath@@ -124,11 +116,3 @@   exitCode <- waitForProcess ph    pure (exitCode, stdoutText, stderrText)--when :: Applicative f => Bool -> f () -> f ()-when True action = action-when False _ = pure ()--unless :: Applicative f => Bool -> f () -> f ()-unless False action = action-unless True _ = pure ()
src/SynapseCC/Types.hs view
@@ -9,10 +9,24 @@     -- * Configuration   , Config(..)   , Target(..)+  , targetToText+  , parseLanguage   , Backend(..)+  , TransportType(..)   , Options(..)   , defaultOptions +    -- * Synapse Config File+  , SynapseConfig(..)+  , TargetConfig(..)+  , WatchConfig(..)+  , defaultSynapseConfig+  , defaultWatchConfig++    -- * Commands (CLI subcommands)+  , Command(..)+  , WatchArgs(..)+     -- * Tool Locations   , ToolLocations(..)   , ToolPath(..)@@ -31,21 +45,27 @@   , CacheResult(..)   , CacheMissReason(..) -    -- * Metadata Types-  , CodegenMetadata(..)-  , CacheInfo(..)+    -- * Codegen Output+  , CodegenOutput(..)+  , CodegenWarning(..)      -- * Errors   , SynapseCCError(..)   , formatError+  , summarizeStderr   ) where  import Data.Aeson (FromJSON, ToJSON, fieldLabelModifier) import qualified Data.Aeson as Aeson+import Data.Aeson.Types (Parser) import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes) import Data.Text (Text) import qualified Data.Text as T+import Data.Version (showVersion) import GHC.Generics (Generic)+import Paths_synapse_cc (version) import System.FilePath (FilePath)  -- ============================================================================@@ -54,7 +74,7 @@  -- | synapse-cc version (from cabal file: synapse-cc.cabal) synapseCCVersion :: Text-synapseCCVersion = "0.1.0.0"+synapseCCVersion = T.pack (showVersion version)  -- ============================================================================ -- Configuration@@ -77,47 +97,201 @@   deriving stock (Show, Eq, Ord, Generic)   deriving anyclass (FromJSON, ToJSON) +-- | Canonical Target → lowercase string (used in cache paths, manifest fields, etc.)+targetToText :: Target -> Text+targetToText TypeScript = "typescript"+targetToText Python     = "python"+targetToText Rust       = "rust"++-- | Parse a language string from synapse.config.json into a Target.+-- Unrecognised values default to TypeScript.+parseLanguage :: Text -> Target+parseLanguage "typescript" = TypeScript+parseLanguage "python"     = Python+parseLanguage "rust"       = Rust+parseLanguage _            = TypeScript+ -- | Backend identifier data Backend = Backend   { backendName :: !Text  -- ^ Backend name (e.g., "substrate", "plexus")   } deriving stock (Show, Eq, Generic)   deriving anyclass (FromJSON, ToJSON) +-- | Transport environment for generated TypeScript code+data TransportType = WsTransport | BrowserTransport+  deriving stock (Show, Eq, Generic)+ -- | Options for code generation and compilation data Options = Options   { optOutput          :: !FilePath-  , optBundleTransport :: !Bool+  , optTransport       :: !TransportType   , optInstallDeps     :: !Bool   , optBuild           :: !Bool   , optRunTests        :: !Bool   , optCacheDir        :: !FilePath   , optForce           :: !Bool-  , optWatch           :: !Bool   , optDebug           :: !Bool+  , optSynapsePath     :: !(Maybe FilePath)  -- ^ Override synapse binary path+  , optHubCodegenPath  :: !(Maybe FilePath)  -- ^ Override hub-codegen binary path   } deriving stock (Show, Eq, Generic)  -- | Default options defaultOptions :: Options defaultOptions = Options   { optOutput          = "./generated"-  , optBundleTransport = True+  , optTransport       = WsTransport   , optInstallDeps     = True   , optBuild           = True   , optRunTests        = False   , optCacheDir        = "~/.cache/plexus-codegen"   , optForce           = False-  , optWatch           = False   , optDebug           = False+  , optSynapsePath     = Nothing+  , optHubCodegenPath  = Nothing   }  -- ============================================================================+-- Synapse Config File (synapse.config.json)+-- ============================================================================++-- | Watch-mode configuration+data WatchConfig = WatchConfig+  { wcPollInterval :: !Int   -- ^ Milliseconds between hash polls (default: 1000)+  , wcHotReload    :: !Bool  -- ^ Reload config on file change if valid (default: False)+  } deriving stock (Show, Eq, Generic)++instance FromJSON WatchConfig where+  parseJSON = Aeson.withObject "WatchConfig" $ \o -> WatchConfig+    <$> o Aeson..:? "pollInterval" Aeson..!= 1000+    <*> o Aeson..:? "hotReload"    Aeson..!= False++instance ToJSON WatchConfig where+  toJSON wc = Aeson.object+    [ "pollInterval" Aeson..= wcPollInterval wc+    , "hotReload"    Aeson..= wcHotReload wc+    ]++defaultWatchConfig :: WatchConfig+defaultWatchConfig = WatchConfig+  { wcPollInterval = 1000+  , wcHotReload    = False+  }++-- | Per-target configuration entry in synapse.config.json+data TargetConfig = TargetConfig+  { tcGenerate   :: ![Text]           -- ^ Selectors: ["transport","rpc","plugins"] or ["all"]+  , tcTransport  :: !TransportType    -- ^ Transport for this target+  , tcOutputDir  :: !FilePath         -- ^ Where to write generated files+  , tcSmokePath  :: !(Maybe Text)     -- ^ Relative path to transport for smoke targets+  } deriving stock (Show, Eq, Generic)++instance FromJSON TargetConfig where+  parseJSON = Aeson.withObject "TargetConfig" $ \o -> TargetConfig+    <$> o Aeson..: "generate"+    <*> (parseTransport =<< o Aeson..: "transport")+    <*> o Aeson..: "outputDir"+    <*> o Aeson..:? "smokePath"+    where+      parseTransport :: Text -> Parser TransportType+      parseTransport "ws"      = pure WsTransport+      parseTransport "browser" = pure BrowserTransport+      parseTransport t         = fail $ "Unknown transport: " <> show t++instance ToJSON TargetConfig where+  toJSON tc = Aeson.object $+    [ "generate"  Aeson..= tcGenerate tc+    , "transport" Aeson..= (case tcTransport tc of WsTransport -> "ws" :: Text; BrowserTransport -> "browser")+    , "outputDir" Aeson..= tcOutputDir tc+    ] ++ case tcSmokePath tc of+           Nothing -> []+           Just p  -> ["smokePath" Aeson..= p]++-- | Top-level synapse.config.json configuration.+-- \"backend\" and \"url\" are optional when every target has generate: [\"transport\"]+-- (transport.ts is a static template that needs no backend connection).+data SynapseConfig = SynapseConfig+  { scSchema         :: !Text                -- ^ Config schema version (e.g. "1.0")+  , scLanguage       :: !Text                -- ^ Target language (e.g. "typescript")+  , scBackend        :: !(Maybe Text)        -- ^ Backend identifier; omit for transport-only configs+  , scUrl            :: !(Maybe Text)        -- ^ Backend WebSocket URL; omit for transport-only configs+  , scPackageManager :: !Text                -- ^ Package manager (e.g. "bun")+  , scWatch          :: !WatchConfig         -- ^ Watch mode settings+  , scTargets        :: !(Map Text TargetConfig)  -- ^ Named build targets+  } deriving stock (Show, Eq, Generic)++instance FromJSON SynapseConfig where+  parseJSON = Aeson.withObject "SynapseConfig" $ \o -> SynapseConfig+    <$> o Aeson..: "schema"+    <*> o Aeson..: "language"+    <*> o Aeson..:? "backend"+    <*> o Aeson..:? "url"+    <*> o Aeson..:? "packageManager" Aeson..!= "bun"+    <*> o Aeson..:? "watch"          Aeson..!= defaultWatchConfig+    <*> o Aeson..: "targets"++instance ToJSON SynapseConfig where+  toJSON sc = Aeson.object $ catMaybes+    [ Just $ "schema"         Aeson..= scSchema sc+    , Just $ "language"       Aeson..= scLanguage sc+    , ("backend" Aeson..=) <$> scBackend sc+    , ("url"     Aeson..=) <$> scUrl sc+    , Just $ "packageManager" Aeson..= scPackageManager sc+    , Just $ "watch"          Aeson..= scWatch sc+    , Just $ "targets"        Aeson..= scTargets sc+    ]++defaultSynapseConfig :: SynapseConfig+defaultSynapseConfig = SynapseConfig+  { scSchema         = "1.0"+  , scLanguage       = "typescript"+  , scBackend        = Just "substrate"+  , scUrl            = Just "ws://127.0.0.1:4444"+  , scPackageManager = "bun"+  , scWatch          = defaultWatchConfig+  , scTargets        = Map.fromList+      [ ( "client"+        , TargetConfig+            { tcGenerate  = ["transport", "rpc", "plugins"]+            , tcTransport = WsTransport+            , tcOutputDir = "src/lib/plexus"+            , tcSmokePath = Nothing+            }+        )+      ]+  }++-- ============================================================================+-- Commands (CLI Subcommands)+-- ============================================================================++-- | Top-level CLI command+data Command+  = CmdBuild Config         -- ^ Build with explicit target/backend (CLI args)+  | CmdBuildFromConfig      -- ^ Build all targets from synapse.config.json+      Options               -- ^ CLI flags that override per-target config+  | CmdWatch WatchArgs      -- ^ Watch mode+  | CmdInit                 -- ^ Scaffold synapse.config.json+  deriving stock (Show, Eq)++-- | Arguments for the watch subcommand+data WatchArgs = WatchArgs+  { waBackend   :: !Text         -- ^ Backend identifier (e.g. "substrate")+  , waPlugins   :: ![Text]       -- ^ Plugin prefix filters (empty = all)+  , waTarget    :: !(Maybe Text) -- ^ Pin to a single named config target+  , waInterval  :: !(Maybe Int)  -- ^ Poll interval override in ms (overrides config)+  , waOptions   :: !Options      -- ^ Inherited options (host, port, cache, debug, etc.)+  } deriving stock (Show, Eq)++-- ============================================================================ -- Tool Locations -- ============================================================================  -- | Discovered locations of required tools data ToolLocations = ToolLocations-  { toolSynapse    :: !ToolPath-  , toolHubCodegen :: !ToolPath+  { toolSynapse            :: !ToolPath+  , toolHubCodegen         :: !ToolPath+  , toolSynapseVersion     :: !Text   -- ^ Result of @synapse --version@+  , toolHubCodegenVersion  :: !Text   -- ^ Result of @hub-codegen --version@   } deriving stock (Show, Eq)  -- | Path to a discovered tool@@ -215,47 +389,42 @@   deriving stock (Show, Eq)  -- ============================================================================--- Metadata Types (for parsing .codegen-metadata.json)+-- Codegen Output Types (JSON response from hub-codegen --output-format json) -- ============================================================================ --- | Cache information from .codegen-metadata.json-data CacheInfo = CacheInfo-  { ciFileHashes :: !(Map Text Text)  -- file path -> hash+-- | JSON output from hub-codegen when invoked with --output-format json+data CodegenOutput = CodegenOutput+  { coFiles             :: !(Map Text Text)   -- ^ Generated file contents (relPath -> content)+  , coFileHashes        :: !(Map Text Text)   -- ^ Per-file content hashes (relPath -> hash)+  , coWarnings          :: ![CodegenWarning]  -- ^ Warnings from generation+  , coHubCodegenVersion :: !Text             -- ^ hub-codegen version string+  , coDependencies      :: !(Map Text Text)   -- ^ Runtime dependencies (name -> version)+  , coDevDependencies   :: !(Map Text Text)   -- ^ Dev dependencies (name -> version)   } deriving stock (Show, Eq, Generic) -instance FromJSON CacheInfo where+instance FromJSON CodegenOutput where   parseJSON = Aeson.genericParseJSON Aeson.defaultOptions     { fieldLabelModifier = \case-        "ciFileHashes" -> "file_hashes"-        other -> other-    }--instance ToJSON CacheInfo where-  toJSON = Aeson.genericToJSON Aeson.defaultOptions-    { fieldLabelModifier = \case-        "ciFileHashes" -> "file_hashes"+        "coFiles"             -> "files"+        "coFileHashes"        -> "fileHashes"+        "coWarnings"          -> "warnings"+        "coHubCodegenVersion" -> "hubCodegenVersion"+        "coDependencies"      -> "dependencies"+        "coDevDependencies"   -> "devDependencies"         other -> other     } --- | Metadata file generated by hub-codegen-data CodegenMetadata = CodegenMetadata-  { cmFormatVersion :: !Text-  , cmCache         :: !CacheInfo+-- | A warning emitted during code generation+data CodegenWarning = CodegenWarning+  { cwLocation :: !Text+  , cwMessage  :: !Text   } deriving stock (Show, Eq, Generic) -instance FromJSON CodegenMetadata where+instance FromJSON CodegenWarning where   parseJSON = Aeson.genericParseJSON Aeson.defaultOptions     { fieldLabelModifier = \case-        "cmFormatVersion" -> "format_version"-        "cmCache" -> "cache"-        other -> other-    }--instance ToJSON CodegenMetadata where-  toJSON = Aeson.genericToJSON Aeson.defaultOptions-    { fieldLabelModifier = \case-        "cmFormatVersion" -> "format_version"-        "cmCache" -> "cache"+        "cwLocation" -> "location"+        "cwMessage"  -> "message"         other -> other     } @@ -283,6 +452,37 @@     -- ^ Invalid configuration   deriving stock (Show, Eq) +-- | Extract the first meaningful non-empty line from stderr output.+-- Filters out Node.js stack frames and internal paths.+-- Falls back to the first non-empty line if nothing more useful is found.+-- Trims to 120 chars if very long.+summarizeStderr :: Text -> Text+summarizeStderr stderr =+  let ls = T.lines stderr+      isUseful l =+        not (T.null (T.strip l)) &&+        not ("    at " `T.isPrefixOf` l) &&+        not ("at " `T.isPrefixOf` T.strip l) &&+        not ("node:internal" `T.isInfixOf` l) &&+        not ("node_modules" `T.isInfixOf` l)+      firstUseful = case filter isUseful ls of+        (x:_) -> T.take 120 (T.strip x)+        []    -> case filter (not . T.null . T.strip) ls of+          (x:_) -> T.take 120 (T.strip x)+          []    -> "(no output)"+  in firstUseful++-- | Extract the key part of an aeson parse error, stripping the path prefix.+-- E.g. "Error in $['irPlugins']: key \"foo\" not found"+--   -> "key \"foo\" not found"+summarizeAesonError :: Text -> Text+summarizeAesonError msg =+  -- aeson errors look like "Error in $...path...: actual message"+  let afterColon = case T.breakOn ": " msg of+        (_, rest) | not (T.null rest) -> T.drop 2 rest+        _                             -> msg+  in T.take 120 afterColon+ -- | Format error for display to user formatError :: SynapseCCError -> Text formatError = \case@@ -298,13 +498,8 @@       , "For more info: https://github.com/hypermemetic/synapse-cc"       ] -  SynapseError stderr exitCode ->-    T.unlines-      [ "[!] Error: synapse failed (exit code " <> T.pack (show exitCode) <> ")"-      , ""-      , "Output:"-      , stderr-      ]+  SynapseError msg _ ->+    "[!] Error: " <> msg <> "\n"    HubCodegenError stderr exitCode ->     T.unlines@@ -314,12 +509,11 @@       , stderr       ] -  LanguageToolError tool stderr exitCode ->+  LanguageToolError tool output exitCode ->     T.unlines       [ "[!] Error: " <> tool <> " failed (exit code " <> T.pack (show exitCode) <> ")"       , ""-      , "Output:"-      , stderr+      , output       ]    CacheError msg ->@@ -327,9 +521,14 @@    InvalidIR msg ->     T.unlines-      [ "[!] Error: Invalid IR"+      [ "[!] Failed to parse IR output from synapse"       , ""-      , msg+      , "  The IR JSON was not in the expected format."+      , "  This usually means synapse and synapse-cc are out of sync."+      , ""+      , "  Detail: " <> summarizeAesonError msg+      , ""+      , "  Try: rebuild synapse from source, or check synapse --version"       ]    BackendUnreachable url msg ->
+ src/SynapseCC/Watch.hs view
@@ -0,0 +1,389 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Watch mode: poll backend for changes, incrementally rebuild affected plugins+module SynapseCC.Watch+  ( -- * Entry point+    runWatch++    -- * Pure utilities (exported for testing)+  , matchesAnyPrefix+  , parseUrl+  , extractPluginHashesFromBytes+  , filterTargets+  , buildConfigFromTarget++    -- * IO utilities (exported for testing)+  , fetchBackendHash+  , makeSubstrateConfig+  ) where++import Control.Concurrent (threadDelay)+import Control.Exception (try, SomeException)+import Control.Monad (forM_, when, unless)+import Data.Aeson (Value)+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Key as AKey+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as BL+import Data.IORef+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe, mapMaybe, isJust)+import Data.Text (Text)+import qualified Data.Text as T+import Data.Time.Clock (getCurrentTime, diffUTCTime)+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.FilePath ((</>))++import Plexus.Client (SubstrateConfig(..), defaultConfig)+import qualified Plexus.Transport as Transport+import Plexus.Types (PlexusStreamItem(..))+import Synapse.Monad (initEnv, runSynapseM)+import Synapse.IR.Builder (buildIR)++import SynapseCC.Cache (expandTilde)+import qualified SynapseCC.Config as Config+import SynapseCC.Config (loadSynapseConfig)+import SynapseCC.Logging+import SynapseCC.Merge (applyMerge)+import SynapseCC.Pipeline (formatSynapseError, generateCode, runPipeline)+import SynapseCC.Types++-- ============================================================================+-- Public Entry Point+-- ============================================================================++-- | Run the watch loop.+-- 1. Load synapse.config.json (falls back to defaults from CLI args)+-- 2. Full initial build across all active targets+-- 3. Poll {backend}.hash every pollInterval ms:+--    - unchanged → no-op+--    - changed   → fetch IR, diff plugin hashes, partial-rebuild changed plugins+runWatch :: WatchArgs -> ToolLocations -> IO ()+runWatch watchArgs tools = do+  let debug  = optDebug (waOptions watchArgs)+      bkName = waBackend watchArgs++  -- Load synapse.config.json+  cfgResult <- loadSynapseConfig+  synapseConfig <- case cfgResult of+    Left err -> do+      logInfo $ "[!] Config: " <> err+      logInfo   "    Falling back to CLI defaults"+      pure (synapseConfigFromArgs watchArgs)+    Right cfg -> pure cfg++  let allTargets    = scTargets synapseConfig+      activeTargets = filterTargets watchArgs allTargets++  when (Map.null activeTargets) $+    logInfo "[!] No targets with \"plugins\" in their generate list — nothing to watch"++  -- Full initial build for each active target+  logStep "Running initial build..."+  forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) -> do+    let config = buildConfigFromTarget watchArgs synapseConfig targetCfg+    result <- runPipeline config tools+    case result of+      Left err -> let msg  = T.strip (formatError err)+                      bare = maybe msg T.stripStart (T.stripPrefix "[!] Error:" msg)+                  in logError $ targetName <> ": " <> bare+      Right _  -> logSuccess $ targetName <> " built"++  -- State: last seen backend hash+  lastHashRef <- newIORef (Nothing :: Maybe Text)++  -- Config hot-reload state+  let hotReload = wcHotReload (scWatch synapseConfig)+      pollMs    = maybe (wcPollInterval (scWatch synapseConfig)) id (waInterval watchArgs)+      pollUs    = pollMs * 1000+  configRef <- newIORef synapseConfig++  when hotReload $+    logInfo "  Config hot-reload enabled (watching synapse.config.json)"++  logInfo $ "Watching " <> bkName <> " (polling every " <> T.pack (show pollMs) <> "ms)..."++  let loop = do+        -- Hot-reload config if enabled+        when hotReload $ do+          newCfgResult <- loadSynapseConfig+          case newCfgResult of+            Right newCfg -> writeIORef configRef newCfg+            Left _       -> pure ()  -- keep old config on error++        currentCfg <- readIORef configRef+        let subCfg  = makeSubstrateConfig watchArgs currentCfg++        hashResult <- (try (fetchBackendHash subCfg bkName) :: IO (Either SomeException (Either Text Text)))++        case hashResult of+          Left ex -> logInfo $ "[!] Backend unreachable: " <> T.pack (show ex)++          Right (Left err) -> logInfo $ "[!] Hash poll error: " <> err++          Right (Right newHash) -> do+            lastHash <- readIORef lastHashRef+            when (Just newHash /= lastHash) $ do+              -- Skip partial rebuild on first poll — initial build covered it+              when (isJust lastHash) $+                handleHashChange watchArgs currentCfg tools debug newHash++              writeIORef lastHashRef (Just newHash)++        threadDelay pollUs+        loop++  loop++-- ============================================================================+-- Hash Polling+-- ============================================================================++-- | Build a SubstrateConfig from watch args + synapse config URL+makeSubstrateConfig :: WatchArgs -> SynapseConfig -> SubstrateConfig+makeSubstrateConfig watchArgs synapseConfig =+  let (host, portTxt) = parseUrl (fromMaybe "ws://127.0.0.1:4444" (scUrl synapseConfig))+      port = read (T.unpack portTxt) :: Int+      bk   = waBackend watchArgs+  in (defaultConfig bk)+       { substrateHost = T.unpack host+       , substratePort = port+       }++-- | Call {backend}.hash and return the composite hash string.+fetchBackendHash :: SubstrateConfig -> Text -> IO (Either Text Text)+fetchBackendHash cfg bkName = do+  let method = bkName <> ".hash"+  result <- Transport.rpcCallWith cfg method Aeson.Null+  case result of+    Left err   -> pure $ Left $ T.pack (show err)+    Right items ->+      case mapMaybe extractHashValue items of+        (Right h : _) -> pure $ Right h+        (Left e  : _) -> pure $ Left e+        []             -> pure $ Left "No hash in response"++-- | Extract a hash string from a Plexus stream item for {backend}.hash calls.+-- Substrate sends content_type "{ns}.hash" with body {"event":"hash","value":"..."}.+extractHashValue :: PlexusStreamItem -> Maybe (Either Text Text)+extractHashValue (StreamData _ _ ct dat)+  | ".hash" `T.isSuffixOf` ct =+      case dat of+        Aeson.Object o ->+          case ( KM.lookup (AKey.fromText "event") o+               , KM.lookup (AKey.fromText "value") o ) of+            (Just (Aeson.String "hash"), Just (Aeson.String v)) -> Just (Right v)+            _ -> Nothing+        _ -> Nothing+  | otherwise = Nothing+extractHashValue (StreamError _ _ err _) = Just (Left err)+extractHashValue _                        = Nothing++-- ============================================================================+-- Change Handling+-- ============================================================================++-- | Handle a backend hash change: fetch full IR, diff plugin hashes, rebuild changed plugins.+handleHashChange+  :: WatchArgs+  -> SynapseConfig+  -> ToolLocations+  -> Bool    -- ^ debug+  -> Text    -- ^ new backend hash (informational)+  -> IO ()+handleHashChange watchArgs synapseConfig tools debug _newHash = do+  let bkName = waBackend watchArgs+      opts   = waOptions watchArgs+      (host, portTxt) = parseUrl (fromMaybe "ws://127.0.0.1:4444" (scUrl synapseConfig))+      port = read (T.unpack portTxt) :: Int+      pluginFilter = waPlugins watchArgs+      generatorInfo = ["synapse-cc:" <> synapseCCVersion]++  logInfo "Backend changed — fetching IR..."+  t0 <- getCurrentTime++  -- Fetch fresh IR via plexus-synapse library+  env <- initEnv host port bkName+  irResult <- runSynapseM env (buildIR generatorInfo [])++  case irResult of+    Left synapseErr ->+      logInfo $ "[!] IR fetch failed: " <> formatSynapseError synapseErr++    Right ir -> do+      -- Write IR to cache path+      cacheDir <- expandTilde (optCacheDir opts)+      let irDir  = cacheDir </> "synapse" </> "ir" </> T.unpack bkName+          irFile = irDir </> "ir.json"+      createDirectoryIfMissing True irDir+      let irBytes = BL.toStrict (Aeson.encode ir)+      BS.writeFile irFile irBytes++      t1 <- getCurrentTime+      let fetchMs = round (diffUTCTime t1 t0 * 1000) :: Int++      -- Extract per-plugin hashes from fresh IR JSON+      let freshHashes = extractPluginHashesFromBytes irBytes++      -- Diff against cache to find changed/removed plugin namespaces+      (changedNs, removedNs) <- diffPluginHashes opts bkName freshHashes++      -- Apply prefix filter from CLI+      let filteredNs = if null pluginFilter+            then changedNs+            else filter (matchesAnyPrefix pluginFilter) changedNs++      let activeTargets = filterTargets watchArgs (scTargets synapseConfig)++      -- If plugins were removed from the backend, trigger a full rebuild per target.+      -- A partial rebuild cannot clean up stale files or fix index.ts exports.+      unless (null removedNs) $ do+        logInfo $ "  " <> T.pack (show (length removedNs))+               <> " plugin(s) removed — triggering full rebuild"+        when debug $ forM_ removedNs $ \ns ->+          logInfo $ "    - " <> ns+        forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) -> do+          let config = buildConfigFromTarget watchArgs synapseConfig targetCfg+          result <- runPipeline config tools+          case result of+            Left err ->+              let msg  = T.strip (formatError err)+                  bare = maybe msg T.stripStart (T.stripPrefix "[!] Error:" msg)+              in logError $ targetName <> ": " <> bare+            Right _  -> logSuccess $ targetName <> " rebuilt (structural change)"++      if null filteredNs+        then when (null removedNs) $+               logInfo $ "  IR fetched in " <> T.pack (show fetchMs) <> "ms — no plugin changes"+        else do+          logInfo $ "  IR fetched in " <> T.pack (show fetchMs) <> "ms — "+                 <> T.pack (show (length filteredNs)) <> " plugin(s) changed"++          -- Rebuild each changed plugin across affected targets+          forM_ filteredNs $ \ns -> do+            forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) ->+              when ("plugins" `elem` tcGenerate targetCfg || "all" `elem` tcGenerate targetCfg) $ do+                tStart <- getCurrentTime+                rebuildResult <- rebuildPlugin watchArgs synapseConfig tools (IRPath irFile) ns targetCfg debug+                tEnd <- getCurrentTime+                let ms = round (diffUTCTime tEnd tStart * 1000) :: Int+                case rebuildResult of+                  Left err -> logInfo $ "  [!] " <> ns <> " (" <> targetName <> "): " <> err+                  Right () -> logSuccess $ ns <> " (" <> targetName <> ") rebuilt in " <> T.pack (show ms) <> "ms"++-- | Rebuild a single plugin namespace for a single target using hub-codegen.+rebuildPlugin+  :: WatchArgs+  -> SynapseConfig+  -> ToolLocations+  -> IRPath+  -> Text         -- ^ plugin namespace+  -> TargetConfig+  -> Bool         -- ^ debug+  -> IO (Either Text ())+rebuildPlugin watchArgs synapseConfig tools irPath ns targetCfg _debug = do+  let config    = buildConfigFromTarget watchArgs synapseConfig targetCfg+      outputDir = tcOutputDir targetCfg++  codeResult <- generateCode config tools irPath (Just [ns])+  case codeResult of+    Left err -> pure $ Left $ formatError err+    Right out -> do+      let files      = coFiles out+          fileHashes = coFileHashes out+      _mergeResult <- applyMerge files fileHashes Map.empty outputDir+      pure $ Right ()++-- ============================================================================+-- Plugin Hash Helpers+-- ============================================================================++-- | Extract per-plugin composite hashes from raw IR JSON bytes.+-- Returns a Map of plugin namespace → composite hash.+extractPluginHashesFromBytes :: BS.ByteString -> Map Text Text+extractPluginHashesFromBytes bytes =+  case Aeson.decodeStrict bytes of+    Nothing -> Map.empty+    Just v  -> extractPluginHashesFromValue v++-- | Extract plugin hashes from a parsed IR JSON Value.+extractPluginHashesFromValue :: Value -> Map Text Text+extractPluginHashesFromValue v =+  case v of+    Aeson.Object obj ->+      case KM.lookup (AKey.fromText "irPluginHashes") obj of+        Just (Aeson.Object hashesObj) ->+          Map.fromList+            [ (AKey.toText k, extractCompositeHash hv)+            | (k, hv) <- KM.toList hashesObj+            ]+        _ -> Map.empty+    _ -> Map.empty++-- | Pull the "hash" field from a PluginHashInfo JSON object.+extractCompositeHash :: Value -> Text+extractCompositeHash (Aeson.Object o) =+  case KM.lookup (AKey.fromText "hash") o of+    Just (Aeson.String h) -> h+    _                     -> ""+extractCompositeHash _ = ""++-- | Compute which plugin namespaces have changed or been removed vs the cached IR manifest.+-- Returns (changed, removed).+-- If no cache exists, treats all current plugins as changed and none as removed.+diffPluginHashes :: Options -> Text -> Map Text Text -> IO ([Text], [Text])+diffPluginHashes opts bkName freshHashes = do+  cacheDir <- expandTilde (optCacheDir opts)+  let manifestPath = cacheDir </> "synapse" </> "ir" </> T.unpack bkName </> "manifest.json"+  exists <- doesFileExist manifestPath+  if not exists+    then pure (Map.keys freshHashes, [])+    else do+      result <- Aeson.eitherDecodeFileStrict manifestPath :: IO (Either String IRCacheManifest)+      case result of+        Left _         -> pure (Map.keys freshHashes, [])+        Right manifest -> do+          let changed = Map.keys $ Map.filterWithKey (isChanged manifest) freshHashes+              removed = Map.keys $ Map.difference (ircmPlugins manifest) freshHashes+          pure (changed, removed)+  where+    isChanged manifest ns newHash =+      case Map.lookup ns (ircmPlugins manifest) of+        Nothing     -> True  -- plugin is new+        Just cached -> ipcSchemaHash cached /= newHash++-- ============================================================================+-- Utilities+-- ============================================================================++-- | Does a plugin namespace match any of the given prefix filters?+-- A prefix "echo" matches "echo", "echo.health", "echo.workspace.repos", etc.+matchesAnyPrefix :: [Text] -> Text -> Bool+matchesAnyPrefix prefixes ns =+  any (\p -> p == ns || (p <> ".") `T.isPrefixOf` ns) prefixes++-- | Build a Config from WatchArgs and a TargetConfig entry.+-- Thin wrapper around 'Config.buildConfigFromTarget' extracting Options from WatchArgs.+buildConfigFromTarget :: WatchArgs -> SynapseConfig -> TargetConfig -> Config+buildConfigFromTarget wa = Config.buildConfigFromTarget (waOptions wa)++-- | Filter targets to those with "plugins" or "all" in generate list,+-- optionally pinning to a single named target (--target flag).+filterTargets :: WatchArgs -> Map Text TargetConfig -> Map Text TargetConfig+filterTargets watchArgs allTargets =+  let hasPlugins tc = "plugins" `elem` tcGenerate tc || "all" `elem` tcGenerate tc+  in case waTarget watchArgs of+    Just name -> maybe Map.empty (Map.singleton name) (Map.lookup name allTargets)+    Nothing   -> Map.filter hasPlugins allTargets++-- | Synthesize a SynapseConfig from CLI args alone (no config file)+synapseConfigFromArgs :: WatchArgs -> SynapseConfig+synapseConfigFromArgs watchArgs =+  defaultSynapseConfig { scBackend = Just (waBackend watchArgs) }++-- | Parse host and port from a WebSocket URL. Alias for 'Config.parseWsUrl'.+parseUrl :: Text -> (Text, Text)+parseUrl = Config.parseWsUrl
synapse-cc.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               synapse-cc-version:            0.1.0.0+version:            0.2.0 synopsis:           Unified compiler toolchain for Plexus backends description:   synapse-cc orchestrates the complete pipeline from backend schema@@ -19,17 +19,24 @@     import:           warnings     exposed-modules:  SynapseCC.Types                     , SynapseCC.CLI+                    , SynapseCC.Config+                    , SynapseCC.Detect                     , SynapseCC.Discover+                    , SynapseCC.Lock+                    , SynapseCC.Merge                     , SynapseCC.Pipeline                     , SynapseCC.Process                     , SynapseCC.Cache                     , SynapseCC.Dependency                     , SynapseCC.Language                     , SynapseCC.Logging+                    , SynapseCC.Benchmark+                    , SynapseCC.Watch     build-depends:    base >= 4.14 && < 5                     , optparse-applicative >= 0.18                     , process >= 1.6                     , aeson >= 2.0+                    , aeson-pretty >= 0.8                     , bytestring >= 0.11                     , text >= 1.2                     , filepath >= 1.4@@ -38,7 +45,17 @@                     , ansi-terminal >= 0.11                     , time >= 1.9                     , cryptohash-sha256 >= 0.11+                    , base16-bytestring >= 1.0                     , containers >= 0.6+                    , stm >= 2.5+                    , mtl >= 2.3+                    , transformers >= 0.6+                    , unordered-containers >= 0.2+                    , fsnotify >= 0.4+                    , plexus-protocol+                    , plexus-synapse+    autogen-modules:  Paths_synapse_cc+    other-modules:    Paths_synapse_cc     hs-source-dirs:   src     default-language: Haskell2010     default-extensions: OverloadedStrings@@ -71,3 +88,17 @@                     , synapse-cc                     , hspec >= 2.0                     , QuickCheck >= 2.14+                    , text >= 1.2+                    , directory >= 1.3+                    , filepath >= 1.4+                    , process >= 1.6+                    , aeson >= 2.0+                    , aeson-pretty >= 0.8+                    , bytestring >= 0.11+                    , containers >= 0.6+                    , optparse-applicative >= 0.18+                    , plexus-protocol+                    , stm >= 2.5+    default-extensions: OverloadedStrings+                      , LambdaCase+                      , ScopedTypeVariables
test/Main.hs view
@@ -2,8 +2,1581 @@  import Test.Hspec -main :: IO ()-main = hspec $ do-  describe "SynapseCC" $ do-    it "placeholder" $ do-      True `shouldBe` True+import Control.Concurrent (threadDelay)+import Control.Exception (try, SomeException)+import Control.Monad (forM_, when, unless, void)+import Data.Aeson (Value(..), decode)+import qualified Data.Aeson as Aeson+import Data.Aeson ((.=))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BSC8+import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Lazy.Char8 as LBS8+import Data.Char (isAlpha, isDigit)+import Data.Either (isLeft, isRight)+import Data.List (isInfixOf, isPrefixOf, find)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import System.Directory+  ( doesDirectoryExist, doesFileExist, findExecutable+  , getTemporaryDirectory, listDirectory+  , createDirectoryIfMissing, removeDirectoryRecursive+  , withCurrentDirectory+  )+import System.Exit (ExitCode(..))+import System.FilePath ((</>), takeExtension)+import System.Process+  ( readProcessWithExitCode, CreateProcess(..), proc+  , readCreateProcessWithExitCode, createProcess, terminateProcess+  , waitForProcess, std_out, std_err, StdStream(..)+  )+import GHC.IO.Encoding (setLocaleEncoding, utf8)+import Options.Applicative (execParserPure, defaultPrefs, getParseResult)+import Plexus.Client (SubstrateConfig(..), defaultConfig)+import qualified Plexus.Transport as Transport+import Plexus.Types (TransportError(..), PlexusStreamItem)+import SynapseCC.CLI (synapseCCParserInfo, synapseCCCommandParserInfo)+import SynapseCC.Config (loadSynapseConfig, validateSynapseConfig, initSynapseConfig, synapseConfigPath)+import SynapseCC.Detect+  ( ProjectHint(..), Detector(..), emptyHint+  , runDetectors, defaultDetectors+  , detectTauri, detectVite, detectNextJS, detectNodeProject+  )+import SynapseCC.Discover (discoverTools)+import SynapseCC.Pipeline (runPipeline)+import SynapseCC.Types+  ( Config(..), Backend(..), Target(..), Options(..), defaultOptions, formatError+  , SynapseConfig(..), TargetConfig(..), WatchConfig(..)+  , WatchArgs(..), Command(..)+  , defaultSynapseConfig+  , TransportType(..)+  )+import SynapseCC.Watch+  ( matchesAnyPrefix, parseUrl, extractPluginHashesFromBytes+  , filterTargets, buildConfigFromTarget+  , fetchBackendHash+  )++-- | Shared test context: the path to generated output+data TestEnv = TestEnv+  { teOutputDir :: !FilePath+  , tePipelineRan :: !Bool  -- True if we ran synapse-cc, False if using existing generated/+  , tePipelineExit :: !ExitCode+  , tePipelineError :: !String  -- Captured error output when pipeline fails+  }++-- | Test context for Tauri integration: output goes into a plexus/ subdir+data TauriTestEnv = TauriTestEnv+  { taOutputDir :: !FilePath  -- the generated output dir (proj </> "plexus")+  , taProjDir   :: !FilePath  -- the project root (has user-owned package.json)+  }++-- | Recursively find all files with a given extension+findFilesWithExt :: FilePath -> String -> IO [FilePath]+findFilesWithExt root ext = go root+  where+    go dir = do+      entries <- listDirectory dir+      paths <- concat <$> mapM (process dir) entries+      return paths+    process dir name = do+      let path = dir </> name+      isDir <- doesDirectoryExist path+      if isDir+        then go path+        else return [path | takeExtension name == ext]++-- | Check if a file contains a substring (case-sensitive)+fileContains :: FilePath -> String -> IO Bool+fileContains path needle = do+  content <- readFile path+  return $ needle `isInfixOf` content++-- | Count top-level namespace directories (those containing index.ts)+countNamespaceDirs :: FilePath -> IO Int+countNamespaceDirs root = do+  entries <- listDirectory root+  let candidates = filter (\e -> not ("." `isPrefixOf` e) && e /= "test" && e /= "node_modules") entries+  dirs <- mapM (\e -> doesDirectoryExist (root </> e)) candidates+  let dirNames = [e | (e, True) <- zip candidates dirs]+  -- Count dirs that have index.ts (are namespace modules)+  counts <- mapM (\d -> doesFileExist (root </> d </> "index.ts")) dirNames+  return $ length [() | True <- counts]++-- | Count "export * as" lines in a file+countNamespaceExports :: FilePath -> IO Int+countNamespaceExports path = do+  content <- readFile path+  return $ length $ filter ("export * as " `isPrefixOf`) $ lines content++-- | Run a process and return (exitcode, stdout, stderr)+runProc :: Maybe FilePath -> String -> [String] -> IO (ExitCode, String, String)+runProc _mCwd cmd args = readProcessWithExitCode cmd args ""++-- | Discover substrate URL by querying the registry through synapse CLI.+--   Returns the ws:// URL if substrate is found, Nothing otherwise.+discoverSubstrateUrl :: String -> IO (Maybe String)+discoverSubstrateUrl registryPort = do+  mSynapse <- findExecutable "synapse"+  case mSynapse of+    Nothing -> do+      putStrLn "  synapse not found, skipping registry discovery"+      return Nothing+    Just synapse -> do+      (exit, stdout, _) <- readProcessWithExitCode synapse+        ["-P", registryPort, "--json", "registry-hub", "registry", "list"] ""+      case exit of+        ExitSuccess -> return $ parseSubstrateUrl stdout+        _ -> return Nothing++-- | Parse synapse --json output to find substrate's URL.+--   The output contains JSON stream items, one per line.+--   We look for a "backends" data item containing a substrate entry.+parseSubstrateUrl :: String -> Maybe String+parseSubstrateUrl output =+  let jsonLines = mapMaybe (decode . LBS8.pack) (lines output) :: [Value]+      -- Find the data item with backends list+      backends = concatMap extractBackends jsonLines+      substrate = find isSubstrate backends+  in fmap backendToUrl substrate+  where+    extractBackends :: Value -> [Value]+    extractBackends (Object obj) =+      case KM.lookup "content" obj of+        Just (Object content) ->+          case KM.lookup "backends" content of+            Just (Array arr) -> foldr (:) [] arr+            _ -> []+        _ -> []+    extractBackends _ = []++    isSubstrate :: Value -> Bool+    isSubstrate (Object obj) =+      case KM.lookup "name" obj of+        Just (String name) -> name == "substrate"+        _ -> False+    isSubstrate _ = False++    backendToUrl :: Value -> String+    backendToUrl (Object obj) =+      let proto = case KM.lookup "protocol" obj of+            Just (String p) -> T.unpack p+            _ -> "ws"+          host = case KM.lookup "host" obj of+            Just (String h) -> T.unpack h+            _ -> "localhost"+          port = case KM.lookup "port" obj of+            Just (Number n) -> show (round n :: Int)+            _ -> "4445"+      in proto ++ "://" ++ host ++ ":" ++ port+    backendToUrl _ = "ws://localhost:4445"++-- | Patch the smoke test URL if it points to the registry instead of substrate.+--   Reads the smoke test, checks if URL differs from discovered substrate URL,+--   and rewrites the file if needed.+patchSmokeTestUrl :: FilePath -> String -> IO ()+patchSmokeTestUrl smokeTestPath substrateUrl = do+  content <- readFile smokeTestPath+  let patched = replaceWsUrls content substrateUrl+  when (patched /= content) $ do+    putStrLn $ "  Patching smoke test URL -> " ++ substrateUrl+    length patched `seq` writeFile smokeTestPath patched++-- | Replace ws:// URLs in the smoke test content with the given URL+replaceWsUrls :: String -> String -> String+replaceWsUrls [] _ = []+replaceWsUrls content@(c:cs) newUrl+  | "ws://localhost:" `isPrefixOf` content =+      let rest = drop (length ("ws://localhost:" :: String)) content+          (_port, after) = span isDigit rest+      in newUrl ++ after+  | "ws://127.0.0.1:" `isPrefixOf` content =+      let rest = drop (length ("ws://127.0.0.1:" :: String)) content+          (_port, after) = span isDigit rest+      in newUrl ++ after+  | otherwise = c : replaceWsUrls cs newUrl++-- | Set up the test environment by running the pipeline in-process+setupTestEnv :: IO TestEnv+setupTestEnv = do+  tmpBase <- getTemporaryDirectory+  let tmpDir = tmpBase </> "synapse-cc-test-output"+  tmpExists <- doesDirectoryExist tmpDir+  when tmpExists $ removeDirectoryRecursive tmpDir+  createDirectoryIfMissing True tmpDir++  -- Parse args exactly as the CLI would, using the real parser+  let args = [ "typescript", "substrate"+             , "-P", "4444"+             , "-o", tmpDir+             , "--force"+             , "--no-build"+             , "--no-tests"+             ]+  config <- case getParseResult (execParserPure defaultPrefs synapseCCParserInfo args) of+    Nothing -> fail "Failed to parse synapse-cc arguments"+    Just c  -> return c++  toolsResult <- discoverTools (cfgOptions config)+  tools <- case toolsResult of+    Left err -> fail $ T.unpack (formatError err)+    Right t  -> return t+  -- Run pipeline with cwd = tmpDir to simulate standalone use:+  -- the user runs synapse-cc from the directory that becomes the package root.+  result <- withCurrentDirectory tmpDir $ runPipeline config tools+  case result of+    Left err -> fail $ T.unpack (formatError err)+    Right _  -> return TestEnv+      { teOutputDir    = tmpDir+      , tePipelineRan  = True+      , tePipelineExit = ExitSuccess+      , tePipelineError = ""+      }++-- | Set up the Tauri test environment: a user-owned project with a plexus/ output subdir+setupTauriTestEnv :: IO TauriTestEnv+setupTauriTestEnv = do+  tmpBase <- getTemporaryDirectory+  let tmpDir = tmpBase </> "synapse-cc-tauri-test"+  tmpExists <- doesDirectoryExist tmpDir+  when tmpExists $ removeDirectoryRecursive tmpDir+  createDirectoryIfMissing True tmpDir++  -- Write a user-owned package.json (no _generatedBy marker = integration mode)+  writeFile (tmpDir </> "package.json") $+    "{\n  \"name\": \"my-tauri-app\",\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"private\": true\n}\n"++  -- Output goes into a plexus/ subdirectory (like a real Tauri integration)+  let outputDir = tmpDir </> "plexus"++  let args = [ "typescript", "substrate"+             , "-P", "4444"+             , "-o", outputDir+             , "--transport", "browser"+             , "--force"+             , "--no-build"+             , "--no-tests"+             ]+  config <- case getParseResult (execParserPure defaultPrefs synapseCCParserInfo args) of+    Nothing -> fail "Failed to parse tauri synapse-cc arguments"+    Just c  -> return c++  toolsResult <- discoverTools (cfgOptions config)+  tools <- case toolsResult of+    Left err -> fail $ T.unpack (formatError err)+    Right t  -> return t++  result <- withCurrentDirectory tmpDir $ runPipeline config tools+  case result of+    Left err -> fail $ T.unpack (formatError err)+    Right _  -> return TauriTestEnv+      { taOutputDir = outputDir+      , taProjDir   = tmpDir+      }++main :: IO ()+main = do+  setLocaleEncoding utf8+  env <- setupTestEnv+  tauriEnv <- setupTauriTestEnv+  let dir = teOutputDir env+  hspec $ do+    -- ═══════════════════════════════════════════+    -- Section 1: Pipeline+    -- ═══════════════════════════════════════════+    describe "Pipeline" $ do+      it "exit code is 0" $ do+        tePipelineExit env `shouldBe` ExitSuccess++      it "output directory exists" $ do+        exists <- doesDirectoryExist dir+        exists `shouldBe` True++    -- ═══════════════════════════════════════════+    -- Section 2: File structure+    -- ═══════════════════════════════════════════+    describe "File structure" $ do+      let coreFiles =+            [ "types.ts", "rpc.ts", "transport.ts", "index.ts"+            , "package.json", "tsconfig.json", "ir.json"+            ]+      forM_ coreFiles $ \f ->+        it ("core file exists: " ++ f) $ do+          exists <- doesFileExist (dir </> f)+          exists `shouldBe` True++      let knownNamespaces = ["echo", "health", "cone", "hyperforge"]+      forM_ knownNamespaces $ \ns -> do+        it (ns ++ "/types.ts exists") $ do+          exists <- doesFileExist (dir </> ns </> "types.ts")+          exists `shouldBe` True+        it (ns ++ "/client.ts exists") $ do+          exists <- doesFileExist (dir </> ns </> "client.ts")+          exists `shouldBe` True+        it (ns ++ "/index.ts exists") $ do+          exists <- doesFileExist (dir </> ns </> "index.ts")+          exists `shouldBe` True++      it "nested namespace: solar/earth/luna/client.ts exists" $ do+        exists <- doesFileExist (dir </> "solar" </> "earth" </> "luna" </> "client.ts")+        exists `shouldBe` True++      it "test/smoke.test.ts exists" $ do+        exists <- doesFileExist (dir </> "test" </> "smoke.test.ts")+        exists `shouldBe` True++    -- ═══════════════════════════════════════════+    -- Section 3: IR invariants+    -- ═══════════════════════════════════════════+    describe "IR invariants" $ do+      it "ir.json parses as valid JSON" $ do+        bs <- LBS.readFile (dir </> "ir.json")+        let mVal = decode bs :: Maybe Value+        mVal `shouldSatisfy` isJust++      it "irVersion is \"2.0\"" $ do+        bs <- LBS.readFile (dir </> "ir.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "irVersion" obj of+              Just (String v) -> T.unpack v `shouldBe` "2.0"+              other -> expectationFailure $ "irVersion not a string: " ++ show other+          _ -> expectationFailure "ir.json is not an object"++      it "irTypes is non-empty" $ do+        bs <- LBS.readFile (dir </> "ir.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "irTypes" obj of+              Just (Object types) -> KM.size types `shouldSatisfy` (> 0)+              other -> expectationFailure $ "irTypes not an object: " ++ show other+          _ -> expectationFailure "ir.json is not an object"++      it "irMethods is non-empty" $ do+        bs <- LBS.readFile (dir </> "ir.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "irMethods" obj of+              Just (Object methods) -> KM.size methods `shouldSatisfy` (> 0)+              other -> expectationFailure $ "irMethods not an object: " ++ show other+          _ -> expectationFailure "ir.json is not an object"++      it "irPlugins contains echo, health, cone, hyperforge" $ do+        bs <- LBS.readFile (dir </> "ir.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "irPlugins" obj of+              Just (Object plugins) -> do+                let keys = map Key.toText $ KM.keys plugins+                forM_ ["echo", "health", "cone", "hyperforge"] $ \ns ->+                  keys `shouldSatisfy` (T.pack ns `elem`)+              other -> expectationFailure $ "irPlugins not an object: " ++ show other+          _ -> expectationFailure "ir.json is not an object"++    -- ═══════════════════════════════════════════+    -- Section 4: Import invariants+    -- ═══════════════════════════════════════════+    describe "Import invariants" $ do+      it "no dots in import type identifiers" $ do+        tsFiles <- findFilesWithExt dir ".ts"+        forM_ tsFiles $ \f -> do+          content <- readFile f+          let importLines = filter ("import type" `isInfixOf`) (lines content)+          forM_ importLines $ \line -> do+            -- Check for patterns like {X.Y} which indicate unresolved qualified names+            let braceContent = extractBraceContent line+            forM_ braceContent $ \ident ->+              ident `shouldSatisfy` (not . ('.' `elem`))++      it "every namespace index.ts re-exports ./types and ./client" $ do+        let namespaces = ["echo", "health", "cone", "hyperforge"]+        forM_ namespaces $ \ns -> do+          let indexPath = dir </> ns </> "index.ts"+          hasTypes <- fileContains indexPath "./types"+          hasClient <- fileContains indexPath "./client"+          (hasTypes && hasClient) `shouldBe` True++    -- ═══════════════════════════════════════════+    -- Section 5: Code patterns+    -- ═══════════════════════════════════════════+    describe "Code patterns" $ do+      let clientChecks =+            [ ("echo",       "EchoClient",       "EchoClientImpl",       "createEchoClient")+            , ("health",     "HealthClient",      "HealthClientImpl",     "createHealthClient")+            , ("hyperforge", "HyperforgeClient",  "HyperforgeClientImpl", "createHyperforgeClient")+            ]+      forM_ clientChecks $ \(ns, iface, impl, factory) -> do+        it (ns ++ "/client.ts has " ++ iface ++ " interface") $ do+          has <- fileContains (dir </> ns </> "client.ts") ("interface " ++ iface)+          has `shouldBe` True++        it (ns ++ "/client.ts has " ++ impl ++ " class") $ do+          has <- fileContains (dir </> ns </> "client.ts") ("class " ++ impl)+          has `shouldBe` True++        it (ns ++ "/client.ts has " ++ factory ++ " factory") $ do+          has <- fileContains (dir </> ns </> "client.ts") ("function " ++ factory)+          has `shouldBe` True++      it "echo/types.ts has discriminated union with type: field" $ do+        has <- fileContains (dir </> "echo" </> "types.ts") "type:"+        has `shouldBe` True++      it "cone/types.ts has discriminated union with type: field" $ do+        has <- fileContains (dir </> "cone" </> "types.ts") "type:"+        has `shouldBe` True++      it "echo/client.ts uses collectOne (non-streaming)" $ do+        has <- fileContains (dir </> "echo" </> "client.ts") "collectOne<"+        has `shouldBe` True++      it "cone/client.ts uses extractData (streaming)" $ do+        has <- fileContains (dir </> "cone" </> "client.ts") "extractData<"+        has `shouldBe` True++      it "root index.ts export count matches namespace directory count" $ do+        nsExports <- countNamespaceExports (dir </> "index.ts")+        nsDirs <- countNamespaceDirs dir+        -- The export count should be >= the namespace dir count+        -- (could be more due to nested namespaces like solar/earth/luna)+        nsExports `shouldSatisfy` (>= nsDirs)++    -- ═══════════════════════════════════════════+    -- Section 6: package.json+    -- ═══════════════════════════════════════════+    describe "package.json" $ do+      it "parses as valid JSON" $ do+        bs <- LBS.readFile (dir </> "package.json")+        let mVal = decode bs :: Maybe Value+        mVal `shouldSatisfy` isJust++      it "name is @plexus/client" $ do+        bs <- LBS.readFile (dir </> "package.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "name" obj of+              Just (String name) -> T.unpack name `shouldBe` "@plexus/client"+              other -> expectationFailure $ "name not a string: " ++ show other+          _ -> expectationFailure "package.json is not an object"++      it "type is module" $ do+        bs <- LBS.readFile (dir </> "package.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "type" obj of+              Just (String t) -> T.unpack t `shouldBe` "module"+              other -> expectationFailure $ "type not a string: " ++ show other+          _ -> expectationFailure "package.json is not an object"++      it "has scripts.test" $ do+        bs <- LBS.readFile (dir </> "package.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "scripts" obj of+              Just (Object scripts) ->+                KM.lookup "test" scripts `shouldSatisfy` isJust+              other -> expectationFailure $ "scripts not an object: " ++ show other+          _ -> expectationFailure "package.json is not an object"++      it "has scripts.typecheck" $ do+        bs <- LBS.readFile (dir </> "package.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "scripts" obj of+              Just (Object scripts) ->+                KM.lookup "typecheck" scripts `shouldSatisfy` isJust+              other -> expectationFailure $ "scripts not an object: " ++ show other+          _ -> expectationFailure "package.json is not an object"++      it "has typescript devDependency" $ do+        bs <- LBS.readFile (dir </> "package.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "devDependencies" obj of+              Just (Object devDeps) ->+                KM.lookup "typescript" devDeps `shouldSatisfy` isJust+              other -> expectationFailure $ "devDependencies not an object: " ++ show other+          _ -> expectationFailure "package.json is not an object"++      it "has ws dependency" $ do+        bs <- LBS.readFile (dir </> "package.json")+        case decode bs of+          Just (Object obj) -> do+            case KM.lookup "dependencies" obj of+              Just (Object deps) ->+                KM.lookup "ws" deps `shouldSatisfy` isJust+              other -> expectationFailure $ "dependencies not an object: " ++ show other+          _ -> expectationFailure "package.json is not an object"++    -- ═══════════════════════════════════════════+    -- Section 7: TypeScript smoke tests+    -- ═══════════════════════════════════════════+    describe "TypeScript smoke tests" $ do+      it "bun install exits 0" $ do+        bunAvail <- findExecutable "bun"+        case bunAvail of+          Nothing -> pendingWith "bun not found on PATH"+          Just bun -> do+            let p = (proc bun ["install"]) { cwd = Just dir }+            (exit, stdout, stderr) <- readCreateProcessWithExitCode p ""+            case exit of+              ExitSuccess -> return ()+              ExitFailure 127 -> pendingWith $+                "bun runtime unavailable: " ++ take 200 (stderr ++ stdout)+              ExitFailure c -> expectationFailure $+                "bun install failed (exit " ++ show c ++ "): "+                ++ take 500 (stderr ++ stdout)++      it "bun x tsc --noEmit exits 0" $ do+        bunAvail <- findExecutable "bun"+        case bunAvail of+          Nothing -> pendingWith "bun not found on PATH"+          Just bun -> do+            nodeModules <- doesDirectoryExist (dir </> "node_modules")+            unless nodeModules $ pendingWith "node_modules not found (bun install may have failed)"+            let p = (proc bun ["x", "tsc", "--noEmit"]) { cwd = Just dir }+            (exit, stdout, stderr) <- readCreateProcessWithExitCode p ""+            case exit of+              ExitSuccess -> return ()+              ExitFailure 127 -> pendingWith $+                "bun runtime unavailable: " ++ take 200 (stderr ++ stdout)+              ExitFailure c -> expectationFailure $+                "tsc --noEmit failed (exit " ++ show c ++ "): "+                ++ take 500 (stderr ++ stdout)++      it "bun test exits 0" $ do+        bunAvail <- findExecutable "bun"+        case bunAvail of+          Nothing -> pendingWith "bun not found on PATH"+          Just bun -> do+            nodeModules <- doesDirectoryExist (dir </> "node_modules")+            unless nodeModules $ pendingWith "node_modules not found (bun install may have failed)"+            -- Discover substrate's actual URL through the registry and patch+            -- the smoke test if it points at the wrong port (e.g. registry port)+            let smokeTest = dir </> "test" </> "smoke.test.ts"+            smokeExists <- doesFileExist smokeTest+            when smokeExists $ do+              mUrl <- discoverSubstrateUrl "4444"+              case mUrl of+                Just url -> patchSmokeTestUrl smokeTest url+                Nothing -> putStrLn "  Could not discover substrate URL, running smoke test as-is"+            let p = (proc bun ["test"]) { cwd = Just dir }+            (exit, stdout, stderr) <- readCreateProcessWithExitCode p ""+            case exit of+              ExitSuccess -> return ()+              ExitFailure 127 -> pendingWith $+                "bun runtime unavailable: " ++ take 200 (stderr ++ stdout)+              ExitFailure c -> expectationFailure $+                "bun test failed (exit " ++ show c ++ "):\n"+                ++ take 500 stdout ++ "\n" ++ take 500 stderr++    -- ═══════════════════════════════════════════+    -- Section 8: Tauri (browser transport)+    -- ═══════════════════════════════════════════+    describe "Tauri (browser transport)" $ do+      let tdir = taOutputDir tauriEnv++      describe "Integration mode file structure" $ do+        it "output directory exists" $ do+          exists <- doesDirectoryExist tdir+          exists `shouldBe` True++        it "no tsconfig.json in output (integration mode)" $ do+          exists <- doesFileExist (tdir </> "tsconfig.json")+          exists `shouldBe` False++        it "no test/ directory in output (integration mode)" $ do+          exists <- doesDirectoryExist (tdir </> "test")+          exists `shouldBe` False++        it "no package.json in output (host project owns it)" $ do+          exists <- doesFileExist (tdir </> "package.json")+          exists `shouldBe` False++        let coreFiles = ["types.ts", "rpc.ts", "transport.ts", "index.ts", "ir.json"]+        forM_ coreFiles $ \f ->+          it ("core file exists: " ++ f) $ do+            exists <- doesFileExist (tdir </> f)+            exists `shouldBe` True++      describe "Browser transport constraints" $ do+        it "transport.ts has no 'import WebSocket from ws'" $ do+          content <- readFile (tdir </> "transport.ts")+          (("import WebSocket from 'ws'" `isInfixOf` content) ||+           ("import WebSocket from \"ws\"" `isInfixOf` content))+            `shouldBe` False++        it "no 'export namespace' in any generated .ts file" $ do+          tsFiles <- findFilesWithExt tdir ".ts"+          forM_ tsFiles $ \f -> do+            content <- readFile f+            content `shouldSatisfy` (not . ("export namespace" `isInfixOf`))++        it "no parameter properties (private readonly rpc) in any generated .ts file" $ do+          tsFiles <- findFilesWithExt tdir ".ts"+          forM_ tsFiles $ \f -> do+            content <- readFile f+            content `shouldSatisfy` (not . ("private readonly rpc" `isInfixOf`))++      describe "TypeScript compilation (erasableSyntaxOnly)" $ do+        it "tsc --noEmit with erasableSyntaxOnly: true exits 0" $ do+          -- Write a strict tsconfig at project root targeting just the generated output+          let tsconfigContent = unlines+                [ "{"+                , "  \"compilerOptions\": {"+                , "    \"target\": \"ES2022\","+                , "    \"module\": \"ESNext\","+                , "    \"moduleResolution\": \"bundler\","+                , "    \"strict\": true,"+                , "    \"erasableSyntaxOnly\": true,"+                , "    \"noUnusedLocals\": true,"+                , "    \"lib\": [\"ES2022\", \"DOM\"]"+                , "  },"+                , "  \"include\": [\"plexus/**/*.ts\"]"+                , "}"+                ]+          writeFile (taProjDir tauriEnv </> "tsconfig.tauri-check.json") tsconfigContent+          let cp = (proc "bun" ["x", "tsc", "--noEmit", "-p", "tsconfig.tauri-check.json"])+                     { cwd = Just (taProjDir tauriEnv) }+          (exit, stdout, stderr) <- readCreateProcessWithExitCode cp ""+          when (exit /= ExitSuccess) $+            expectationFailure $ "tsc with erasableSyntaxOnly failed:\n" ++ stdout ++ stderr+          exit `shouldBe` ExitSuccess++    -- ═══════════════════════════════════════════+    -- Section 9: Merge and cache invariants+    -- ═══════════════════════════════════════════+    -- These tests run a second pipeline against the already-generated dir+    -- to verify the two most important correctness guarantees:+    --   (a) user edits survive a normal rerun (three-way merge skips modified files)+    --   (b) --force overwrites user edits (explicit opt-in to overwrite)+    describe "Merge and cache invariants" $ do++      -- Helper: run the pipeline with arbitrary extra args into `dir` as both+      -- cwd and output, using the already-discovered tools.+      let runAgain extraArgs = do+            let args = [ "typescript", "substrate"+                       , "-P", "4444"+                       , "-o", dir+                       , "--no-build", "--no-tests"+                       ] ++ extraArgs+            cfg <- case getParseResult (execParserPure defaultPrefs synapseCCParserInfo args) of+                     Nothing -> fail "Failed to parse args for second run"+                     Just c  -> return c+            t <- discoverTools (cfgOptions cfg) >>= either (fail . T.unpack . formatError) return+            withCurrentDirectory dir $ runPipeline cfg t++      it "user edit in a generated file survives rerun without --force" $ do+        -- This is the core safety guarantee of the whole system.+        -- If it breaks, users lose work every time they regenerate.+        let target = dir </> "rpc.ts"+        original <- TIO.readFile target+        let userEdit = original <> "\n// user addition — must survive rerun"+        TIO.writeFile target userEdit++        _ <- runAgain []   -- no --force++        after <- TIO.readFile target+        after `shouldBe` userEdit++      it "user edit is overwritten with --force" $ do+        -- --force must actually force; silent failure would make the flag useless.+        let target = dir </> "rpc.ts"+        original <- TIO.readFile target+        let userEdit = original <> "\n// this should be gone after --force"+        TIO.writeFile target userEdit++        _ <- runAgain ["--force"]++        after <- TIO.readFile target+        after `shouldNotBe` userEdit++    -- ═══════════════════════════════════════════+    -- Section 10: synapse.config.json+    -- ═══════════════════════════════════════════+    describe "synapse.config.json" $ do++      describe "initSynapseConfig" $ do+        it "creates a valid JSON file" $ do+          tmpBase <- getTemporaryDirectory+          let dir' = tmpBase </> "synapse-cc-init-test"+          tmpExists <- doesDirectoryExist dir'+          when tmpExists $ removeDirectoryRecursive dir'+          createDirectoryIfMissing True dir'+          -- Pass [] — no detectors, so defaults apply regardless of test environment+          result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []+          result `shouldSatisfy` isRight+          exists <- doesFileExist (dir' </> synapseConfigPath)+          exists `shouldBe` True++        it "returns Left if file already exists" $ do+          tmpBase <- getTemporaryDirectory+          let dir' = tmpBase </> "synapse-cc-init-exists-test"+          createDirectoryIfMissing True dir'+          writeFile (dir' </> synapseConfigPath) "{}"+          result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []+          result `shouldSatisfy` isLeft++        it "created file round-trips through loadSynapseConfig" $ do+          tmpBase <- getTemporaryDirectory+          let dir' = tmpBase </> "synapse-cc-init-roundtrip-test"+          tmpExists <- doesDirectoryExist dir'+          when tmpExists $ removeDirectoryRecursive dir'+          createDirectoryIfMissing True dir'+          _ <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []+          result <- withCurrentDirectory dir' loadSynapseConfig+          result `shouldSatisfy` isRight++      describe "loadSynapseConfig" $ do+        it "returns Left when file is missing" $ do+          tmpBase <- getTemporaryDirectory+          let dir' = tmpBase </> "synapse-cc-no-config-test"+          tmpExists <- doesDirectoryExist dir'+          when tmpExists $ removeDirectoryRecursive dir'+          createDirectoryIfMissing True dir'+          result <- withCurrentDirectory dir' loadSynapseConfig+          result `shouldSatisfy` isLeft++        it "parses the standalone fixture" $ do+          let fixturePath = "test/fixtures/standalone.config.json"+          exists <- doesFileExist fixturePath+          if not exists+            then pendingWith "test/fixtures/standalone.config.json not found"+            else do+              bs <- BS.readFile fixturePath+              case Aeson.decodeStrict bs of+                Nothing  -> expectationFailure "Fixture failed to parse as JSON"+                Just cfg -> scBackend (cfg :: SynapseConfig) `shouldBe` Just "substrate"++        it "fixture has two targets" $ do+          let fixturePath = "test/fixtures/standalone.config.json"+          exists <- doesFileExist fixturePath+          if not exists+            then pendingWith "test/fixtures/standalone.config.json not found"+            else do+              bs <- BS.readFile fixturePath+              case Aeson.decodeStrict bs of+                Nothing  -> expectationFailure "Fixture failed to parse"+                Just cfg -> Map.size (scTargets (cfg :: SynapseConfig)) `shouldBe` 2++      describe "validateSynapseConfig" $ do+        it "accepts defaultSynapseConfig" $+          validateSynapseConfig defaultSynapseConfig `shouldBe` Right ()++        it "rejects empty backend" $+          validateSynapseConfig (defaultSynapseConfig { scBackend = Just "" })+            `shouldSatisfy` isLeft++        it "rejects whitespace-only backend" $+          validateSynapseConfig (defaultSynapseConfig { scBackend = Just "   " })+            `shouldSatisfy` isLeft++        it "rejects empty targets map" $+          validateSynapseConfig (defaultSynapseConfig { scTargets = Map.empty })+            `shouldSatisfy` isLeft++        it "rejects target with empty generate list" $ do+          let badTarget = TargetConfig+                { tcGenerate  = []+                , tcTransport = WsTransport+                , tcOutputDir = "out"+                , tcSmokePath = Nothing+                }+          let cfg = defaultSynapseConfig { scTargets = Map.singleton "t" badTarget }+          validateSynapseConfig cfg `shouldSatisfy` isLeft++        it "rejects target with empty outputDir" $ do+          let badTarget = TargetConfig+                { tcGenerate  = ["plugins"]+                , tcTransport = WsTransport+                , tcOutputDir = ""+                , tcSmokePath = Nothing+                }+          let cfg = defaultSynapseConfig { scTargets = Map.singleton "t" badTarget }+          validateSynapseConfig cfg `shouldSatisfy` isLeft++      describe "JSON round-trip" $ do+        it "encode/decode SynapseConfig preserves data" $ do+          let cfg = defaultSynapseConfig+          case Aeson.decode (Aeson.encode cfg) of+            Nothing      -> expectationFailure "Failed to decode encoded SynapseConfig"+            Just decoded -> scBackend (decoded :: SynapseConfig) `shouldBe` scBackend cfg++        it "WatchConfig defaults parse correctly" $ do+          let json = "{\"pollInterval\":500,\"hotReload\":true}"+          case Aeson.decodeStrict (BSC8.pack json) of+            Nothing -> expectationFailure "Failed to parse WatchConfig"+            Just wc -> do+              wcPollInterval (wc :: WatchConfig) `shouldBe` 500+              wcHotReload wc `shouldBe` True++    -- ═══════════════════════════════════════════+    -- Section 11: CLI subcommand parsing+    -- ═══════════════════════════════════════════+    describe "CLI subcommand parsing" $ do+      let parse args = getParseResult (execParserPure defaultPrefs synapseCCCommandParserInfo args)++      describe "build subcommand" $ do+        it "parses 'build typescript substrate'" $+          parse ["build", "typescript", "substrate"] `shouldSatisfy` isJust++        it "produces CmdBuild" $ do+          case parse ["build", "typescript", "substrate"] of+            Just (CmdBuild _) -> pure ()+            other -> expectationFailure $ "Expected CmdBuild, got: " ++ show other++        it "--transport browser gives BrowserTransport" $ do+          case parse ["build", "typescript", "substrate", "--transport", "browser"] of+            Just (CmdBuild cfg) -> optTransport (cfgOptions cfg) `shouldBe` BrowserTransport+            _ -> expectationFailure "Parse failed or wrong command"++        it "--no-install disables dep install" $ do+          case parse ["build", "typescript", "substrate", "--no-install"] of+            Just (CmdBuild cfg) -> optInstallDeps (cfgOptions cfg) `shouldBe` False+            _ -> expectationFailure "Parse failed"++        it "--no-build disables build step" $ do+          case parse ["build", "typescript", "substrate", "--no-build"] of+            Just (CmdBuild cfg) -> optBuild (cfgOptions cfg) `shouldBe` False+            _ -> expectationFailure "Parse failed"++        it "--force enables force flag" $ do+          case parse ["build", "typescript", "substrate", "--force"] of+            Just (CmdBuild cfg) -> optForce (cfgOptions cfg) `shouldBe` True+            _ -> expectationFailure "Parse failed"++        it "unknown target fails to parse" $+          parse ["build", "cobol", "substrate"] `shouldSatisfy` isNothing++        it "'build' with no args produces CmdBuildFromConfig" $+          case parse ["build"] of+            Just (CmdBuildFromConfig _) -> pure ()+            other -> expectationFailure $ "Expected CmdBuildFromConfig, got: " ++ show other++        it "'build --no-install' with no positional args passes flag through" $+          case parse ["build", "--no-install"] of+            Just (CmdBuildFromConfig opts) -> optInstallDeps opts `shouldBe` False+            other -> expectationFailure $ "Expected CmdBuildFromConfig, got: " ++ show other++        it "'build --force' with no positional args passes flag through" $+          case parse ["build", "--force"] of+            Just (CmdBuildFromConfig opts) -> optForce opts `shouldBe` True+            other -> expectationFailure $ "Expected CmdBuildFromConfig, got: " ++ show other++      describe "watch subcommand" $ do+        it "parses 'watch substrate'" $+          parse ["watch", "substrate"] `shouldSatisfy` isJust++        it "produces CmdWatch" $ do+          case parse ["watch", "substrate"] of+            Just (CmdWatch _) -> pure ()+            other -> expectationFailure $ "Expected CmdWatch, got: " ++ show other++        it "backend is set correctly" $ do+          case parse ["watch", "substrate"] of+            Just (CmdWatch wa) -> waBackend wa `shouldBe` "substrate"+            _ -> expectationFailure "Parse failed"++        it "no plugin args -> empty list" $ do+          case parse ["watch", "substrate"] of+            Just (CmdWatch wa) -> waPlugins wa `shouldBe` []+            _ -> expectationFailure "Parse failed"++        it "plugin args become prefix filters" $ do+          case parse ["watch", "substrate", "echo", "health"] of+            Just (CmdWatch wa) -> waPlugins wa `shouldBe` ["echo", "health"]+            other -> expectationFailure $ "Expected CmdWatch with plugins, got: " ++ show other++        it "--target pins to a named target" $ do+          case parse ["watch", "substrate", "echo", "--target", "client"] of+            Just (CmdWatch wa) -> waTarget wa `shouldBe` Just "client"+            other -> expectationFailure $ show other++        it "no --target -> Nothing" $ do+          case parse ["watch", "substrate"] of+            Just (CmdWatch wa) -> waTarget wa `shouldBe` Nothing+            _ -> expectationFailure "Parse failed"++      describe "init subcommand" $ do+        it "parses 'init'" $+          parse ["init"] `shouldSatisfy` isJust++        it "produces CmdInit" $ do+          case parse ["init"] of+            Just CmdInit -> pure ()+            other -> expectationFailure $ "Expected CmdInit, got: " ++ show other++      describe "legacy build parser compatibility" $ do+        it "synapseCCParserInfo still parses old-style args" $+          getParseResult (execParserPure defaultPrefs synapseCCParserInfo+            ["typescript", "substrate", "--no-build", "--no-tests"])+            `shouldSatisfy` isJust++    -- ═══════════════════════════════════════════+    -- Section 12: Watch utilities (pure)+    -- ═══════════════════════════════════════════+    describe "Watch utilities" $ do++      describe "matchesAnyPrefix" $ do+        it "exact match" $+          matchesAnyPrefix ["echo"] "echo" `shouldBe` True+        it "prefix.child match" $+          matchesAnyPrefix ["echo"] "echo.workspace" `shouldBe` True+        it "deep prefix match" $+          matchesAnyPrefix ["echo"] "echo.workspace.repos" `shouldBe` True+        it "does NOT match substring without dot" $+          matchesAnyPrefix ["echo"] "echoes" `shouldBe` False+        it "does NOT match unrelated namespace" $+          matchesAnyPrefix ["echo"] "health" `shouldBe` False+        it "empty filter list matches nothing" $+          matchesAnyPrefix [] "echo" `shouldBe` False+        it "multiple prefixes, first matches" $+          matchesAnyPrefix ["echo", "health"] "echo.run" `shouldBe` True+        it "multiple prefixes, second matches" $+          matchesAnyPrefix ["echo", "health"] "health.status" `shouldBe` True+        it "multiple prefixes, none match" $+          matchesAnyPrefix ["echo", "health"] "cone.render" `shouldBe` False++      describe "parseUrl" $ do+        it "ws://localhost:4444" $+          parseUrl "ws://localhost:4444" `shouldBe` ("localhost", "4444")+        it "ws://127.0.0.1:4444" $+          parseUrl "ws://127.0.0.1:4444" `shouldBe` ("127.0.0.1", "4444")+        it "wss://host:8443" $+          parseUrl "wss://host:8443" `shouldBe` ("host", "8443")+        it "path after port is ignored" $+          snd (parseUrl "ws://localhost:4444/backend") `shouldBe` "4444"+        it "defaults port to 4444 when missing" $+          snd (parseUrl "ws://localhost") `shouldBe` "4444"+        it "custom port 9999" $+          snd (parseUrl "ws://myhost:9999") `shouldBe` "9999"++      describe "extractPluginHashesFromBytes" $ do+        it "returns empty map for invalid JSON" $+          extractPluginHashesFromBytes "not json" `shouldBe` Map.empty++        it "returns empty map for JSON without irPluginHashes" $+          extractPluginHashesFromBytes "{\"irVersion\":\"2.0\"}" `shouldBe` Map.empty++        it "extracts hash from irPluginHashes" $ do+          let ir = BSC8.pack "{\"irPluginHashes\":{\"echo\":{\"hash\":\"abc123\",\"selfHash\":\"x\",\"childrenHash\":\"y\"}}}"+          Map.lookup "echo" (extractPluginHashesFromBytes ir) `shouldBe` Just "abc123"++        it "extracts multiple plugin hashes" $ do+          let ir = BSC8.pack "{\"irPluginHashes\":{\"echo\":{\"hash\":\"aaa\"},\"health\":{\"hash\":\"bbb\"}}}"+          let hashes = extractPluginHashesFromBytes ir+          Map.size hashes `shouldBe` 2+          Map.lookup "echo"   hashes `shouldBe` Just "aaa"+          Map.lookup "health" hashes `shouldBe` Just "bbb"++        it "returns empty string for hash field missing" $ do+          let ir = BSC8.pack "{\"irPluginHashes\":{\"echo\":{\"selfHash\":\"x\"}}}"+          Map.lookup "echo" (extractPluginHashesFromBytes ir) `shouldBe` Just ""++      describe "filterTargets" $ do+        let mkTarget gen = TargetConfig gen WsTransport "out" Nothing+        let targets = Map.fromList+              [ ("client", mkTarget ["transport", "rpc", "plugins"])+              , ("smoke",  mkTarget ["smoke"])+              , ("all",    mkTarget ["all"])+              , ("empty",  mkTarget ["transport"])+              ]+        let noPin = WatchArgs "substrate" [] Nothing Nothing defaultOptions++        it "includes targets with 'plugins'" $+          Map.member "client" (filterTargets noPin targets) `shouldBe` True+        it "excludes targets without 'plugins' or 'all'" $+          Map.member "smoke" (filterTargets noPin targets) `shouldBe` False+        it "includes targets with 'all'" $+          Map.member "all" (filterTargets noPin targets) `shouldBe` True+        it "excludes transport-only targets" $+          Map.member "empty" (filterTargets noPin targets) `shouldBe` False++        it "--target pins to exactly one target" $ do+          let pinned = WatchArgs "substrate" [] (Just "smoke") Nothing defaultOptions+          Map.keys (filterTargets pinned targets) `shouldBe` ["smoke"]++        it "--target for missing name returns empty" $ do+          let pinned = WatchArgs "substrate" [] (Just "nonexistent") Nothing defaultOptions+          filterTargets pinned targets `shouldBe` Map.empty++      describe "buildConfigFromTarget" $ do+        it "sets outputDir from target" $ do+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions+          let sc  = defaultSynapseConfig { scUrl = Just "ws://localhost:4444" }+          let tc  = TargetConfig ["plugins"] BrowserTransport "my/output/dir" Nothing+          let cfg = buildConfigFromTarget wa sc tc+          optOutput (cfgOptions cfg) `shouldBe` "my/output/dir"++        it "sets transport from target" $ do+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions+          let sc  = defaultSynapseConfig+          let tc  = TargetConfig ["plugins"] BrowserTransport "out" Nothing+          let cfg = buildConfigFromTarget wa sc tc+          optTransport (cfgOptions cfg) `shouldBe` BrowserTransport++        it "parses host from URL" $ do+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions+          let sc  = defaultSynapseConfig { scUrl = Just "ws://myhost:9000" }+          let tc  = TargetConfig ["plugins"] WsTransport "out" Nothing+          let cfg = buildConfigFromTarget wa sc tc+          cfgHost cfg `shouldBe` "myhost"++        it "parses port from URL" $ do+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions+          let sc  = defaultSynapseConfig { scUrl = Just "ws://localhost:9000" }+          let tc  = TargetConfig ["plugins"] WsTransport "out" Nothing+          let cfg = buildConfigFromTarget wa sc tc+          cfgPort cfg `shouldBe` "9000"++    -- ═══════════════════════════════════════════+    -- Section 13: Project detection (Detect.hs)+    -- ═══════════════════════════════════════════+    describe "SynapseCC.Detect" $ do++      -- Helper: create a temp project dir, run action, clean up.+      -- The action receives the dir path; withCurrentDirectory is the caller's responsibility.+      let withTempProject :: (FilePath -> IO a) -> IO a+          withTempProject action = do+            tmpBase <- getTemporaryDirectory+            let dir = tmpBase </> "synapse-detect-test"+            exists <- doesDirectoryExist dir+            when exists $ removeDirectoryRecursive dir+            createDirectoryIfMissing True dir+            action dir++      describe "detectTauri" $ do+        it "returns BrowserTransport when src-tauri/ exists" $ do+          withTempProject $ \dir -> do+            createDirectoryIfMissing True (dir </> "src-tauri")+            result <- withCurrentDirectory dir (runDetector detectTauri)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns BrowserTransport when tauri.conf.json exists" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "tauri.conf.json") "{}"+            result <- withCurrentDirectory dir (runDetector detectTauri)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns BrowserTransport when src-tauri/tauri.conf.json exists" $ do+          withTempProject $ \dir -> do+            createDirectoryIfMissing True (dir </> "src-tauri")+            writeFile (dir </> "src-tauri" </> "tauri.conf.json") "{}"+            result <- withCurrentDirectory dir (runDetector detectTauri)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns emptyHint when no Tauri markers present" $ do+          withTempProject $ \dir -> do+            result <- withCurrentDirectory dir (runDetector detectTauri)+            phTransport result `shouldBe` Nothing++      describe "detectVite" $ do+        it "returns BrowserTransport for vite.config.ts" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "vite.config.ts") "export default {}"+            result <- withCurrentDirectory dir (runDetector detectVite)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns BrowserTransport for vite.config.js" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "vite.config.js") "module.exports = {}"+            result <- withCurrentDirectory dir (runDetector detectVite)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns BrowserTransport for vite.config.mts" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "vite.config.mts") "export default {}"+            result <- withCurrentDirectory dir (runDetector detectVite)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns emptyHint when no Vite markers present" $ do+          withTempProject $ \dir -> do+            result <- withCurrentDirectory dir (runDetector detectVite)+            phTransport result `shouldBe` Nothing++      describe "detectNextJS" $ do+        it "returns BrowserTransport for next.config.js" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "next.config.js") "module.exports = {}"+            result <- withCurrentDirectory dir (runDetector detectNextJS)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns BrowserTransport for next.config.ts" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "next.config.ts") "export default {}"+            result <- withCurrentDirectory dir (runDetector detectNextJS)+            phTransport result `shouldBe` Just BrowserTransport++        it "returns emptyHint when no Next.js markers present" $ do+          withTempProject $ \dir -> do+            result <- withCurrentDirectory dir (runDetector detectNextJS)+            phTransport result `shouldBe` Nothing++      describe "detectNodeProject" $ do+        it "returns WsTransport when package.json exists" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "package.json") "{\"name\":\"test\"}"+            result <- withCurrentDirectory dir (runDetector detectNodeProject)+            phTransport result `shouldBe` Just WsTransport++        it "returns emptyHint when package.json is absent" $ do+          withTempProject $ \dir -> do+            result <- withCurrentDirectory dir (runDetector detectNodeProject)+            phTransport result `shouldBe` Nothing++      describe "runDetectors priority" $ do+        it "Tauri beats Node (browser wins when both src-tauri/ and package.json present)" $ do+          withTempProject $ \dir -> do+            createDirectoryIfMissing True (dir </> "src-tauri")+            writeFile (dir </> "package.json") "{}"+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)+            phTransport result `shouldBe` Just BrowserTransport++        it "Vite beats Node (browser wins when both vite.config.ts and package.json present)" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "vite.config.ts") "export default {}"+            writeFile (dir </> "package.json") "{}"+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)+            phTransport result `shouldBe` Just BrowserTransport++        it "NextJS beats Node (browser wins when both next.config.js and package.json present)" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "next.config.js") "module.exports = {}"+            writeFile (dir </> "package.json") "{}"+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)+            phTransport result `shouldBe` Just BrowserTransport++        it "bare Node project → WsTransport (fallback)" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "package.json") "{}"+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)+            phTransport result `shouldBe` Just WsTransport++        it "empty project → no transport opinion" $ do+          withTempProject $ \dir -> do+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)+            phTransport result `shouldBe` Nothing++        it "custom detector prepended overrides defaultDetectors" $ do+          withTempProject $ \dir -> do+            -- Custom detector that always says WsTransport, even in a Tauri project+            let myDetector = Detector "custom" (pure emptyHint { phTransport = Just WsTransport })+            createDirectoryIfMissing True (dir </> "src-tauri")+            result <- withCurrentDirectory dir (runDetectors (myDetector : defaultDetectors))+            phTransport result `shouldBe` Just WsTransport++      describe "initSynapseConfig with detectors" $ do+        it "Tauri project → generated config has browser transport" $ do+          withTempProject $ \dir -> do+            createDirectoryIfMissing True (dir </> "src-tauri")+            hint <- withCurrentDirectory dir $+              initSynapseConfig synapseConfigPath defaultDetectors+            hint `shouldSatisfy` isRight+            cfgResult <- withCurrentDirectory dir loadSynapseConfig+            case cfgResult of+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err+              Right sc -> do+                let transports = map tcTransport (Map.elems (scTargets sc))+                transports `shouldSatisfy` all (== BrowserTransport)++        it "Vite project → generated config has browser transport" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "vite.config.ts") "export default {}"+            hint <- withCurrentDirectory dir $+              initSynapseConfig synapseConfigPath defaultDetectors+            hint `shouldSatisfy` isRight+            cfgResult <- withCurrentDirectory dir loadSynapseConfig+            case cfgResult of+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err+              Right sc -> do+                let transports = map tcTransport (Map.elems (scTargets sc))+                transports `shouldSatisfy` all (== BrowserTransport)++        it "Node project → generated config has ws transport" $ do+          withTempProject $ \dir -> do+            writeFile (dir </> "package.json") "{}"+            hint <- withCurrentDirectory dir $+              initSynapseConfig synapseConfigPath defaultDetectors+            hint `shouldSatisfy` isRight+            cfgResult <- withCurrentDirectory dir loadSynapseConfig+            case cfgResult of+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err+              Right sc -> do+                let transports = map tcTransport (Map.elems (scTargets sc))+                transports `shouldSatisfy` all (== WsTransport)++        it "empty project → generated config has ws transport (default)" $ do+          withTempProject $ \dir -> do+            hint <- withCurrentDirectory dir $+              initSynapseConfig synapseConfigPath defaultDetectors+            hint `shouldSatisfy` isRight+            cfgResult <- withCurrentDirectory dir loadSynapseConfig+            case cfgResult of+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err+              Right sc -> do+                -- No detector fired → no hint → default transport (WsTransport from defaultSynapseConfig)+                let transports = map tcTransport (Map.elems (scTargets sc))+                transports `shouldSatisfy` (not . null)++        it "hint reason is included in successful result" $ do+          withTempProject $ \dir -> do+            createDirectoryIfMissing True (dir </> "src-tauri")+            result <- withCurrentDirectory dir $+              initSynapseConfig synapseConfigPath defaultDetectors+            case result of+              Left err -> expectationFailure $ "Expected Right but got Left: " <> T.unpack err+              Right hint -> phReason hint `shouldSatisfy` isJust++    -- ═══════════════════════════════════════════+    -- Section 14: Standalone config-driven build+    -- ═══════════════════════════════════════════+    describe "Standalone config-driven build" $ do++      it "init + load + pipeline produces output in configured target dir" $ do+        -- Write the standalone fixture config, run the pipeline with+        -- settings derived from the config, verify output lands in tcOutputDir.+        let fixturePath = "test/fixtures/standalone.config.json"+        fixtureExists <- doesFileExist fixturePath+        when (not fixtureExists) $ pendingWith "test/fixtures/standalone.config.json not found"++        synapseAvail <- findExecutable "synapse"+        when (not (isJust synapseAvail)) $ pendingWith "synapse binary not found"++        tmpBase <- getTemporaryDirectory+        let projDir = tmpBase </> "synapse-cc-standalone-project"+        tmpExists <- doesDirectoryExist projDir+        when tmpExists $ removeDirectoryRecursive projDir+        createDirectoryIfMissing True projDir++        -- Copy fixture config into project+        fixtureBytes <- readFile fixturePath+        writeFile (projDir </> synapseConfigPath) fixtureBytes++        -- Load config and build using the "client" target+        cfgResult <- withCurrentDirectory projDir loadSynapseConfig+        case cfgResult of+          Left err -> expectationFailure $ "Config load failed: " ++ T.unpack err+          Right synapseConfig -> do+            case Map.lookup "client" (scTargets synapseConfig) of+              Nothing -> expectationFailure "Expected 'client' target in fixture"+              Just targetCfg -> do+                let wa      = WatchArgs (fromMaybe "" (scBackend synapseConfig)) [] Nothing Nothing defaultOptions+                    config  = buildConfigFromTarget wa synapseConfig targetCfg+                    config' = config { cfgOptions = (cfgOptions config)+                                { optInstallDeps = False+                                , optBuild       = False+                                , optRunTests    = False+                                , optForce       = True+                                , optOutput      = projDir </> tcOutputDir targetCfg+                                } }+                toolsResult <- discoverTools (cfgOptions config')+                case toolsResult of+                  Left err -> expectationFailure $ T.unpack (formatError err)+                  Right tools -> do+                    result <- withCurrentDirectory projDir $ runPipeline config' tools+                    case result of+                      Left err -> expectationFailure $ T.unpack (formatError err)+                      Right _  -> do+                        let outDir = projDir </> tcOutputDir targetCfg+                        exists <- doesDirectoryExist outDir+                        exists `shouldBe` True+                        typesExists <- doesFileExist (outDir </> "types.ts")+                        typesExists `shouldBe` True++      it "both targets in fixture produce separate output directories" $ do+        let fixturePath = "test/fixtures/standalone.config.json"+        fixtureExists <- doesFileExist fixturePath+        when (not fixtureExists) $ pendingWith "test/fixtures/standalone.config.json not found"+        synapseAvail <- findExecutable "synapse"+        when (not (isJust synapseAvail)) $ pendingWith "synapse binary not found"++        tmpBase <- getTemporaryDirectory+        let projDir = tmpBase </> "synapse-cc-two-targets"+        tmpExists <- doesDirectoryExist projDir+        when tmpExists $ removeDirectoryRecursive projDir+        createDirectoryIfMissing True projDir++        fixtureBytes <- readFile fixturePath+        writeFile (projDir </> synapseConfigPath) fixtureBytes++        cfgResult <- withCurrentDirectory projDir loadSynapseConfig+        case cfgResult of+          Left err -> expectationFailure $ "Config load failed: " ++ T.unpack err+          Right synapseConfig -> do+            toolsResult <- discoverTools defaultOptions+            case toolsResult of+              Left err -> expectationFailure $ T.unpack (formatError err)+              Right tools ->+                forM_ (Map.toList (scTargets synapseConfig)) $ \(targetName, targetCfg) -> do+                  let wa     = WatchArgs (fromMaybe "" (scBackend synapseConfig)) [] Nothing Nothing defaultOptions+                      config = buildConfigFromTarget wa synapseConfig targetCfg+                      config' = config { cfgOptions = (cfgOptions config)+                                  { optInstallDeps = False+                                  , optBuild       = False+                                  , optRunTests    = False+                                  , optForce       = True+                                  , optOutput      = projDir </> tcOutputDir targetCfg+                                  } }+                  result <- withCurrentDirectory projDir $ runPipeline config' tools+                  case result of+                    Left err -> expectationFailure $+                      "Target " ++ T.unpack targetName ++ " failed: " ++ T.unpack (formatError err)+                    Right _ -> do+                      exists <- doesDirectoryExist (projDir </> tcOutputDir targetCfg)+                      exists `shouldBe` True++    -- Section 14: Watch integration (requires fidget-spinner binary)+    -- ═══════════════════════════════════════════════════════════════+    describe "Watch integration (fidget-spinner)" $ do++      let testPort    = 5557 :: Int+          testPortStr = show testPort++      -- Find fidget-spinner binary (installed or build output)+      mbFidgetBin <- runIO $ do+        mb <- findExecutable "fidget-spinner"+        case mb of+          Just p  -> pure (Just p)+          Nothing -> do+            let buildPath = "/workspace/hypermemetic/fidget-spinner/target/debug/fidget-spinner"+            exists <- doesFileExist buildPath+            pure $ if exists then Just buildPath else Nothing++      -- Start fidget-spinner and wait for readiness+      -- Returns Nothing if binary not found or failed to start+      mbHandle <- runIO $ case mbFidgetBin of+        Nothing -> pure Nothing+        Just bin -> do+          (_, _, _, ph) <- createProcess (proc bin ["--port", testPortStr, "--no-register"])+            { std_out = NoStream, std_err = NoStream }+          -- Poll until ready (up to 5 seconds)+          ready <- waitForFidgetReady testPort 5000+          if ready+            then pure (Just ph)+            else do+              terminateProcess ph+              _ <- waitForProcess ph+              pure Nothing++      -- Cleanup after all tests in this section+      afterAll_ (case mbHandle of+        Nothing -> pure ()+        Just ph -> terminateProcess ph >> void (waitForProcess ph)) $ do++        it "fidget-spinner starts and backend hash is non-empty" $ do+          case mbHandle of+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"+            Just _ -> do+              let cfg = makeFidgetConfig testPort+              result <- fetchBackendHash cfg "fidget-spinner"+              result `shouldSatisfy` isRight+              case result of+                Right h -> T.length h `shouldSatisfy` (> 0)+                Left _  -> pure ()++        it "initial build generates fidget/client.ts with register/unregister/list" $ do+          case mbHandle of+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"+            Just _ -> do+              tmpBase <- getTemporaryDirectory+              let projDir = tmpBase </> "synapse-cc-fidget-initial"+              createDirectoryIfMissing True projDir+              let outDir = projDir </> "client"++              let fixturePath = "test/fixtures/fidget.config.json"+              fixtureBytes <- readFile fixturePath+              writeFile (projDir </> "synapse.config.json") fixtureBytes++              let config = makeFidgetPipelineConfig testPort outDir+              toolsResult <- discoverTools (cfgOptions config)+              case toolsResult of+                Left err -> expectationFailure $ T.unpack (formatError err)+                Right tools -> do+                  result <- withCurrentDirectory projDir $ runPipeline config tools+                  case result of+                    Left err -> expectationFailure $ T.unpack (formatError err)+                    Right _ -> do+                      let clientFile = outDir </> "fidget" </> "client.ts"+                      exists <- doesFileExist clientFile+                      exists `shouldBe` True+                      content <- readFile clientFile+                      content `shouldSatisfy` ("register" `isInfixOf`)+                      content `shouldSatisfy` ("unregister" `isInfixOf`)+                      content `shouldSatisfy` ("list" `isInfixOf`)+                      content `shouldSatisfy` (not . ("spin" `isInfixOf`))++        it "register changes the backend hash" $ do+          case mbHandle of+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"+            Just _ -> do+              let cfg = makeFidgetConfig testPort+              Right hash1 <- fetchBackendHash cfg "fidget-spinner"++              -- Register a new method+              callFidgetRegister testPortStr "hashtest" "A method to test hash changes"++              Right hash2 <- fetchBackendHash cfg "fidget-spinner"+              hash1 `shouldNotBe` hash2++              -- Cleanup: unregister it+              callFidgetUnregister testPortStr "hashtest"++        it "register + rebuild adds the method to generated TypeScript" $ do+          case mbHandle of+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"+            Just _ -> do+              tmpBase <- getTemporaryDirectory+              let projDir = tmpBase </> "synapse-cc-fidget-rebuild"+              createDirectoryIfMissing True projDir+              let outDir = projDir </> "client"++              -- Initial build+              let config = makeFidgetPipelineConfig testPort outDir+              toolsResult <- discoverTools (cfgOptions config)+              case toolsResult of+                Left err -> expectationFailure $ T.unpack (formatError err)+                Right tools -> do+                  _ <- withCurrentDirectory projDir $ runPipeline config tools++                  -- Register "spin"+                  callFidgetRegister testPortStr "spin" "Make it spin"++                  -- Rebuild with --force+                  let forceConfig = config { cfgOptions = (cfgOptions config) { optForce = True } }+                  result <- withCurrentDirectory projDir $ runPipeline forceConfig tools+                  case result of+                    Left err -> expectationFailure $ T.unpack (formatError err)+                    Right _ -> do+                      let clientFile = outDir </> "fidget" </> "client.ts"+                      content <- readFile clientFile+                      content `shouldSatisfy` ("spin" `isInfixOf`)++                  -- Cleanup+                  callFidgetUnregister testPortStr "spin"++        it "unregister + rebuild removes the method from generated TypeScript" $ do+          case mbHandle of+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"+            Just _ -> do+              tmpBase <- getTemporaryDirectory+              let projDir = tmpBase </> "synapse-cc-fidget-unregister"+              createDirectoryIfMissing True projDir+              let outDir = projDir </> "client"++              -- Register "wobble" first+              callFidgetRegister testPortStr "wobble" "A wobble method"++              -- Build with wobble present+              let config = makeFidgetPipelineConfig testPort outDir+              toolsResult <- discoverTools (cfgOptions config)+              case toolsResult of+                Left err -> expectationFailure $ T.unpack (formatError err)+                Right tools -> do+                  _ <- withCurrentDirectory projDir $ runPipeline config tools++                  -- Unregister "wobble"+                  callFidgetUnregister testPortStr "wobble"++                  -- Rebuild with --force+                  let forceConfig = config { cfgOptions = (cfgOptions config) { optForce = True } }+                  result <- withCurrentDirectory projDir $ runPipeline forceConfig tools+                  case result of+                    Left err -> expectationFailure $ T.unpack (formatError err)+                    Right _ -> do+                      let clientFile = outDir </> "fidget" </> "client.ts"+                      content <- readFile clientFile+                      content `shouldSatisfy` (not . ("wobble" `isInfixOf`))++-- | Extract identifiers from import type { ... } braces+extractBraceContent :: String -> [String]+extractBraceContent line =+  case break (== '{') line of+    (_, []) -> []+    (_, _:rest) ->+      case break (== '}') rest of+        (inside, _) ->+          map (filter (\c -> isAlpha c || c == '.' || c == '_'))+            $ filter (not . null)+            $ map strip+            $ splitOn ',' inside++-- | Split a string on a delimiter+splitOn :: Char -> String -> [String]+splitOn _ [] = []+splitOn c s =+  let (chunk, rest) = break (== c) s+  in chunk : case rest of+    [] -> []+    (_:xs) -> splitOn c xs++-- | Strip leading/trailing whitespace+strip :: String -> String+strip = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ')++-- ── fidget-spinner test helpers ──────────────────────────────────────────────++-- | Poll until fidget-spinner is ready to serve requests.+-- Returns True if ready within timeoutMs, False if timed out.+waitForFidgetReady :: Int -> Int -> IO Bool+waitForFidgetReady port timeoutMs = go (timeoutMs `div` 200)+  where+    go 0 = pure False+    go n = do+      let cfg = makeFidgetConfig port+      result <- (try (Transport.rpcCallWith cfg "fidget-spinner.hash" Aeson.Null) :: IO (Either SomeException (Either TransportError [PlexusStreamItem])))+      case result of+        Right (Right (_ : _)) -> pure True+        _ -> do+          threadDelay 200000  -- 200ms+          go (n - 1)++-- | Build a SubstrateConfig for a local fidget-spinner on the given port.+makeFidgetConfig :: Int -> SubstrateConfig+makeFidgetConfig port = (defaultConfig "fidget-spinner")+  { substrateHost = "127.0.0.1"+  , substratePort = port+  }++-- | Build a Config for running the pipeline against fidget-spinner.+makeFidgetPipelineConfig :: Int -> FilePath -> Config+makeFidgetPipelineConfig port outDir =+  Config+    { cfgTarget  = TypeScript+    , cfgBackend = Backend { backendName = "fidget-spinner" }+    , cfgHost    = "127.0.0.1"+    , cfgPort    = T.pack (show port)+    , cfgOptions = defaultOptions+        { optOutput      = outDir+        , optInstallDeps = False+        , optBuild       = False+        , optRunTests    = False+        , optForce       = True+        , optTransport   = WsTransport+        }+    }++-- | Call fidget.register via direct RPC.+-- | Call fidget.register via the fidget-spinner.call RPC routing.+callFidgetRegister :: String -> Text -> Text -> IO ()+callFidgetRegister portStr name desc = do+  let port = read portStr :: Int+      cfg  = makeFidgetConfig port+      innerParams = Aeson.object ["name" .= name, "description" .= desc]+      callParams  = Aeson.object ["method" .= ("fidget.register" :: Text), "params" .= innerParams]+  _ <- Transport.rpcCallWith cfg "fidget-spinner.call" callParams+  pure ()++-- | Call fidget.unregister via the fidget-spinner.call RPC routing.+callFidgetUnregister :: String -> Text -> IO ()+callFidgetUnregister portStr name = do+  let port = read portStr :: Int+      cfg  = makeFidgetConfig port+      innerParams = Aeson.object ["name" .= name]+      callParams  = Aeson.object ["method" .= ("fidget.unregister" :: Text), "params" .= innerParams]+  _ <- Transport.rpcCallWith cfg "fidget-spinner.call" callParams+  pure ()