plexus-synapse 3.10.1 → 3.13.0
raw patch · 13 files changed
+1121/−45 lines, 13 filesdep ~aesonPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: aeson
API changes (from Hackage documentation)
+ Synapse.Algebra.Render: deprecationMarker :: Text
+ Synapse.Algebra.Render: formatDeprecationLine :: DeprecationInfo -> Text
+ Synapse.Deprecation: emitActivationWarning :: Bool -> Text -> Maybe DeprecationInfo -> IO ()
+ Synapse.Deprecation: emitMethodWarning :: Bool -> Text -> Maybe DeprecationInfo -> IO ()
+ Synapse.Deprecation: formatActivationWarningLine :: Text -> DeprecationInfo -> Text
+ Synapse.Deprecation: formatMethodWarningLine :: Text -> DeprecationInfo -> Text
+ Synapse.Deprecation: resetDeprecationStateForTesting :: IO ()
+ Synapse.IR.Types: MethodRoleDynamicChild :: Maybe Text -> Maybe Text -> MethodRole
+ Synapse.IR.Types: MethodRoleRpc :: MethodRole
+ Synapse.IR.Types: MethodRoleStaticChild :: MethodRole
+ Synapse.IR.Types: [listMethod] :: MethodRole -> Maybe Text
+ Synapse.IR.Types: [mdRole] :: MethodDef -> MethodRole
+ Synapse.IR.Types: [pdDeprecation] :: ParamDef -> Maybe DeprecationInfo
+ Synapse.IR.Types: [searchMethod] :: MethodRole -> Maybe Text
+ Synapse.IR.Types: data MethodRole
+ Synapse.Schema.Types: [methodDeprecation] :: MethodSchema -> Maybe DeprecationInfo
+ Synapse.Schema.Types: [methodParamSchemas] :: MethodSchema -> Maybe [ParamSchema]
+ Synapse.Schema.Types: [methodRole] :: MethodSchema -> MethodRole
+ Synapse.Schema.Types: [psDeprecation] :: PluginSchema -> Maybe DeprecationInfo
- Synapse.IR.Types: MethodDef :: Text -> Text -> Text -> Maybe Text -> Bool -> [ParamDef] -> TypeRef -> Maybe TypeRef -> Maybe TypeRef -> Maybe Value -> MethodDef
+ Synapse.IR.Types: MethodDef :: Text -> Text -> Text -> Maybe Text -> Bool -> [ParamDef] -> TypeRef -> Maybe TypeRef -> Maybe TypeRef -> Maybe Value -> MethodRole -> MethodDef
- Synapse.IR.Types: ParamDef :: Text -> TypeRef -> Maybe Text -> Bool -> Maybe Value -> ParamDef
+ Synapse.IR.Types: ParamDef :: Text -> TypeRef -> Maybe Text -> Bool -> Maybe Value -> Maybe DeprecationInfo -> ParamDef
- Synapse.Schema.Types: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> MethodSchema
+ Synapse.Schema.Types: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> MethodSchema
- Synapse.Schema.Types: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> PluginSchema
+ Synapse.Schema.Types: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> PluginSchema
Files
- app/Main.hs +30/−2
- plexus-synapse.cabal +40/−3
- src/Synapse/Algebra/Render.hs +102/−16
- src/Synapse/CLI/Help.hs +22/−4
- src/Synapse/CLI/Parse.hs +1/−1
- src/Synapse/Deprecation.hs +167/−0
- src/Synapse/IR/Builder.hs +47/−10
- src/Synapse/IR/Types.hs +81/−7
- test/BidirIntegrationSpec.hs +4/−1
- test/DeprecationSpec.hs +450/−0
- test/IR12MethodRoleSpec.hs +170/−0
- test/IRSpec.hs +4/−1
- test/ParseSpec.hs +3/−0
app/Main.hs view
@@ -48,6 +48,7 @@ import Synapse.IR.Types (IR, irMethods, MethodDef) import qualified Data.Map.Strict as Map import qualified Synapse.CLI.Template as TemplateIR+import Synapse.Deprecation (emitActivationWarning, emitMethodWarning) import Synapse.Transport import Synapse.Bidir (BidirMode(..), parseBidirMode, detectBidirMode, handleBidirRequest) import Plexus.Types (Response(..))@@ -84,6 +85,7 @@ , soLogSubsystems :: [Text] -- ^ Filter logs by subsystems (empty = all) , soToken :: Maybe Text -- ^ JWT sent as Cookie: access_token=<jwt> , soTokenFile :: Maybe Text -- ^ Path to token file (overridden by --token)+ , soNoDeprecationWarnings :: Bool -- ^ Suppress invocation-time deprecation warnings (IR-15) } deriving Show @@ -409,6 +411,28 @@ ViewMethod method path -> do let fullPath = T.intercalate "." path + -- IR-15: emit stderr deprecation warnings right before any+ -- real invocation. Dedupe is per-process-lifetime, so this+ -- action is idempotent on repeat calls. We bind it as a+ -- local SynapseM action and invoke it at both invocation+ -- sites below (fast path and slow path). --help, --dry-run,+ -- --schema, and --emit-ir do not invoke, and intentionally+ -- do not fire these warnings.+ let fireDeprecationWarnings :: SynapseM ()+ fireDeprecationWarnings = do+ liftIO $ emitMethodWarning+ soNoDeprecationWarnings+ fullPath+ (methodDeprecation method)+ let namespacePath = init path+ when (not (null namespacePath)) $ do+ let activationNs = T.intercalate "." namespacePath+ parentPlugin <- fetchSchemaAt namespacePath+ liftIO $ emitActivationWarning+ soNoDeprecationWarnings+ activationNs+ (psDeprecation parentPlugin)+ -- Optimization: Skip IR construction for parameter-less methods -- IR is only needed for: -- 1. Help rendering (--help flag)@@ -420,7 +444,7 @@ -- Fast path: parameter-less method with no help requested if not needsIR- then invokeMethod path (object [])+ then fireDeprecationWarnings >> invokeMethod path (object []) else do -- Build IR for this method's namespace ir <- buildIR soGeneratorInfo (init path)@@ -477,7 +501,8 @@ Nothing -> -- Fallback: method not in IR, use basic info liftIO $ TIO.putStrLn $ T.intercalate "." path <> " - " <> methodDescription method- else invokeMethod path params+ -- IR-15: fire stderr deprecation warnings before real invocation.+ else fireDeprecationWarnings >> invokeMethod path params where handleRespondCommand :: [(Text, Text)] -> SynapseM () handleRespondCommand params = do@@ -966,4 +991,7 @@ soTokenFile <- optional $ T.pack <$> strOption ( long "token-file" <> metavar "PATH" <> help "Path to token file (default lookup: ~/.plexus/tokens/<backend>)" )+ soNoDeprecationWarnings <- switch+ ( long "no-deprecation-warnings"+ <> help "Suppress invocation-time deprecation warnings on stderr (IR-15)" ) pure SynapseOpts{..}
plexus-synapse.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: plexus-synapse-version: 3.10.1+version: 3.13.0 synopsis: Schema-driven CLI for Plexus RPC servers license: MIT author:@@ -24,6 +24,7 @@ Synapse.Algebra.Recursion Synapse.Transport Synapse.Cache+ Synapse.Deprecation Synapse.Renderer Synapse.IR.Types Synapse.IR.Builder@@ -192,7 +193,8 @@ containers >= 0.6 && < 0.8, hspec >= 2.10 && < 2.12, aeson >= 2.2 && < 2.3,- bytestring >= 0.11 && < 0.13+ bytestring >= 0.11 && < 0.13,+ katip >= 0.8 && < 0.9 hs-source-dirs: test default-language: GHC2021 default-extensions:@@ -288,7 +290,8 @@ plexus-synapse, text >= 2.0 && < 2.2, containers >= 0.6 && < 0.8,- hspec >= 2.10 && < 2.12+ hspec >= 2.10 && < 2.12,+ katip >= 0.8 && < 0.9 hs-source-dirs: test default-language: GHC2021 default-extensions:@@ -303,6 +306,40 @@ base >= 4.17 && < 5, plexus-synapse, text >= 2.0 && < 2.2,+ aeson >= 2.2 && < 2.3,+ bytestring >= 0.11 && < 0.13,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite deprecation-test+ type: exitcode-stdio-1.0+ main-is: DeprecationSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse,+ text >= 2.0 && < 2.2,+ aeson >= 2.0 && < 2.3,+ containers >= 0.6 && < 0.8,+ directory >= 1.3 && < 1.4,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite ir12-method-role-test+ type: exitcode-stdio-1.0+ main-is: IR12MethodRoleSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse, aeson >= 2.2 && < 2.3, bytestring >= 0.11 && < 0.13, hspec >= 2.10 && < 2.12
src/Synapse/Algebra/Render.hs view
@@ -12,6 +12,10 @@ , RenderStyle(..) , defaultStyle , compactStyle++ -- * Deprecation Rendering (IR-6)+ , deprecationMarker+ , formatDeprecationLine ) where import Data.Text (Text)@@ -24,6 +28,7 @@ import Prettyprinter.Render.Text (renderStrict) import Synapse.Schema.Types+import Plexus.Schema.Recursive (DeprecationInfo(..), ParamSchema(..)) -- | Rendering style configuration data RenderStyle = RenderStyle@@ -54,16 +59,39 @@ -- | Render with custom style renderSchemaWith :: RenderStyle -> PluginSchema -> Text renderSchemaWith _style PluginSchema{..}- | null psMethods && maybe True null psChildren = headerText+ | null psMethods && maybe True null psChildren = deprecationBanner <> headerText | otherwise = renderStrict $ layoutPretty layoutOpts doc where layoutOpts = LayoutOptions (AvailablePerLine 80 1.0) + -- Activation-level deprecation banner (plain-text, rendered ahead+ -- of the compact header variant used for trivial schemas).+ deprecationBanner = case psDeprecation of+ Just di -> deprecationMarker <> " " <> formatDeprecationLine di <> "\n"+ Nothing -> ""+ headerText = psNamespace <> " v" <> psVersion <> "\n" <> psDescription <> "\n" + -- Name line; decorated with the warning marker when the activation+ -- carries Just DeprecationInfo.+ namespaceHeading = case psDeprecation of+ Just _ -> pretty (deprecationMarker <> " " <> psNamespace)+ <+> pretty ("v" <> psVersion)+ Nothing -> pretty psNamespace <+> pretty ("v" <> psVersion)++ -- Full deprecation notice rendered immediately below the heading+ -- when relevant, before the description block.+ deprecationNoticeDoc = case psDeprecation of+ Just di -> vsep+ [ emptyDoc+ , indent 2 $ pretty (formatDeprecationLine di)+ ]+ Nothing -> emptyDoc+ doc :: Doc ann doc = vsep- [ pretty psNamespace <+> pretty ("v" <> psVersion)+ [ namespaceHeading+ , deprecationNoticeDoc , emptyDoc , indent 2 $ align $ fillSep $ map pretty $ T.words psDescription , emptyDoc@@ -92,10 +120,16 @@ renderChildDoc child = fillBreak 12 (pretty $ csNamespace child) <+> align (fillSep $ map pretty $ T.words $ csDescription child) - renderMethodDoc method = vsep $- [ fillBreak 12 (pretty $ methodName method)- <+> align (fillSep $ map pretty $ T.words $ methodDescription method)- ] ++ paramsDocs (methodParams method)+ renderMethodDoc method =+ let nameText = case methodDeprecation method of+ Just _ -> deprecationMarker <> " " <> methodName method+ Nothing -> methodName method+ nameLine = fillBreak 12 (pretty nameText)+ <+> align (fillSep $ map pretty $ T.words $ methodDescription method)+ depLines = case methodDeprecation method of+ Just di -> [indent 12 $ pretty (formatDeprecationLine di)]+ Nothing -> []+ in vsep $ [nameLine] ++ depLines ++ paramsDocs (methodParams method) paramsDocs Nothing = [] paramsDocs (Just (Object o)) = case KM.lookup "properties" o of@@ -130,7 +164,12 @@ " " <> padRight 16 (methodName m) <> methodDescription m <> if rsShowTypes then renderParams (methodParams m) else "" --- | Render a method (full form with all params)+-- | Render a method (full form with all params).+--+-- IR-14: per-parameter deprecation info carried on @methodParamSchemas@+-- is threaded into the param rendering so each deprecated parameter+-- receives the ⚠ marker and the formatted @DEPRECATED since …@ line.+-- Non-deprecated parameters render exactly as before. renderMethodFull :: MethodSchema -> Text renderMethodFull m = T.unlines $ [ methodName m <> " - " <> methodDescription m@@ -139,7 +178,7 @@ where paramLines = case methodParams m of Nothing -> [" (no parameters)"]- Just schema -> renderParamsFull schema+ Just schema -> renderParamsFull (methodParamSchemas m) schema -- | Render a child summary renderChild :: ChildSummary -> Text@@ -171,26 +210,53 @@ reqMarker = if isReq then "" else "?" in " --" <> flagName <> " <" <> typ <> ">" <> reqMarker <> " " <> desc --- | Render parameters in full (for method help)-renderParamsFull :: Value -> [Text]-renderParamsFull (Object o) = case KM.lookup "properties" o of+-- | Render parameters in full (for method help).+--+-- IR-14: the first argument carries optional per-parameter metadata+-- ('ParamSchema') advertised by IR-5 producers. Any parameter whose+-- 'paramDeprecation' is @Just@ is decorated with the ⚠ marker and a+-- trailing @DEPRECATED since … removed in … — …@ line. Parameters+-- without a matching entry render identically to pre-ticket output.+renderParamsFull :: Maybe [ParamSchema] -> Value -> [Text]+renderParamsFull paramSchemas (Object o) = case KM.lookup "properties" o of Just (Object props) -> let reqList = case KM.lookup "required" o of Just (Array arr) -> [t | String t <- foldr (:) [] arr] _ -> [] propList = KM.toList props sorted = sortOn (\(k, _) -> (K.toText k `notElem` reqList, K.toText k)) propList- in map (renderParamFull reqList) sorted+ in map (renderParamFull paramSchemas reqList) sorted _ -> []-renderParamsFull _ = []+renderParamsFull _ _ = [] -renderParamFull :: [Text] -> (K.Key, Value) -> Text-renderParamFull required (name, propSchema) =+-- | Look up per-parameter deprecation info by name, scanning the+-- optional ParamSchema list attached to the method.+lookupParamDeprecation :: Maybe [ParamSchema] -> Text -> Maybe DeprecationInfo+lookupParamDeprecation Nothing _ = Nothing+lookupParamDeprecation (Just pss) pname =+ case filter ((== pname) . paramName) pss of+ (ps:_) -> paramDeprecation ps+ [] -> Nothing++renderParamFull :: Maybe [ParamSchema] -> [Text] -> (K.Key, Value) -> Text+renderParamFull paramSchemas required (name, propSchema) = let nameText = K.toText name isReq = nameText `elem` required (typ, desc) = extractTypeDesc propSchema reqText = if isReq then " (required)" else " (optional)"- in " --" <> nameText <> " <" <> typ <> ">" <> reqText <> "\n " <> desc+ -- IR-14: deprecation decoration. Marker prepended to the flag+ -- name, detail line appended after the description. Pre-ticket+ -- non-deprecated output is preserved exactly when depInfo is+ -- Nothing.+ depInfo = lookupParamDeprecation paramSchemas nameText+ decoratedName = case depInfo of+ Just _ -> deprecationMarker <> " --" <> nameText+ Nothing -> "--" <> nameText+ depLine = case depInfo of+ Just di -> "\n " <> formatDeprecationLine di+ Nothing -> ""+ in " " <> decoratedName <> " <" <> typ <> ">" <> reqText+ <> "\n " <> desc <> depLine -- | Extract type and description from property schema extractTypeDesc :: Value -> (Text, Text)@@ -205,3 +271,23 @@ padRight n t | T.length t >= n = t <> " " | otherwise = t <> T.replicate (n - T.length t) " "++-- ============================================================================+-- Deprecation Rendering (IR-6)+-- ============================================================================++-- | Visible marker for deprecated surfaces.+--+-- Plain UTF-8 — a TTY/color-aware renderer lives at the CLI boundary;+-- this module only emits text markers.+deprecationMarker :: Text+deprecationMarker = "\x26A0" -- ⚠++-- | Format the one-line deprecation detail, e.g.+-- @DEPRECATED since 0.5, removed in 0.7 — use move_doc@.+formatDeprecationLine :: DeprecationInfo -> Text+formatDeprecationLine di =+ "DEPRECATED since " <> depSince di+ <> ", removed in " <> depRemovedIn di+ <> " \x2014 " <> depMessage di -- em-dash+
src/Synapse/CLI/Help.hs view
@@ -64,6 +64,7 @@ import qualified Data.Text as T import Synapse.IR.Types+import Synapse.Algebra.Render (deprecationMarker, formatDeprecationLine) -- ============================================================================ -- Configuration@@ -118,14 +119,25 @@ ["Parameters:"] ++ concatMap (renderParamBlock style ir) (mdParams method) --- | Render a parameter as a block (may be multi-line for complex types)+-- | Render a parameter as a block (may be multi-line for complex types).+--+-- IR-14: when @pdDeprecation param@ is @Just DeprecationInfo@ the+-- warning marker (⚠) is prepended to the parameter's flag name and the+-- formatted @DEPRECATED since … removed in … — …@ line is appended+-- (indented one level below the header). Non-deprecated parameters are+-- rendered exactly as before. renderParamBlock :: HelpStyle -> IR -> ParamDef -> [Text] renderParamBlock style ir param = let indent' = T.replicate (hsIndent style) " " indent2 = T.replicate (hsIndent style * 2) " " - -- Parameter header line- flagName = "--" <> T.replace "_" "-" (pdName param) -- Display as kebab-case+ -- Parameter header line (optionally prefixed with the deprecation+ -- marker; see IR-14). The marker sits before the flag so callers+ -- who grep for '--name' still match a deprecated flag line.+ rawFlag = "--" <> T.replace "_" "-" (pdName param) -- Display as kebab-case+ flagName = case pdDeprecation param of+ Just _ -> deprecationMarker <> " " <> rawFlag+ Nothing -> rawFlag typeStr = renderTypeRef ir (pdType param) reqText = if pdRequired param then "(required)" else "(optional)" headerLine = indent' <> flagName <> " <" <> typeStr <> "> " <> reqText@@ -135,12 +147,18 @@ Just desc -> [indent2 <> desc] Nothing -> [] + -- Deprecation detail line (IR-14). Indented one level beyond the+ -- header to match the description block's indentation.+ deprecationLines = case pdDeprecation param of+ Just di -> [indent2 <> formatDeprecationLine di]+ Nothing -> []+ -- Type expansion (for enums/unions) expansionLines = if hsExpandEnums style then map (indent2 <>) (expandType ir (pdName param) (pdType param)) else [] - in [""] ++ [headerLine] ++ descLines ++ expansionLines+ in [""] ++ [headerLine] ++ descLines ++ deprecationLines ++ expansionLines -- ============================================================================ -- Parameter Help
src/Synapse/CLI/Parse.hs view
@@ -242,7 +242,7 @@ KindAlias target -> -- Follow the alias- buildParamValue ir ParamDef{pdName = paramName, pdType = target, pdDescription = Nothing, pdRequired = True, pdDefault = Nothing} kvs+ buildParamValue ir ParamDef{pdName = paramName, pdType = target, pdDescription = Nothing, pdRequired = True, pdDefault = Nothing, pdDeprecation = Nothing} kvs KindStringEnum allowedValues -> -- Expect single string value from allowed set
+ src/Synapse/Deprecation.hs view
@@ -0,0 +1,167 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Invocation-time deprecation warnings (IR-15).+--+-- IR-6 landed tree-rendering markers for deprecated activations and methods.+-- IR-15 closes the gap for users who never browse the tree — they run a+-- method directly (often from a script). This module emits a one-line+-- notice to 'stderr' at the moment of invocation.+--+-- = Contract+--+-- * Warning text is emitted to 'stderr' only. 'stdout' is untouched so+-- pipe consumers see the same bytes they saw before.+-- * The warning line format is built from IR-6's 'deprecationMarker' and+-- 'formatDeprecationLine' helpers (re-exported from 'Synapse.Algebra.Render')+-- so tree rendering and invocation warnings match byte-for-byte after the+-- key/path prefix.+-- * A per-process 'IORef' dedupes warnings by key. Method-level keys are the+-- full dotted path (e.g. @"docs.move_doc"@); activation-level keys are the+-- activation's namespace (e.g. @"docs"@). Keyspaces are disjoint by+-- construction (methods always carry a dot; activation namespaces never+-- carry one in the key we emit below).+-- * A boolean "suppressed" flag short-circuits both emission functions. The+-- CLI wires this flag to the @--no-deprecation-warnings@ option.+--+-- = Implementation+--+-- The dedupe 'IORef' is a top-level, 'NOINLINE' global because "per session"+-- in the ticket means "per process lifetime". Embedding the set in+-- 'Synapse.Monad.SynapseEnv' would also work but would force every call site+-- that currently runs in 'IO' (e.g. bidir callbacks) to thread the env around.+-- The IORef approach keeps the API callable from plain 'IO'.+module Synapse.Deprecation+ ( -- * Emission+ emitMethodWarning+ , emitActivationWarning++ -- * Testing Support+ , resetDeprecationStateForTesting+ , formatMethodWarningLine+ , formatActivationWarningLine+ ) where++import Control.Monad (when)+import Data.IORef (IORef, atomicModifyIORef', newIORef, writeIORef)+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text.IO as TIO+import System.IO (hFlush, stderr)+import System.IO.Unsafe (unsafePerformIO)++import Plexus.Schema.Recursive (DeprecationInfo)+import Synapse.Algebra.Render (deprecationMarker, formatDeprecationLine)++-- ============================================================================+-- Dedupe State+-- ============================================================================++-- | Process-wide set of keys already warned-about. Keys follow the scheme+-- documented at the top of this module (method full-path, or activation+-- namespace). The same 'IORef' serves both keyspaces; collisions are+-- prevented by the dotted-path vs. plain-namespace distinction.+--+-- NOINLINE is load-bearing: GHC must share a single 'IORef' across every+-- call site, otherwise each call would get its own empty set and dedupe+-- would silently break.+warnedKeys :: IORef (Set Text)+warnedKeys = unsafePerformIO (newIORef Set.empty)+{-# NOINLINE warnedKeys #-}++-- | Try to claim a key for first-time emission. Returns 'True' when the+-- caller is the first to claim it (warning should fire), 'False' when+-- the key was already present (caller must stay silent).+--+-- 'atomicModifyIORef'' makes this safe under the IO concurrency we care+-- about here (streaming RPC callback threads, bidir handlers). There is+-- no correctness problem if two threads lose the race and both see the+-- key as "already present" — the semantics we promise are "fires at+-- least once per session", and 'atomicModifyIORef'' gives us exactly+-- "fires exactly once per session" under any interleaving.+claimKey :: Text -> IO Bool+claimKey k = atomicModifyIORef' warnedKeys $ \s ->+ if Set.member k s+ then (s, False)+ else (Set.insert k s, True)++-- | Reset the dedupe set. Exported only for tests that drive multiple+-- simulated "sessions" inside a single 'hspec' process. Production code+-- should never call this.+resetDeprecationStateForTesting :: IO ()+resetDeprecationStateForTesting = writeIORef warnedKeys Set.empty++-- ============================================================================+-- Line Formatting+-- ============================================================================++-- | Format the stderr line for a deprecated method invocation. The visible+-- shape is:+--+-- @⚠ <activation>.<method> is DEPRECATED since <since>, removed in <removed_in> — <message>@+--+-- The trailing DEPRECATED clause is delegated to 'formatDeprecationLine'+-- so the wording matches IR-6's tree rendering verbatim.+formatMethodWarningLine :: Text -> DeprecationInfo -> Text+formatMethodWarningLine fullPath di =+ deprecationMarker+ <> " " <> fullPath+ <> " is " <> formatDeprecationLine di++-- | Format the stderr line for an invocation on a deprecated activation.+-- Shape:+--+-- @⚠ activation '<namespace>' is DEPRECATED since <since>, removed in <removed_in> — <message>@+--+-- Uses 'formatDeprecationLine' for the DEPRECATED clause.+formatActivationWarningLine :: Text -> DeprecationInfo -> Text+formatActivationWarningLine namespace di =+ deprecationMarker+ <> " activation '" <> namespace <> "' is "+ <> formatDeprecationLine di++-- ============================================================================+-- Emission+-- ============================================================================++-- | Emit a one-line stderr warning for a deprecated method invocation if+-- (a) warnings aren't globally suppressed, and (b) this method hasn't+-- already been warned about in the current session.+--+-- No-ops silently when the method isn't deprecated, so call sites can+-- hand over the raw 'Maybe DeprecationInfo' without pre-checking.+emitMethodWarning+ :: Bool -- ^ suppress all warnings (e.g. @--no-deprecation-warnings@)+ -> Text -- ^ full dotted method path (e.g. @"docs.move_doc"@)+ -> Maybe DeprecationInfo -- ^ deprecation metadata from IR / MethodSchema+ -> IO ()+emitMethodWarning _ _ Nothing = pure ()+emitMethodWarning True _ _ = pure ()+emitMethodWarning False fullPath (Just di) = do+ first <- claimKey fullPath+ when first $ do+ TIO.hPutStrLn stderr (formatMethodWarningLine fullPath di)+ hFlush stderr++-- | Emit a one-line stderr warning for invoking any method on a deprecated+-- activation. Dedupe key is the activation namespace so subsequent+-- invocations on the same activation stay silent.+--+-- No-ops when the activation isn't deprecated.+emitActivationWarning+ :: Bool -- ^ suppress all warnings+ -> Text -- ^ activation namespace (e.g. @"docs"@)+ -> Maybe DeprecationInfo -- ^ deprecation metadata from the parent PluginSchema+ -> IO ()+emitActivationWarning _ _ Nothing = pure ()+emitActivationWarning True _ _ = pure ()+emitActivationWarning False namespace (Just di) = do+ -- Guard against accidentally colliding with the method keyspace.+ -- Method keys always contain at least one '.'; activation namespaces+ -- may also, but the 'activation:' prefix keeps the two families+ -- unambiguously separated regardless of dot count.+ let key = "activation:" <> namespace+ first <- claimKey key+ when first $ do+ TIO.hPutStrLn stderr (formatActivationWarningLine namespace di)+ hFlush stderr
src/Synapse/IR/Builder.hs view
@@ -40,6 +40,7 @@ import Synapse.Monad import Synapse.IR.Types hiding (QualifiedName(..), qualifiedNameFull) import Synapse.IR.Types (QualifiedName(..), qualifiedNameFull, synapseVersion)+import Plexus.Schema.Recursive (DeprecationInfo, ParamSchema(..)) -- ============================================================================ -- Building IR@@ -267,6 +268,7 @@ , mdBidirType = fmap (updateTypeRef redirects) (mdBidirType md) , mdBidirResponseType = mdBidirResponseType md -- Preserve as-is , mdBidirResponseSchema = mdBidirResponseSchema md -- Preserve as-is+ -- mdRole: left unchanged via record-update syntax } -- | Update type references in a parameter@@ -338,8 +340,12 @@ then namespace <> "." <> name else pathPrefix <> "." <> name - -- Extract types from params (namespace-qualified)- (paramTypes, params) = extractParams namespace (methodParams method)+ -- Extract types from params (namespace-qualified).+ -- Also pass methodParamSchemas so per-param deprecation info+ -- (IR-14) flows from ParamSchema.paramDeprecation onto the IR+ -- ParamDef.pdDeprecation.+ (paramTypes, params) =+ extractParams namespace (methodParams method) (methodParamSchemas method) -- Extract types from returns (namespace-qualified) (returnTypes, returnRef, streaming) = extractReturns namespace name (methodReturns method)@@ -377,6 +383,12 @@ , mdBidirType = bidirTypeRef , mdBidirResponseType = Nothing -- TODO: could extract from response_type title , mdBidirResponseSchema = bidirResponseSchema+ , mdRole = methodRole method+ -- IR-12: lift MethodSchema.role onto the IR so hub-codegen's+ -- typed-handle codegen (IR-9) sees DynamicChild/StaticChild+ -- tags. plexus-protocol defaults this to MethodRoleRpc when+ -- the upstream schema omits the field, so pre-IR servers+ -- continue to work with no change in behaviour. } in (allTypes, mdef) @@ -408,16 +420,30 @@ -- Parameter Extraction -- ============================================================================ --- | Extract types and param defs from method params schema--- Types are namespace-qualified to avoid collisions-extractParams :: Text -> Maybe Value -> (Map Text TypeDef, [ParamDef])-extractParams _ Nothing = (Map.empty, [])-extractParams namespace (Just val) = case val of- Object o -> extractParamsFromObject namespace o+-- | Extract types and param defs from method params schema.+--+-- The third argument carries optional per-parameter metadata+-- ('ParamSchema') emitted by IR-5 producers. When present, each entry's+-- 'paramDeprecation' is lifted onto the resulting 'ParamDef.pdDeprecation'+-- by matching parameter name. Absent entries (pre-IR-5 producers, or+-- non-deprecated params) leave 'pdDeprecation' as 'Nothing'.+-- Types are namespace-qualified to avoid collisions.+extractParams+ :: Text+ -> Maybe Value+ -> Maybe [ParamSchema]+ -> (Map Text TypeDef, [ParamDef])+extractParams _ Nothing _ = (Map.empty, [])+extractParams namespace (Just val) paramSchemas = case val of+ Object o -> extractParamsFromObject namespace o paramSchemas _ -> (Map.empty, []) -extractParamsFromObject :: Text -> KM.KeyMap Value -> (Map Text TypeDef, [ParamDef])-extractParamsFromObject namespace o =+extractParamsFromObject+ :: Text+ -> KM.KeyMap Value+ -> Maybe [ParamSchema]+ -> (Map Text TypeDef, [ParamDef])+extractParamsFromObject namespace o paramSchemas = let -- Extract $defs (namespace-qualified) defs = extractDefs namespace o @@ -431,6 +457,16 @@ Just (Array arr) -> [t | String t <- V.toList arr] _ -> [] + -- Look up per-parameter deprecation by name from the structured+ -- ParamSchema list (IR-5). 'Nothing' when either the list is absent+ -- or the named parameter isn't in it.+ lookupDeprecation :: Text -> Maybe DeprecationInfo+ lookupDeprecation pname = case paramSchemas of+ Nothing -> Nothing+ Just xs -> case filter ((== pname) . paramName) xs of+ (ps:_) -> paramDeprecation ps+ [] -> Nothing+ -- Build param defs params = [ ParamDef@@ -439,6 +475,7 @@ , pdDescription = extractDescription v , pdRequired = K.toText k `elem` required , pdDefault = extractDefault v+ , pdDeprecation = lookupDeprecation (K.toText k) } | (k, v) <- props ]
src/Synapse/IR/Types.hs view
@@ -46,6 +46,7 @@ -- * Method Definitions , MethodDef(..)+ , MethodRole(..) , ParamDef(..) -- * Type References@@ -67,6 +68,8 @@ import qualified Data.Text as T import GHC.Generics (Generic) +import Plexus.Schema.Recursive (DeprecationInfo(..), MethodRole(..))+ -- ============================================================================ -- Version Information -- ============================================================================@@ -262,20 +265,91 @@ -- Cached from MethodSchema.response_type so synapse can include it -- in bidir_request output. Synapse controls whether to print this -- (e.g., first request, --bidir-schemas flag, etc.)+ , mdRole :: MethodRole+ -- ^ Structural role of this method in the activation graph+ -- (IR-12). Mirrors 'Plexus.Schema.Recursive.MethodRole' /+ -- 'plexus_core::MethodRole'. Defaults to 'MethodRoleRpc' when the+ -- upstream schema omits @role@ (pre-IR servers).+ --+ -- Consumed by hub-codegen (Rust field @md_role@, JSON key @mdRole@)+ -- to emit typed-handle clients for @DynamicChild@ methods. } deriving stock (Show, Eq, Generic)- deriving anyclass (ToJSON, FromJSON) +-- | Manual JSON instances so @mdRole@ defaults to 'MethodRoleRpc' when+-- deserializing pre-IR-12 IR JSON (same back-compat posture as the+-- Rust @#[serde(default)]@ on @MethodDef.md_role@ in hub-codegen).+--+-- All other fields preserve the generic derivation defaults (field+-- names used verbatim as JSON keys; @Maybe@ fields omitted when+-- @Nothing@ to match aeson's legacy behaviour).+instance ToJSON MethodDef where+ toJSON MethodDef{..} = object+ [ "mdName" .= mdName+ , "mdFullPath" .= mdFullPath+ , "mdNamespace" .= mdNamespace+ , "mdDescription" .= mdDescription+ , "mdStreaming" .= mdStreaming+ , "mdParams" .= mdParams+ , "mdReturns" .= mdReturns+ , "mdBidirType" .= mdBidirType+ , "mdBidirResponseType" .= mdBidirResponseType+ , "mdBidirResponseSchema" .= mdBidirResponseSchema+ , "mdRole" .= mdRole+ ]++instance FromJSON MethodDef where+ parseJSON = withObject "MethodDef" $ \o -> MethodDef+ <$> o .: "mdName"+ <*> o .: "mdFullPath"+ <*> o .: "mdNamespace"+ <*> o .:? "mdDescription"+ <*> o .: "mdStreaming"+ <*> o .: "mdParams"+ <*> o .: "mdReturns"+ <*> o .:? "mdBidirType"+ <*> o .:? "mdBidirResponseType"+ <*> o .:? "mdBidirResponseSchema"+ <*> o .:? "mdRole" .!= MethodRoleRpc+ -- | A parameter definition data ParamDef = ParamDef- { pdName :: Text -- ^ Parameter name- , pdType :: TypeRef -- ^ Parameter type- , pdDescription :: Maybe Text -- ^ Documentation- , pdRequired :: Bool -- ^ Is this required?- , pdDefault :: Maybe Value -- ^ Default value if any+ { pdName :: Text -- ^ Parameter name+ , pdType :: TypeRef -- ^ Parameter type+ , pdDescription :: Maybe Text -- ^ Documentation+ , pdRequired :: Bool -- ^ Is this required?+ , pdDefault :: Maybe Value -- ^ Default value if any+ , pdDeprecation :: Maybe DeprecationInfo+ -- ^ Per-parameter deprecation info (IR-14).+ -- Populated from 'Plexus.Schema.Recursive.ParamSchema.paramDeprecation'+ -- when the upstream schema advertises it (via @param_schemas@).+ -- Defaults to 'Nothing' when the schema lacks @param_schemas@, so+ -- pre-IR-5 producers and non-deprecated parameters are indistinguishable+ -- at the rendering layer. } deriving stock (Show, Eq, Generic)- deriving anyclass (ToJSON, FromJSON)++-- | Manual JSON instances so @pdDeprecation@ defaults to 'Nothing' when+-- deserializing IR JSON that predates IR-14.+-- All other fields preserve the derivation defaults.+instance ToJSON ParamDef where+ toJSON ParamDef{..} = object+ [ "pdName" .= pdName+ , "pdType" .= pdType+ , "pdDescription" .= pdDescription+ , "pdRequired" .= pdRequired+ , "pdDefault" .= pdDefault+ , "pdDeprecation" .= pdDeprecation+ ]++instance FromJSON ParamDef where+ parseJSON = withObject "ParamDef" $ \o -> ParamDef+ <$> o .: "pdName"+ <*> o .: "pdType"+ <*> o .:? "pdDescription"+ <*> o .: "pdRequired"+ <*> o .:? "pdDefault"+ <*> o .:? "pdDeprecation" -- ============================================================================ -- Type References
test/BidirIntegrationSpec.hs view
@@ -25,6 +25,8 @@ import Synapse.IR.Types import Synapse.IR.Builder (buildIR) import Synapse.Monad+import qualified Synapse.Log as Log+import qualified Katip main :: IO () main = do@@ -42,7 +44,8 @@ putStrLn $ "(backend: " <> T.unpack backend <> ")" -- Initialize environment- env <- initEnv "127.0.0.1" port backend+ logger <- Log.makeLogger Katip.ErrorS+ env <- initEnv "127.0.0.1" port backend logger Nothing -- Build IR once for all tests irResult <- runSynapseM env (buildIR [] [])
+ test/DeprecationSpec.hs view
@@ -0,0 +1,450 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Unit tests for IR-6, IR-14 deprecation rendering, and IR-15+-- invocation-time stderr deprecation warnings.+--+-- IR-6: per-method and per-activation decoration in 'renderSchema'.+-- IR-14: per-parameter decoration in both 'renderMethodFull' (reads+-- 'methodParamSchemas' directly) and 'renderMethodHelpWith'+-- (reads 'ParamDef.pdDeprecation' after the IR builder has+-- lifted the info across).+-- IR-15: stderr warnings emitted at invocation time from+-- 'Synapse.Deprecation', reusing IR-6's 'deprecationMarker' and+-- 'formatDeprecationLine' for line wording. Per-session dedupe,+-- disjoint method/activation keyspaces, and the+-- @--no-deprecation-warnings@ suppression toggle.+module Main where++import Control.Exception (finally)+import Data.Aeson (object, (.=), Value)+import qualified Data.Map.Strict as Map+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import GHC.IO.Handle (hDuplicate, hDuplicateTo)+import System.Directory (getTemporaryDirectory, removeFile)+import System.IO (hClose, hFlush, openTempFile, stderr)+import Test.Hspec++import Plexus.Schema.Recursive+ ( DeprecationInfo(..)+ , MethodRole(..)+ , MethodSchema(..)+ , ParamSchema(..)+ , PluginSchema(..)+ )+import Synapse.Algebra.Render+ ( deprecationMarker+ , formatDeprecationLine+ , renderMethodFull+ , renderSchema+ )+import Synapse.CLI.Help (renderMethodHelp)+import Synapse.Deprecation+ ( emitActivationWarning+ , emitMethodWarning+ , formatActivationWarningLine+ , formatMethodWarningLine+ , resetDeprecationStateForTesting+ )+import Synapse.IR.Builder (extractMethodDef)+import Synapse.IR.Types (IR(..), emptyIR)++-- ---------------------------------------------------------------------------+-- Fixtures+-- ---------------------------------------------------------------------------++baseMethod :: MethodSchema+baseMethod = MethodSchema+ { methodName = "move_doc"+ , methodDescription = "Relocate a document."+ , methodHash = "hash-move-doc"+ , methodParams = Nothing+ , methodReturns = Nothing+ , methodStreaming = False+ , methodBidirectional = False+ , methodRequestType = Nothing+ , methodResponseType = Nothing+ , methodDeprecation = Nothing+ , methodParamSchemas = Nothing+ , methodRole = MethodRoleRpc+ }++deprecatedMethod :: MethodSchema+deprecatedMethod = baseMethod+ { methodDeprecation = Just DeprecationInfo+ { depSince = "0.5"+ , depRemovedIn = "0.7"+ , depMessage = "use move_doc"+ }+ }++plainMethod :: MethodSchema+plainMethod = baseMethod { methodName = "list_docs" }++basePlugin :: PluginSchema+basePlugin = PluginSchema+ { psNamespace = "docs"+ , psVersion = "1.0"+ , psDescription = "Document activation"+ , psLongDescription = Nothing+ , psHash = "hash-docs"+ , psMethods = [deprecatedMethod, plainMethod]+ , psChildren = Nothing+ , psDeprecation = Nothing+ }++deprecatedPlugin :: PluginSchema+deprecatedPlugin = basePlugin+ { psDeprecation = Just DeprecationInfo+ { depSince = "0.4"+ , depRemovedIn = "0.8"+ , depMessage = "use docs_v2"+ }+ }++-- ---------------------------------------------------------------------------+-- IR-14 Fixtures: param-level deprecation+-- ---------------------------------------------------------------------------++-- | JSON Schema for a method with two parameters: 'path' (deprecated, the+-- one IR-14 decorates) and 'target' (not deprecated, the control).+--+-- This matches the minimum shape 'renderParamsFull' / 'extractParams'+-- actually inspect: an object with @properties@ and @required@.+paramsSchemaJSON :: Value+paramsSchemaJSON = object+ [ "type" .= ("object" :: T.Text)+ , "properties" .= object+ [ "path" .= object+ [ "type" .= ("string" :: T.Text)+ , "description" .= ("Old document path (deprecated)." :: T.Text)+ ]+ , "target" .= object+ [ "type" .= ("string" :: T.Text)+ , "description" .= ("Destination identifier." :: T.Text)+ ]+ ]+ , "required" .= (["path", "target"] :: [T.Text])+ ]++-- | ParamSchema list carrying the per-param deprecation info for 'path'+-- and leaving 'target' clean. This is the IR-5 wire-level payload.+paramSchemas :: [ParamSchema]+paramSchemas =+ [ ParamSchema+ { paramName = "path"+ , paramDescription = Just "Old document path (deprecated)."+ , paramRequired = True+ , paramDeprecation = Just DeprecationInfo+ { depSince = "0.5"+ , depRemovedIn = "0.7"+ , depMessage = "use target"+ }+ }+ , ParamSchema+ { paramName = "target"+ , paramDescription = Just "Destination identifier."+ , paramRequired = True+ , paramDeprecation = Nothing+ }+ ]++-- | Method with a deprecated parameter. Used to drive both+-- 'renderMethodFull' (Render.hs) and 'renderMethodHelp' (Help.hs).+methodWithDeprecatedParam :: MethodSchema+methodWithDeprecatedParam = baseMethod+ { methodName = "relocate"+ , methodDescription = "Relocate a document."+ , methodHash = "hash-relocate"+ , methodParams = Just paramsSchemaJSON+ , methodParamSchemas = Just paramSchemas+ }++-- | Build a minimal IR containing just this method, so we can exercise+-- 'renderMethodHelp' which consumes the IR 'MethodDef' (where+-- 'pdDeprecation' lives, lifted there by 'extractMethodDef').+helpFixture :: (IR, T.Text)+helpFixture =+ let (_types, mdef) = extractMethodDef "docs" "docs" methodWithDeprecatedParam+ fullPath = "docs.relocate"+ ir = emptyIR { irMethods = Map.singleton fullPath mdef }+ in (ir, renderMethodHelp ir mdef)++-- ---------------------------------------------------------------------------+-- IR-15 Fixtures: invocation-time deprecation warnings+-- ---------------------------------------------------------------------------++-- | Method-level deprecation info used by IR-15 invocation tests.+-- Shaped identically to the IR-6 fixture so both the marker and the+-- 'formatDeprecationLine' byte sequence line up verbatim, which is+-- the whole point of IR-15 reusing those helpers.+methodDep :: DeprecationInfo+methodDep = DeprecationInfo+ { depSince = "0.5"+ , depRemovedIn = "0.7"+ , depMessage = "use move_doc"+ }++-- | Activation-level deprecation info. Distinct @since@ from 'methodDep'+-- so tests that emit both can tell them apart in captured stderr.+activationDep :: DeprecationInfo+activationDep = DeprecationInfo+ { depSince = "0.4"+ , depRemovedIn = "0.8"+ , depMessage = "use docs_v2"+ }++-- ---------------------------------------------------------------------------+-- IR-15 Helpers: stderr capture+-- ---------------------------------------------------------------------------++-- | Run an action with 'stderr' swapped for a fresh temporary file;+-- return the captured text alongside the action's result.+--+-- The swap uses 'hDuplicate' / 'hDuplicateTo' so 'System.IO.stderr'+-- itself (the global handle 'Data.Text.IO.hPutStrLn' writes to inside+-- 'Synapse.Deprecation') is redirected — not just a local 'Handle'.+-- Using 'finally' for cleanup keeps the tempfile path removed even if+-- a test assertion throws.+captureStderr :: IO a -> IO (a, T.Text)+captureStderr action = do+ tmpDir <- getTemporaryDirectory+ (path, h) <- openTempFile tmpDir "synapse-deprecation-stderr.txt"+ (do+ originalStderr <- hDuplicate stderr+ hDuplicateTo h stderr+ hClose h+ result <- action+ hFlush stderr+ hDuplicateTo originalStderr stderr+ hClose originalStderr+ captured <- TIO.readFile path+ pure (result, captured))+ `finally` removeFile path++-- | Count occurrences of a substring. Distinguishes "fires once" from+-- "fires twice" in dedupe tests.+countOccurrences :: T.Text -> T.Text -> Int+countOccurrences needle haystack+ | T.null needle = 0+ | otherwise = length (T.breakOnAll needle haystack)++main :: IO ()+main = hspec $ do+ describe "renderSchema (IR-6 method-level deprecation)" $ do+ it "prepends the warning marker to deprecated method names" $ do+ let rendered = renderSchema basePlugin+ rendered `shouldSatisfy` T.isInfixOf deprecationMarker++ it "emits the full DEPRECATED detail line for deprecated methods" $ do+ let rendered = renderSchema basePlugin+ rendered `shouldSatisfy` T.isInfixOf "DEPRECATED since 0.5"+ rendered `shouldSatisfy` T.isInfixOf "removed in 0.7"+ rendered `shouldSatisfy` T.isInfixOf "use move_doc"++ it "leaves non-deprecated methods unchanged (no marker on their name line)" $ do+ let rendered = renderSchema basePlugin+ ls = T.lines rendered+ -- Lines containing list_docs must NOT contain the marker.+ listDocLines = filter (T.isInfixOf "list_docs") ls+ all (not . T.isInfixOf deprecationMarker) listDocLines+ `shouldBe` True++ it "emits no deprecation markers for plugins whose methods all lack deprecation" $ do+ let cleanPlugin = basePlugin { psMethods = [plainMethod] }+ rendered = renderSchema cleanPlugin+ rendered `shouldSatisfy` (not . T.isInfixOf deprecationMarker)+ rendered `shouldSatisfy` (not . T.isInfixOf "DEPRECATED")++ describe "renderSchema (IR-6 activation-level deprecation)" $ do+ it "renders a warning marker on the activation heading" $ do+ let rendered = renderSchema deprecatedPlugin+ ls = T.lines rendered+ -- Some early line must contain both the namespace and the marker.+ decoratedHeading =+ any (\l -> T.isInfixOf "docs" l && T.isInfixOf deprecationMarker l)+ (take 3 ls)+ decoratedHeading `shouldBe` True++ it "includes the activation-level deprecation detail string" $ do+ let rendered = renderSchema deprecatedPlugin+ rendered `shouldSatisfy` T.isInfixOf "DEPRECATED since 0.4"+ rendered `shouldSatisfy` T.isInfixOf "removed in 0.8"+ rendered `shouldSatisfy` T.isInfixOf "use docs_v2"++ it "keeps activation rendering unchanged when psDeprecation is Nothing" $ do+ let rendered = renderSchema (basePlugin { psMethods = [plainMethod] })+ rendered `shouldSatisfy` (not . T.isInfixOf "use docs_v2")++ describe "renderMethodFull (IR-14 per-parameter deprecation in Render.hs)" $ do+ it "prepends the warning marker on the deprecated parameter's flag line" $ do+ let rendered = renderMethodFull methodWithDeprecatedParam+ ls = T.lines rendered+ -- Line that mentions --path must carry the marker.+ pathLines = filter (T.isInfixOf "--path") ls+ pathLines `shouldSatisfy` (not . null)+ all (T.isInfixOf deprecationMarker) pathLines `shouldBe` True++ it "emits the DEPRECATED detail line for the deprecated parameter" $ do+ let rendered = renderMethodFull methodWithDeprecatedParam+ rendered `shouldSatisfy` T.isInfixOf "DEPRECATED since 0.5"+ rendered `shouldSatisfy` T.isInfixOf "removed in 0.7"+ rendered `shouldSatisfy` T.isInfixOf "use target"++ it "leaves non-deprecated sibling parameter undecorated" $ do+ let rendered = renderMethodFull methodWithDeprecatedParam+ ls = T.lines rendered+ -- Lines mentioning --target must NOT carry the marker. We also+ -- filter out lines that happen to contain 'use target' (the+ -- deprecation detail line for --path), since that substring+ -- would false-match.+ targetLines =+ [ l | l <- ls+ , T.isInfixOf "--target" l+ , not (T.isInfixOf "use target" l)+ ]+ targetLines `shouldSatisfy` (not . null)+ all (not . T.isInfixOf deprecationMarker) targetLines `shouldBe` True++ describe "renderMethodHelp (IR-14 per-parameter deprecation in Help.hs)" $ do+ it "prepends the warning marker on the deprecated parameter's flag line" $ do+ let (_, rendered) = helpFixture+ ls = T.lines rendered+ pathLines = filter (T.isInfixOf "--path") ls+ pathLines `shouldSatisfy` (not . null)+ all (T.isInfixOf deprecationMarker) pathLines `shouldBe` True++ it "emits the DEPRECATED detail line for the deprecated parameter" $ do+ let (_, rendered) = helpFixture+ rendered `shouldSatisfy` T.isInfixOf "DEPRECATED since 0.5"+ rendered `shouldSatisfy` T.isInfixOf "removed in 0.7"+ rendered `shouldSatisfy` T.isInfixOf "use target"++ it "leaves non-deprecated sibling parameter undecorated" $ do+ let (_, rendered) = helpFixture+ ls = T.lines rendered+ targetLines =+ [ l | l <- ls+ , T.isInfixOf "--target" l+ , not (T.isInfixOf "use target" l)+ ]+ targetLines `shouldSatisfy` (not . null)+ all (not . T.isInfixOf deprecationMarker) targetLines `shouldBe` True++ -- =========================================================================+ -- IR-15: invocation-time stderr deprecation warnings+ -- =========================================================================++ describe "formatMethodWarningLine / formatActivationWarningLine (IR-15 reuses IR-6 helpers)" $ do+ it "method line carries the IR-6 deprecationMarker and the IR-6 formatDeprecationLine verbatim" $ do+ let line = formatMethodWarningLine "docs.move_doc" methodDep+ line `shouldSatisfy` T.isInfixOf deprecationMarker+ line `shouldSatisfy` T.isInfixOf "docs.move_doc"+ line `shouldSatisfy` T.isInfixOf (formatDeprecationLine methodDep)++ it "method line matches the ticket-spec shape for since / removed_in / message" $ do+ let line = formatMethodWarningLine "docs.move_doc" methodDep+ line `shouldSatisfy` T.isInfixOf "DEPRECATED since 0.5"+ line `shouldSatisfy` T.isInfixOf "removed in 0.7"+ line `shouldSatisfy` T.isInfixOf "use move_doc"++ it "activation line names the namespace and embeds the IR-6 DEPRECATED clause" $ do+ let line = formatActivationWarningLine "docs" activationDep+ line `shouldSatisfy` T.isInfixOf deprecationMarker+ line `shouldSatisfy` T.isInfixOf "'docs'"+ line `shouldSatisfy` T.isInfixOf (formatDeprecationLine activationDep)++ it "activation line mentions 'activation' so consumers can grep it apart from method lines" $ do+ let line = formatActivationWarningLine "docs" activationDep+ line `shouldSatisfy` T.isInfixOf "activation"++ -- Every IR-15 emission test resets the global dedupe IORef in a+ -- 'before_' hook so IORef leakage across tests is impossible.+ before_ resetDeprecationStateForTesting $ do+ describe "emitMethodWarning (IR-15 method-level invocation warning)" $ do+ it "(AC 2 / a) fires on the first invocation of a deprecated method" $ do+ ((), captured) <- captureStderr $+ emitMethodWarning False "docs.move_doc" (Just methodDep)+ captured `shouldSatisfy` T.isInfixOf "docs.move_doc"+ captured `shouldSatisfy` T.isInfixOf "DEPRECATED since 0.5"++ it "(AC 3 / b) suppresses on the second invocation of the same method in the same session" $ do+ ((), captured) <- captureStderr $ do+ emitMethodWarning False "docs.move_doc" (Just methodDep)+ emitMethodWarning False "docs.move_doc" (Just methodDep)+ emitMethodWarning False "docs.move_doc" (Just methodDep)+ countOccurrences "DEPRECATED since 0.5" captured `shouldBe` 1++ it "fires once for each distinct deprecated method (dedupe is per full-path key)" $ do+ ((), captured) <- captureStderr $ do+ emitMethodWarning False "docs.move_doc" (Just methodDep)+ emitMethodWarning False "docs.retire_doc"+ (Just methodDep { depMessage = "use retire_doc_v2" })+ captured `shouldSatisfy` T.isInfixOf "docs.move_doc"+ captured `shouldSatisfy` T.isInfixOf "docs.retire_doc"++ it "stays silent when the method is not deprecated" $ do+ ((), captured) <- captureStderr $+ emitMethodWarning False "docs.list" Nothing+ captured `shouldBe` ""++ describe "emitActivationWarning (IR-15 activation-level invocation warning)" $ do+ it "(AC 4 / c) fires once per session on a deprecated activation, across multiple invocations" $ do+ ((), captured) <- captureStderr $ do+ -- Simulate three calls to *different methods* on the same+ -- deprecated activation. The per-session dedupe must collapse+ -- these to a single line.+ emitActivationWarning False "docs" (Just activationDep)+ emitActivationWarning False "docs" (Just activationDep)+ emitActivationWarning False "docs" (Just activationDep)+ countOccurrences "activation" captured `shouldBe` 1+ countOccurrences "'docs'" captured `shouldBe` 1++ it "keeps method and activation keyspaces disjoint (same string key does not collide)" $ do+ -- An activation called @docs@ and a method whose full path is+ -- literally @docs@ must dedupe independently: firing one must+ -- not silence the other.+ ((), captured) <- captureStderr $ do+ emitActivationWarning False "docs" (Just activationDep)+ emitMethodWarning False "docs" (Just methodDep)+ countOccurrences "activation" captured `shouldBe` 1+ countOccurrences "DEPRECATED since 0.5" captured `shouldBe` 1 -- method+ countOccurrences "DEPRECATED since 0.4" captured `shouldBe` 1 -- activation++ it "stays silent when the activation is not deprecated" $ do+ ((), captured) <- captureStderr $+ emitActivationWarning False "docs" Nothing+ captured `shouldBe` ""++ describe "--no-deprecation-warnings suppression (IR-15 (AC 5 / d))" $ do+ it "suppresses method-level warning when flag is on" $ do+ ((), captured) <- captureStderr $+ emitMethodWarning True "docs.move_doc" (Just methodDep)+ captured `shouldBe` ""++ it "suppresses activation-level warning when flag is on" $ do+ ((), captured) <- captureStderr $+ emitActivationWarning True "docs" (Just activationDep)+ captured `shouldBe` ""++ it "a suppressed call does NOT claim the dedupe key (next unsuppressed call still fires)" $ do+ -- Behavioural contract: toggling the flag on must not poison+ -- the dedupe set for future unsuppressed calls. If it did,+ -- scripts that toggle the flag between calls would silently+ -- hide real warnings.+ ((), captured) <- captureStderr $ do+ emitMethodWarning True "docs.move_doc" (Just methodDep) -- suppressed+ emitMethodWarning False "docs.move_doc" (Just methodDep) -- should fire+ countOccurrences "DEPRECATED since 0.5" captured `shouldBe` 1++ describe "(AC 6 / e) exit codes are not the concern of the emission layer" $ do+ -- The emission functions return (), so they cannot alter exit codes+ -- by construction. This is asserted here as a contract rather than+ -- a runtime check. The end-to-end exit-code guarantee belongs to+ -- the CLI integration suite (driven against a live backend).+ it "emitMethodWarning returns unit (:: IO ())" $+ let _ = emitMethodWarning :: Bool -> T.Text -> Maybe DeprecationInfo -> IO ()+ in True `shouldBe` True+ it "emitActivationWarning returns unit (:: IO ())" $+ let _ = emitActivationWarning :: Bool -> T.Text -> Maybe DeprecationInfo -> IO ()+ in True `shouldBe` True
+ test/IR12MethodRoleSpec.hs view
@@ -0,0 +1,170 @@+{-# LANGUAGE OverloadedStrings #-}++-- | IR-12 regression tests: 'MethodRole' round-trips through the+-- Plexus RPC wire format and into synapse's IR output.+--+-- Acceptance criteria covered:+--+-- * A JSON @MethodSchema@ with+-- @role: {"kind": "dynamic_child", "list_method": "planet_names",+-- "search_method": null}@ decodes to 'MethodRoleDynamicChild' with+-- the matching fields.+--+-- * A pre-IR @MethodSchema@ (no @role@ key) decodes to+-- 'MethodRoleRpc' — the wire-level back-compat guarantee for+-- pre-IR servers.+--+-- * A synthesised 'MethodDef' whose 'mdRole' is 'MethodRoleDynamicChild'+-- round-trips through JSON and emits the exact @kind@/@list_method@+-- shape consumed by hub-codegen (whose Rust @MethodRole@ enum uses+-- @#[serde(tag = \"kind\", rename_all = \"snake_case\")]@).+module Main where++import qualified Data.Aeson as Aeson+import qualified Data.Aeson.KeyMap as KM+import Data.Aeson (Value(..), (.=), eitherDecode, encode, object)+import Test.Hspec++import Plexus.Schema.Recursive+ ( MethodRole(..)+ , MethodSchema(..)+ )++import Synapse.IR.Types+ ( MethodDef(..)+ , TypeRef(..)+ )++-- | Baseline 'MethodDef' used by the round-trip tests.+baseMethodDef :: MethodDef+baseMethodDef = MethodDef+ { mdName = "planet"+ , mdFullPath = "solar.planet"+ , mdNamespace = "solar"+ , mdDescription = Just "Fetch a planet child by name."+ , mdStreaming = False+ , mdParams = []+ , mdReturns = RefAny+ , mdBidirType = Nothing+ , mdBidirResponseType = Nothing+ , mdBidirResponseSchema = Nothing+ , mdRole = MethodRoleRpc+ }++-- | Build a JSON @MethodSchema@ with an optional @role@ key. Passing+-- 'Nothing' produces the pre-IR shape (no @role@ key at all).+methodSchemaJson :: Maybe Value -> Value+methodSchemaJson mRole =+ let base =+ [ "name" .= ("planet" :: String)+ , "description" .= ("Fetch a planet child by name." :: String)+ , "hash" .= ("h-planet" :: String)+ ]+ in object $ case mRole of+ Nothing -> base+ Just role -> ("role" .= role) : base++-- | A hand-written pre-IR-12 MethodDef JSON blob: same field names as+-- the generic derivation produces, minus @mdRole@. Exercising the+-- back-compat path of synapse's manual @FromJSON MethodDef@ instance.+preIrMethodDefJson :: Value+preIrMethodDefJson = object+ [ "mdName" .= ("planet" :: String)+ , "mdFullPath" .= ("solar.planet" :: String)+ , "mdNamespace" .= ("solar" :: String)+ , "mdDescription" .= Aeson.Null+ , "mdStreaming" .= False+ , "mdParams" .= ([] :: [Value])+ , "mdReturns" .= object [ "tag" .= ("RefAny" :: String) ]+ ]++main :: IO ()+main = hspec $ do+ describe "IR-12: MethodRole wire round-trip via MethodSchema" $ do+ it "decodes DynamicChild with list_method and a null search_method" $ do+ let j = methodSchemaJson $ Just $ object+ [ "kind" .= ("dynamic_child" :: String)+ , "list_method" .= ("planet_names" :: String)+ , "search_method" .= Aeson.Null+ ]+ case Aeson.fromJSON j :: Aeson.Result MethodSchema of+ Aeson.Error e -> expectationFailure $ "decode failed: " <> e+ Aeson.Success ms ->+ methodRole ms `shouldBe`+ MethodRoleDynamicChild+ { listMethod = Just "planet_names"+ , searchMethod = Nothing+ }++ it "decodes StaticChild" $ do+ let j = methodSchemaJson $ Just $ object+ [ "kind" .= ("static_child" :: String) ]+ case Aeson.fromJSON j :: Aeson.Result MethodSchema of+ Aeson.Error e -> expectationFailure $ "decode failed: " <> e+ Aeson.Success ms -> methodRole ms `shouldBe` MethodRoleStaticChild++ it "decodes explicit Rpc variant" $ do+ let j = methodSchemaJson $ Just $ object [ "kind" .= ("rpc" :: String) ]+ case Aeson.fromJSON j :: Aeson.Result MethodSchema of+ Aeson.Error e -> expectationFailure $ "decode failed: " <> e+ Aeson.Success ms -> methodRole ms `shouldBe` MethodRoleRpc++ it "defaults to Rpc when role is absent (pre-IR wire shape)" $ do+ case Aeson.fromJSON (methodSchemaJson Nothing) :: Aeson.Result MethodSchema of+ Aeson.Error e -> expectationFailure $ "decode failed: " <> e+ Aeson.Success ms -> methodRole ms `shouldBe` MethodRoleRpc++ it "serialises DynamicChild back to the exact Rust-compatible tag shape" $ do+ let role = MethodRoleDynamicChild+ { listMethod = Just "planet_names"+ , searchMethod = Nothing+ }+ encoded = Aeson.toJSON role+ case encoded of+ Object o -> do+ KM.lookup "kind" o+ `shouldBe` Just (String "dynamic_child")+ KM.lookup "list_method" o+ `shouldBe` Just (String "planet_names")+ -- Matches the Rust #[serde(skip_serializing_if = "Option::is_none")]+ -- behaviour on search_method, so the encoded object never+ -- carries an explicit null for the absent field.+ KM.lookup "search_method" o `shouldBe` Nothing+ _ -> expectationFailure $+ "expected JSON Object, got: " <> show encoded++ describe "IR-12: MethodDef mdRole is emitted in synapse's IR JSON" $ do+ it "includes mdRole in the serialised IR method" $ do+ let md = baseMethodDef+ { mdRole = MethodRoleDynamicChild+ { listMethod = Just "planet_names"+ , searchMethod = Nothing+ }+ }+ encoded = Aeson.toJSON md+ case encoded of+ Object o ->+ KM.lookup "mdRole" o `shouldBe` Just+ (object+ [ "kind" .= ("dynamic_child" :: String)+ , "list_method" .= ("planet_names" :: String)+ ])+ _ -> expectationFailure $+ "expected JSON Object, got: " <> show encoded++ it "round-trips DynamicChild through encode >>> decode" $ do+ let original = baseMethodDef+ { mdRole = MethodRoleDynamicChild+ { listMethod = Just "planet_names"+ , searchMethod = Just "search_planets"+ }+ }+ case eitherDecode (encode original) :: Either String MethodDef of+ Left e -> expectationFailure $ "decode failed: " <> e+ Right md -> mdRole md `shouldBe` mdRole original++ it "pre-IR-12 MethodDef JSON (no mdRole) decodes with default Rpc" $ do+ -- Matches hub-codegen's Rust-side #[serde(default)] posture.+ case eitherDecode (encode preIrMethodDefJson) :: Either String MethodDef of+ Left e -> expectationFailure $ "decode failed: " <> e+ Right md -> mdRole md `shouldBe` MethodRoleRpc
test/IRSpec.hs view
@@ -38,6 +38,8 @@ import Synapse.CLI.Support (SupportLevel(..), methodSupport) import Synapse.Monad import Synapse.Backend.Discovery (Backend(..), BackendDiscovery(..), registryDiscovery)+import qualified Synapse.Log as Log+import qualified Katip main :: IO () main = do@@ -45,7 +47,8 @@ (backend, host, port) <- resolveBackend args putStrLn $ "Running IR integration tests against " <> T.unpack host <> ":" <> show port <> " (backend: " <> T.unpack backend <> ")" - env <- initEnv host port backend+ logger <- Log.makeLogger Katip.ErrorS+ env <- initEnv host port backend logger Nothing -- Build IR once for all tests irResult <- runSynapseM env (buildIR [] [])
test/ParseSpec.hs view
@@ -48,6 +48,7 @@ , pdDescription = Nothing , pdRequired = True , pdDefault = Nothing+ , pdDeprecation = Nothing } let kvs = [("", "backend"), ("", "critical"), ("", "urgent")] let expected = Right $ Array $ V.fromList [String "backend", String "critical", String "urgent"]@@ -60,6 +61,7 @@ , pdDescription = Nothing , pdRequired = True , pdDefault = Nothing+ , pdDeprecation = Nothing } let kvs = [("", "1"), ("", "2"), ("", "3")] let expected = Right $ Array $ V.fromList [Number 1, Number 2, Number 3]@@ -72,6 +74,7 @@ , pdDescription = Nothing , pdRequired = True , pdDefault = Nothing+ , pdDeprecation = Nothing } let kvs = [] buildParamValue emptyIR param kvs `shouldSatisfy` isLeft