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 +8/−0
- src/SynapseCC/CLI.hs +34/−1
- src/SynapseCC/Config.hs +42/−10
- src/SynapseCC/Lock.hs +11/−7
- src/SynapseCC/Merge.hs +95/−14
- src/SynapseCC/Pipeline.hs +146/−16
- src/SynapseCC/Types.hs +44/−8
- src/SynapseCC/Wait.hs +151/−0
- src/SynapseCC/Watch.hs +4/−1
- synapse-cc.cabal +3/−1
- test/Main.hs +88/−13
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.