packages feed

synapse-cc 0.2.0 → 0.3.2

raw patch · 11 files changed

+626/−71 lines, 11 filesdep +katip

Dependencies added: katip

Files

app/Main.hs view
@@ -13,6 +13,7 @@ import SynapseCC.Logging import SynapseCC.Pipeline import SynapseCC.Types+import SynapseCC.Wait (runWait) import SynapseCC.Watch (runWatch)  -- ============================================================================@@ -120,6 +121,13 @@               if all (either (const False) (const True) . snd) results                 then exitSuccess                 else exitFailure++    -- ------------------------------------------------------------------+    -- synapse-cc wait [--timeout N] [--interval MS]+    -- ------------------------------------------------------------------+    CmdWait waitArgs -> do+      ok <- runWait waitArgs+      if ok then exitSuccess else exitFailure      -- ------------------------------------------------------------------     -- synapse-cc watch <backend> [plugins...] [--target name]
src/SynapseCC/CLI.hs view
@@ -42,6 +42,7 @@   <*> hostParser   <*> portParser   <*> optionsParser+  <*> pure Nothing  -- ============================================================================ -- Command Parser (with subcommands: build, watch, init)@@ -65,6 +66,8 @@       (progDesc "Build client from backend schema (default)"))  <> command "watch" (info watchCommandParser       (progDesc "Watch backend for changes and incrementally rebuild"))+ <> command "wait"  (info waitCommandParser+      (progDesc "Block until all configured backends are reachable"))  <> command "init"  (info initCommandParser       (progDesc "Scaffold a synapse.config.json in the current directory"))   )@@ -77,7 +80,7 @@   <*> portParser   <*> optionsParser   where-    mkCmd (Just t) (Just b) host port opts = CmdBuild (Config t b host port opts)+    mkCmd (Just t) (Just b) host port opts = CmdBuild (Config t b host port opts Nothing)     mkCmd _        _        _    _    opts = CmdBuildFromConfig opts  watchCommandParser :: Parser Command@@ -103,6 +106,36 @@        <> help "Poll interval in milliseconds (overrides synapse.config.json)"         ))   <*> watchOptionsParser++waitCommandParser :: Parser Command+waitCommandParser = fmap CmdWait $ WaitArgs+  <$> optional (T.pack <$> argument str+        ( metavar "BACKEND"+       <> help "Backend to wait for (omit to wait for all from config)"+        ))+  <*> optional (T.pack <$> option str+        ( long "url"+       <> short 'u'+       <> metavar "URL"+       <> help "WebSocket URL (e.g. ws://127.0.0.1:4448). Requires BACKEND arg."+        ))+  <*> option auto+        ( long "timeout"+       <> short 't'+       <> metavar "SECONDS"+       <> value 30+       <> showDefault+       <> help "Max seconds to wait before giving up"+        )+  <*> option auto+        ( long "interval"+       <> short 'i'+       <> metavar "MS"+       <> value 500+       <> showDefault+       <> help "Poll interval in milliseconds"+        )+  <*> switch (long "debug" <> help "Enable debug logging")  initCommandParser :: Parser Command initCommandParser = pure CmdInit
src/SynapseCC/Config.hs view
@@ -55,12 +55,16 @@   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 ()+  -- Each non-transport-only target needs a backend from either its own fields or top-level+  let targetsNeedingBackend = filter ((/= ["transport"]) . tcGenerate . snd) (Map.toList (scTargets cfg))+      missingBackend = filter (\(_, tc) ->+        case (tcBackend tc, scBackend cfg) of+          (Nothing, Nothing) -> True+          _                  -> False+        ) targetsNeedingBackend+  case missingBackend of+    ((name, _):_) -> Left $ "target \"" <> name <> "\": no \"backend\" specified (set per-target or top-level)"+    []            -> 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))@@ -71,13 +75,40 @@         else Right ()  validateTargetConfig :: Map Text TargetConfig -> (Text, TargetConfig) -> Either Text ()-validateTargetConfig _all (name, tc) = do+validateTargetConfig allTargets (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 ()+  -- Validate sharedTransport references+  case tcSharedTransport tc of+    Nothing -> Right ()+    Just ref -> do+      -- 1. Referenced target must exist+      case Map.lookup ref allTargets of+        Nothing -> Left $ "target \"" <> name <> "\": sharedTransport references unknown target \"" <> ref <> "\""+        Just provider -> do+          -- 2. Provider must have "transport" in its generate list+          if "transport" `notElem` tcGenerate provider && tcGenerate provider /= ["all"]+            then Left $ "target \"" <> name <> "\": sharedTransport target \"" <> ref+                      <> "\" does not generate transport"+            else Right ()+          -- 3. Both targets must use the same transport mode+          if tcTransport tc /= tcTransport provider+            then Left $ "target \"" <> name <> "\": transport mode differs from sharedTransport target \"" <> ref <> "\""+            else Right ()+          -- 4. Consumer must NOT include "transport" or "rpc" in its generate list+          if "transport" `elem` tcGenerate tc || "rpc" `elem` tcGenerate tc+            then Left $ "target \"" <> name+                      <> "\": generate must not include \"transport\" or \"rpc\" when using sharedTransport"+            else Right ()+          -- 5. No circular references+          case tcSharedTransport provider of+            Just _ -> Left $ "target \"" <> name <> "\": sharedTransport chain detected (\"" <> ref+                           <> "\" also uses sharedTransport)"+            Nothing -> Right ()  -- ============================================================================ -- Config-to-Runtime Conversions@@ -101,9 +132,9 @@ -- (--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)+  let url            = fromMaybe (fromMaybe "ws://127.0.0.1:4444" (scUrl sc)) (tcUrl tc)       (host, port)   = parseWsUrl url-      backendName    = fromMaybe "" (scBackend sc)+      backendName    = fromMaybe (fromMaybe "" (scBackend sc)) (tcBackend tc)   in Config        { cfgTarget  = parseLanguage (scLanguage sc)        , cfgBackend = Backend backendName@@ -113,6 +144,7 @@            { optOutput    = tcOutputDir tc            , optTransport = tcTransport tc            }+       , cfgPlugins = tcPlugins tc        }  -- | Scaffold a synapse.config.json, using 'detectors' to infer the right@@ -150,6 +182,6 @@   { Pretty.confCompare = Pretty.keyOrder       [ "schema", "language", "backend", "url", "packageManager"       , "watch", "pollInterval", "hotReload"-      , "targets", "generate", "transport", "outputDir", "smokePath"+      , "targets", "generate", "transport", "outputDir", "smokePath", "sharedTransport"       ]   }
src/SynapseCC/Lock.hs view
@@ -31,10 +31,11 @@  -- | 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+  { ltBackend         :: !Text+  , ltIrHash          :: !Text+  , ltTransport       :: !Text+  , ltFiles           :: !(Map Text Text)  -- ^ relPath → content hash+  , ltSharedTransport :: !(Maybe Text)     -- ^ Name of provider target (if transport is shared)   } deriving (Show, Eq)  instance FromJSON LockTarget where@@ -43,14 +44,17 @@     <*> o Aeson..: "irHash"     <*> o Aeson..: "transport"     <*> o Aeson..: "files"+    <*> o Aeson..:? "sharedTransport"  instance ToJSON LockTarget where-  toJSON lt = Aeson.object+  toJSON lt = Aeson.object $     [ "backend"   Aeson..= ltBackend lt     , "irHash"    Aeson..= ltIrHash lt     , "transport" Aeson..= ltTransport lt     , "files"     Aeson..= ltFiles lt-    ]+    ] ++ case ltSharedTransport lt of+           Nothing -> []+           Just st -> ["sharedTransport" Aeson..= st]  -- | Top-level synapse.lock structure data SynapseLock = SynapseLock@@ -96,7 +100,7 @@ lockPrettyConfig = Pretty.defConfig   { Pretty.confCompare = Pretty.keyOrder       [ "version", "synapseCC", "hubCodegen", "updatedAt", "targets"-      , "backend", "irHash", "transport", "files"+      , "backend", "irHash", "transport", "files", "sharedTransport"       ]   } 
src/SynapseCC/Merge.hs view
@@ -6,6 +6,7 @@   ( computeFileHash   , applyMerge   , cleanRemovedFiles+  , additiveMerge   , MergeResult(..)   ) where @@ -14,6 +15,7 @@ import qualified Data.ByteString.Base16 as Base16 import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map+import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE@@ -35,6 +37,7 @@   | SafeToUpdate   | Unchanged   | UserModified+  | SmartMerged   deriving (Show, Eq)  -- | Three-way merge decision table:@@ -60,11 +63,13 @@  -- | 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+  { 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+  , mrMerged       :: [Text]      -- ^ Files smart-merged (new content added, user mods preserved)+  , mrMergedHashes :: Map Text Text  -- ^ relPath -> merged hash for smart-merged files   } deriving (Show, Eq)  -- | Apply three-way merge: write generated files to disk, skipping user-modified ones.@@ -78,7 +83,7 @@   results <- mapM processFile (Map.toList generatedFiles)   pure $ foldr addResult emptyResult results   where-    emptyResult = MergeResult [] [] [] [] []+    emptyResult = MergeResult [] [] [] [] [] [] Map.empty      processFile (relPath, content) = do       let fullPath   = outputDir </> T.unpack relPath@@ -87,10 +92,19 @@       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)+        NewFile      -> writeFile' fullPath content >> pure (relPath, NewFile, Nothing)+        SafeToUpdate -> writeFile' fullPath content >> pure (relPath, SafeToUpdate, Nothing)+        Unchanged    -> pure (relPath, Unchanged, Nothing)+        SmartMerged  -> pure (relPath, SmartMerged, Nothing)  -- not reached from determineStatus+        UserModified -> do+          currentBytes <- BS.readFile fullPath+          let currentContent = TE.decodeUtf8With TEE.lenientDecode currentBytes+          case additiveMerge content currentContent of+            Just merged -> do+              writeFile' fullPath merged+              let mergedHash = computeFileHash merged+              pure (relPath, SmartMerged, Just mergedHash)+            Nothing -> pure (relPath, UserModified, Nothing)      readCurrentHash path = do       exists <- doesFileExist path@@ -104,10 +118,77 @@       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 }+    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 }+    addResult (p, SmartMerged, mh) mr = mr+      { mrMerged       = p : mrMerged mr+      , mrMergedHashes = case mh of+          Just h  -> Map.insert p h (mrMergedHashes mr)+          Nothing -> mrMergedHashes mr+      }++-- | Additive merge: insert novel lines from new content into user-modified current content.+-- Returns Nothing if files are too divergent (<50% shared lines) or an anchor can't be found.+additiveMerge :: Text -> Text -> Maybe Text+additiveMerge newContent currentContent+  | newLines == currentLines = Just currentContent  -- identical, no-op+  | sharedRatio < 0.5       = Nothing               -- too divergent+  | null insertionBlocks    = Just currentContent    -- no novel lines+  | otherwise               = applyInsertions currentLines insertionBlocks+  where+    newLines     = T.lines newContent+    currentLines = T.lines currentContent+    currentSet   = Set.fromList currentLines++    -- Count how many new lines exist in current+    sharedCount  = length $ filter (`Set.member` currentSet) newLines+    sharedRatio  = fromIntegral sharedCount / max 1 (fromIntegral (length newLines)) :: Double++    -- Walk newLines, grouping consecutive novel lines with their anchor+    -- An anchor is the last shared line before a block of novel lines+    insertionBlocks = extractBlocks Nothing [] [] newLines++    -- Extract insertion blocks: (Maybe anchor, [novel lines])+    extractBlocks :: Maybe Text -> [(Maybe Text, [Text])] -> [Text] -> [Text] -> [(Maybe Text, [Text])]+    extractBlocks _anchor acc novelAcc [] =+      if null novelAcc then reverse acc+      else reverse ((Nothing, reverse novelAcc) : acc)  -- trailing novel lines get no anchor+    extractBlocks anchor acc novelAcc (l:ls)+      | l `Set.member` currentSet =+          let acc' = if null novelAcc then acc+                     else (anchor, reverse novelAcc) : acc+          in extractBlocks (Just l) acc' [] ls+      | otherwise =+          extractBlocks anchor acc (l : novelAcc) ls++    -- Apply insertion blocks into currentLines+    applyInsertions :: [Text] -> [(Maybe Text, [Text])] -> Maybe Text+    applyInsertions curr blocks = go curr blocks 0+      where+        go :: [Text] -> [(Maybe Text, [Text])] -> Int -> Maybe Text+        go remaining [] _ = Just $ T.unlines (remaining)+        go remaining ((Nothing, novel):rest) pos =+          -- No anchor: prepend to result (novel lines before any shared line)+          go (novel ++ remaining) rest pos+        go remaining ((Just anchor, novel):rest) pos =+          -- Find anchor in remaining lines (forward scan)+          case findAnchor anchor remaining of+            Nothing -> Nothing  -- can't find anchor, bail+            Just idx ->+              let (before, afterAnchor) = splitAt (idx + 1) remaining+                  remaining' = before ++ novel ++ afterAnchor+                  newPos = pos + idx + 1 + length novel+              in go remaining' rest newPos++    -- Find first occurrence of anchor line in a list+    findAnchor :: Text -> [Text] -> Maybe Int+    findAnchor _anchor [] = Nothing+    findAnchor anchor ls  = go' 0 ls+      where+        go' _ []     = Nothing+        go' i (x:xs) = if x == anchor then Just i else go' (i + 1) xs  -- | Delete stale generated files: those present in the previous generation's -- cached hashes but absent from the new generation.
src/SynapseCC/Pipeline.hs view
@@ -8,7 +8,7 @@   ) where  import Control.Exception (try, SomeException)-import Control.Monad (when, unless, forM_)+import Control.Monad (when, unless, forM_, forM) import Data.Aeson (FromJSON, eitherDecodeStrict, eitherDecodeFileStrict) import qualified Data.Aeson as Aeson import qualified Data.ByteString as BS@@ -24,10 +24,12 @@ import GHC.Generics (Generic) import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, listDirectory) import System.Exit (ExitCode(..))-import System.FilePath ((</>))+import System.FilePath ((</>), splitPath)  import Synapse.Monad (initEnv, runSynapseM, SynapseError(..)) import Synapse.IR.Builder (buildIR)+import qualified Synapse.Log as Log+import qualified Katip  import SynapseCC.Benchmark (timeStep, baselinePath, reportBenchmarks) import SynapseCC.Config (buildConfigFromTarget)@@ -148,6 +150,29 @@     , "}"     ] +-- | Generate tsconfig.json for integration mode (generated code inside a host project).+-- Enables project references (composite, declaration, declarationMap) so the host+-- project can reference the generated directory as a sub-project with its own settings.+generateIntegrationTsconfig :: Text+generateIntegrationTsconfig = T.unlines+    [ "{"+    , "  \"compilerOptions\": {"+    , "    \"target\": \"ES2022\","+    , "    \"module\": \"ES2022\","+    , "    \"moduleResolution\": \"bundler\","+    , "    \"composite\": true,"+    , "    \"declaration\": true,"+    , "    \"declarationMap\": true,"+    , "    \"strict\": true,"+    , "    \"skipLibCheck\": true,"+    , "    \"esModuleInterop\": true,"+    , "    \"outDir\": \"../dist/generated\","+    , "    \"rootDir\": \".\""+    , "  },"+    , "  \"include\": [\"**/*.ts\"]"+    , "}"+    ]+ -- | Run the full pipeline without cache runFullPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath) runFullPipeline config tools = do@@ -243,6 +268,14 @@           irContent <- BS.readFile (unIRPath irPath)           BS.writeFile (outputDir </> "ir.json") irContent +          -- Log smart-merged files (new content added, user modifications preserved)+          let merged = Merge.mrMerged mergeResult+          unless (null merged) $ do+            logInfo $ "  ✓ " <> T.pack (show (length merged))+                    <> " file(s) smart-merged (new content added, user modifications preserved)"+            when debug $ forM_ merged $ \f ->+              logDebug debug $ "      - " <> f+           -- Log skipped files (user modifications preserved)           let skipped = Merge.mrSkipped mergeResult           unless (null skipped) $ do@@ -253,12 +286,14 @@            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))+          -- Write synapse-cc's tsconfig to the output dir.+          -- Standalone mode: tight config (noEmit, transport-specific types).+          -- Integration mode: project-references config (composite, declaration).+          do logDebug debug "  Writing tsconfig.json"+             let tsconfig = if isIntegration+                              then generateIntegrationTsconfig+                              else generateTsconfig (optTransport opts)+             TIO.writeFile (outputDir </> "tsconfig.json") tsconfig            -- Step 3: Install dependencies (if enabled)           -- Standalone: deps go into the generated dir (genPath) — it owns its own package.json.@@ -333,7 +368,11 @@                   case testResult of                     Left err -> pure $ Left err                     Right () -> do-                      writeCache config tools irPath compiledPath out+                      -- Patch file hashes: replace hashes for smart-merged files+                      -- with their merged hashes so subsequent runs see them as SafeToUpdate+                      let patchedHashes = Map.union (Merge.mrMergedHashes mergeResult) (coFileHashes out)+                          patchedOut = out { coFileHashes = patchedHashes }+                      writeCache config tools irPath compiledPath patchedOut                        -- Benchmarks                       pipelineEnd <- getCurrentTime@@ -394,10 +433,11 @@                     BrowserTransport -> "browser"       Backend bkName = cfgBackend config       lt = Lock.LockTarget-             { Lock.ltBackend   = bkName-             , Lock.ltIrHash    = irHash-             , Lock.ltTransport = transport-             , Lock.ltFiles     = coFileHashes codegenOutput+             { Lock.ltBackend         = bkName+             , Lock.ltIrHash          = irHash+             , Lock.ltTransport       = transport+             , Lock.ltFiles           = coFileHashes codegenOutput+             , Lock.ltSharedTransport = Nothing              }   lock <- fromMaybe Lock.emptySynapseLock <$> Lock.readSynapseLock   now  <- getCurrentTime@@ -471,14 +511,63 @@ -- | 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.+-- Providers (no sharedTransport) are built first, then consumers (with sharedTransport)+-- get shim files that re-export from the provider's output. -- 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))+runBuildFromConfig sc opts tools = do+  let allTargets  = Map.toList (scTargets sc)+      -- Partition: providers first (no sharedTransport), then consumers+      (providers, consumers) = foldr partitionTarget ([], []) allTargets+      partitionTarget (name, tc) (ps, cs) =+        case tcSharedTransport tc of+          Nothing -> ((name, tc) : ps, cs)+          Just _  -> (ps, (name, tc) : cs)++  -- Build providers first+  providerResults <- mapM buildTarget providers++  -- Build consumers: run hub-codegen for plugins only, then write shim files+  consumerResults <- forM consumers $ \(name, tc) -> do+    let config = buildConfigFromTarget opts sc tc+    logInfo $ "Building \"" <> name <> "\" → " <> T.pack (optOutput (cfgOptions config))+    logDebug (optDebug opts) $ "  target \"" <> name <> "\" uses sharedTransport"+    -- Run normal pipeline for plugins (generate list should be ["plugins"])+    result <- runPipeline config tools+    -- Write re-export shim files and update lock+    case (result, tcSharedTransport tc) of+      (Right _, Just providerName) ->+        case Map.lookup providerName (scTargets sc) of+          Just providerTc -> do+            let shimFiles = generateShimFiles (tcOutputDir tc) (tcOutputDir providerTc)+            createDirectoryIfMissing True (tcOutputDir tc)+            forM_ (Map.toList shimFiles) $ \(fileName, content) -> do+              let filePath = tcOutputDir tc </> T.unpack fileName+              TIO.writeFile filePath content+              logDebug (optDebug opts) $ "  Wrote shim: " <> T.pack filePath+            -- Update lock file with shim file hashes+            lock <- fromMaybe Lock.emptySynapseLock <$> Lock.readSynapseLock+            let outputKey = T.pack (tcOutputDir tc)+            case Map.lookup outputKey (Lock.slTargets lock) of+              Just lt -> do+                let shimHashes = Map.mapWithKey (\_ content -> Merge.computeFileHash content) shimFiles+                    updatedLt = lt+                      { Lock.ltSharedTransport = Just providerName+                      , Lock.ltFiles = Map.union shimHashes (Lock.ltFiles lt)+                      }+                Lock.writeSynapseLock $ lock+                  { Lock.slTargets = Map.insert outputKey updatedLt (Lock.slTargets lock) }+              Nothing -> pure ()+            logSuccess $ "Shim files written for \"" <> name <> "\""+          Nothing -> pure ()  -- Should not happen (validated in Config.hs)+      _ -> pure ()+    pure (name, result)++  pure $ providerResults ++ consumerResults   where     buildTarget (name, tc) = do       let config = buildConfigFromTarget opts sc tc@@ -489,6 +578,40 @@                    else runPipeline config tools       pure (name, result) +-- | Generate re-export shim files for a child target that shares transport+-- with a parent target. Computes the relative path from child → parent+-- and returns a map of filename → shim content.+generateShimFiles :: FilePath -> FilePath -> Map.Map Text Text+generateShimFiles childDir parentDir =+  let relPath = computeRelativePath childDir parentDir+      shimContent file = "export * from '" <> relPath <> "/" <> file <> "';\n"+  in Map.fromList+    [ ("transport.ts", shimContent "transport")+    , ("rpc.ts",       shimContent "rpc")+    , ("types.ts",     shimContent "types")+    ]++-- | Compute the relative path from childDir to parentDir.+-- E.g., "generated/substrate" → "generated" yields ".."+-- E.g., "src/gen/sub" → "src/gen" yields ".."+-- E.g., "generated-sub" → "generated" yields "../generated"+computeRelativePath :: FilePath -> FilePath -> Text+computeRelativePath childDir parentDir =+  let -- splitPath keeps trailing '/' on components; strip for comparison+      normalize = map (filter (/= '/'))+      childParts  = normalize (splitPath childDir)+      parentParts = normalize (splitPath parentDir)+      -- Find common prefix length+      commonLen   = length $ takeWhile id $ zipWith (==) childParts parentParts+      -- Steps up from child to common ancestor+      stepsUp     = length childParts - commonLen+      -- Steps down from common ancestor to parent+      stepsDown   = drop commonLen parentParts+      upParts     = replicate stepsUp ".."+      relParts    = upParts ++ stepsDown+      rel = T.intercalate "/" $ map T.pack relParts+  in if T.null rel then "." else rel+ -- ============================================================================ -- Transport-Only Pipeline (no backend connection required) -- ============================================================================@@ -573,7 +696,8 @@   -- 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+  logger <- Log.makeLogger Katip.ErrorS+  env <- initEnv host port bkName logger   -- Wrap in try to catch hard exceptions from child-plugin fetch errors   rawResult <- try (runSynapseM env (buildIR generatorInfo []))   result <- case rawResult of@@ -656,9 +780,14 @@       target         = cfgTarget config       targetArg      = T.unpack (targetToText target)       backendUrl     = "ws://" <> T.unpack (cfgHost config) <> ":" <> T.unpack (cfgPort config)+      -- nsFilter: incremental mode (--generate plugins --plugins ...)       filterArgs = case nsFilter of         Nothing  -> []         Just nss -> ["--generate", "plugins", "--plugins", T.unpack (T.intercalate "," nss)]+      -- cfgPlugins: full generation with plugin filtering (--plugins ... only, no --generate plugins)+      pluginFilterArgs = case cfgPlugins config of+        Just ps | null filterArgs -> ["--plugins", T.unpack (T.intercalate "," ps)]+        _                         -> []       args =         [ "--target",           targetArg         , "--output-format",    "json"@@ -668,6 +797,7 @@         , "--backend-url",      backendUrl         ]         ++ filterArgs+        ++ pluginFilterArgs         ++ [ unIRPath irPath ]    result <- runProcess hubCodegenPath args Nothing debug
src/SynapseCC/Types.hs view
@@ -26,6 +26,7 @@     -- * Commands (CLI subcommands)   , Command(..)   , WatchArgs(..)+  , WaitArgs(..)      -- * Tool Locations   , ToolLocations(..)@@ -87,6 +88,7 @@   , cfgHost           :: !Text   , cfgPort           :: !Text   , cfgOptions        :: !Options+  , cfgPlugins        :: !(Maybe [Text])  -- ^ Plugin filter (Nothing = all)   } deriving stock (Show, Eq, Generic)  -- | Target language for code generation@@ -179,10 +181,14 @@  -- | 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+  { 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+  , tcBackend         :: !(Maybe Text)     -- ^ Per-target backend override+  , tcUrl             :: !(Maybe Text)     -- ^ Per-target WebSocket URL override+  , tcPlugins         :: !(Maybe [Text])   -- ^ Plugin filter (Nothing = all)+  , tcSharedTransport :: !(Maybe Text)     -- ^ Name of another target whose transport to reuse   } deriving stock (Show, Eq, Generic)  instance FromJSON TargetConfig where@@ -191,6 +197,10 @@     <*> (parseTransport =<< o Aeson..: "transport")     <*> o Aeson..: "outputDir"     <*> o Aeson..:? "smokePath"+    <*> o Aeson..:? "backend"+    <*> o Aeson..:? "url"+    <*> o Aeson..:? "plugins"+    <*> o Aeson..:? "sharedTransport"     where       parseTransport :: Text -> Parser TransportType       parseTransport "ws"      = pure WsTransport@@ -205,6 +215,18 @@     ] ++ case tcSmokePath tc of            Nothing -> []            Just p  -> ["smokePath" Aeson..= p]+      ++ case tcBackend tc of+           Nothing -> []+           Just b  -> ["backend" Aeson..= b]+      ++ case tcUrl tc of+           Nothing -> []+           Just u  -> ["url" Aeson..= u]+      ++ case tcPlugins tc of+           Nothing -> []+           Just ps -> ["plugins" Aeson..= ps]+      ++ case tcSharedTransport tc of+           Nothing -> []+           Just st -> ["sharedTransport" Aeson..= st]  -- | Top-level synapse.config.json configuration. -- \"backend\" and \"url\" are optional when every target has generate: [\"transport\"]@@ -251,10 +273,14 @@   , scTargets        = Map.fromList       [ ( "client"         , TargetConfig-            { tcGenerate  = ["transport", "rpc", "plugins"]-            , tcTransport = WsTransport-            , tcOutputDir = "src/lib/plexus"-            , tcSmokePath = Nothing+            { tcGenerate        = ["transport", "rpc", "plugins"]+            , tcTransport       = WsTransport+            , tcOutputDir       = "src/lib/plexus"+            , tcSmokePath       = Nothing+            , tcBackend         = Nothing+            , tcUrl             = Nothing+            , tcPlugins         = Nothing+            , tcSharedTransport = Nothing             }         )       ]@@ -270,6 +296,7 @@   | CmdBuildFromConfig      -- ^ Build all targets from synapse.config.json       Options               -- ^ CLI flags that override per-target config   | CmdWatch WatchArgs      -- ^ Watch mode+  | CmdWait WaitArgs        -- ^ Wait for backends to become reachable   | CmdInit                 -- ^ Scaffold synapse.config.json   deriving stock (Show, Eq) @@ -280,6 +307,15 @@   , 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)++-- | Arguments for the wait subcommand+data WaitArgs = WaitArgs+  { waitBackend  :: !(Maybe Text)  -- ^ Specific backend (if None, use all from config)+  , waitUrl      :: !(Maybe Text)  -- ^ Explicit URL (requires backend)+  , waitTimeout  :: !Int           -- ^ Max seconds to wait (default 30)+  , waitInterval :: !Int           -- ^ Poll interval in ms (default 500)+  , waitDebug    :: !Bool          -- ^ Debug logging   } deriving stock (Show, Eq)  -- ============================================================================
+ src/SynapseCC/Wait.hs view
@@ -0,0 +1,151 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Wait command: block until configured backends are reachable.+--+-- Usage:+--   synapse-cc wait                         -- wait for all backends in config+--   synapse-cc wait monochrome              -- wait for one backend (URL from config)+--   synapse-cc wait monochrome -u ws://...: -- wait for explicit backend+URL+module SynapseCC.Wait+  ( runWait+  ) where++import Control.Concurrent (threadDelay)+import Control.Exception (try, SomeException)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe)+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T+import Data.Time.Clock (getCurrentTime, diffUTCTime)+import Control.Monad (when)++import qualified Data.Aeson as Aeson+import Plexus.Client (SubstrateConfig(..), defaultConfig)+import qualified Plexus.Transport as Transport++import SynapseCC.Config (loadSynapseConfig, parseWsUrl)+import SynapseCC.Logging+import SynapseCC.Types++-- | Extract all unique (backend, url) pairs from the config.+collectEndpoints :: SynapseConfig -> [(Text, Text)]+collectEndpoints sc =+  let defaultUrl = fromMaybe "ws://127.0.0.1:4444" (scUrl sc)+      defaultBk  = fromMaybe "substrate" (scBackend sc)+      pairs = [ let bk  = fromMaybe defaultBk (tcBackend tc)+                    url = fromMaybe defaultUrl (tcUrl tc)+                in (bk, url)+              | tc <- Map.elems (scTargets sc)+              ]+  in Map.toList (Map.fromList pairs)++-- | Try to connect to a backend by calling {backend}.hash+probeBackend :: Text -> Text -> IO (Either Text ())+probeBackend backend url = do+  let (host, portTxt) = parseWsUrl url+      port = read (T.unpack portTxt) :: Int+      cfg = (defaultConfig backend)+              { substrateHost = T.unpack host+              , substratePort = port+              }+      method = backend <> ".hash"+  outcome <- try $ Transport.rpcCallWith cfg method Aeson.Null+  case outcome of+    Left (ex :: SomeException) -> pure $ Left $ T.pack (show ex)+    Right (Left err)           -> pure $ Left $ T.pack (show err)+    Right (Right _)            -> pure $ Right ()++-- | Determine which endpoints to wait for based on args.+resolveEndpoints :: WaitArgs -> IO (Either Text [(Text, Text)])+resolveEndpoints args = case waitBackend args of+  -- Explicit backend + URL: no config needed+  Just bk | Just url <- waitUrl args ->+    pure $ Right [(bk, url)]++  -- Explicit backend, URL from config+  Just bk -> do+    cfgResult <- loadSynapseConfig+    case cfgResult of+      Left err -> pure $ Left err+      Right sc -> do+        let allEps = collectEndpoints sc+        case lookup bk allEps of+          Just url -> pure $ Right [(bk, url)]+          Nothing  ->+            -- Backend not in config — try top-level URL as fallback+            let url = fromMaybe "ws://127.0.0.1:4444" (scUrl sc)+            in pure $ Right [(bk, url)]++  -- No backend specified: all from config+  Nothing -> do+    cfgResult <- loadSynapseConfig+    case cfgResult of+      Left err -> pure $ Left err+      Right sc -> do+        let eps = collectEndpoints sc+        if null eps+          then pure $ Left "no backends configured in synapse.config.json"+          else pure $ Right eps++-- | Run the wait command. Returns True on success, False on timeout/error.+runWait :: WaitArgs -> IO Bool+runWait args = do+  epResult <- resolveEndpoints args+  case epResult of+    Left err -> do+      logError err+      pure False++    Right endpoints -> do+      let n = length endpoints+          names = T.intercalate ", " [bk <> " (" <> url <> ")" | (bk, url) <- endpoints]+      logStep $ "Waiting for " <> T.pack (show n)+             <> " backend" <> (if n > 1 then "s" else "")+             <> ": " <> names+             <> " (timeout: " <> T.pack (show (waitTimeout args)) <> "s)"++      startTime <- getCurrentTime+      let timeoutSecs = fromIntegral (waitTimeout args) :: Double+          intervalUs  = waitInterval args * 1000++          loop :: Set Text -> IO Bool+          loop remaining+            | Set.null remaining = pure True+            | otherwise = do+                now <- getCurrentTime+                let elapsed = realToFrac (diffUTCTime now startTime) :: Double+                if elapsed >= timeoutSecs then do+                  logError $ "Timed out after " <> T.pack (show (waitTimeout args))+                          <> "s waiting for: "+                          <> T.intercalate ", " (Set.toList remaining)+                  pure False+                else do+                  newRemaining <- probeAll remaining endpoints+                  if Set.null newRemaining+                    then pure True+                    else do+                      threadDelay intervalUs+                      loop newRemaining++          probeAll :: Set Text -> [(Text, Text)] -> IO (Set Text)+          probeAll remaining eps = go remaining eps+            where+              go acc [] = pure acc+              go acc ((bk, url):rest)+                | not (Set.member bk acc) = go acc rest+                | otherwise = do+                    result <- probeBackend bk url+                    case result of+                      Right () -> do+                        logSuccess $ bk <> " reachable"+                        go (Set.delete bk acc) rest+                      Left _ -> do+                        when (waitDebug args) $+                          logInfo $ "  " <> bk <> " not yet reachable"+                        go acc rest++      let backendNames = Set.fromList [bk | (bk, _) <- endpoints]+      loop backendNames
src/SynapseCC/Watch.hs view
@@ -42,6 +42,8 @@ import Plexus.Types (PlexusStreamItem(..)) import Synapse.Monad (initEnv, runSynapseM) import Synapse.IR.Builder (buildIR)+import qualified Synapse.Log as Log+import qualified Katip  import SynapseCC.Cache (expandTilde) import qualified SynapseCC.Config as Config@@ -206,7 +208,8 @@   t0 <- getCurrentTime    -- Fetch fresh IR via plexus-synapse library-  env <- initEnv host port bkName+  logger <- Log.makeLogger Katip.ErrorS+  env <- initEnv host port bkName logger   irResult <- runSynapseM env (buildIR generatorInfo [])    case irResult of
synapse-cc.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               synapse-cc-version:            0.2.0+version:            0.3.2 synopsis:           Unified compiler toolchain for Plexus backends description:   synapse-cc orchestrates the complete pipeline from backend schema@@ -31,6 +31,7 @@                     , SynapseCC.Language                     , SynapseCC.Logging                     , SynapseCC.Benchmark+                    , SynapseCC.Wait                     , SynapseCC.Watch     build-depends:    base >= 4.14 && < 5                     , optparse-applicative >= 0.18@@ -54,6 +55,7 @@                     , fsnotify >= 0.4                     , plexus-protocol                     , plexus-synapse+                    , katip >= 0.8 && < 0.9     autogen-modules:  Paths_synapse_cc     other-modules:    Paths_synapse_cc     hs-source-dirs:   src
test/Main.hs view
@@ -48,6 +48,7 @@   , detectTauri, detectVite, detectNextJS, detectNodeProject   ) import SynapseCC.Discover (discoverTools)+import SynapseCC.Merge (additiveMerge) import SynapseCC.Pipeline (runPipeline) import SynapseCC.Types   ( Config(..), Backend(..), Target(..), Options(..), defaultOptions, formatError@@ -696,7 +697,72 @@         after <- TIO.readFile target         after `shouldNotBe` userEdit +      it "user edit survives rerun AND new methods appear via smart merge" $ do+        -- If the backend has new methods, additiveMerge should insert them+        -- while preserving user modifications.+        let target = dir </> "rpc.ts"+        original <- TIO.readFile target+        -- Add a user comment somewhere in the file+        let userEdit = T.replace "// Generated" "// Generated\n// user custom line" original+            fallback = original <> "\n// user custom line"+            edited   = if userEdit /= original then userEdit else fallback+        TIO.writeFile target edited++        _ <- runAgain []++        after <- TIO.readFile target+        -- User modification must survive+        T.isInfixOf "// user custom line" after `shouldBe` True+     -- ═══════════════════════════════════════════+    -- Section 9b: additiveMerge unit tests+    -- ═══════════════════════════════════════════+    describe "additiveMerge" $ do++      it "inserts a novel block after its anchor" $ do+        let current = T.unlines ["line1", "line2", "line3"]+            new     = T.unlines ["line1", "line2", "new_a", "new_b", "line3"]+        case additiveMerge new current of+          Nothing     -> expectationFailure "Expected Just, got Nothing"+          Just merged -> do+            T.isInfixOf "new_a" merged `shouldBe` True+            T.isInfixOf "new_b" merged `shouldBe` True+            T.isInfixOf "line2" merged `shouldBe` True+            T.isInfixOf "line3" merged `shouldBe` True++      it "handles multiple insertion blocks at different positions" $ do+        let current = T.unlines ["a", "b", "c", "d"]+            new     = T.unlines ["a", "x1", "b", "c", "x2", "d"]+        case additiveMerge new current of+          Nothing     -> expectationFailure "Expected Just, got Nothing"+          Just merged -> do+            T.isInfixOf "x1" merged `shouldBe` True+            T.isInfixOf "x2" merged `shouldBe` True++      it "handles novel lines at the beginning (before any shared line)" $ do+        let current = T.unlines ["shared1", "shared2"]+            new     = T.unlines ["brand_new", "shared1", "shared2"]+        case additiveMerge new current of+          Nothing     -> expectationFailure "Expected Just, got Nothing"+          Just merged -> T.isInfixOf "brand_new" merged `shouldBe` True++      it "returns current unchanged when no novel lines exist" $ do+        let content = T.unlines ["a", "b", "c"]+        additiveMerge content content `shouldBe` Just content++      it "returns Nothing when files are too divergent" $ do+        let current = T.unlines ["x", "y", "z"]+            new     = T.unlines ["a", "b", "c", "d", "e"]+        additiveMerge new current `shouldBe` Nothing++      it "handles duplicate anchor lines via forward scanning" $ do+        let current = T.unlines ["}", "code_a", "}", "code_b", "}"]+            new     = T.unlines ["}", "code_a", "}", "inserted", "code_b", "}"]+        case additiveMerge new current of+          Nothing     -> expectationFailure "Expected Just, got Nothing"+          Just merged -> T.isInfixOf "inserted" merged `shouldBe` True++    -- ═══════════════════════════════════════════     -- Section 10: synapse.config.json     -- ═══════════════════════════════════════════     describe "synapse.config.json" $ do@@ -782,20 +848,28 @@          it "rejects target with empty generate list" $ do           let badTarget = TargetConfig-                { tcGenerate  = []-                , tcTransport = WsTransport-                , tcOutputDir = "out"-                , tcSmokePath = Nothing+                { tcGenerate        = []+                , tcTransport       = WsTransport+                , tcOutputDir       = "out"+                , tcSmokePath       = Nothing+                , tcBackend         = Nothing+                , tcUrl             = Nothing+                , tcPlugins         = Nothing+                , tcSharedTransport = 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+                { tcGenerate        = ["plugins"]+                , tcTransport       = WsTransport+                , tcOutputDir       = ""+                , tcSmokePath       = Nothing+                , tcBackend         = Nothing+                , tcUrl             = Nothing+                , tcPlugins         = Nothing+                , tcSharedTransport = Nothing                 }           let cfg = defaultSynapseConfig { scTargets = Map.singleton "t" badTarget }           validateSynapseConfig cfg `shouldSatisfy` isLeft@@ -979,7 +1053,7 @@           Map.lookup "echo" (extractPluginHashesFromBytes ir) `shouldBe` Just ""        describe "filterTargets" $ do-        let mkTarget gen = TargetConfig gen WsTransport "out" Nothing+        let mkTarget gen = TargetConfig gen WsTransport "out" Nothing Nothing Nothing Nothing Nothing         let targets = Map.fromList               [ ("client", mkTarget ["transport", "rpc", "plugins"])               , ("smoke",  mkTarget ["smoke"])@@ -1009,28 +1083,28 @@         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 tc  = TargetConfig ["plugins"] BrowserTransport "my/output/dir" Nothing Nothing Nothing Nothing 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 tc  = TargetConfig ["plugins"] BrowserTransport "out" Nothing Nothing Nothing Nothing 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 tc  = TargetConfig ["plugins"] WsTransport "out" Nothing Nothing Nothing Nothing 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 tc  = TargetConfig ["plugins"] WsTransport "out" Nothing Nothing Nothing Nothing Nothing           let cfg = buildConfigFromTarget wa sc tc           cfgPort cfg `shouldBe` "9000" @@ -1558,6 +1632,7 @@         , optForce       = True         , optTransport   = WsTransport         }+    , cfgPlugins = Nothing     }  -- | Call fidget.register via direct RPC.