synapse-cc 0.3.3 → 0.4.0
raw patch · 22 files changed
+1708/−282 lines, 22 filesdep +aeson-qqdep +temporarydep ~plexus-protocoldep ~plexus-synapsedep ~text
Dependencies added: aeson-qq, temporary
Dependency ranges changed: plexus-protocol, plexus-synapse, text
Files
- CHANGELOG.md +35/−0
- PLAN.md +11/−11
- README.md +179/−82
- app/Main.hs +35/−8
- src/SynapseCC/Auth.hs +51/−0
- src/SynapseCC/AuthCheck.hs +98/−0
- src/SynapseCC/CLI.hs +51/−6
- src/SynapseCC/Cache.hs +8/−6
- src/SynapseCC/Config.hs +55/−10
- src/SynapseCC/Detect.hs +145/−1
- src/SynapseCC/Discover.hs +78/−25
- src/SynapseCC/Language.hs +25/−3
- src/SynapseCC/Lock.hs +6/−5
- src/SynapseCC/Merge.hs +1/−1
- src/SynapseCC/Pipeline.hs +117/−60
- src/SynapseCC/RegistryResolve.hs +82/−0
- src/SynapseCC/Types.hs +57/−29
- src/SynapseCC/Watch.hs +8/−4
- synapse-cc.cabal +57/−5
- test/AuthCheckSpec.hs +154/−0
- test/Main.hs +338/−26
- test/NamingSpec.hs +117/−0
+ CHANGELOG.md view
@@ -0,0 +1,35 @@+# Changelog for synapse-cc++## 0.4.0++### Breaking++- Built on `plexus-synapse >= 4.0 && < 5` and `plexus-protocol >= 0.6 && < 0.7`+ (was `plexus-synapse >= 3.14.3 && < 3.18`). The IR synapse-cc builds and+ hands to the generator is synapse 4.0's IR 3.0 (a method's `returns`,+ `error`, `progress` and `cardinality` read from the plexus 0.7 schema),+ so the generator must read IR 3.0. Backends must serve plexus 0.7 or the+ published 0.7.0 line.+- `watch`: a failed hash call reports the server's typed error, rendered as+ plexus-protocol 0.6's `renderRemoteError` line.++### Changed++- `synapse-cc-test`, which needs a live substrate on `127.0.0.1:4444`, is+ now opt-in: `cabal test -f live-tests`. The hermetic suites+ (`safe6-authcheck`, `naming`) run by default.+- `cabal.project` lists only `.`; the plexus packages come from Hackage.++## Since 0.3.3 (unpublished 0.3.4 - 0.3.6)++- The generator is `service-codegen`, found by that name first;+ `--service-codegen` replaces the generator's old flag (hidden, still+ read). `synapse.lock` writes `serviceCodegen` and reads either key.+- Warns when the generator found is older than 0.5.0, which does not leave+ server-injected parameters out of generated methods.+- `synapse-cc init` takes or infers the backend; a `cargo build` step for+ generated Rust clients; the registry endpoint is read from+ `PLEXUS_REGISTRY_URL`.+- Auth: `--cookie` and `--header`, credentials scanned from the+ environment, token resolution through synapse's `_self` credential+ store, and a warning when cookie auth does not match the backend.
PLAN.md view
@@ -21,7 +21,7 @@ ↓ ├─→ synapse (Haskell) → IR (JSON) ↓- ├─→ hub-codegen (Rust) → Generated Code+ ├─→ service-codegen (Rust) → Generated Code ↓ └─→ Language Tools (npm, tsc, etc.) → Compiled Artifact ```@@ -64,7 +64,7 @@ - Fail with helpful error 4. ✅ Pipeline orchestration: - Run synapse to generate IR- - Run hub-codegen on IR+ - Run service-codegen on IR - Write output to directory 5. ✅ Basic error handling and logging 6. ✅ End-to-end test with substrate@@ -119,7 +119,7 @@ 3. Parallel generation for multiple targets 4. Progress indicators and spinners 5. Colorized output-6. Auto-install missing tools (synapse, hub-codegen)+6. Auto-install missing tools (synapse, service-codegen) **Deliverable**: Production-ready developer tool @@ -136,7 +136,7 @@ └── SynapseCC/ ├── Types.hs # Core types (Config, Target, Backend, etc.) ├── CLI.hs # Command-line argument parsing- ├── Discover.hs # Tool discovery (find synapse, hub-codegen)+ ├── Discover.hs # Tool discovery (find synapse, service-codegen) ├── Pipeline.hs # Pipeline orchestration ├── Process.hs # Subprocess execution helpers ├── Cache.hs # IR/code caching by hash@@ -148,7 +148,7 @@ ### 1. Subprocess Execution (Not FFI) -**Decision**: Call synapse and hub-codegen as separate processes+**Decision**: Call synapse and service-codegen as separate processes **Rationale**: - Clean separation of concerns@@ -160,7 +160,7 @@ **Implementation**: ```haskell runSynapse :: Backend -> URL -> FilePath -> IO (Either Error IRPath)-runHubCodegen :: IRPath -> Target -> Options -> FilePath -> IO (Either Error GeneratedPath)+runServiceCodegen :: IRPath -> Target -> Options -> FilePath -> IO (Either Error GeneratedPath) ``` ### 2. Tool Discovery Strategy@@ -168,12 +168,12 @@ **Priority Order**: 1. Local development paths (for contributors) - `../synapse/dist-newstyle/build/.../synapse`- - `../hub-codegen/target/release/hub-codegen`+ - `../service-codegen/target/release/service-codegen` 2. System PATH (for installed versions)- - `synapse` and `hub-codegen` in $PATH+ - `synapse` and `service-codegen` in $PATH 3. Plexus bin directory (for managed installs) - `~/.plexus/bin/synapse`- - `~/.plexus/bin/hub-codegen`+ - `~/.plexus/bin/service-codegen` 4. Fail with helpful installation instructions **Future**: Auto-install via cabal/cargo@@ -251,7 +251,7 @@ # Output: # 🔍 Discovering tools... # ✓ Found synapse at ../synapse/dist-newstyle/.../synapse-# ✓ Found hub-codegen at ../hub-codegen/target/release/hub-codegen+# ✓ Found service-codegen at ../service-codegen/target/release/service-codegen # # 📡 Connecting to substrate at ws://localhost:4444... # ✓ Connected@@ -309,7 +309,7 @@ ### External Tools (Runtime) - `synapse` - Schema discovery and IR generation-- `hub-codegen` - Code generation from IR+- `service-codegen` - Code generation from IR - Language tools (npm, tsc, cargo, pip, etc.) - Language-specific builds ## Future Extensions
README.md view
@@ -8,9 +8,10 @@ - 🔧 **Unified Interface**: Single command to generate clients from any backend - 🔍 **Smart Tool Discovery**: Finds local development builds or installed versions-- ⚡ **Fast**: Smart caching avoids regeneration when schemas haven't changed (coming in Phase 3)-- 🎯 **Multi-Language**: TypeScript, Python, Rust support (TypeScript in MVP)-- 🛠️ **Complete Pipeline**: From schema → IR → generated code → compiled artifacts+- ⚡ **Fast**: Incremental caching skips regeneration when schemas haven't changed (IR cache at `~/.cache/plexus-codegen/synapse/ir/`, per-file code cache via service-codegen)+- 🎯 **Multi-Language**: TypeScript and Rust today (`rust` requires a service-codegen built with `--features rust`); `python` parses but has no generator yet+- 🛠️ **Complete Pipeline**: schema → IR → generated code → installed deps → compiled artifact → smoke tests+- 🔐 **Credential lifecycle**: Per-backend defaults store shared with `synapse` — set, inspect, and rotate JWTs / cookies / headers via `synapse-cc _self <backend> …`. Values held as reference URIs (`literal:`, `env://`, `file://`, `keychain://`) so secrets live in the OS keychain or env vars, not plaintext. JWT decoding flags expired tokens at a glance. ## Installation @@ -26,71 +27,175 @@ `synapse-cc` requires these tools to be available: - **synapse** - Schema discovery and IR generation-- **hub-codegen** - Code generation from IR+- **service-codegen** ≥ 0.5.0 - Code generation from IR. Earlier releases write a+ parameter the server injects (`pdSource`: auth, header, derived, …) into the+ generated methods as an optional `unknown` argument; synapse-cc says so when+ it finds one (SYNAPSE-CC-2) The tool will automatically find them if they're: - In your `$PATH`-- In local development directories (`../synapse`, `../hub-codegen`)+- In local development directories (`../synapse`, `../service-codegen`) - In `~/.plexus/bin/` +**The generator is `service-codegen`** (0.5.1 on; NAMING-10 — a Plexus backend+is a *service*). synapse-cc 0.3.6 looks for it under that name first — on+`$PATH`, then `../service-codegen/target/…`, `~/.plexus/bin`, `~/.cargo/bin` —+and only then under its old name, saying the old name is deprecated. Its path+is `--service-codegen PATH` (the old flag still works for one release, hidden,+with a note). `synapse.lock` is written with `serviceCodegen`, the generator's+JSON is read as `serviceCodegenVersion`, a code cache's toolchain as+`tvServiceCodegen`; each old key is still read. NAMING-18 removes the old+names; `scripts/naming-gate.sh` (the `naming` test suite) fails on a new one.+synapse-cc 0.3.6 builds against `plexus-synapse` 3.14.3 up to 3.17.+ ## Usage -### Basic TypeScript Client+The CLI is subcommand-shaped: ```bash-synapse-cc typescript substrate ws://localhost:4444+synapse-cc init [BACKEND] # scaffold synapse.config.json+synapse-cc build # build every target in synapse.config.json+synapse-cc build TARGET BACKEND [OPTIONS] # one-off build, no config file+synapse-cc watch BACKEND [PLUGIN...] # rebuild on schema changes+synapse-cc wait [BACKEND] # block until backend(s) reachable+synapse-cc _self BACKEND VERB ... # credentials store (shared with synapse) ``` -This will:-1. Connect to substrate at `ws://localhost:4444`-2. Discover the schema-3. Generate IR-4. Generate TypeScript client-5. Output to `./generated/`+### Quickstart (config-driven) -### Options+A service scaffolded by `axon new` ships a `synapse.config.json` pre-pointed+at itself — `synapse-cc build` works unedited from the service directory+(server must be running). Real run: -```bash-synapse-cc <target> <backend> <url> [OPTIONS]+```+$ synapse-cc build+==> Discovering tools...+[+] Found all required tools+[i] Building "client" → src/lib/plexus -Arguments:- target Target language (typescript, python, rust)- backend Backend identifier (substrate, plexus, synapse, etc.)- url Backend WebSocket URL (e.g., ws://localhost:4444)+==> Reading schema...+[+] Schema ready (2 plugins) -Options:- -o, --output DIR Output directory (default: ./generated)- --bundle-transport BOOL Bundle transport code (default: true)- --no-install Skip dependency installation- --no-build Skip compilation step- --cache-dir DIR Cache directory (default: ~/.plexus/cache)- --force Force regeneration (ignore cache)- --watch Watch backend and regenerate on changes- --debug Enable debug logging- -h, --help Show help message+==> Generating code...+[+] Code generated (11 files)++==> Installing dependencies...+[+] Dependencies installed++==> Building...+[+] Build passed++==> Running tests...+[+] Tests passed+[+] client → src/lib/plexus/ ``` -### Examples+Without a config file, `synapse-cc init <backend>` scaffolds one; with the+backend argument omitted it is **inferred** — from a co-located Plexus+service crate, then from the local registry — and the inference is+announced. There is no silent default backend: -**External transport mode** (uses `@plexus/rpc-client` package):-```bash-synapse-cc typescript substrate ws://localhost:4444 \- --bundle-transport=false \- --output ./packages/substrate-client ```+$ synapse-cc init+[!] missing BACKEND argument — no co-located Plexus service crate found and+ no backend discoverable on the registry at 127.0.0.1:4444.+ Run: synapse-cc init <backend>+``` -**Skip build steps** (just generate code):+### Quickstart (one-off, no config)+ ```bash-synapse-cc typescript substrate ws://localhost:4444 \- --no-install \- --no-build+synapse-cc build typescript substrate # TS client → ./generated/+synapse-cc build rust substrate -o ./client # Rust client, ends in cargo build ``` -**Debug mode**:+The backend is resolved **by name via the registry** (endpoint from+`PLEXUS_REGISTRY_URL`, default `ws://127.0.0.1:4444`; `-H`/`-P` set the+fallback). A real `rust` run:++```+$ synapse-cc build rust zechodev -o ./rust-client+==> Discovering tools...+[+] Found all required tools++==> Resolving zechodev via registry at ws://127.0.0.1:4444...++==> Reading schema...+[+] Schema ready (2 plugins)++==> Generating code...+[+] Code generated (5 files)++==> Building...+[+] Build passed++==> Running tests...+[+] Tests passed++[+] Client → ./rust-client/+```++`-t rust` ends in a real `cargo build` of the generated crate (Z2H-7) — the+artifact compiles or the command fails.++### Options (build / watch)++```+ -o, --output DIR Output directory (default: ./generated)+ --transport ws|browser ws (Node.js, default) or browser (native WebSocket,+ for Tauri/WebView)+ --no-install Skip dependency installation+ --no-build Skip compilation step+ --no-tests Skip running smoke tests+ --cache-dir DIR Cache directory (default: ~/.cache/plexus-codegen)+ --force Force regeneration (ignore cache)+ --debug Enable debug logging+ --synapse PATH synapse binary path (overrides discovery)+ --service-codegen PATH service-codegen binary path (overrides discovery)+ -t, --token JWT JWT auth token (see Credentials below)+ --token-file PATH Read JWT from file+ -H, --host HOST Registry/discovery host (default: 127.0.0.1)+ -P, --port PORT Registry/discovery port (default: 4444)+```++### Rust target prerequisite++`build rust` requires a service-codegen binary compiled with the `rust` feature+(`cargo build --release --features all` in the service-codegen repo). A+typescript-only binary fails with:++```+[!] Error: service-codegen failed (exit code 1)+Error: Rust codegen not enabled. Rebuild with --features rust+```++Point `--service-codegen` at a rust-enabled build if the discovered one is not.++### Credentials & Headers++synapse-cc shares a **single defaults store** with synapse at `~/.plexus/<backend>/defaults.json`. Any JWT, cookie, or header you set via either CLI is immediately visible to the other. The file holds credential-reference URIs (`literal:`, `env://`, `file://`, `keychain://`), resolved at request time.+ ```bash-synapse-cc typescript substrate ws://localhost:4444 --debug+# Inspect what's stored + decode any JWTs + flag expired tokens+synapse-cc _self <backend> show++# Store a JWT (auto-wrapped as literal:)+synapse-cc _self <backend> set cookie access_token "eyJ..."++# Store a reference to an env var (preferred in CI)+synapse-cc _self <backend> set cookie access_token "env://MY_JWT"++# Import a JWT file+synapse-cc _self <backend> import-token ~/Downloads/jwt.txt++# Clear all defaults for a backend+synapse-cc _self <backend> clear --yes ``` +The `_self` subcommand tree is identical to `synapse _self <backend> …` — they share the same library implementation. See synapse's README for the full verb list, URI scheme reference, and legacy `~/.plexus/tokens/<backend>` migration behavior.++Per-invocation overrides: `--token <jwt>`, `SYNAPSE_TOKEN` env, `--token-file <path>`, `--cookie KEY=VALUE`, `--header KEY=VALUE` — all flow through the same priority chain as synapse, with CLI values winning over stored defaults per key.+ ## Architecture ```@@ -98,7 +203,7 @@ ↓ ├─→ synapse (Haskell) → IR (JSON) ↓- ├─→ hub-codegen (Rust) → Generated Code+ ├─→ service-codegen (Rust) → Generated Code ↓ └─→ Language Tools (npm, tsc, etc.) → Compiled Artifact ```@@ -110,19 +215,26 @@ ``` synapse-cc/ ├── synapse-cc.cabal # Cabal project file-├── PLAN.md # Detailed implementation plan ├── app/-│ └── Main.hs # Entry point+│ └── Main.hs # Entry point, subcommand dispatch └── src/ └── SynapseCC/- ├── Types.hs # Core types- ├── CLI.hs # Command-line parsing- ├── Discover.hs # Tool discovery- ├── Pipeline.hs # Pipeline orchestration- ├── Process.hs # Subprocess helpers- ├── Cache.hs # Caching (Phase 3)- ├── Language.hs # Language integration (Phase 2)- └── Logging.hs # Pretty output+ ├── Types.hs # Core types, Options, synapse.config.json types+ ├── CLI.hs # Subcommand + option parsing+ ├── Config.hs # synapse.config.json load/init+ ├── Detect.hs # Project detection for `init` inference+ ├── Discover.hs # Tool discovery (synapse, service-codegen)+ ├── RegistryResolve.hs # Backend-name resolution via the registry+ ├── Pipeline.hs # Pipeline orchestration+ ├── Process.hs # Subprocess helpers+ ├── Cache.hs # Incremental IR caching+ ├── Merge.hs # Three-way merge bookkeeping+ ├── Dependency.hs # Plugin dependency resolution+ ├── Language.hs # Per-language install/build/test steps+ ├── Auth.hs # Token priority chain (wraps Synapse.Self)+ ├── Watch.hs # watch subcommand+ ├── Wait.hs # wait subcommand+ └── Logging.hs # Pretty output ``` ### Building@@ -134,45 +246,30 @@ ### Running from source ```bash-cabal run synapse-cc -- typescript substrate ws://localhost:4444+cabal run synapse-cc -- build typescript substrate --debug ``` ### Testing ```bash-# Ensure substrate is running-cd ../plexus-substrate-cargo run -- --port 4444+# Scaffold and start a throwaway backend (any free port)+axon new mysvc --port 4452 && cd mysvc && cargo run & -# In another terminal, test synapse-cc-cd ../synapse-cc-cabal run synapse-cc -- typescript substrate ws://localhost:4444 --debug+# Generate, compile, and smoke-test a client against it — the scaffold's+# synapse.config.json is already pointed at ws://127.0.0.1:4452+synapse-cc build ``` -## Roadmap--### Phase 1: MVP (Current)-- ✅ Project scaffolding-- ✅ Tool discovery-- ✅ Pipeline orchestration-- ✅ Basic error handling-- [ ] End-to-end testing--### Phase 2: Language Integration-- [ ] Dependency installation (npm, pip, cargo)-- [ ] Build automation-- [ ] Multi-package manager support+## Status -### Phase 3: Smart Caching-- [ ] Hash-based caching-- [ ] Cache management-- [ ] Fast iteration+Shipped: tool discovery, pipeline orchestration, config-driven multi-target+builds (`synapse.config.json`), incremental IR + code caching with three-way+merge, dependency installation + compile + smoke-test steps (bun/npm for+TypeScript, cargo for Rust), watch mode, `wait`, registry-based backend+resolution (`PLEXUS_REGISTRY_URL`), shared credentials store (`_self`). -### Phase 4: Production Features-- [ ] Watch mode-- [ ] Progress indicators-- [ ] Auto-install tools-- [ ] Configuration files+Not shipped: a `python` generator (the target parses; service-codegen has no+emitter), remote/distributed caching. ## Contributing
app/Main.hs view
@@ -3,15 +3,19 @@ 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.Environment (getArgs)+import System.Exit (ExitCode(..), exitFailure, exitSuccess, exitWith) import System.IO (stdout, stderr, hSetEncoding, hSetBuffering, utf8, BufferMode(..)) +import qualified Synapse.Self.Command as SelfCmd+ import SynapseCC.CLI-import SynapseCC.Config (initSynapseConfig, loadSynapseConfig, synapseConfigPath)+import SynapseCC.Config (initSynapseConfig, InitResult(..), loadSynapseConfig, synapseConfigPath) import SynapseCC.Detect (ProjectHint(..), defaultDetectors) import SynapseCC.Discover import SynapseCC.Logging import SynapseCC.Pipeline+import qualified SynapseCC.RegistryResolve as Registry import SynapseCC.Types import SynapseCC.Wait (runWait) import SynapseCC.Watch (runWatch)@@ -28,23 +32,31 @@ hSetBuffering stdout LineBuffering hSetBuffering stderr LineBuffering + getArgs >>= mapM_ logInfo . deprecatedFlagNotes cmd <- parseCommand case cmd of -- ------------------------------------------------------------------ -- synapse-cc init -- ------------------------------------------------------------------- CmdInit -> do- result <- initSynapseConfig synapseConfigPath defaultDetectors+ CmdInit mBackend -> do+ -- Z2H-5: explicit backend wins; otherwise infer (co-located service+ -- crate, then local registry at 127.0.0.1:4444). Never a silent default.+ result <- initSynapseConfig synapseConfigPath defaultDetectors mBackend "127.0.0.1" 4444 case result of Left err -> do TIO.putStrLn $ "[!] " <> err exitFailure- Right hint -> do+ Right initResult -> do logSuccess $ "Created " <> T.pack synapseConfigPath+ <> " (backend: " <> irBackend initResult <> ")"+ -- If the backend was inferred (not given explicitly), SAY SO.+ case irBackendNote initResult of+ Just note -> logInfo note+ Nothing -> pure () -- If a detector gave a reason, surface it so the user knows -- why the generated config has a particular transport.- case phReason hint of+ case phReason (irHint initResult) of Just reason -> logInfo reason Nothing -> pure () TIO.putStrLn "Edit it to configure your targets, then run:"@@ -67,8 +79,11 @@ Right tools -> do logSuccess "Found all required tools"- logStep $ "Connecting to " <> backendName (cfgBackend config)- <> " at ws://" <> cfgHost config <> ":" <> cfgPort config <> "..."+ -- Z2H-6: report the EFFECTIVE registry endpoint (PLEXUS_REGISTRY_URL+ -- may override the default --host/--port).+ (effHost, effPort) <- Registry.resolveRegistryEndpoint (cfgHost config) (cfgPort config)+ logStep $ "Resolving " <> backendName (cfgBackend config)+ <> " via registry at ws://" <> effHost <> ":" <> T.pack (show effPort) <> "..." pipelineResult <- runPipeline config tools case pipelineResult of@@ -146,3 +161,15 @@ Right tools -> do logSuccess "Found all required tools" runWatch watchArgs tools++ -- ------------------------------------------------------------------+ -- synapse-cc _self <backend> <verb> ... (SELF-6)+ -- ------------------------------------------------------------------+ -- Delegates straight to the shared handler that @synapse _self@ uses.+ -- Keeping the dispatch thin here (one line of real work) is the+ -- point of SELF-6: both CLIs must stay behavior-identical.+ CmdSelf selfCmd -> do+ code <- SelfCmd.runSelfCommand selfCmd+ case code of+ ExitSuccess -> exitSuccess+ ExitFailure _ -> exitWith code
+ src/SynapseCC/Auth.hs view
@@ -0,0 +1,51 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Token resolution for synapse-cc (SAFE-2; deduplicated in SELF-6).+--+-- This module is the CLI-priority chain that sits in front of the shared+-- 'Synapse.Self.resolveToken' helper. The chain (highest first):+--+-- 1. @--token \<jwt\>@ (explicit flag)+-- 2. @SYNAPSE_TOKEN@ env var+-- 3. @--token-file \<path\>@ (explicit file)+-- 4. 'Synapse.Self.resolveToken' — reads+-- @~\/.plexus\/\<backend\>\/defaults.json@ via 'defaultRegistry'+--+-- Step 4 delegates through the shared library so synapse and synapse-cc+-- stay byte-identical on persistent defaults. Prior to SELF-6 this+-- module carried a hand-rolled copy of the defaults.json read; SELF-6+-- collapses that onto 'Synapse.Self.resolveToken'.+--+-- 'SynapseCC.Auth.resolveToken' itself stays as a synapse-cc-local+-- orchestrator because 'Options' is a synapse-cc type — the Options+-- record and the CLI flag priority belong here, not in the library.+-- The /library-visible/ fallback is what SELF-6 dedups.+module SynapseCC.Auth+ ( resolveToken+ ) where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import System.Environment (lookupEnv)++import qualified Synapse.Self as Self++import SynapseCC.Types (Options(..))++-- | Resolve the auth token for a given backend.+-- Returns 'Nothing' if no source provides one.+resolveToken :: Options -> Text -> IO (Maybe Text)+resolveToken opts backend =+ case optToken opts of+ Just tok | not (T.null tok) -> pure (Just tok)+ _ -> do+ mEnv <- lookupEnv "SYNAPSE_TOKEN"+ case mEnv of+ Just envTok | not (null envTok) -> pure (Just (T.pack envTok))+ _ -> case optTokenFile opts of+ Just p -> do+ contents <- TIO.readFile p+ let tok = T.strip contents+ pure $ if T.null tok then Nothing else Just tok+ Nothing -> Self.resolveToken backend
+ src/SynapseCC/AuthCheck.hs view
@@ -0,0 +1,98 @@+{-# LANGUAGE OverloadedStrings #-}++-- | SAFE-6: detect backends that require cookie-based WebSocket auth+-- and warn when the target's generated @transport.ts@ lacks the+-- SAFE-7 cookie-auth marker.+--+-- Detection signal: the IR contains at least one method whose params+-- schema has a property annotated @x-plexus-source.from = "auth"@.+-- That annotation is emitted by REQ-6 from @#[from_auth(...)]@ method+-- params. Any presence means the backend is using cookie-driven auth+-- at dispatch time; clients without cookie support will fail silently.+--+-- The SAFE-7 marker is the string @SAFE-7-cookie-auth-marker@ embedded+-- in the transport template by @service-codegen@'s TS generator. Its+-- presence in an existing @transport.ts@ indicates the file was+-- produced by a SAFE-7-era service-codegen (i.e. cookie-capable).+module SynapseCC.AuthCheck+ ( detectCookieAuthMismatch+ , backendRequiresCookieAuth+ ) where++import Data.Aeson (Value(..))+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.Text as T+import Data.Text (Text)+import qualified Data.Text.IO as TIO+import System.Directory (doesFileExist)+import System.FilePath ((</>))++-- | The marker string embedded by SAFE-7 in @transport.ts@. Kept in+-- sync with @service-codegen/src/generator/typescript/transport.rs@.+safe7Marker :: Text+safe7Marker = "SAFE-7-cookie-auth-marker"++-- | Inspect the IR JSON at @irFile@ and the target's output directory+-- @outputDir@. Returns @Just warningText@ when the backend requires+-- cookie auth (any method has @x-plexus-source.from = "auth"@) AND an+-- existing @transport.ts@ in the output directory lacks the SAFE-7+-- marker. Returns @Nothing@ otherwise (including first builds where+-- no transport.ts exists — nothing to mismatch with yet).+detectCookieAuthMismatch+ :: FilePath -- ^ IR JSON file (e.g. cache's ir.json)+ -> FilePath -- ^ Target output directory (will inspect transport.ts inside)+ -> IO (Maybe Text)+detectCookieAuthMismatch irFile outputDir = do+ irBytes <- BS.readFile irFile+ case Aeson.eitherDecodeStrict irBytes of+ Left _ -> pure Nothing -- unreadable IR is not this module's problem+ Right (val :: Value) ->+ if backendRequiresCookieAuth val+ then inspectTarget outputDir+ else pure Nothing++-- | Returns 'True' when the IR describes at least one method that uses+-- @#[from_auth(...)]@ (i.e. a param with @x-plexus-source.from = "auth"@).+backendRequiresCookieAuth :: Value -> Bool+backendRequiresCookieAuth val = case val of+ Object top -> case KM.lookup "irMethods" top of+ Just (Object ms) -> any hasAuthParam (KM.elems ms)+ _ -> False+ _ -> False+ where+ hasAuthParam :: Value -> Bool+ hasAuthParam (Object m) = case KM.lookup "mdParams" m of+ Just (Array params) -> any paramIsAuth (foldr (:) [] params)+ _ -> False+ hasAuthParam _ = False++ paramIsAuth :: Value -> Bool+ paramIsAuth (Object p) = case KM.lookup "pdSource" p of+ Just (Object src) -> case KM.lookup "from" src of+ Just (String "auth") -> True+ _ -> False+ _ -> False+ paramIsAuth _ = False++-- | Inspect the target directory for a SAFE-7-marked transport.ts.+-- Returns a warning when transport.ts exists but lacks the marker.+-- Returns 'Nothing' when transport.ts doesn't exist (first build) or+-- does contain the marker (correctly configured).+inspectTarget :: FilePath -> IO (Maybe Text)+inspectTarget outputDir = do+ let tsFile = outputDir </> "transport.ts"+ exists <- doesFileExist tsFile+ if not exists+ then pure Nothing -- first build; nothing to compare+ else do+ contents <- TIO.readFile tsFile+ if safe7Marker `T.isInfixOf` contents+ then pure Nothing+ else pure . Just $+ "[!] SAFE-6: backend requires cookie auth but target's transport.ts "+ <> "does not look like a SAFE-7-era service-codegen output (missing marker '"+ <> safe7Marker+ <> "'). Cookie-based WS upgrade may not work. "+ <> "Regenerate with an up-to-date service-codegen or pass --force."
src/SynapseCC/CLI.hs view
@@ -10,12 +10,18 @@ -- * Version , versionInfo++ -- * Deprecated spellings (NAMING-10)+ , deprecatedFlagNotes ) where +import Data.List (isPrefixOf) import Data.Text (Text) import qualified Data.Text as T import Options.Applicative +import qualified Synapse.Self.Command as SelfCmd+ import SynapseCC.Types -- ============================================================================@@ -70,6 +76,8 @@ (progDesc "Block until all configured backends are reachable")) <> command "init" (info initCommandParser (progDesc "Scaffold a synapse.config.json in the current directory"))+ <> command "_self" (info (CmdSelf <$> SelfCmd.parseSelfCommand)+ (progDesc "Manage per-backend defaults (cookies, headers, credentials)")) ) buildCommandParser :: Parser Command@@ -137,13 +145,20 @@ ) <*> switch (long "debug" <> help "Enable debug logging") +-- | Z2H-5: @init@ takes an optional BACKEND argument. When omitted, the+-- backend is inferred (co-located service crate, then local registry) —+-- and the inference is announced. There is no hardcoded default. initCommandParser :: Parser Command-initCommandParser = pure CmdInit+initCommandParser = CmdInit+ <$> optional (T.pack <$> argument str+ ( metavar "BACKEND"+ <> help "Backend identifier to scaffold for (omit to infer from a co-located service crate or the local registry)"+ )) -- | 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 ->+watchOptionsParser = (\cacheDir debug synapse service token tokenFile -> defaultOptions { optInstallDeps = False , optBuild = False@@ -151,7 +166,9 @@ , optCacheDir = cacheDir , optDebug = debug , optSynapsePath = synapse- , optHubCodegenPath = hub+ , optServiceCodegenPath = service+ , optToken = token+ , optTokenFile = tokenFile }) <$> option str ( long "cache-dir"@@ -162,12 +179,33 @@ ) <*> 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"))+ <*> serviceCodegenPathParser "service-codegen binary path"+ <*> optional (T.pack <$> option str (long "token" <> short 't' <> metavar "JWT" <> help "JWT auth token (overrides SYNAPSE_TOKEN, --token-file, and any access_token in ~/.plexus/<backend>/defaults.json)"))+ <*> optional (option str (long "token-file" <> metavar "PATH" <> help "Read JWT token from file")) -- ============================================================================ -- Shared Parsers -- ============================================================================ +-- | @--service-codegen PATH@. NAMING-10: the flag's old spelling still works+-- for one release, hidden from help; 'deprecatedFlagNotes' says so.+serviceCodegenPathParser :: String -> Parser (Maybe FilePath)+serviceCodegenPathParser helpText =+ (<|>) <$> optional (option str (long "service-codegen" <> metavar "PATH" <> help helpText))+ <*> optional (option str (long oldCodegenFlag <> metavar "PATH" <> hidden))++-- | The generator flag's spelling before NAMING-10.+oldCodegenFlag :: String+oldCodegenFlag = "hub-codegen" -- naming-alias: a hidden spelling of --service-codegen; removed by NAMING-18++-- | A note for each deprecated flag among the arguments, to print before+-- running (NAMING-10).+deprecatedFlagNotes :: [String] -> [Text]+deprecatedFlagNotes args =+ [ "--" <> T.pack oldCodegenFlag <> " is deprecated: use --service-codegen (NAMING-10)"+ | any (\a -> a == "--" <> oldCodegenFlag || ("--" <> oldCodegenFlag <> "=") `isPrefixOf` a) args+ ]+ -- | Parse target language targetParser :: Parser Target targetParser = argument readTarget@@ -267,10 +305,17 @@ <> metavar "PATH" <> help "Path to synapse binary (overrides discovery)" ))+ <*> serviceCodegenPathParser "Path to service-codegen binary (overrides discovery)"+ <*> optional (T.pack <$> option str+ ( long "token"+ <> short 't'+ <> metavar "JWT"+ <> help "JWT auth token (overrides SYNAPSE_TOKEN env, --token-file, and any access_token in ~/.plexus/<backend>/defaults.json)"+ )) <*> optional (option str- ( long "hub-codegen"+ ( long "token-file" <> metavar "PATH"- <> help "Path to hub-codegen binary (overrides discovery)"+ <> help "Read JWT token from file (overrides any access_token in ~/.plexus/<backend>/defaults.json)" )) -- ============================================================================
src/SynapseCC/Cache.hs view
@@ -51,7 +51,7 @@ -- │ ├── manifest.json # IR cache manifest -- │ ├── cone.json # Generated IR fragments -- │ └── arbor.json--- └── hub-codegen/+-- └── service-codegen/ -- └── typescript/ # Level 3: Code cache -- ├── manifest.json # Code cache manifest -- ├── cone/ # Generated code per plugin@@ -124,7 +124,7 @@ target = cfgTarget config debug = optDebug opts synapseVer = toolSynapseVersion tools- hubCodegenVer = toolHubCodegenVersion tools+ serviceCodegenVer = toolServiceCodegenVersion tools -- If --force flag is set, skip cache entirely if optForce opts@@ -161,7 +161,7 @@ let codeToolchain = ccmToolchain codeManifest if tvSynapseCC codeToolchain /= synapseCCVersion || tvSynapse codeToolchain /= synapseVer ||- isJust (tvHubCodegen codeToolchain) && tvHubCodegen codeToolchain /= Just hubCodegenVer+ isJust (tvServiceCodegen codeToolchain) && tvServiceCodegen codeToolchain /= Just serviceCodegenVer then do logDebug debug "Tool versions changed, invalidating code cache" pure $ CacheMiss ToolVersionChanged@@ -338,7 +338,8 @@ , ircmToolchain = ToolchainVersions { tvSynapseCC = synapseCCVersion , tvSynapse = synapseVer- , tvHubCodegen = Nothing+ , tvServiceCodegen = Nothing+ , tvPlexusCore = Nothing -- SAFE-4 degraded mode: backend doesn't expose version yet (SAFE-S02) } , ircmUpdatedAt = timestamp , ircmPlugins = plugins@@ -349,7 +350,7 @@ -- | Write code cache manifest writeCodeCacheManifest :: Options -> Backend -> Target -> Map Text CodePluginCache -> Text -> Text -> IO ()-writeCodeCacheManifest opts backend target plugins synapseVer hubCodegenVer = do+writeCodeCacheManifest opts backend target plugins synapseVer serviceCodegenVer = do cacheDir <- getCodeCacheDir opts backend target createDirectoryIfMissing True cacheDir @@ -362,7 +363,8 @@ , ccmToolchain = ToolchainVersions { tvSynapseCC = synapseCCVersion , tvSynapse = synapseVer- , tvHubCodegen = Just hubCodegenVer+ , tvServiceCodegen = Just serviceCodegenVer+ , tvPlexusCore = Nothing -- SAFE-4 degraded mode (SAFE-S02) } , ccmUpdatedAt = timestamp , ccmPlugins = plugins
src/SynapseCC/Config.hs view
@@ -5,6 +5,7 @@ ( loadSynapseConfig , validateSynapseConfig , initSynapseConfig+ , InitResult(..) , synapseConfigPath -- * Shared config-to-runtime conversions@@ -22,7 +23,10 @@ import qualified Data.Text as T import System.Directory (doesFileExist) -import SynapseCC.Detect (Detector, ProjectHint(..), runDetectors)+import SynapseCC.Detect+ ( Detector, ProjectHint(..), runDetectors+ , BackendInference(..), InferenceOutcome(..), inferBackend+ ) import SynapseCC.Types -- | Standard config file name (looked up from CWD)@@ -147,25 +151,66 @@ , cfgPlugins = tcPlugins tc } +-- | Result of a successful @synapse-cc init@.+data InitResult = InitResult+ { irHint :: !ProjectHint -- ^ Transport detection result ('phReason' explains)+ , irBackend :: !Text -- ^ Backend name written into the config+ , irBackendNote :: !(Maybe Text) -- ^ Announcement when the backend was INFERRED+ -- (Nothing when given explicitly)+ } deriving (Show, Eq)+ -- | 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.+-- Z2H-5: the backend name is either given explicitly (@Just backend@) or+-- inferred via 'inferBackend' (co-located service crate, then local+-- registry at @regHost:regPort@). There is no hardcoded fallback — when+-- nothing is inferable the result is a @Left@ naming the missing argument. --+-- Returns @Left@ if the file already exists or no backend could be+-- determined. Returns @Right InitResult@ on success — 'irBackendNote'+-- carries the inference announcement the caller MUST surface.+-- -- 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+initSynapseConfig+ :: FilePath -- ^ Config path (usually 'synapseConfigPath')+ -> [Detector] -- ^ Transport detectors+ -> Maybe Text -- ^ Explicit backend name (Nothing = infer)+ -> Text -- ^ Registry host for inference (default "127.0.0.1")+ -> Int -- ^ Registry port for inference (default 4444)+ -> IO (Either Text InitResult)+initSynapseConfig path detectors mBackend regHost regPort = 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+ backendResult <- case mBackend of+ Just b -> pure $ Right (b, Nothing)+ Nothing -> do+ outcome <- inferBackend regHost regPort+ pure $ case outcome of+ Inferred bi -> Right (biBackend bi, Just (biReason bi))+ AmbiguousRegistry known -> Left $+ "missing BACKEND argument — multiple backends are registered ("+ <> T.intercalate ", " known+ <> "). Pick one: synapse-cc init <backend>"+ NoInference -> Left $+ "missing BACKEND argument — no co-located Plexus service crate found"+ <> " and no backend discoverable on the registry at "+ <> regHost <> ":" <> T.pack (show regPort)+ <> ". Run: synapse-cc init <backend>"+ case backendResult of+ Left err -> pure $ Left err+ Right (backend, note) -> do+ hint <- runDetectors detectors+ let config = applyHint hint (defaultSynapseConfig backend)+ BL.writeFile path (Pretty.encodePretty' prettyConfig config)+ pure $ Right InitResult+ { irHint = hint+ , irBackend = backend+ , irBackendNote = note+ } -- | Apply detected hints to the default config. applyHint :: ProjectHint -> SynapseConfig -> SynapseConfig
src/SynapseCC/Detect.hs view
@@ -25,13 +25,27 @@ , detectVite , detectNextJS , detectNodeProject++ -- * Backend inference (Z2H-5)+ , BackendInference(..)+ , InferenceOutcome(..)+ , inferBackend+ , inferBackendFromCrate+ , inferBackendFromRegistry+ , parseServiceCrateName ) where import Control.Applicative ((<|>))+import Control.Exception (try, SomeException) import Control.Monad (foldM)+import Data.Maybe (listToMaybe, mapMaybe) import qualified Data.Text as T-import System.Directory (doesDirectoryExist, doesFileExist)+import qualified Data.Text.IO as TIO+import System.Directory (doesDirectoryExist, doesFileExist, getCurrentDirectory)+import System.FilePath ((</>), takeDirectory) +import qualified Synapse.Backend.Discovery as D+ import SynapseCC.Types (TransportType(..)) -- ============================================================================@@ -192,3 +206,133 @@ where step True _ = pure True step False path = doesFileExist path++-- ============================================================================+-- Backend inference (Z2H-5)+-- ============================================================================++-- | A successfully inferred backend name plus a human-readable explanation+-- of where it came from. The explanation is ALWAYS surfaced to the user —+-- bare @synapse-cc init@ must never silently pick a backend.+data BackendInference = BackendInference+ { biBackend :: !T.Text -- ^ Inferred backend identifier+ , biReason :: !T.Text -- ^ Why (announced to the user)+ } deriving (Show, Eq)++-- | Outcome of trying to infer a backend when none was given.+data InferenceOutcome+ = Inferred !BackendInference+ -- ^ Exactly one candidate found+ | AmbiguousRegistry ![T.Text]+ -- ^ Registry reachable but lists multiple backends — user must choose+ | NoInference+ -- ^ Nothing inferable (no service crate, registry empty/unreachable)+ deriving (Show, Eq)++-- | Infer the backend for @synapse-cc init@ when no argument was given.+--+-- Sources, in priority order:+--+-- 1. A co-located Plexus service crate: a @Cargo.toml@ in the current+-- directory (or its parent) whose package depends on a plexus service+-- crate (@plexus-rpc@, @plexus-core@, or @plexus-transport@). The crate+-- name becomes the backend name.+-- 2. The local Plexus registry at the given host:port (default+-- @127.0.0.1:4444@), reusing the existing+-- 'Synapse.Backend.Discovery' mechanism — but only when it lists+-- exactly one backend. Multiple backends are ambiguous.+inferBackend+ :: T.Text -- ^ registry host+ -> Int -- ^ registry port+ -> IO InferenceOutcome+inferBackend regHost regPort = do+ cwd <- getCurrentDirectory+ fromCrate <- inferBackendFromCrate cwd+ case fromCrate of+ Just bi -> pure (Inferred bi)+ Nothing -> inferBackendFromRegistry regHost regPort++-- | Look for a co-located Plexus service crate: @Cargo.toml@ in the given+-- directory, then in its parent. Returns the crate's package name when the+-- crate looks like a Plexus service (see 'parseServiceCrateName').+inferBackendFromCrate :: FilePath -> IO (Maybe BackendInference)+inferBackendFromCrate dir = go [dir, takeDirectory dir]+ where+ go [] = pure Nothing+ go (d:rest) = do+ let manifest = d </> "Cargo.toml"+ exists <- doesFileExist manifest+ if not exists+ then go rest+ else do+ content <- TIO.readFile manifest+ case parseServiceCrateName content of+ Just name -> pure $ Just BackendInference+ { biBackend = name+ , biReason = "Inferred backend '" <> name+ <> "' from co-located Plexus service crate ("+ <> T.pack manifest <> ")"+ }+ Nothing -> go rest++-- | Extract the package name from Cargo.toml text — but only when the crate+-- depends on a Plexus service crate (@plexus-rpc@, @plexus-core@, or+-- @plexus-transport@). A random Rust crate is NOT a backend.+--+-- This is a line-oriented scan, not a full TOML parser: it understands+-- @[package]@ section @name = "..."@ entries, @[dependencies]@-style section+-- keys, and table-form deps like @[dependencies.plexus-rpc]@.+parseServiceCrateName :: T.Text -> Maybe T.Text+parseServiceCrateName toml =+ let lns = map T.strip (T.lines toml)+ -- Pair each line with its enclosing [section]+ sectioned = go "" lns+ where+ go _ [] = []+ go sec (l:rest)+ | Just hdr <- sectionHeader l = (hdr, l) : go hdr rest+ | otherwise = (sec, l) : go sec rest+ sectionHeader l = do+ inner <- T.stripPrefix "[" l+ body <- T.stripSuffix "]" inner+ pure (T.strip body)+ isDepSection sec =+ sec == "dependencies" || "dependencies." `T.isPrefixOf` sec+ || sec == "workspace.dependencies" || "workspace.dependencies." `T.isPrefixOf` sec+ plexusCrates = ["plexus-rpc", "plexus-core", "plexus-transport"]+ -- A dep either appears as a key in a deps section…+ keyOf l = T.strip (fst (T.breakOn "=" l))+ hasInlineDep = any (\(sec, l) -> isDepSection sec && keyOf l `elem` plexusCrates) sectioned+ -- …or as a table-form section header [dependencies.plexus-rpc]+ hasTableDep = any (\(sec, _) ->+ any (\c -> sec == "dependencies." <> c+ || sec == "workspace.dependencies." <> c) plexusCrates) sectioned+ pkgName = listToMaybe $ mapMaybe+ (\(sec, l) ->+ if sec == "package" && keyOf l == "name"+ then unquote (T.strip (T.drop 1 (snd (T.breakOn "=" l))))+ else Nothing)+ sectioned+ unquote t = T.stripPrefix "\"" =<< T.stripSuffix "\"" t+ in if hasInlineDep || hasTableDep then pkgName else Nothing++-- | Query the local registry for registered backends, reusing the existing+-- 'Synapse.Backend.Discovery' machinery (no new registry protocol — Z2H-6+-- owns registration mechanics). Infers only when EXACTLY one backend is+-- registered; multiple candidates are ambiguous and reported as such.+-- Unreachable or empty registry → 'NoInference' (never an error here —+-- the caller decides how to phrase the failure).+inferBackendFromRegistry :: T.Text -> Int -> IO InferenceOutcome+inferBackendFromRegistry regHost regPort = do+ let discovery = D.registryDiscovery regHost regPort+ result <- try (D.discoverBackends discovery) :: IO (Either SomeException [D.Backend])+ case result of+ Left _ -> pure NoInference+ Right [] -> pure NoInference+ Right [b] -> pure $ Inferred BackendInference+ { biBackend = D.backendName b+ , biReason = "Inferred backend '" <> D.backendName b+ <> "' — the only backend registered on the local registry at "+ <> regHost <> ":" <> T.pack (show regPort)+ }+ Right many -> pure $ AmbiguousRegistry (map D.backendName many)
src/SynapseCC/Discover.hs view
@@ -1,9 +1,13 @@--- | Tool discovery - finding synapse and hub-codegen executables+-- | Tool discovery - finding synapse and service-codegen executables module SynapseCC.Discover ( discoverTools , findTool , toolPathToFilePath , findInDistNewstyle+ , serviceCodegenSendsInjectedParams+ , findServiceCodegen+ , serviceCodegenName+ , oldCodegenName ) where import Control.Exception (catch, SomeException, try)@@ -14,6 +18,7 @@ import Data.Ord (comparing, Down(..)) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Read as TR import System.Directory (doesFileExist, doesDirectoryExist, findExecutable, getHomeDirectory, listDirectory, getModificationTime) import System.FilePath ((</>)) import System.Process (readProcessWithExitCode)@@ -37,27 +42,47 @@ case synapsePath of Nothing -> pure $ Left $ ToolNotFound "synapse" synapseSuggestions Just synapseToolPath -> do- hubCodegenPath <- case optHubCodegenPath opts of- Just explicit -> resolveExplicit "hub-codegen" explicit debug- Nothing -> findTool debug "hub-codegen" hubCodegenFallbackPaths+ serviceCodegenPath <- case optServiceCodegenPath opts of+ Just explicit -> resolveExplicit serviceCodegenName explicit debug+ Nothing -> findServiceCodegen debug >>= \case+ Nothing -> pure Nothing+ Just (path, mNote) -> mapM_ logInfo mNote >> pure (Just path) - case hubCodegenPath of- Nothing -> pure $ Left $ ToolNotFound "hub-codegen" hubCodegenSuggestions- Just hubCodegenToolPath -> do+ case serviceCodegenPath of+ Nothing -> pure $ Left $ ToolNotFound (T.pack serviceCodegenName) serviceCodegenSuggestions+ Just serviceCodegenToolPath -> do -- Run --version for each discovered tool synapseVer <- getToolVersion (toolPathToFilePath synapseToolPath)- hubCodegenVer <- getToolVersion (toolPathToFilePath hubCodegenToolPath)+ serviceCodegenVer <- getToolVersion (toolPathToFilePath serviceCodegenToolPath) logDebug debug $ " synapse " <> T.pack (toolPathToFilePath synapseToolPath) <> " (" <> synapseVer <> ")"- logDebug debug $ " hub-codegen " <> T.pack (toolPathToFilePath hubCodegenToolPath) <> " (" <> hubCodegenVer <> ")"+ logDebug debug $ " service-codegen " <> T.pack (toolPathToFilePath serviceCodegenToolPath) <> " (" <> serviceCodegenVer <> ")"+ when (serviceCodegenSendsInjectedParams serviceCodegenVer) $+ logInfo $ "the code generator " <> serviceCodegenVer <> " predates 0.5.0: a parameter the server injects "+ <> "(pdSource: auth, header, derived, …) comes out as an optional `unknown` argument "+ <> "of the generated methods. Use service-codegen 0.5.0 or later (SYNAPSE-CC-2)." pure $ Right $ ToolLocations { toolSynapse = synapseToolPath- , toolHubCodegen = hubCodegenToolPath+ , toolServiceCodegen = serviceCodegenToolPath , toolSynapseVersion = synapseVer- , toolHubCodegenVersion = hubCodegenVer+ , toolServiceCodegenVersion = serviceCodegenVer } +-- | SYNAPSE-CC-2: whether a service-codegen of this version (as 'getToolVersion'+-- reports it) still writes server-injected parameters into the generated+-- methods — every release before 0.5.0. An unparseable version is given+-- the benefit of the doubt.+serviceCodegenSendsInjectedParams :: Text -> Bool+serviceCodegenSendsInjectedParams ver =+ case traverse readNat (T.splitOn "." (T.takeWhile (/= '-') ver)) of+ Just (major : minor : _) -> (major, minor) < (0, 5)+ _ -> False+ where+ readNat t = case TR.decimal t of+ Right (n, rest) | T.null rest -> Just (n :: Int)+ _ -> Nothing+ -- | Resolve an explicitly-provided path, failing clearly if it doesn't exist resolveExplicit :: String -> FilePath -> Bool -> IO (Maybe ToolPath) resolveExplicit name path debug = do@@ -121,7 +146,7 @@ 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")+ -- Take last word (handles "service-codegen 0.1.0" and "3.5.0") lastWord = case reverse (T.words firstLine) of (w:_) -> w [] -> ""@@ -208,19 +233,47 @@ ] pure $ devPaths ++ installPaths --- | Fallback paths for hub-codegen (checked if not in PATH)-hubCodegenFallbackPaths :: IO [FilePath]-hubCodegenFallbackPaths = do+-- | NAMING-10: the generator is service-codegen; before 0.5.1 it had another+-- name. Discovery looks for the new name first — on PATH, then beside this+-- checkout and in the install directories — and only then for the old one.+serviceCodegenName :: String+serviceCodegenName = "service-codegen"++-- | The generator's name before NAMING-10, still found (second, with a note)+-- for one release.+oldCodegenName :: String+oldCodegenName = "hub-codegen" -- naming-alias: found after service-codegen; removed by NAMING-18++-- | Find the generator under its name, else under its old one. The second+-- part is a note to log when only the old name was found.+findServiceCodegen :: Bool -> IO (Maybe (ToolPath, Maybe Text))+findServiceCodegen debug = do+ new <- findTool debug serviceCodegenName (codegenFallbackPaths serviceCodegenName)+ case new of+ Just path -> pure (Just (path, Nothing))+ Nothing -> do+ old <- findTool debug oldCodegenName (codegenFallbackPaths oldCodegenName)+ pure $ fmap (\path -> (path, Just (oldNameNote path))) old+ where+ oldNameNote path =+ "found the code generator under its old name, " <> T.pack oldCodegenName <> " ("+ <> T.pack (toolPathToFilePath path) <> "); the name is deprecated: install "+ <> T.pack serviceCodegenName <> " (0.5.1 or later) — the old name goes in NAMING-18"++-- | Fallback paths for the generator under a name (checked if not in PATH):+-- the sibling checkout's build, then the install directories.+codegenFallbackPaths :: String -> IO [FilePath]+codegenFallbackPaths name = 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"+ [ ".." </> name </> "target/release" </> name+ , ".." </> name </> "target/debug" </> name+ , "../.." </> name </> "target/release" </> name+ , "../.." </> name </> "target/debug" </> name ] installPaths =- [ home </> ".plexus/bin/hub-codegen"- , home </> ".cargo/bin/hub-codegen"+ [ home </> ".plexus/bin" </> name+ , home </> ".cargo/bin" </> name ] pure $ devPaths ++ installPaths @@ -235,9 +288,9 @@ , "Add to PATH: export PATH=\"$HOME/.local/bin:$PATH\"" ] -hubCodegenSuggestions :: [Text]-hubCodegenSuggestions =- [ "Install hub-codegen: cd ../hub-codegen && cargo install --path ."- , "Or specify path: --hub-codegen /path/to/hub-codegen"+serviceCodegenSuggestions :: [Text]+serviceCodegenSuggestions =+ [ "Install service-codegen: cd ../service-codegen && cargo install --path ."+ , "Or specify path: --service-codegen /path/to/service-codegen" , "Add to PATH: export PATH=\"$HOME/.cargo/bin:$PATH\"" ]
src/SynapseCC/Language.hs view
@@ -165,7 +165,9 @@ pure $ Right () installDependencies Rust _ debug = do- when debug $ putStrLn "[*] Rust dependency installation not yet implemented (Phase 2 TODO)"+ -- Z2H-7: no separate install step for Rust — the generated Cargo.toml+ -- declares all dependencies and `cargo build` resolves them.+ when debug $ putStrLn "[*] Rust dependencies are declared in the generated Cargo.toml — cargo build resolves them" pure $ Right () -- | Add specific packages to the project using the detected package manager.@@ -253,9 +255,29 @@ when debug $ putStrLn "[*] Python build not yet implemented (Phase 2 TODO)" pure $ Right $ CompiledPath $ unGeneratedPath genPath +-- Z2H-7: compile the generated Rust crate with `cargo build`.+-- Incremental rebuilds are cargo's own concern (its target/ cache);+-- synapse-cc's manifest/lock cache decides whether this step runs at all. buildProject Rust genPath debug = do- when debug $ putStrLn "[*] Rust build not yet implemented (Phase 2 TODO)"- pure $ Right $ CompiledPath $ unGeneratedPath genPath+ when debug $ putStrLn $ "[*] Compiling Rust crate in " ++ unGeneratedPath genPath++ mbCargo <- findExecutable "cargo"+ case mbCargo of+ Nothing -> pure $ Left $ ToolNotFound "cargo"+ [ "Install Rust via rustup: https://rustup.rs"+ , "Ensure cargo is on PATH: export PATH=\"$HOME/.cargo/bin:$PATH\""+ ]+ Just cargo -> do+ result <- runProcess cargo ["build"] (Just $ unGeneratedPath genPath) debug+ case prExitCode result of+ ExitSuccess -> do+ when debug $ putStrLn " [+] cargo build passed"+ pure $ Right $ CompiledPath $ unGeneratedPath genPath+ ExitFailure code -> do+ -- cargo writes diagnostics to stderr; keep stdout too in case of+ -- build-script output. These must reach the user verbatim.+ let combined = T.unlines $ filter (not . T.null . T.strip) [prStdout result, prStderr result]+ pure $ Left $ LanguageToolError "cargo build" combined code -- ============================================================================ -- Testing
src/SynapseCC/Lock.hs view
@@ -9,6 +9,7 @@ , emptySynapseLock ) where +import Control.Applicative ((<|>)) import Data.Aeson (FromJSON, ToJSON, eitherDecodeFileStrict) import qualified Data.Aeson as Aeson import qualified Data.Aeson.Encode.Pretty as Pretty@@ -60,7 +61,7 @@ data SynapseLock = SynapseLock { slVersion :: !Text , slSynapseCC :: !Text- , slHubCodegen :: !Text+ , slServiceCodegen :: !Text , slUpdatedAt :: !Text , slTargets :: !(Map Text LockTarget) -- ^ outputDir → LockTarget } deriving (Show, Eq)@@ -69,7 +70,7 @@ parseJSON = Aeson.withObject "SynapseLock" $ \o -> SynapseLock <$> o Aeson..: "version" <*> o Aeson..: "synapseCC"- <*> o Aeson..: "hubCodegen"+ <*> (o Aeson..: "serviceCodegen" <|> o Aeson..: "hubCodegen") -- naming-alias: a lock written before NAMING-10 reads <*> o Aeson..: "updatedAt" <*> o Aeson..: "targets" @@ -77,7 +78,7 @@ toJSON sl = Aeson.object [ "version" Aeson..= slVersion sl , "synapseCC" Aeson..= slSynapseCC sl- , "hubCodegen" Aeson..= slHubCodegen sl+ , "serviceCodegen" Aeson..= slServiceCodegen sl , "updatedAt" Aeson..= slUpdatedAt sl , "targets" Aeson..= slTargets sl ]@@ -87,7 +88,7 @@ emptySynapseLock = SynapseLock { slVersion = "1" , slSynapseCC = synapseCCVersion- , slHubCodegen = ""+ , slServiceCodegen = "" , slUpdatedAt = "" , slTargets = Map.empty }@@ -99,7 +100,7 @@ lockPrettyConfig :: Pretty.Config lockPrettyConfig = Pretty.defConfig { Pretty.confCompare = Pretty.keyOrder- [ "version", "synapseCC", "hubCodegen", "updatedAt", "targets"+ [ "version", "synapseCC", "serviceCodegen", "updatedAt", "targets" , "backend", "irHash", "transport", "files", "sharedTransport" ] }
src/SynapseCC/Merge.hs view
@@ -27,7 +27,7 @@ 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.+-- Algorithm matches service-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
src/SynapseCC/Pipeline.hs view
@@ -26,7 +26,9 @@ import System.Exit (ExitCode(..)) import System.FilePath ((</>), splitPath) -import Synapse.Monad (initEnv, runSynapseM, SynapseError(..))+-- The constructors by name: synapse 3.16 adds its own CacheMiss (SYNAPSE-28),+-- which would clash with SynapseCC.Types' (NAMING-10 moved the bound to 3.16).+import Synapse.Monad (initEnv, runSynapseM, SynapseError(NavError, TransportError, TransportErrorContext, ParseError, ValidationError, BackendError)) import Synapse.IR.Builder (buildIR) import qualified Synapse.Log as Log import qualified Katip@@ -42,6 +44,9 @@ import SynapseCC.Cache (getCacheDir) import qualified SynapseCC.Merge as Merge import qualified SynapseCC.Lock as Lock+import qualified SynapseCC.Auth as Auth+import qualified SynapseCC.RegistryResolve as Registry+import qualified SynapseCC.AuthCheck as AuthCheck -- ============================================================================ -- Pipeline Orchestration@@ -52,6 +57,11 @@ runPipeline config tools = do let debug = optDebug (cfgOptions config) + -- SAFE-4 (degraded mode): plexus-core does not yet expose its toolchain+ -- version (see SAFE-S01 spike). Warn once per build that version-gating+ -- is inactive until SAFE-S02 lands.+ logDebug debug " SAFE-4: version-gating inactive (plexus-core does not expose toolchain version; see SAFE-S02)"+ -- Step 0: Check cache (unless --force is set) cacheResult <- Cache.validateCache config tools case cacheResult of@@ -122,7 +132,7 @@ synapseCCMarker :: Text synapseCCMarker = "\"_generatedBy\": \"synapse-cc\"" --- | Marker present in package.json files generated by hub-codegen.+-- | Marker present in package.json files generated by service-codegen. -- Used alongside synapseCCMarker to detect generated (standalone) output directories. generatedByMarker :: Text generatedByMarker = "\"_generatedBy\""@@ -198,6 +208,15 @@ logSuccess $ "Schema ready (" <> T.pack (show pluginCount) <> " plugins)" logDebug debug $ " IR at " <> T.pack (unIRPath irPath) + -- SAFE-6: warn if backend requires cookie auth but the target's+ -- existing transport.ts lacks the SAFE-7 marker. Runs BEFORE+ -- regeneration so the inspection sees the stale (pre-SAFE-7) output.+ let outputDir = optOutput (cfgOptions config)+ mWarn <- AuthCheck.detectCookieAuthMismatch (unIRPath irPath) outputDir+ case mWarn of+ Just w -> logInfo w+ Nothing -> pure ()+ -- Step 2: Generate code logStep "Generating code..." (codeResult, codeMs) <- timeStep $ generateCode config tools irPath Nothing@@ -209,7 +228,7 @@ -- 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+ -- "_generatedBy" field, service-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@@ -224,7 +243,12 @@ outPkgJsonPath = outputDir </> "package.json" outPkgExists <- doesFileExist outPkgJsonPath isIntegration <-- if outPkgExists+ if cfgTarget config /= TypeScript+ -- Z2H-7: integration mode is a TypeScript (package.json) concept.+ -- A Rust target emits a standalone crate; a package.json in the+ -- CWD must not suppress the cargo build step.+ then pure False+ else if outPkgExists then do -- outputDir has a package.json — if it has _generatedBy it's ours (standalone) content <- TIO.readFile outPkgJsonPath@@ -286,10 +310,12 @@ let genPath = GeneratedPath outputDir - -- Write synapse-cc's tsconfig to the output dir.+ -- Write synapse-cc's tsconfig to the output dir (TypeScript only —+ -- a generated Rust crate has no business containing a tsconfig). -- Standalone mode: tight config (noEmit, transport-specific types). -- Integration mode: project-references config (composite, declaration).- do logDebug debug " Writing tsconfig.json"+ when (cfgTarget config == TypeScript) $ do+ logDebug debug " Writing tsconfig.json" let tsconfig = if isIntegration then generateIntegrationTsconfig else generateTsconfig (optTransport opts)@@ -301,7 +327,7 @@ 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+ (installResult, installMs) <- if optInstallDeps opts && cfgTarget config == TypeScript then do (addResult, addMs) <- timeStep $ Language.addDependencies depPath@@ -325,7 +351,11 @@ Left _ -> pure () pure (result, addMs + ms) else do- logDebug debug "Skipping dependency installation (--no-install)"+ if cfgTarget config == TypeScript+ then logDebug debug "Skipping dependency installation (--no-install)"+ -- Z2H-7: no package-manager step for Rust — dependencies live+ -- in the generated Cargo.toml and cargo build resolves them.+ else logDebug debug "Dependencies declared in generated Cargo.toml (resolved by cargo build)" pure (Right (), 0) case installResult of@@ -399,7 +429,7 @@ writeCache config tools irPath _compiledPath codegenOutput = do let debug = optDebug (cfgOptions config) synapseVer = toolSynapseVersion tools- hubCodegenVer = coHubCodegenVersion codegenOutput+ serviceCodegenVer = coServiceCodegenVersion codegenOutput logDebug debug "Writing cache manifests..." -- Read IR to extract plugin hashes@@ -415,7 +445,7 @@ , cpcCachedAt = "" } Cache.writeCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config)- pluginCaches synapseVer hubCodegenVer+ pluginCaches synapseVer serviceCodegenVer -- Write synapse.lock (project-level, committed to git) irBytes <- BS.readFile (unIRPath irPath)@@ -444,7 +474,7 @@ 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+ , Lock.slServiceCodegen = serviceCodegenVer } logDebug debug " Cache manifests written"@@ -531,7 +561,7 @@ -- Build providers first providerResults <- mapM buildTarget providers - -- Build consumers: run hub-codegen for plugins only, then write shim files+ -- Build consumers: run service-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))@@ -644,11 +674,11 @@ 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).+-- | Call service-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)+ serviceCodegenPath = toolPathToFilePath (toolServiceCodegen tools) opts = cfgOptions config targetArg = T.unpack (targetToText (cfgTarget config)) args =@@ -659,14 +689,14 @@ WsTransport -> "ws" BrowserTransport -> "browser" ]- result <- runProcess hubCodegenPath args Nothing debug+ result <- runProcess serviceCodegenPath args Nothing debug case prExitCode result of ExitSuccess -> case eitherDecodeStrict (TE.encodeUtf8 (prStdout result)) of- Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0+ Left parseErr -> pure $ Left $ ServiceCodegenError (T.pack parseErr) 0 Right out -> pure $ Right out ExitFailure code ->- pure $ Left $ HubCodegenError (prStderr result) code+ pure $ Left $ ServiceCodegenError (prStderr result) code -- ============================================================================ -- IR Generation@@ -679,51 +709,77 @@ Backend bkName = cfgBackend config outputDir = optOutput (cfgOptions config) opts = cfgOptions config- host = cfgHost config- port = read (T.unpack (cfgPort config)) :: Int+ regHost = cfgHost config+ regPort = read (T.unpack (cfgPort config)) :: Int generatorInfo = ["synapse-cc:" <> synapseCCVersion] - -- 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"+ -- SAFE-3 + Z2H-6: resolve backend host:port via the registry.+ -- The registry endpoint comes from --host/--port; at their defaults,+ -- PLEXUS_REGISTRY_URL may override (default ws://127.0.0.1:4444).+ (effHost, effPort) <- Registry.resolveRegistryEndpoint regHost (cfgPort config)+ resolution <- Registry.resolveBackendAddress effHost effPort bkName+ hpResult <- case resolution of+ Registry.ResolvedFromRegistry h p -> do+ logDebug debug $ " Registry resolved " <> bkName <> " -> " <> h <> ":" <> T.pack (show p)+ pure $ Right (h, p)+ Registry.ResolvedFallback h p -> do+ logDebug debug $ " Registry unreachable; using provided address " <> h <> ":" <> T.pack (show p)+ pure $ Right (h, p)+ Registry.NotInRegistry known ->+ -- Z2H-6: actionable error naming the registration step.+ pure $ Left $ BackendUnreachable bkName $+ "Backend '" <> bkName <> "' is not registered. Known: "+ <> (if null known then "<none>" else T.intercalate ", " known)+ <> ". Backends self-register at startup — start '" <> bkName+ <> "' (registry at $PLEXUS_REGISTRY_URL, default ws://127.0.0.1:4444),"+ <> " or pass -H/-P to connect directly."+ case hpResult of+ Left e -> pure (Left e)+ Right (host, port) -> do+ -- 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+ -- Ensure output and IR cache directories exist+ createDirectoryIfMissing True outputDir+ createDirectoryIfMissing True irDir - -- 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)- 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- 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+ -- 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)+ logger <- Log.makeLogger Katip.ErrorS+ -- SAFE-2: resolve JWT token from --token / SYNAPSE_TOKEN / --token-file /+ -- cookies.access_token in ~/.plexus/<backend>/defaults.json (SELF-3)+ token <- Auth.resolveToken opts bkName+ env <- initEnv host port bkName logger token+ -- 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 - case result of- Left msg -> do- logDebug debug $ " Synapse error: " <> msg- pure $ Left $ SynapseError msg 1+ case result of+ Left msg -> do+ logDebug debug $ " Synapse error: " <> msg+ pure $ Left $ SynapseError msg 1 - Right ir -> do- -- Encode IR to JSON and write to cache path- let irBytes = BL.toStrict (Aeson.encode ir)- BS.writeFile irFile irBytes+ Right ir -> do+ -- Encode IR to JSON and write to cache path+ let irBytes = BL.toStrict (Aeson.encode ir)+ BS.writeFile irFile irBytes - -- 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+ -- 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 -- | Format a SynapseError (from plexus-synapse) for display formatSynapseError :: SynapseError -> Text@@ -739,6 +795,7 @@ ParseError t -> "Parse error: " <> t ValidationError t -> "Validation error: " <> t BackendError bt _ -> "Backend error: " <> T.pack (show bt)+ other -> "synapse: " <> T.pack (show other) -- a later synapse's own (3.16: StaleSchema, CacheMiss) -- | IR structure for reading plugin hashes -- We only need the fields relevant for caching@@ -768,14 +825,14 @@ -- Code Generation -- ============================================================================ --- | Generate code using hub-codegen, returning parsed JSON output.+-- | Generate code using service-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)+ serviceCodegenPath = toolPathToFilePath (toolServiceCodegen tools) opts = cfgOptions config target = cfgTarget config targetArg = T.unpack (targetToText target)@@ -800,13 +857,13 @@ ++ pluginFilterArgs ++ [ unIRPath irPath ] - result <- runProcess hubCodegenPath args Nothing debug+ result <- runProcess serviceCodegenPath args Nothing debug case prExitCode result of ExitSuccess -> do let stdoutBytes = TE.encodeUtf8 (prStdout result) case eitherDecodeStrict stdoutBytes of- Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0+ Left parseErr -> pure $ Left $ ServiceCodegenError (T.pack parseErr) 0 Right out -> pure $ Right out ExitFailure code ->- pure $ Left $ HubCodegenError (prStderr result) code+ pure $ Left $ ServiceCodegenError (prStderr result) code
+ src/SynapseCC/RegistryResolve.hs view
@@ -0,0 +1,82 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Registry-based backend address resolution (SAFE-3).+--+-- synapse-cc previously assumed every backend lived at the registry's+-- own host:port (typically @127.0.0.1:4444@), which broke for backends+-- like @lforge@ that live on different ports. This module queries the+-- Plexus registry to map a backend name to its actual host:port.+--+-- The registry's location defaults to @127.0.0.1:4444@; pass+-- @--host@\/@--port@ to override.+--+-- Resolution flow:+--+-- 1. Query @registry.get@ at the given registry address for the named backend.+-- 2. If found, return the registered host:port.+-- 3. If the backend is not registered, list known backends and fail.+-- 4. If the registry itself is unreachable, fall back to the registry+-- address as the backend address (preserves the legacy "substrate IS+-- the registry on 4444" path) and warn.+module SynapseCC.RegistryResolve+ ( ResolveResult(..)+ , resolveBackendAddress+ , resolveRegistryEndpoint+ ) where++import Control.Exception (try, SomeException)+import Data.Text (Text)+import qualified Data.Text as T+import Text.Read (readMaybe)++import qualified Synapse.Backend.Discovery as D++-- | Outcome of resolving a backend name against the registry.+data ResolveResult+ = ResolvedFromRegistry !Text !Int -- ^ Backend found in registry: returned host, port+ | ResolvedFallback !Text !Int -- ^ Registry unreachable: falling back to provided address+ | NotInRegistry ![Text] -- ^ Backend not in registry; suggestions = known backend names+ deriving stock (Show, Eq)++-- | Z2H-6: resolve the effective registry endpoint from the CLI flags.+--+-- When @--host@/@--port@ sit at their defaults (@127.0.0.1@/@4444@),+-- the @PLEXUS_REGISTRY_URL@ env var (shared with plexus-transport's+-- boot-time self-registration and synapse) may point the registry+-- elsewhere. Explicit non-default flags always win.+resolveRegistryEndpoint+ :: Text -- ^ cfg host (CLI --host, default "127.0.0.1")+ -> Text -- ^ cfg port (CLI --port, default "4444")+ -> IO (Text, Int)+resolveRegistryEndpoint cfgHost cfgPortText = do+ let cfgPort = maybe D.defaultRegistryPort id (readMaybe (T.unpack cfgPortText))+ if cfgHost == D.defaultRegistryHost && cfgPort == D.defaultRegistryPort+ then D.registryEndpointFromEnv+ else pure (cfgHost, cfgPort)++-- | Resolve a backend name to a host:port using the registry at the+-- provided address. Always returns SOMETHING — never throws.+resolveBackendAddress+ :: Text -- ^ registry host (also fallback)+ -> Int -- ^ registry port (also fallback)+ -> Text -- ^ backend name to resolve+ -> IO ResolveResult+resolveBackendAddress regHost regPort bkName = do+ let discovery = D.registryDiscovery regHost regPort+ -- Try to look up the named backend.+ result <- try (D.getBackendInfo discovery bkName) :: IO (Either SomeException (Maybe D.Backend))+ case result of+ Right (Just b) ->+ pure $ ResolvedFromRegistry (D.backendHost b) (D.backendPort b)+ Right Nothing -> do+ -- Backend not in registry. Try to list registered ones for a helpful error.+ knownE <- try (D.discoverBackends discovery) :: IO (Either SomeException [D.Backend])+ let known = case knownE of+ Right bs -> map D.backendName bs+ Left _ -> []+ pure $ NotInRegistry known+ Left _ ->+ -- Registry call failed (registry unreachable or RPC error).+ -- Fall back to the registry address as the backend address —+ -- preserves the "substrate is reachable on the same port" legacy path.+ pure $ ResolvedFallback regHost regPort
src/SynapseCC/Types.hs view
@@ -56,7 +56,8 @@ , summarizeStderr ) where -import Data.Aeson (FromJSON, ToJSON, fieldLabelModifier)+import Control.Applicative ((<|>))+import Data.Aeson (FromJSON(..), ToJSON, fieldLabelModifier) import qualified Data.Aeson as Aeson import Data.Aeson.Types (Parser) import Data.Map.Strict (Map)@@ -67,6 +68,7 @@ import Data.Version (showVersion) import GHC.Generics (Generic) import Paths_synapse_cc (version)+import Synapse.Self.Command (SelfCommand) import System.FilePath (FilePath) -- ============================================================================@@ -134,7 +136,9 @@ , optForce :: !Bool , optDebug :: !Bool , optSynapsePath :: !(Maybe FilePath) -- ^ Override synapse binary path- , optHubCodegenPath :: !(Maybe FilePath) -- ^ Override hub-codegen binary path+ , optServiceCodegenPath :: !(Maybe FilePath) -- ^ Override service-codegen binary path+ , optToken :: !(Maybe Text) -- ^ JWT token (--token; SAFE-2)+ , optTokenFile :: !(Maybe FilePath) -- ^ Token file path (--token-file; SAFE-2) } deriving stock (Show, Eq, Generic) -- | Default options@@ -149,7 +153,9 @@ , optForce = False , optDebug = False , optSynapsePath = Nothing- , optHubCodegenPath = Nothing+ , optServiceCodegenPath = Nothing+ , optToken = Nothing+ , optTokenFile = Nothing } -- ============================================================================@@ -262,11 +268,14 @@ , Just $ "targets" Aeson..= scTargets sc ] -defaultSynapseConfig :: SynapseConfig-defaultSynapseConfig = SynapseConfig+-- | Scaffold template for synapse.config.json, parameterised by backend name.+-- Z2H-5: there is deliberately NO zero-argument default — the backend must be+-- supplied explicitly or inferred (see 'SynapseCC.Detect.inferBackend').+defaultSynapseConfig :: Text -> SynapseConfig+defaultSynapseConfig backend = SynapseConfig { scSchema = "1.0" , scLanguage = "typescript"- , scBackend = Just "substrate"+ , scBackend = Just backend , scUrl = Just "ws://127.0.0.1:4444" , scPackageManager = "bun" , scWatch = defaultWatchConfig@@ -291,13 +300,20 @@ -- ============================================================================ -- | Top-level CLI command+--+-- @CmdSelf@ (SELF-6) wraps the shared 'Synapse.Self.Command.SelfCommand'+-- surface so @synapse-cc _self@ dispatches through the same handler as+-- @synapse _self@. The payload is kept as an opaque dynamic here+-- (@!SelfCommand@) rather than pattern-matched by synapse-cc itself —+-- the dispatcher in @app\/Main.hs@ only calls 'runSelfCommand'. 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 | CmdWait WaitArgs -- ^ Wait for backends to become reachable- | CmdInit -- ^ Scaffold synapse.config.json+ | CmdInit !(Maybe Text) -- ^ Scaffold synapse.config.json (Z2H-5: optional explicit backend; Nothing = infer)+ | CmdSelf SelfCommand -- ^ @_self@ — see "Synapse.Self.Command" deriving stock (Show, Eq) -- | Arguments for the watch subcommand@@ -325,9 +341,9 @@ -- | Discovered locations of required tools data ToolLocations = ToolLocations { toolSynapse :: !ToolPath- , toolHubCodegen :: !ToolPath+ , toolServiceCodegen :: !ToolPath , toolSynapseVersion :: !Text -- ^ Result of @synapse --version@- , toolHubCodegenVersion :: !Text -- ^ Result of @hub-codegen --version@+ , toolServiceCodegenVersion :: !Text -- ^ Result of @service-codegen --version@ } deriving stock (Show, Eq) -- | Path to a discovered tool@@ -361,10 +377,23 @@ data ToolchainVersions = ToolchainVersions { tvSynapseCC :: !Text , tvSynapse :: !Text- , tvHubCodegen :: !(Maybe Text) -- Only known after codegen+ , tvServiceCodegen :: !(Maybe Text) -- Only known after codegen+ , tvPlexusCore :: !(Maybe Text) -- SAFE-4: backend-reported plexus-core version (Nothing until SAFE-S02) } deriving stock (Show, Eq, Generic)- deriving anyclass (FromJSON, ToJSON)+ deriving anyclass (ToJSON) +-- | NAMING-10: a cache manifest written before the generator's rename keeps+-- its version under the field's old name; it is read, so the version still+-- invalidates the cache when it moves.+instance FromJSON ToolchainVersions where+ parseJSON v = do+ tv <- Aeson.genericParseJSON Aeson.defaultOptions v+ case tvServiceCodegen tv of+ Just _ -> pure tv+ Nothing -> flip (Aeson.withObject "ToolchainVersions") v $ \o -> do+ old <- o Aeson..:? "tvHubCodegen" -- naming-alias: removed by NAMING-18+ pure tv { tvServiceCodegen = old }+ -- | Cache entry for a single plugin's IR data IRPluginCache = IRPluginCache { ipcIRHash :: !Text -- Hash of the generated IR for this plugin@@ -394,7 +423,7 @@ } deriving stock (Show, Eq, Generic) deriving anyclass (FromJSON, ToJSON) --- | Cache manifest for generated code (hub-codegen/typescript/manifest.json)+-- | Cache manifest for generated code (service-codegen/typescript/manifest.json) data CodeCacheManifest = CodeCacheManifest { ccmVersion :: !Text , ccmTarget :: !Text@@ -425,30 +454,29 @@ deriving stock (Show, Eq) -- ============================================================================--- Codegen Output Types (JSON response from hub-codegen --output-format json)+-- Codegen Output Types (JSON response from service-codegen --output-format json) -- ============================================================================ --- | JSON output from hub-codegen when invoked with --output-format json+-- | JSON output from service-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+ , coServiceCodegenVersion :: !Text -- ^ service-codegen version string , coDependencies :: !(Map Text Text) -- ^ Runtime dependencies (name -> version) , coDevDependencies :: !(Map Text Text) -- ^ Dev dependencies (name -> version) } deriving stock (Show, Eq, Generic) +-- | NAMING-10: the version is @serviceCodegenVersion@; a generator before+-- 0.5.1 wrote it under its old name, which still reads. instance FromJSON CodegenOutput where- parseJSON = Aeson.genericParseJSON Aeson.defaultOptions- { fieldLabelModifier = \case- "coFiles" -> "files"- "coFileHashes" -> "fileHashes"- "coWarnings" -> "warnings"- "coHubCodegenVersion" -> "hubCodegenVersion"- "coDependencies" -> "dependencies"- "coDevDependencies" -> "devDependencies"- other -> other- }+ parseJSON = Aeson.withObject "CodegenOutput" $ \o -> CodegenOutput+ <$> o Aeson..: "files"+ <*> o Aeson..: "fileHashes"+ <*> o Aeson..: "warnings"+ <*> (o Aeson..: "serviceCodegenVersion" <|> o Aeson..: "hubCodegenVersion") -- naming-alias: removed by NAMING-18+ <*> o Aeson..: "dependencies"+ <*> o Aeson..: "devDependencies" -- | A warning emitted during code generation data CodegenWarning = CodegenWarning@@ -474,8 +502,8 @@ -- ^ Tool not found with suggestions | SynapseError !Text !Int -- ^ Synapse execution failed with stderr and exit code- | HubCodegenError !Text !Int- -- ^ hub-codegen execution failed with stderr and exit code+ | ServiceCodegenError !Text !Int+ -- ^ service-codegen execution failed with stderr and exit code | LanguageToolError !Text !Text !Int -- ^ Language tool (npm, tsc, etc.) failed | CacheError !Text@@ -537,9 +565,9 @@ SynapseError msg _ -> "[!] Error: " <> msg <> "\n" - HubCodegenError stderr exitCode ->+ ServiceCodegenError stderr exitCode -> T.unlines- [ "[!] Error: hub-codegen failed (exit code " <> T.pack (show exitCode) <> ")"+ [ "[!] Error: service-codegen failed (exit code " <> T.pack (show exitCode) <> ")" , "" , "Output:" , stderr
src/SynapseCC/Watch.hs view
@@ -40,6 +40,7 @@ import Plexus.Client (SubstrateConfig(..), defaultConfig) import qualified Plexus.Transport as Transport import Plexus.Types (PlexusStreamItem(..))+import Plexus.RemoteError (renderRemoteError) import Synapse.Monad (initEnv, runSynapseM) import Synapse.IR.Builder (buildIR) import qualified Synapse.Log as Log@@ -52,6 +53,7 @@ import SynapseCC.Merge (applyMerge) import SynapseCC.Pipeline (formatSynapseError, generateCode, runPipeline) import SynapseCC.Types+import qualified SynapseCC.Auth as Auth -- ============================================================================ -- Public Entry Point@@ -181,7 +183,7 @@ _ -> Nothing _ -> Nothing | otherwise = Nothing-extractHashValue (StreamError _ _ err _) = Just (Left err)+extractHashValue (StreamError _ _ err _) = Just (Left (renderRemoteError err)) extractHashValue _ = Nothing -- ============================================================================@@ -209,7 +211,9 @@ -- Fetch fresh IR via plexus-synapse library logger <- Log.makeLogger Katip.ErrorS- env <- initEnv host port bkName logger+ -- SAFE-2: resolve JWT token before connecting+ token <- Auth.resolveToken opts bkName+ env <- initEnv host port bkName logger token irResult <- runSynapseM env (buildIR generatorInfo []) case irResult of@@ -277,7 +281,7 @@ 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.+-- | Rebuild a single plugin namespace for a single target using service-codegen. rebuildPlugin :: WatchArgs -> SynapseConfig@@ -385,7 +389,7 @@ -- | Synthesize a SynapseConfig from CLI args alone (no config file) synapseConfigFromArgs :: WatchArgs -> SynapseConfig synapseConfigFromArgs watchArgs =- defaultSynapseConfig { scBackend = Just (waBackend watchArgs) }+ defaultSynapseConfig (waBackend watchArgs) -- | Parse host and port from a WebSocket URL. Alias for 'Config.parseWsUrl'. parseUrl :: Text -> (Text, Text)
synapse-cc.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: synapse-cc-version: 0.3.3+version: 0.4.0 synopsis: Unified compiler toolchain for Plexus backends description: synapse-cc orchestrates the complete pipeline from backend schema@@ -10,14 +10,25 @@ maintainer: dev@hypermemetic.com category: Development build-type: Simple-extra-doc-files: PLAN.md, README.md+extra-doc-files: PLAN.md, README.md, CHANGELOG.md +flag live-tests+ description: Build synapse-cc-test, which needs a substrate backend+ registered at 127.0.0.1:4444. Opt-in, so a default+ `cabal test` needs nothing running.+ Enable with: cabal test -f live-tests+ default: False+ manual: True+ common warnings ghc-options: -Wall library import: warnings exposed-modules: SynapseCC.Types+ , SynapseCC.Auth+ , SynapseCC.AuthCheck+ , SynapseCC.RegistryResolve , SynapseCC.CLI , SynapseCC.Config , SynapseCC.Detect@@ -53,8 +64,8 @@ , transformers >= 0.6 , unordered-containers >= 0.2 , fsnotify >= 0.4- , plexus-protocol- , plexus-synapse+ , plexus-protocol >= 0.6 && < 0.7+ , plexus-synapse >= 4.0 && < 5 , katip >= 0.8 && < 0.9 autogen-modules: Paths_synapse_cc other-modules: Paths_synapse_cc@@ -75,6 +86,7 @@ build-depends: base >= 4.14 && < 5 , synapse-cc , text+ , plexus-synapse >= 4.0 && < 5 hs-source-dirs: app default-language: Haskell2010 ghc-options: -threaded -rtsopts -with-rtsopts=-N@@ -99,8 +111,48 @@ , bytestring >= 0.11 , containers >= 0.6 , optparse-applicative >= 0.18- , plexus-protocol+ , plexus-protocol >= 0.6 && < 0.7+ , plexus-synapse >= 4.0 && < 5 , stm >= 2.5 default-extensions: OverloadedStrings , LambdaCase , ScopedTypeVariables+ if !flag(live-tests)+ buildable: False++test-suite safe6-authcheck+ default-language: Haskell2010+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: AuthCheckSpec.hs+ build-depends: base >= 4.14 && < 5+ , synapse-cc+ , aeson >= 2.0+ , aeson-qq >= 0.8+ , bytestring >= 0.11+ , filepath >= 1.4+ , temporary >= 1.3+ , text >= 1.2+ default-extensions: OverloadedStrings, QuasiQuotes+ ghc-options: -threaded++-- NAMING-10: the naming gate and the old names read but never written.+-- Hermetic (no backend), unlike synapse-cc-test.+test-suite naming+ default-language: Haskell2010+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: NamingSpec.hs+ build-depends: base >= 4.14 && < 5+ , synapse-cc+ , hspec >= 2.0+ , aeson >= 2.0+ , bytestring >= 0.11+ , directory >= 1.3+ , filepath >= 1.4+ , optparse-applicative >= 0.18+ , process >= 1.6+ , temporary >= 1.3+ , text >= 1.2+ default-extensions: OverloadedStrings+ ghc-options: -threaded
+ test/AuthCheckSpec.hs view
@@ -0,0 +1,154 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | SAFE-6 acceptance tests for SynapseCC.AuthCheck.+module Main where++import Data.Aeson (Value, eitherDecodeStrict)+import Data.Aeson.QQ.Simple (aesonQQ)+import qualified Data.IORef as IORef+import qualified Data.Text as T+import Data.Text (Text)+import System.Exit (exitFailure, exitSuccess)+import System.FilePath ((</>))+import qualified System.IO.Temp as Temp+import qualified Data.Text.IO as TIO+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as BL+import qualified Data.Aeson as Aeson++import SynapseCC.AuthCheck (backendRequiresCookieAuth, detectCookieAuthMismatch)++-- ─── Fixtures ─────────────────────────────────────────────────++irWithAuthMethod :: Value+irWithAuthMethod = [aesonQQ|{+ "irVersion": "2.0",+ "irBackend": "test",+ "irMethods": {+ "svc.list": {+ "mdName": "list",+ "mdFullPath": "svc.list",+ "mdNamespace": "svc",+ "mdParams": [+ { "pdName": "user",+ "pdType": {"tag":"RefPrimitive","contents":["string",null]},+ "pdRequired": false,+ "pdSource": { "from": "auth", "resolver": "self.db.validate_user" }+ },+ { "pdName": "search",+ "pdType": {"tag":"RefPrimitive","contents":["string",null]},+ "pdRequired": false+ }+ ]+ }+ }+}|]++irWithoutAuthMethods :: Value+irWithoutAuthMethods = [aesonQQ|{+ "irVersion": "2.0",+ "irBackend": "test",+ "irMethods": {+ "svc.ping": {+ "mdName": "ping",+ "mdFullPath": "svc.ping",+ "mdNamespace": "svc",+ "mdParams": [+ { "pdName": "message",+ "pdType": {"tag":"RefPrimitive","contents":["string",null]},+ "pdRequired": true+ }+ ]+ }+ }+}|]++irWithCookieButNoAuth :: Value+irWithCookieButNoAuth = [aesonQQ|{+ "irVersion": "2.0",+ "irBackend": "test",+ "irMethods": {+ "svc.origin_log": {+ "mdName": "origin_log",+ "mdFullPath": "svc.origin_log",+ "mdNamespace": "svc",+ "mdParams": [+ { "pdName": "origin",+ "pdType": {"tag":"RefPrimitive","contents":["string",null]},+ "pdRequired": false,+ "pdSource": { "from": "header", "key": "origin" }+ }+ ]+ }+ }+}|]++-- ─── Runner ───────────────────────────────────────────────────++data TestResult = Pass String | Fail String String++main :: IO ()+main = do+ failures <- IORef.newIORef (0 :: Int)+ let run (Pass name) = putStrLn $ " [PASS] " <> name+ run (Fail name reason) = do+ putStrLn $ " [FAIL] " <> name <> ": " <> reason+ IORef.modifyIORef' failures (+ 1)++ putStrLn "=== SAFE-6: backendRequiresCookieAuth detection ==="++ run $ boolCase "IR with #[from_auth] method → detected as requiring cookie auth"+ (backendRequiresCookieAuth irWithAuthMethod) True++ run $ boolCase "IR with only RPC params → not detected"+ (backendRequiresCookieAuth irWithoutAuthMethods) False++ run $ boolCase "IR with cookie/header params but no auth → not detected"+ (backendRequiresCookieAuth irWithCookieButNoAuth) False++ putStrLn "=== SAFE-6: detectCookieAuthMismatch end-to-end ==="++ Temp.withSystemTempDirectory "safe6" $ \tmp -> do+ let irFile = tmp </> "ir.json"+ tsFile = tmp </> "transport.ts"+ BS.writeFile irFile (BL.toStrict (Aeson.encode irWithAuthMethod))++ -- Case 1: auth required, no prior transport.ts → no warning+ r1 <- detectCookieAuthMismatch irFile tmp+ run $ case r1 of+ Nothing -> Pass "first build (no transport.ts) → no warning"+ Just _ -> Fail "first build" "expected Nothing; got warning"++ -- Case 2: auth required, transport.ts lacks SAFE-7 marker → warning+ TIO.writeFile tsFile "// legacy transport.ts, no marker\nexport const ws = null;"+ r2 <- detectCookieAuthMismatch irFile tmp+ run $ case r2 of+ Just w | "SAFE-6" `T.isInfixOf` w && "cookie" `T.isInfixOf` w ->+ Pass "stale transport.ts + auth required → warning mentions SAFE-6 + cookie"+ Just _ -> Fail "stale transport.ts" "got a warning but wrong content"+ Nothing -> Fail "stale transport.ts" "expected warning; got Nothing"++ -- Case 3: auth required, transport.ts contains SAFE-7 marker → no warning+ TIO.writeFile tsFile "// SAFE-7-cookie-auth-marker: ok\nexport const ws = null;"+ r3 <- detectCookieAuthMismatch irFile tmp+ run $ case r3 of+ Nothing -> Pass "SAFE-7-marked transport.ts → no warning"+ Just _ -> Fail "marked transport.ts" "expected Nothing; got warning"++ -- Case 4: no auth in backend → no warning regardless of transport.ts+ BS.writeFile irFile (BL.toStrict (Aeson.encode irWithoutAuthMethods))+ r4 <- detectCookieAuthMismatch irFile tmp -- transport.ts still marked from case 3+ run $ case r4 of+ Nothing -> Pass "no-auth backend → no warning regardless of transport.ts"+ Just _ -> Fail "no-auth backend" "expected Nothing; got warning"++ fc <- IORef.readIORef failures+ if fc == 0+ then putStrLn "\nSAFE-6: ALL TESTS PASSED" >> exitSuccess+ else putStrLn ("\nSAFE-6: " <> show fc <> " test(s) failed") >> exitFailure++boolCase :: String -> Bool -> Bool -> TestResult+boolCase name got want+ | got == want = Pass name+ | otherwise = Fail name ("expected " <> show want <> ", got " <> show got)
test/Main.hs view
@@ -37,17 +37,23 @@ ) import GHC.IO.Encoding (setLocaleEncoding, utf8) import Options.Applicative (execParserPure, defaultPrefs, getParseResult)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import qualified Synapse.Self as Self+import qualified SynapseCC.Auth as Auth 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.Config (loadSynapseConfig, validateSynapseConfig, initSynapseConfig, InitResult(..), synapseConfigPath) import SynapseCC.Detect ( ProjectHint(..), Detector(..), emptyHint , runDetectors, defaultDetectors , detectTauri, detectVite, detectNextJS, detectNodeProject+ , BackendInference(..)+ , inferBackendFromCrate, parseServiceCrateName )-import SynapseCC.Discover (discoverTools)+import SynapseCC.Discover (discoverTools, serviceCodegenSendsInjectedParams)+import qualified SynapseCC.Language as Language import SynapseCC.Merge (additiveMerge) import SynapseCC.Pipeline (runPipeline) import SynapseCC.Types@@ -56,6 +62,7 @@ , WatchArgs(..), Command(..) , defaultSynapseConfig , TransportType(..)+ , SynapseCCError(..), GeneratedPath(..) ) import SynapseCC.Watch ( matchesAnyPrefix, parseUrl, extractPluginHashesFromBytes@@ -63,6 +70,20 @@ , fetchBackendHash ) +-- | Sample scaffold config for tests. The backend name here is arbitrary+-- test data — Z2H-5 removed the zero-argument default from the init path,+-- so tests must construct their sample explicitly.+testSynapseConfig :: SynapseConfig+testSynapseConfig = defaultSynapseConfig "substrate"++-- | Registry coordinates guaranteed dead for init/inference tests+-- (discard port — connection refused immediately, never inferable).+deadRegistryHost :: Text+deadRegistryHost = "127.0.0.1"++deadRegistryPort :: Int+deadRegistryPort = 9+ -- | Shared test context: the path to generated output data TestEnv = TestEnv { teOutputDir :: !FilePath@@ -775,7 +796,7 @@ when tmpExists $ removeDirectoryRecursive dir' createDirectoryIfMissing True dir' -- Pass [] — no detectors, so defaults apply regardless of test environment- result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []+ result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath [] (Just "demo") deadRegistryHost deadRegistryPort result `shouldSatisfy` isRight exists <- doesFileExist (dir' </> synapseConfigPath) exists `shouldBe` True@@ -785,7 +806,7 @@ let dir' = tmpBase </> "synapse-cc-init-exists-test" createDirectoryIfMissing True dir' writeFile (dir' </> synapseConfigPath) "{}"- result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []+ result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath [] (Just "demo") deadRegistryHost deadRegistryPort result `shouldSatisfy` isLeft it "created file round-trips through loadSynapseConfig" $ do@@ -794,7 +815,7 @@ tmpExists <- doesDirectoryExist dir' when tmpExists $ removeDirectoryRecursive dir' createDirectoryIfMissing True dir'- _ <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []+ _ <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath [] (Just "demo") deadRegistryHost deadRegistryPort result <- withCurrentDirectory dir' loadSynapseConfig result `shouldSatisfy` isRight @@ -831,19 +852,19 @@ Just cfg -> Map.size (scTargets (cfg :: SynapseConfig)) `shouldBe` 2 describe "validateSynapseConfig" $ do- it "accepts defaultSynapseConfig" $- validateSynapseConfig defaultSynapseConfig `shouldBe` Right ()+ it "accepts testSynapseConfig" $+ validateSynapseConfig testSynapseConfig `shouldBe` Right () it "rejects empty backend" $- validateSynapseConfig (defaultSynapseConfig { scBackend = Just "" })+ validateSynapseConfig (testSynapseConfig { scBackend = Just "" }) `shouldSatisfy` isLeft it "rejects whitespace-only backend" $- validateSynapseConfig (defaultSynapseConfig { scBackend = Just " " })+ validateSynapseConfig (testSynapseConfig { scBackend = Just " " }) `shouldSatisfy` isLeft it "rejects empty targets map" $- validateSynapseConfig (defaultSynapseConfig { scTargets = Map.empty })+ validateSynapseConfig (testSynapseConfig { scTargets = Map.empty }) `shouldSatisfy` isLeft it "rejects target with empty generate list" $ do@@ -857,7 +878,7 @@ , tcPlugins = Nothing , tcSharedTransport = Nothing }- let cfg = defaultSynapseConfig { scTargets = Map.singleton "t" badTarget }+ let cfg = testSynapseConfig { scTargets = Map.singleton "t" badTarget } validateSynapseConfig cfg `shouldSatisfy` isLeft it "rejects target with empty outputDir" $ do@@ -871,12 +892,12 @@ , tcPlugins = Nothing , tcSharedTransport = Nothing }- let cfg = defaultSynapseConfig { scTargets = Map.singleton "t" badTarget }+ let cfg = testSynapseConfig { scTargets = Map.singleton "t" badTarget } validateSynapseConfig cfg `shouldSatisfy` isLeft describe "JSON round-trip" $ do it "encode/decode SynapseConfig preserves data" $ do- let cfg = defaultSynapseConfig+ let cfg = testSynapseConfig case Aeson.decode (Aeson.encode cfg) of Nothing -> expectationFailure "Failed to decode encoded SynapseConfig" Just decoded -> scBackend (decoded :: SynapseConfig) `shouldBe` scBackend cfg@@ -982,9 +1003,37 @@ it "produces CmdInit" $ do case parse ["init"] of- Just CmdInit -> pure ()- other -> expectationFailure $ "Expected CmdInit, got: " ++ show other+ Just (CmdInit Nothing) -> pure ()+ other -> expectationFailure $ "Expected CmdInit Nothing, got: " ++ show other + it "parses 'init demo' with explicit backend" $ do+ case parse ["init", "demo"] of+ Just (CmdInit (Just b)) -> b `shouldBe` "demo"+ other -> expectationFailure $ "Expected CmdInit (Just \"demo\"), got: " ++ show other++ -- SELF-6: `_self` subcommand. synapse-cc's job is to parse the+ -- invocation and hand the resulting SelfCommand to the shared+ -- runSelfCommand handler. These tests cover parsing only — the+ -- handler's behavior is verified in plexus-synapse:self-test.+ describe "_self subcommand" $ do+ it "parses '_self BACKEND show'" $+ parse ["_self", "testbk", "show"] `shouldSatisfy` isJust++ it "produces CmdSelf" $+ case parse ["_self", "testbk", "show"] of+ Just (CmdSelf _) -> pure ()+ other -> expectationFailure $ "Expected CmdSelf, got: " ++ show other++ it "parses '_self BACKEND set cookie NAME VALUE'" $+ case parse ["_self", "testbk", "set", "cookie", "access_token", "literal:abc"] of+ Just (CmdSelf _) -> pure ()+ other -> expectationFailure $ "Expected CmdSelf for set, got: " ++ show other++ it "parses '_self BACKEND resolve header NAME'" $+ case parse ["_self", "testbk", "resolve", "header", "authorization"] of+ Just (CmdSelf _) -> pure ()+ other -> expectationFailure $ "Expected CmdSelf for resolve, got: " ++ show other+ describe "legacy build parser compatibility" $ do it "synapseCCParserInfo still parses old-style args" $ getParseResult (execParserPure defaultPrefs synapseCCParserInfo@@ -992,8 +1041,89 @@ `shouldSatisfy` isJust -- ═══════════════════════════════════════════+ -- SELF-6: dedup between SynapseCC.Auth.resolveToken+ -- and Synapse.Self.resolveToken.+ --+ -- Proves the final-fallback path in SynapseCC.Auth delegates to+ -- the shared Synapse.Self helper. With the CLI-flag + env-var+ -- slots empty, SynapseCC.Auth.resolveToken must produce the same+ -- Maybe Text as Synapse.Self.resolveToken for the same HOME.+ -- ═══════════════════════════════════════════+ describe "SELF-6 resolveToken dedup" $ do+ let backend = "self6-dedup-test" :: Text+ jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig" :: Text+ -- Options with no CLI flags set (final-fallback regime).+ bareOpts = defaultOptions { optToken = Nothing, optTokenFile = Nothing }++ withIsolatedHome :: (FilePath -> IO a) -> IO a+ withIsolatedHome action = do+ -- SYNAPSE_TOKEN must not be set when exercising the fallback.+ mPrevSynTok <- lookupEnv "SYNAPSE_TOKEN"+ unsetEnv "SYNAPSE_TOKEN"+ mPrevHome <- lookupEnv "HOME"+ tmpRoot <- getTemporaryDirectory+ let tmpHome = tmpRoot </> ("synapse-cc-self6-" <> T.unpack backend)+ createDirectoryIfMissing True tmpHome+ setEnv "HOME" tmpHome+ r <- action tmpHome+ -- Restore env; best-effort.+ case mPrevHome of+ Just h -> setEnv "HOME" h+ Nothing -> unsetEnv "HOME"+ case mPrevSynTok of+ Just v -> setEnv "SYNAPSE_TOKEN" v+ Nothing -> pure ()+ removeDirectoryRecursive tmpHome+ pure r++ it "both return Nothing when no defaults file exists" $+ withIsolatedHome $ \_home -> do+ authResult <- Auth.resolveToken bareOpts backend+ selfResult <- Self.resolveToken backend+ authResult `shouldBe` Nothing+ selfResult `shouldBe` Nothing++ it "both return the resolved access_token cookie when defaults carry one" $+ withIsolatedHome $ \_home -> do+ -- Write a defaults.json with a literal: cookie.access_token+ -- through the canonical library surface.+ let sd0 = Self.emptyStoredDefaults+ sd = sd0 { Self.sdCookies =+ Map.singleton "access_token"+ (Self.CredentialRef ("literal:" <> jwt)) }+ Self.writeDefaults backend sd+ authResult <- Auth.resolveToken bareOpts backend+ selfResult <- Self.resolveToken backend+ authResult `shouldBe` Just jwt+ selfResult `shouldBe` Just jwt+ authResult `shouldBe` selfResult++ it "Auth.resolveToken CLI flag still wins over the shared fallback" $+ withIsolatedHome $ \_home -> do+ let sd0 = Self.emptyStoredDefaults+ sd = sd0 { Self.sdCookies =+ Map.singleton "access_token"+ (Self.CredentialRef "literal:from-defaults-file") }+ Self.writeDefaults backend sd+ let optsWithFlag = bareOpts { optToken = Just "from-cli-flag" }+ authResult <- Auth.resolveToken optsWithFlag backend+ selfResult <- Self.resolveToken backend+ authResult `shouldBe` Just "from-cli-flag"+ selfResult `shouldBe` Just "from-defaults-file"++ -- ═══════════════════════════════════════════ -- Section 12: Watch utilities (pure) -- ═══════════════════════════════════════════+ describe "SYNAPSE-CC-2 service-codegen version" $ do+ it "every service-codegen before 0.5.0 sends injected params" $ do+ map serviceCodegenSendsInjectedParams ["0.4.1", "0.4.0", "0.1.0", "0.4.9-rc1"]+ `shouldBe` [True, True, True, True]+ it "0.5.0 and later leave them out" $ do+ map serviceCodegenSendsInjectedParams ["0.5.0", "0.5.1", "0.10.0", "1.0.0"]+ `shouldBe` [False, False, False, False]+ it "an unparseable version is not warned about" $ do+ map serviceCodegenSendsInjectedParams ["unknown", "", "dev"] `shouldBe` [False, False, False]+ describe "Watch utilities" $ do describe "matchesAnyPrefix" $ do@@ -1082,28 +1212,28 @@ 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 sc = testSynapseConfig { scUrl = Just "ws://localhost:4444" } 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 sc = testSynapseConfig 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 sc = testSynapseConfig { scUrl = Just "ws://myhost:9000" } 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 sc = testSynapseConfig { scUrl = Just "ws://localhost:9000" } let tc = TargetConfig ["plugins"] WsTransport "out" Nothing Nothing Nothing Nothing Nothing let cfg = buildConfigFromTarget wa sc tc cfgPort cfg `shouldBe` "9000"@@ -1249,7 +1379,7 @@ withTempProject $ \dir -> do createDirectoryIfMissing True (dir </> "src-tauri") hint <- withCurrentDirectory dir $- initSynapseConfig synapseConfigPath defaultDetectors+ initSynapseConfig synapseConfigPath defaultDetectors (Just "demo") deadRegistryHost deadRegistryPort hint `shouldSatisfy` isRight cfgResult <- withCurrentDirectory dir loadSynapseConfig case cfgResult of@@ -1262,7 +1392,7 @@ withTempProject $ \dir -> do writeFile (dir </> "vite.config.ts") "export default {}" hint <- withCurrentDirectory dir $- initSynapseConfig synapseConfigPath defaultDetectors+ initSynapseConfig synapseConfigPath defaultDetectors (Just "demo") deadRegistryHost deadRegistryPort hint `shouldSatisfy` isRight cfgResult <- withCurrentDirectory dir loadSynapseConfig case cfgResult of@@ -1275,7 +1405,7 @@ withTempProject $ \dir -> do writeFile (dir </> "package.json") "{}" hint <- withCurrentDirectory dir $- initSynapseConfig synapseConfigPath defaultDetectors+ initSynapseConfig synapseConfigPath defaultDetectors (Just "demo") deadRegistryHost deadRegistryPort hint `shouldSatisfy` isRight cfgResult <- withCurrentDirectory dir loadSynapseConfig case cfgResult of@@ -1287,13 +1417,13 @@ it "empty project → generated config has ws transport (default)" $ do withTempProject $ \dir -> do hint <- withCurrentDirectory dir $- initSynapseConfig synapseConfigPath defaultDetectors+ initSynapseConfig synapseConfigPath defaultDetectors (Just "demo") deadRegistryHost deadRegistryPort 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)+ -- No detector fired → no hint → default transport (WsTransport from testSynapseConfig) let transports = map tcTransport (Map.elems (scTargets sc)) transports `shouldSatisfy` (not . null) @@ -1301,10 +1431,192 @@ withTempProject $ \dir -> do createDirectoryIfMissing True (dir </> "src-tauri") result <- withCurrentDirectory dir $- initSynapseConfig synapseConfigPath defaultDetectors+ initSynapseConfig synapseConfigPath defaultDetectors (Just "demo") deadRegistryHost deadRegistryPort case result of Left err -> expectationFailure $ "Expected Right but got Left: " <> T.unpack err- Right hint -> phReason hint `shouldSatisfy` isJust+ Right ir -> phReason (irHint ir) `shouldSatisfy` isJust++ -- ═══════════════════════════════════════════+ -- Z2H-5: init backend naming / inference / error+ -- ═══════════════════════════════════════════+ describe "Z2H-5 init backend resolution" $ do++ let withTempInitDir :: String -> (FilePath -> IO a) -> IO a+ withTempInitDir name action = do+ tmpBase <- getTemporaryDirectory+ let dir' = tmpBase </> name+ exists <- doesDirectoryExist dir'+ when exists $ removeDirectoryRecursive dir'+ createDirectoryIfMissing True dir'+ action dir'++ it "explicit backend: init demo writes backend \"demo\"" $ do+ withTempInitDir "synapse-cc-z2h5-explicit" $ \dir' -> do+ result <- withCurrentDirectory dir' $+ initSynapseConfig synapseConfigPath [] (Just "demo") deadRegistryHost deadRegistryPort+ case result of+ Left err -> expectationFailure (T.unpack err)+ Right ir -> do+ irBackend ir `shouldBe` "demo"+ irBackendNote ir `shouldBe` Nothing -- explicit, nothing to announce+ cfgResult <- withCurrentDirectory dir' loadSynapseConfig+ case cfgResult of+ Left err -> expectationFailure (T.unpack err)+ Right sc -> scBackend sc `shouldBe` Just "demo"++ it "inference: co-located service crate names the backend AND announces it" $ do+ withTempInitDir "synapse-cc-z2h5-crate" $ \dir' -> do+ writeFile (dir' </> "Cargo.toml") $ unlines+ [ "[package]"+ , "name = \"my-cool-service\""+ , "version = \"0.1.0\""+ , ""+ , "[dependencies]"+ , "plexus-core = { path = \"../plexus-core\" }"+ ]+ result <- withCurrentDirectory dir' $+ initSynapseConfig synapseConfigPath [] Nothing deadRegistryHost deadRegistryPort+ case result of+ Left err -> expectationFailure (T.unpack err)+ Right ir -> do+ irBackend ir `shouldBe` "my-cool-service"+ -- Inference MUST be announced (never silent)+ case irBackendNote ir of+ Nothing -> expectationFailure "expected an inference announcement"+ Just note -> note `shouldSatisfy` T.isInfixOf "my-cool-service"+ cfgResult <- withCurrentDirectory dir' loadSynapseConfig+ case cfgResult of+ Left err -> expectationFailure (T.unpack err)+ Right sc -> scBackend sc `shouldBe` Just "my-cool-service"++ it "nothing inferable: clear error naming the missing BACKEND argument, no file written" $ do+ withTempInitDir "synapse-cc-z2h5-nothing" $ \dir' -> do+ result <- withCurrentDirectory dir' $+ initSynapseConfig synapseConfigPath [] Nothing deadRegistryHost deadRegistryPort+ case result of+ Right ir -> expectationFailure $+ "expected an error, but init picked backend: " <> T.unpack (irBackend ir)+ Left err -> do+ err `shouldSatisfy` T.isInfixOf "BACKEND"+ err `shouldSatisfy` T.isInfixOf "synapse-cc init"+ exists <- doesFileExist (dir' </> synapseConfigPath)+ exists `shouldBe` False++ describe "inferBackendFromCrate" $ do+ it "finds a service crate Cargo.toml in the parent directory" $ do+ withTempInitDir "synapse-cc-z2h5-parent" $ \dir' -> do+ writeFile (dir' </> "Cargo.toml") $ unlines+ [ "[package]"+ , "name = \"parent-service\""+ , "[dependencies]"+ , "plexus-transport = { path = \"../plexus-transport\" }"+ ]+ let sub = dir' </> "nested"+ createDirectoryIfMissing True sub+ result <- inferBackendFromCrate sub+ fmap biBackend result `shouldBe` Just "parent-service"++ it "returns Nothing for a non-service crate" $ do+ withTempInitDir "synapse-cc-z2h5-nonservice" $ \dir' -> do+ writeFile (dir' </> "Cargo.toml") $ unlines+ [ "[package]"+ , "name = \"just-a-crate\""+ , "[dependencies]"+ , "serde = \"1.0\""+ ]+ result <- inferBackendFromCrate dir'+ result `shouldBe` Nothing++ describe "parseServiceCrateName" $ do+ it "accepts a crate depending on plexus-core" $+ parseServiceCrateName (T.unlines+ [ "[package]", "name = \"fidget-spinner\"", "version = \"0.1.0\""+ , "[dependencies]", "serde = \"1.0\"", "plexus-core = { path = \"../plexus-core\" }"+ ]) `shouldBe` Just "fidget-spinner"++ it "accepts a crate depending on plexus-rpc" $+ parseServiceCrateName (T.unlines+ [ "[package]", "name = \"svc\""+ , "[dependencies]", "plexus-rpc = \"0.3\""+ ]) `shouldBe` Just "svc"++ it "accepts table-form dependency [dependencies.plexus-rpc]" $+ parseServiceCrateName (T.unlines+ [ "[package]", "name = \"svc-table\""+ , "[dependencies.plexus-rpc]", "version = \"0.3\""+ ]) `shouldBe` Just "svc-table"++ it "rejects a crate with no plexus dependency" $+ parseServiceCrateName (T.unlines+ [ "[package]", "name = \"plain\""+ , "[dependencies]", "serde = \"1.0\""+ ]) `shouldBe` Nothing++ it "rejects plexus mentioned only in a comment" $+ parseServiceCrateName (T.unlines+ [ "[package]", "name = \"sneaky\""+ , "[dependencies]", "# plexus-core would be nice", "serde = \"1.0\""+ ]) `shouldBe` Nothing++ it "rejects when [package] has no name" $+ parseServiceCrateName (T.unlines+ [ "[package]", "version = \"0.1.0\""+ , "[dependencies]", "plexus-core = \"1.0\""+ ]) `shouldBe` Nothing++ -- ═══════════════════════════════════════════+ -- Z2H-7: Rust compile step (Language.buildProject Rust)+ -- ═══════════════════════════════════════════+ describe "Z2H-7 Rust compile step" $ do++ let withTempCrate :: String -> [(FilePath, String)] -> (FilePath -> IO a) -> IO a+ withTempCrate name files action = do+ tmpBase <- getTemporaryDirectory+ let dir' = tmpBase </> name+ exists <- doesDirectoryExist dir'+ when exists $ removeDirectoryRecursive dir'+ createDirectoryIfMissing True (dir' </> "src")+ forM_ files $ \(rel, content) -> writeFile (dir' </> rel) content+ action dir'++ minimalCargoToml = unlines+ [ "[package]"+ , "name = \"z2h7-test-crate\""+ , "version = \"0.1.0\""+ , "edition = \"2021\""+ , ""+ , "[dependencies]"+ ]++ it "cargo build of a valid generated-style crate succeeds" $ do+ cargoAvail <- findExecutable "cargo"+ case cargoAvail of+ Nothing -> pendingWith "cargo not found on PATH"+ Just _ -> withTempCrate "synapse-cc-z2h7-green"+ [ ("Cargo.toml", minimalCargoToml)+ , ("src/lib.rs", "pub fn answer() -> i32 { 42 }\n")+ ] $ \dir' -> do+ result <- Language.buildProject Rust (GeneratedPath dir') False+ case result of+ Left err -> expectationFailure $ T.unpack (formatError err)+ Right _ -> pure ()++ it "cargo build failure surfaces as LanguageToolError with cargo diagnostics" $ do+ cargoAvail <- findExecutable "cargo"+ case cargoAvail of+ Nothing -> pendingWith "cargo not found on PATH"+ Just _ -> withTempCrate "synapse-cc-z2h7-red"+ [ ("Cargo.toml", minimalCargoToml)+ , ("src/lib.rs", "pub fn broken( -> {\n")+ ] $ \dir' -> do+ result <- Language.buildProject Rust (GeneratedPath dir') False+ case result of+ Right _ -> expectationFailure "expected cargo build to fail"+ Left (LanguageToolError tool output _code) -> do+ tool `shouldBe` "cargo build"+ -- cargo diagnostics must reach the pipeline error verbatim+ output `shouldSatisfy` T.isInfixOf "error"+ Left other -> expectationFailure $ "unexpected error variant: " <> show other -- ═══════════════════════════════════════════ -- Section 14: Standalone config-driven build
+ test/NamingSpec.hs view
@@ -0,0 +1,117 @@+-- | NAMING-10: a Plexus backend is a service (owner, 2026-10-29), and the code+-- generator is service-codegen. The gate fails on any remnant of the old word;+-- the rest holds the old spellings to "read, never written" for one release:+-- the lock key, the generator's output key, the cache's toolchain key, the+-- binary's name in discovery and the CLI flag. Hermetic: no backend, no+-- network; a fixture PATH, HOME and working directory.+module Main (main) where++import Control.Exception (bracket)+import qualified Data.Aeson as Aeson+import qualified Data.ByteString.Lazy.Char8 as LBS8+import Data.List (isInfixOf)+import Data.Maybe (isJust)+import qualified Data.Text as T+import Options.Applicative (defaultPrefs, execParserPure, getParseResult)+import System.Directory+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (readProcessWithExitCode)+import Test.Hspec++import SynapseCC.CLI (deprecatedFlagNotes, synapseCCParserInfo)+import SynapseCC.Discover (findServiceCodegen, oldCodegenName, serviceCodegenName, toolPathToFilePath)+import SynapseCC.Lock (SynapseLock (..), readSynapseLock, synapseLockPath, writeSynapseLock)+import SynapseCC.Types++oldLockKey, oldOutputKey, oldToolchainKey :: String+oldLockKey = "hubCodegen" -- naming-alias: synapse.lock's key before NAMING-10+oldOutputKey = "hubCodegenVersion" -- naming-alias: the generator's output key before NAMING-10+oldToolchainKey = "tvHubCodegen" -- naming-alias: the cache manifest's key before NAMING-10++main :: IO ()+main = hspec $ do+ describe "the naming gate" $+ it "scripts/naming-gate.sh finds no remnant of the old word" $ do+ (code, out, err) <- readProcessWithExitCode "bash" ["scripts/naming-gate.sh"] ""+ (code, out <> err) `shouldBe` (ExitSuccess, "")++ describe "synapse.lock" $ do+ let lockWith key = "{\"version\":\"1\",\"synapseCC\":\"0.3.5\",\"" <> key+ <> "\":\"0.5.0\",\"updatedAt\":\"2026-10-29T00:00:00Z\",\"targets\":{}}"+ it "a lock written with the old key reads, and is written back with the new key only" $+ inTempDir $ do+ writeFile synapseLockPath (lockWith oldLockKey)+ Just lock <- readSynapseLock+ slServiceCodegen lock `shouldBe` "0.5.0"+ writeSynapseLock lock+ written <- readFile synapseLockPath+ written `shouldSatisfy` ("\"serviceCodegen\"" `isInfixOf`)+ written `shouldNotSatisfy` (oldLockKey `isInfixOf`)+ it "a lock with the new key reads" $+ inTempDir $ do+ writeFile synapseLockPath (lockWith "serviceCodegen")+ fmap slServiceCodegen <$> readSynapseLock `shouldReturn` Just "0.5.0"++ describe "the generator's JSON" $ do+ let output key = LBS8.pack $ "{\"files\":{},\"fileHashes\":{},\"warnings\":[],\"" <> key+ <> "\":\"0.5.1\",\"dependencies\":{},\"devDependencies\":{}}"+ it "reads serviceCodegenVersion" $+ fmap coServiceCodegenVersion (Aeson.decode (output "serviceCodegenVersion")) `shouldBe` Just "0.5.1"+ it "reads the old output key from a generator before 0.5.1" $+ fmap coServiceCodegenVersion (Aeson.decode (output oldOutputKey)) `shouldBe` Just "0.5.1"+ it "a cache manifest's toolchain under the old key reads, and is written under the new" $ do+ let old = LBS8.pack $ "{\"tvSynapseCC\":\"0.3.5\",\"tvSynapse\":\"3.16.0\",\"" <> oldToolchainKey+ <> "\":\"0.5.0\",\"tvPlexusCore\":null}"+ Just tv <- pure (Aeson.decode old)+ tvServiceCodegen tv `shouldBe` Just "0.5.0"+ let back = LBS8.unpack (Aeson.encode tv)+ back `shouldSatisfy` ("\"tvServiceCodegen\":\"0.5.0\"" `isInfixOf`)+ back `shouldNotSatisfy` (oldToolchainKey `isInfixOf`)++ describe "discovery" $ do+ it "with both names on PATH, finds service-codegen" $+ withFixturePath [serviceCodegenName, oldCodegenName] $ \bin -> do+ Just (path, note) <- findServiceCodegen False+ toolPathToFilePath path `shouldBe` bin </> serviceCodegenName+ note `shouldBe` Nothing+ it "with only the old name on PATH, finds it and says the name is deprecated" $+ withFixturePath [oldCodegenName] $ \bin -> do+ Just (path, note) <- findServiceCodegen False+ toolPathToFilePath path `shouldBe` bin </> oldCodegenName+ note `shouldSatisfy` maybe False (T.isInfixOf "deprecated")+ it "with neither, finds nothing" $+ withFixturePath [] $ \_ -> findServiceCodegen False >>= (`shouldSatisfy` not . isJust)++ describe "the CLI" $ do+ let parse = getParseResult . execParserPure defaultPrefs synapseCCParserInfo+ it "--service-codegen PATH sets the generator's path" $+ fmap (optServiceCodegenPath . cfgOptions) (parse ["typescript", "menger", "--service-codegen", "/x/sc"])+ `shouldBe` Just (Just "/x/sc")+ it "the old flag still works, and is noted as deprecated" $ do+ let args = ["typescript", "menger", "--" <> oldCodegenName, "/x/old"]+ fmap (optServiceCodegenPath . cfgOptions) (parse args) `shouldBe` Just (Just "/x/old")+ deprecatedFlagNotes args `shouldSatisfy` any (T.isInfixOf "deprecated")+ deprecatedFlagNotes ["typescript", "menger", "--service-codegen", "/x"] `shouldBe` []++-- | Run in a fresh temporary working directory.+inTempDir :: IO a -> IO a+inTempDir act = withSystemTempDirectory "naming10" $ \d -> withCurrentDirectory d act++-- | Run with PATH holding only fake executables of these names (each prints a+-- version), HOME and the working directory empty, so no fallback path is found.+withFixturePath :: [String] -> (FilePath -> IO a) -> IO a+withFixturePath names act = withSystemTempDirectory "naming10" $ \d -> do+ let bin = d </> "bin"; home = d </> "home"; cwd = d </> "a" </> "b"+ mapM_ (createDirectoryIfMissing True) [bin, home, cwd]+ mapM_ (\n -> do+ let f = bin </> n+ writeFile f "#!/bin/sh\necho 0.5.1\n"+ p <- getPermissions f+ setPermissions f (setOwnerExecutable True p)) names+ withEnv "PATH" bin $ withEnv "HOME" home $ withCurrentDirectory cwd (act bin)++withEnv :: String -> String -> IO a -> IO a+withEnv k v act = bracket (lookupEnv k <* setEnv k v) (maybe (unsetEnv k) (setEnv k)) (const act)