packages feed

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 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)