From 519635ca4f89164cacc26f0ca9f51a96808a0a6f Mon Sep 17 00:00:00 2001 From: Juniper Alanna Berry <201364921+juniper-shopify@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:53:06 -0400 Subject: [PATCH] feat: extend global config directive to accept name/pattern specifiers Allow `global(:step_name)` and `global(/pattern/)` in Roast config blocks, mirroring the existing name/pattern specifier support in cog-type blocks (agent, chat, cmd, etc.). This enables targeted cross-cutting configuration like: global(:my_step) { async! } global(/^api_/) { abort_on_failure! } The merge cascade expands from 4 tiers to 7: 1. global (bare) 2. global (regexp match) 3. global (exact name) 4. cog-type general 5. cog-type regexp match 6. cog-type exact name 7. inline YAML Also cleans up a vestigial `instance_variable_get(:@values)` call to use the public `.values` accessor instead. --- lib/roast/config_manager.rb | 47 +++++++++++--- sorbet/rbi/shims/lib/roast/config_context.rbi | 8 ++- test/roast/config_manager_test.rb | 63 +++++++++++++++++++ 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/lib/roast/config_manager.rb b/lib/roast/config_manager.rb index 621f5273..877ebafb 100644 --- a/lib/roast/config_manager.rb +++ b/lib/roast/config_manager.rb @@ -17,6 +17,7 @@ def initialize(cog_registry, config_procs, workflow_context) @workflow_context = workflow_context @config_context = ConfigContext.new #: ConfigContext @global_config = Cog::Config.new #: Cog::Config + @global_regexp_configs = {} #: Hash[Regexp, Cog::Config] @general_configs = {} #: Hash[singleton(Cog), Cog::Config] @regexp_scoped_configs = {} #: Hash[singleton(Cog), Hash[Regexp, Cog::Config]] @name_scoped_configs = {} #: Hash[singleton(Cog), Hash[Symbol, Cog::Config]] @@ -48,12 +49,21 @@ def config_for(cog_class, name = nil) raise ConfigManagerNotPreparedError unless prepared? # All cogs will always have a config; empty by default if the cog was never explicitly configured - config = cog_class.config_class.new(@global_config.instance_variable_get(:@values).deep_dup) + # Start with bare global config + config = cog_class.config_class.new(@global_config.values.deep_dup) + # Apply cog-type general config config = config.merge(fetch_general_config(cog_class)) - @regexp_scoped_configs.fetch(cog_class, {}).select do |pattern, _| - pattern.match?(name.to_s) unless name.nil? - end.values.each { |cfg| config = config.merge(cfg) } unless name.nil? + # Apply matching global regexp configs (insertion order) + @global_regexp_configs.each do |pattern, cfg| + config = config.merge(cfg) if pattern.match?(name.to_s) + end + # Apply cog-type regexp configs + @regexp_scoped_configs.fetch(cog_class, {}).select do |pattern, _| + pattern.match?(name.to_s) + end.values.each { |cfg| config = config.merge(cfg) } + # NOTE: global name configs are not implemented, since names must be unique across all cog types + # Apply cog-type name config name_scoped_config = fetch_name_scoped_config(cog_class, name) config = config.merge(name_scoped_config) end @@ -127,19 +137,36 @@ def on_config(cog_class, cog_name_or_pattern, cog_config_proc) def bind_global on_global_method = method(:on_global) - method_to_bind = proc do |&global_proc| - on_global_method.call(global_proc) + method_to_bind = proc do |name_or_pattern = nil, &global_proc| + on_global_method.call(name_or_pattern, global_proc) end @config_context.instance_eval do define_singleton_method(:global, method_to_bind) end end - #: (^() -> void ) -> void - def on_global(global_config_proc) + #: (Regexp?, ^() -> void) -> void + def on_global(pattern, global_config_proc) + # Called when the 'global' method is invoked in the workflow's 'config' block. + # This allows common configuration parameters to be set for all cogs, or cogs of all types matching a pattern + + # NOTE: cast to untyped is to intentional handling the 'unreachable' else case here. + # This method takes user input directly so additional validation with a clearer exception message will be helpful + pattern = pattern #: untyped + config_object = case pattern + when NilClass + @global_config + when Regexp + @global_regexp_configs[pattern] ||= Cog::Config.new + else + raise ArgumentError, "Invalid type '#{pattern.class}' for global pattern" + end + + # NOTE: Sorbet expects the proc passed to instance_exec to be declared as taking an argument + # but our global_config_proc does not get an argument global_config_proc = global_config_proc #: as ^(untyped) -> void - bind_workflow_params(@global_config) - @global_config.instance_exec(&global_config_proc) if global_config_proc + bind_workflow_params(config_object) + config_object.instance_exec(&global_config_proc) if global_config_proc nil end diff --git a/sorbet/rbi/shims/lib/roast/config_context.rbi b/sorbet/rbi/shims/lib/roast/config_context.rbi index 14d8b1a8..490c8961 100644 --- a/sorbet/rbi/shims/lib/roast/config_context.rbi +++ b/sorbet/rbi/shims/lib/roast/config_context.rbi @@ -16,6 +16,10 @@ module Roast # global do # # Configuration here applies to all cogs # end + # + # global(/pattern/) do + # # Configuration here applies to all cogs whose name matches /pattern/ + # end # end # ``` # @@ -35,8 +39,8 @@ module Roast # - `working_directory(path)` - Set the working directory for external commands invoked by the cog # - `use_current_working_directory!` - Use the current working directory # - #: () {() [self: Roast::Cog::Config] -> void} -> void - def global(&block); end + #: (?Regexp?) {() [self: Roast::Cog::Config] -> void} -> void + def global(pattern = nil, &block); end # Configure the `call` cog # diff --git a/test/roast/config_manager_test.rb b/test/roast/config_manager_test.rb index 20249516..7df74d22 100644 --- a/test/roast/config_manager_test.rb +++ b/test/roast/config_manager_test.rb @@ -291,6 +291,69 @@ def build_manager(config_procs = [], params: WorkflowParams.new([], [], {})) assert manager.config_for(TestCog).abort_on_failure? end + test "global with regexp specifier applies to matching cog names" do + config_proc = proc do + global(/^api_/) { async! } + end + manager = build_manager([config_proc]) + manager.prepare! + + matching_config = manager.config_for(TestCog, :api_call) + non_matching_config = manager.config_for(TestCog, :db_query) + + assert matching_config.async? + refute non_matching_config.async? + end + + test "global regexp does not apply when cog name is nil" do + config_proc = proc do + global(/.*/) { async! } + end + manager = build_manager([config_proc]) + manager.prepare! + + config = manager.config_for(TestCog) + + refute config.async? + end + + test "global cascade order: bare < regexp" do + config_proc = proc do + global { self[:priority] = "bare" } + global(/my/) { self[:priority] = "regexp" } + end + manager = build_manager([config_proc]) + manager.prepare! + + # Regexp global should win over bare + config = manager.config_for(TestCog, :my_step) + assert_equal "regexp", config.values[:priority] + end + + test "multiple global regexps apply in insertion order" do + config_proc = proc do + global(/^a/) { self[:priority] = "first" } + global(/api/) { self[:priority] = "second" } + end + manager = build_manager([config_proc]) + manager.prepare! + + # Both match :api_call — second one wins (applied last) + config = manager.config_for(TestCog, :api_call) + assert_equal "second", config.values[:priority] + end + + test "workflow params are accessible inside a global regexp config block" do + captured = nil + config_proc = proc do + global(/something_/) { captured = target! } + end + manager = build_manager([config_proc], params: WorkflowParams.new(["Gemfile"], [], {})) + manager.prepare! + + assert_equal "Gemfile", captured + end + test "workflow params are not accessible in the top-level config block body" do config_proc = proc do args