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 +107/−32
- src/SynapseCC/Benchmark.hs +208/−0
- src/SynapseCC/CLI.hs +133/−22
- src/SynapseCC/Cache.hs +107/−104
- src/SynapseCC/Config.hs +155/−0
- src/SynapseCC/Detect.hs +194/−0
- src/SynapseCC/Discover.hs +172/−77
- src/SynapseCC/Language.hs +132/−58
- src/SynapseCC/Lock.hs +127/−0
- src/SynapseCC/Logging.hs +11/−1
- src/SynapseCC/Merge.hs +157/−0
- src/SynapseCC/Pipeline.hs +520/−159
- src/SynapseCC/Process.hs +28/−44
- src/SynapseCC/Types.hs +249/−50
- src/SynapseCC/Watch.hs +389/−0
- synapse-cc.cabal +32/−1
- test/Main.hs +1578/−5
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 ()