From f09f845e6d010ff85f7ea35ec2f23b6ec694bebd Mon Sep 17 00:00:00 2001 From: "d3mlabs-ai-flow[bot]" <305891656+d3mlabs-ai-flow[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:48:30 -0400 Subject: [PATCH 1/7] ai-flow /build: PR D: Split CommandExecutor into a dispatching composite with injectable BuiltinExecutor / ProjectExecutor / OverriddenExecutor strategies (exec_into vs run_waiting) Co-authored-by: JPDuchesne <2636122+JPDuchesne@users.noreply.github.com> --- src/dev/builtin_executor.rb | 27 ++++ src/dev/command_executor.rb | 72 ++++------ src/dev/overridden_executor.rb | 47 ++++++ src/dev/project_executor.rb | 72 ++++++++++ src/dev/runner.rb | 21 ++- test/dev/builtin_executor_test.rb | 47 ++++++ test/dev/command_executor_test.rb | 207 +++++++-------------------- test/dev/command_service_test.rb | 15 +- test/dev/overridden_executor_test.rb | 96 +++++++++++++ test/dev/project_executor_test.rb | 154 ++++++++++++++++++++ 10 files changed, 558 insertions(+), 200 deletions(-) create mode 100644 src/dev/builtin_executor.rb create mode 100644 src/dev/overridden_executor.rb create mode 100644 src/dev/project_executor.rb create mode 100644 test/dev/builtin_executor_test.rb create mode 100644 test/dev/overridden_executor_test.rb create mode 100644 test/dev/project_executor_test.rb diff --git a/src/dev/builtin_executor.rb b/src/dev/builtin_executor.rb new file mode 100644 index 0000000..4eca0b0 --- /dev/null +++ b/src/dev/builtin_executor.rb @@ -0,0 +1,27 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "command" +require_relative "execution_context" + +module Dev + # In-process execution of a builtin's Ruby body. The delegation is + # deliberately thin: this class exists so CommandExecutor's three sealed + # arms dispatch to uniformly injectable strategies (the builtin arm is + # mocked in tests exactly like the process-boundary arms), not because + # builtin execution needs any mediation. + class BuiltinExecutor + extend T::Sig + + # Run the builtin's body in the current process. + # + # @param command [BuiltinCommand] + # @param args [Array] argv after the command name + # @param context [ExecutionContext] + # @return [void] + sig { params(command: BuiltinCommand, args: T::Array[String], context: ExecutionContext).void } + def execute(command, args:, context:) + command.call(args:, context:) + end + end +end diff --git a/src/dev/command_executor.rb b/src/dev/command_executor.rb index 1e15074..842b6f1 100644 --- a/src/dev/command_executor.rb +++ b/src/dev/command_executor.rb @@ -1,35 +1,53 @@ # typed: strict # frozen_string_literal: true +require_relative "builtin_executor" require_relative "command" -require_relative "command_runner" require_relative "execution_context" +require_relative "overridden_executor" +require_relative "project_executor" module Dev - # The process boundary of a command run: exhaustive dispatch over the - # sealed Command variants. Builtin bodies run in-process; project commands - # hand the process over to the child through CommandRunner. + # The dispatching composite over the sealed Command variants: the case + + # T.absurd sends each variant to its injected strategy and does nothing + # else. Builtin bodies go to BuiltinExecutor (in-process), project + # commands to ProjectExecutor's exec tail-call, overridden slots to + # OverriddenExecutor (builtin stage, then the project tail). class CommandExecutor extend T::Sig + # @param builtin_executor [BuiltinExecutor] + # @param project_executor [ProjectExecutor] + # @param overridden_executor [OverriddenExecutor] + sig do + params( + builtin_executor: BuiltinExecutor, + project_executor: ProjectExecutor, + overridden_executor: OverriddenExecutor, + ).void + end + def initialize(builtin_executor:, project_executor:, overridden_executor:) + @builtin_executor = T.let(builtin_executor, BuiltinExecutor) + @project_executor = T.let(project_executor, ProjectExecutor) + @overridden_executor = T.let(overridden_executor, OverriddenExecutor) + end + + # Dispatch one command to its strategy. + # # @param command [Command] # @param args [Array] argv after the command name # @param context [ExecutionContext] # @return [void] - # @raise [CommandRunner::CommandFailedError] in wait mode, when the - # child command fails + # @raise [CommandRunner::CommandFailedError] when a waited child fails sig { params(command: Command, args: T::Array[String], context: ExecutionContext).void } def execute(command, args:, context:) case command when BuiltinCommand - command.call(args:, context:) + @builtin_executor.execute(command, args:, context:) when ProjectCommand - run_project(command, args:, context:, wait: false) + @project_executor.exec_into(command, args:, context:) when OverriddenCommand - # Virtual dispatch: the builtin body is the hardcoded super(), then - # the project command owns the slot. - command.builtin.call(args:, context:) - run_project(command.project, args:, context:, wait: command.stamps?) + @overridden_executor.execute(command, args:, context:) else # :nocov: — the sealed hierarchy leaves no fourth variant to # construct, so this arm is unreachable at runtime; T.absurd keeps @@ -38,35 +56,5 @@ def execute(command, args:, context:) # :nocov: end end - - private - - # Run a project command through CommandRunner. Wait-vs-exec derives from - # the slot's stamping trait (the dev#85 invariant, enforced here in one - # place): a stamping slot must spawn-and-wait so the caller can sequence - # the installed stamp after execute — exec-replace would make it - # unreachable. Generic project commands keep the exec tail-call (TTY and - # signal passthrough, no double process tree). - # - # @param command [ProjectCommand] - # @param args [Array] - # @param context [ExecutionContext] - # @param wait [Boolean] - # @return [void] - # @raise [CommandRunner::CommandFailedError] in wait mode, when the - # child command fails - sig do - params(command: ProjectCommand, args: T::Array[String], context: ExecutionContext, wait: T::Boolean).void - end - def run_project(command, args:, context:, wait:) - CommandRunner.new( - ui: context.ui, - ruby_version: context.ruby_version, - python_version: context.python_version, - build_container: context.build_container, - project_root: context.project_root, - wait: wait, - ).run(command, args:) - end end end diff --git a/src/dev/overridden_executor.rb b/src/dev/overridden_executor.rb new file mode 100644 index 0000000..7a188c9 --- /dev/null +++ b/src/dev/overridden_executor.rb @@ -0,0 +1,47 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "builtin_executor" +require_relative "command" +require_relative "execution_context" +require_relative "project_executor" + +module Dev + # The virtual-dispatch strategy: the builtin body is the hardcoded + # super(), then the project command owns the slot. Composes the other two + # strategies — the builtin stage runs through BuiltinExecutor, the + # project tail through ProjectExecutor. + class OverriddenExecutor + extend T::Sig + + # @param builtin_executor [BuiltinExecutor] + # @param project_executor [ProjectExecutor] + sig { params(builtin_executor: BuiltinExecutor, project_executor: ProjectExecutor).void } + def initialize(builtin_executor:, project_executor:) + @builtin_executor = T.let(builtin_executor, BuiltinExecutor) + @project_executor = T.let(project_executor, ProjectExecutor) + end + + # Run the builtin stage, then the project tail. The tail's message + # derives from the slot's stamping trait (the dev#85 invariant): a + # stamping slot must run spawn-and-wait so the caller can sequence the + # installed stamp after execute — exec-replace would make it + # unreachable. Non-stamping slots keep the exec tail-call. + # + # @param command [OverriddenCommand] + # @param args [Array] argv after the command name + # @param context [ExecutionContext] + # @return [void] + # @raise [CommandRunner::CommandFailedError] when a waited project tail + # fails + sig { params(command: OverriddenCommand, args: T::Array[String], context: ExecutionContext).void } + def execute(command, args:, context:) + @builtin_executor.execute(command.builtin, args:, context:) + if command.stamps? + @project_executor.run_waiting(command.project, args:, context:) + else + @project_executor.exec_into(command.project, args:, context:) + end + end + end +end diff --git a/src/dev/project_executor.rb b/src/dev/project_executor.rb new file mode 100644 index 0000000..1eed4b8 --- /dev/null +++ b/src/dev/project_executor.rb @@ -0,0 +1,72 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "command" +require_relative "command_runner" +require_relative "execution_context" + +module Dev + # The process boundary for project commands: the only class that owns the + # CommandRunner/Kernel seam. The two child-process shapes are two precise + # messages rather than a wait: flag — exec_into hands the process over to + # the child (never returns), run_waiting spawns, waits, and raises on + # child failure. Callers choose by sending the message they mean. + class ProjectExecutor + extend T::Sig + + # Raised when an exec-mode run returns control to dev. Kernel.exec + # either replaces the process or raises (e.g. Errno::ENOENT), so a + # normal return can only mean the exec boundary was faked out — this + # keeps exec_into's never-returns contract honest even then. + class ExecReturnedError < StandardError; end + + # Hand the process over to the project command: exec-replace, the right + # shape for a leaf command (TTY and signal passthrough, no double + # process tree). The child's exit status becomes the process's own. + # + # @param command [ProjectCommand] + # @param args [Array] argv after the command name + # @param context [ExecutionContext] + # @return [void] never returns + # @raise [ExecReturnedError] if the exec boundary returns control + sig { params(command: ProjectCommand, args: T::Array[String], context: ExecutionContext).returns(T.noreturn) } + def exec_into(command, args:, context:) + command_runner(context, wait: false).run(command, args:) + raise ExecReturnedError, "exec-mode CommandRunner returned instead of replacing the process" + end + + # Run the project command spawn-and-wait, so control returns to the + # caller's success-contingent post-steps (e.g. the installed stamp). + # + # @param command [ProjectCommand] + # @param args [Array] argv after the command name + # @param context [ExecutionContext] + # @return [void] + # @raise [CommandRunner::CommandFailedError] when the child fails, + # carrying its exit status + sig { params(command: ProjectCommand, args: T::Array[String], context: ExecutionContext).void } + def run_waiting(command, args:, context:) + command_runner(context, wait: true).run(command, args:) + end + + private + + # Assemble the CommandRunner for one run. Built per call because every + # collaborator it needs arrives with the per-call ExecutionContext. + # + # @param context [ExecutionContext] + # @param wait [Boolean] + # @return [CommandRunner] + sig { params(context: ExecutionContext, wait: T::Boolean).returns(CommandRunner) } + def command_runner(context, wait:) + CommandRunner.new( + ui: context.ui, + ruby_version: context.ruby_version, + python_version: context.python_version, + build_container: context.build_container, + project_root: context.project_root, + wait: wait, + ) + end + end +end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index d3fe3c2..d938528 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -3,6 +3,7 @@ require "pathname" require "stringio" +require "dev/builtin_executor" require "dev/builtins" require "dev/cli" require "dev/command" @@ -14,6 +15,8 @@ require "dev/dependency_service" require "dev/deps/staleness" require "dev/execution_context" +require "dev/overridden_executor" +require "dev/project_executor" require "dev/project_manifest" require "dev/project_manifest_loader" require "shadowenv_ruby" @@ -122,11 +125,27 @@ def build_command_service(manifest) builtins: build_builtins(manifest, dependency_service), project_commands: manifest.commands, ), - executor: CommandExecutor.new, + executor: build_executor, dependency_service: dependency_service, ) end + # Wire the executor composite: one BuiltinExecutor and one + # ProjectExecutor, shared with the OverriddenExecutor that composes + # them for the virtual-dispatch arm. + # + # @return [CommandExecutor] + sig { returns(CommandExecutor) } + def build_executor + builtin_executor = BuiltinExecutor.new + project_executor = ProjectExecutor.new + CommandExecutor.new( + builtin_executor:, + project_executor:, + overridden_executor: OverriddenExecutor.new(builtin_executor:, project_executor:), + ) + end + # @param manifest [ProjectManifest] # @param dependency_service [DependencyService] # @return [Hash{String => BuiltinCommand}] the builtin set, in listing order diff --git a/test/dev/builtin_executor_test.rb b/test/dev/builtin_executor_test.rb new file mode 100644 index 0000000..6a90c21 --- /dev/null +++ b/test/dev/builtin_executor_test.rb @@ -0,0 +1,47 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/builtin_executor" +require "dev/command" +require "pathname" + +# Builtin fake whose body records its invocations, so the test can assert +# the in-process delegation passed args and context through untouched. +class BuiltinExecutorFakeBuiltin < Dev::BuiltinCommand + attr_reader :calls + + def initialize + @calls = [] + super() + end + + def desc = "a builtin" + + def call(args:, context:) + @calls << [args, context] + end +end unless defined?(BuiltinExecutorFakeBuiltin) + +transform!(RSpock::AST::Transformation) +class Dev::BuiltinExecutorTest < Minitest::Test + include SorbetHelper + + def build_context + ui = typed_mock(Dev::Cli::Ui) + Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/builtin-executor")) + end + + test "execute runs the builtin's Ruby body in-process with args and context" do + Given "a builtin fake and the executor" + builtin = BuiltinExecutorFakeBuiltin.new + executor = Dev::BuiltinExecutor.new + context = build_context + + When "executing" + executor.execute(builtin, args: ["--verbose"], context: context) + + Then "the body received args and context; no child process was involved" + builtin.calls == [[["--verbose"], context]] + end +end diff --git a/test/dev/command_executor_test.rb b/test/dev/command_executor_test.rb index cc01e1e..fd13ff0 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -4,189 +4,84 @@ require "test_helper" require "dev/command_executor" require "dev/command" -require "shadowenv_ruby" -require "fileutils" require "pathname" -require "tmpdir" - -# Builtin fake for dispatch-order assertions; traits configurable so the -# override path can exercise both wait shapes. -class ExecutorFakeBuiltin < Dev::BuiltinCommand - attr_reader :calls - - def initialize(stamps: false, &body) - @stamps = stamps - @calls = [] - @body = body - super() - end +# Minimal builtin fake: the composite only dispatches on the variant's +# type, so the fake carries no behavior — the strategy mock receives it +# untouched. +class DispatchFakeBuiltin < Dev::BuiltinCommand def desc = "a builtin" - def stamps? = @stamps - - def call(args:, context:) - @calls << [args, context] - @body&.call - end -end unless defined?(ExecutorFakeBuiltin) + def call(args:, context:); end +end unless defined?(DispatchFakeBuiltin) transform!(RSpock::AST::Transformation) class Dev::CommandExecutorTest < Minitest::Test include SorbetHelper - def build_context(project_root) + def build_context ui = typed_mock(Dev::Cli::Ui) - ui.stubs(:print_header) - Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: project_root) + Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/executor-test")) end - test "a builtin command runs its Ruby body in-process" do - Given "a builtin and an executor" - builtin = ExecutorFakeBuiltin.new - executor = Dev::CommandExecutor.new - root = Pathname.new(Dir.mktmpdir("executor-builtin-")) - context = build_context(root) - - When "executing" - executor.execute(builtin, args: ["--verbose"], context: context) - - Then "the body received args and context; no child process was involved" - builtin.calls == [[["--verbose"], context]] - - Cleanup - FileUtils.rm_rf(root) + # Strategy mocks are strict: any message a test doesn't expect is an + # unexpected invocation, so each arm proves the other two stayed silent. + def build_strategies + { + builtin_executor: typed_mock(Dev::BuiltinExecutor), + project_executor: typed_mock(Dev::ProjectExecutor), + overridden_executor: typed_mock(Dev::OverriddenExecutor), + } end - test "a project command keeps the exec tail-call" do - Given "a plain project command, pinned to an empty project root" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("executor-exec-tail-")) - command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) - executor = Dev::CommandExecutor.new - # Provisioning's shell-out boundary; llvm/python provisioning no-op on - # an empty project root. - ShadowenvRuby.stubs(:ensure!) - - When "executing a non-stamping command" - executor.execute(command, args: [], context: build_context(root)) + test "a builtin command dispatches to the builtin strategy with exact args" do + Given "a composite whose builtin strategy expects the dispatch" + command = DispatchFakeBuiltin.new + context = build_context + strategies = build_strategies + strategies.fetch(:builtin_executor) + .expects(:execute).with(command, args: ["--verbose"], context: context).once + executor = Dev::CommandExecutor.new(**strategies) - Then "the command exec-replaces the process, never spawn-and-wait" - 1 * Kernel.exec(anything, "shadowenv", "exec", "--", "sh", "-c", includes("./bin/test.sh")) - 0 * Kernel.system(any_parameters) + When "executing" + executor.execute(command, args: ["--verbose"], context: context) - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) + Then "the expectation held and no other strategy was consulted" + true end - # Regression for dev#85: a project-defined `up:` used to exec-replace the - # dev process, so the installed stamp after execute was never reached and - # the staleness gate reported "never installed" forever. - test "an overridden stamping slot runs the builtin first, then the project script spawn-and-wait" do - Given "a stamping builtin slot overridden by a project up:, pinned to an empty project root" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("executor-up-wait-")) - execution_order = [] - builtin = ExecutorFakeBuiltin.new(stamps: true) { execution_order << :builtin_install } - command = Dev::OverriddenCommand.new( - builtin: builtin, - project: Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false), - ) - executor = Dev::CommandExecutor.new - ShadowenvRuby.stubs(:ensure!) - # The project script's execution boundary is Kernel.system (wait mode). - Kernel.stubs(:system).with { - execution_order << :project_script - true }.returns(true) - - When "executing the overridden command" - executor.execute(command, args: [], context: build_context(root)) + test "a project command dispatches to the project strategy's exec tail-call" do + Given "a composite whose project strategy expects exec_into" + command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) + context = build_context + strategies = build_strategies + strategies.fetch(:project_executor) + .expects(:exec_into).with(command, args: ["--fast", "spec/a"], context: context).once + executor = Dev::CommandExecutor.new(**strategies) - Then "builtin super() ran first, and the script was a waited child, never exec-replace" - execution_order == [:builtin_install, :project_script] - 0 * Kernel.exec(any_parameters) + When "executing" + executor.execute(command, args: ["--fast", "spec/a"], context: context) - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) + Then "the expectation held and no other strategy was consulted" + true end - test "a failing waited project script raises CommandFailedError with the child's status" do - Given "an overridden stamping slot whose script exits 7" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("executor-up-fail-")) + test "an overridden command dispatches to the overridden strategy" do + Given "a composite whose overridden strategy expects the dispatch" command = Dev::OverriddenCommand.new( - builtin: ExecutorFakeBuiltin.new(stamps: true), + builtin: DispatchFakeBuiltin.new, project: Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false), ) - executor = Dev::CommandExecutor.new - ShadowenvRuby.stubs(:ensure!) - Kernel.stubs(:system).returns(false) - # Kernel.system is stubbed, so wait on a real child here to leave the - # thread-local $? at exit status 7 — what a real failed child would set. - Process.wait(Process.spawn("sh", "-c", "exit 7")) - # Guard: a regression to exec-replace would otherwise replace the test - # process itself (Kernel.system above is stubbed, Kernel.exec is real). - Kernel.expects(:exec).never - - When "executing the overridden command" - error = nil - begin - executor.execute(command, args: [], context: build_context(root)) - rescue Dev::CommandRunner::CommandFailedError => e - error = e - end - - Then "the child's exit status rides the error" - error.exit_status == 7 - - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) - end - - test "an overridden non-stamping slot keeps the exec tail-call for the project half" do - Given "a non-stamping builtin slot overridden by a project command" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("executor-override-exec-")) - builtin = ExecutorFakeBuiltin.new(stamps: false) - command = Dev::OverriddenCommand.new( - builtin: builtin, - project: Dev::ProjectCommand.new(run: "./bin/lint.sh", desc: "Lint", container: false), - ) - executor = Dev::CommandExecutor.new - ShadowenvRuby.stubs(:ensure!) + context = build_context + strategies = build_strategies + strategies.fetch(:overridden_executor) + .expects(:execute).with(command, args: [], context: context).once + executor = Dev::CommandExecutor.new(**strategies) When "executing" - executor.execute(command, args: [], context: build_context(root)) - - Then "nothing sequences after execute, so the exec tail-call is safe and kept" - builtin.calls.size == 1 - 1 * Kernel.exec(anything, "shadowenv", "exec", "--", "sh", "-c", includes("./bin/lint.sh")) - 0 * Kernel.system(any_parameters) - - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) - end - - test "a project command forwards its args into the child's shell command" do - Given "a project command executed with args" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("executor-args-")) - command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) - executor = Dev::CommandExecutor.new - ShadowenvRuby.stubs(:ensure!) - - When "executing with args" - executor.execute(command, args: ["--fast", "spec/a"], context: build_context(root)) - - Then "the args are shell-joined onto the run string" - 1 * Kernel.exec(anything, "shadowenv", "exec", "--", "sh", "-c", includes("./bin/test.sh --fast spec/a")) + executor.execute(command, args: [], context: context) - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) + Then "the expectation held and no other strategy was consulted" + true end end diff --git a/test/dev/command_service_test.rb b/test/dev/command_service_test.rb index 1df5aa9..6fd12d0 100644 --- a/test/dev/command_service_test.rb +++ b/test/dev/command_service_test.rb @@ -36,7 +36,7 @@ class Dev::CommandServiceTest < Minitest::Test CommandRepositoryClass = Dev.const_get(:CommandRepository) CommandNotFoundErrorClass = CommandRepositoryClass.const_get(:CommandNotFoundError) - def build_service(builtins:, dependency_service:, executor: Dev::CommandExecutor.new) + def build_service(builtins:, dependency_service:, executor: build_executor) Dev::CommandService.new( repository: CommandRepositoryClass.new(builtins: builtins, project_commands: {}), executor: executor, @@ -44,6 +44,19 @@ def build_service(builtins:, dependency_service:, executor: Dev::CommandExecutor ) end + # A real composite (these tests only dispatch builtins, in-process). + def build_executor + builtin_executor = Dev::BuiltinExecutor.new + project_executor = Dev::ProjectExecutor.new + Dev::CommandExecutor.new( + builtin_executor: builtin_executor, + project_executor: project_executor, + overridden_executor: Dev::OverriddenExecutor.new( + builtin_executor: builtin_executor, project_executor: project_executor, + ), + ) + end + def fake_context Dev::ExecutionContext.new( ui: typed_mock(Dev::Cli::Ui), diff --git a/test/dev/overridden_executor_test.rb b/test/dev/overridden_executor_test.rb new file mode 100644 index 0000000..bfd7a84 --- /dev/null +++ b/test/dev/overridden_executor_test.rb @@ -0,0 +1,96 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/overridden_executor" +require "dev/command" +require "pathname" + +# Builtin fake whose stamping trait drives the tail-message choice (the +# OverriddenCommand delegates stamps? to its builtin slot). +class OverriddenExecutorFakeBuiltin < Dev::BuiltinCommand + def initialize(stamps:) + @stamps = stamps + super() + end + + def desc = "a builtin" + + def stamps? = @stamps + + def call(args:, context:); end +end unless defined?(OverriddenExecutorFakeBuiltin) + +transform!(RSpock::AST::Transformation) +class Dev::OverriddenExecutorTest < Minitest::Test + include SorbetHelper + + def build_context + ui = typed_mock(Dev::Cli::Ui) + Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/overridden-executor")) + end + + def build_command(stamps:) + Dev::OverriddenCommand.new( + builtin: OverriddenExecutorFakeBuiltin.new(stamps: stamps), + project: Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false), + ) + end + + # Recording strategy mocks: stages captures every dispatch in order, and + # any message a test doesn't stub is an unexpected invocation — so each + # test also proves the other tail message was never sent. + def build_strategies(stages) + builtin_executor = typed_mock(Dev::BuiltinExecutor) + builtin_executor.stubs(:execute).with { |cmd, args:, context:| + stages << [:builtin_stage, cmd, args, context] + true } + project_executor = typed_mock(Dev::ProjectExecutor) + { builtin_executor: builtin_executor, project_executor: project_executor } + end + + # Regression lineage of dev#85: a stamping slot's project tail must be + # the waiting message, so the caller's installed stamp after execute + # stays reachable. + test "a stamping slot runs the builtin stage first, then the waiting project tail" do + Given "a stamping overridden slot over recording strategies" + command = build_command(stamps: true) + context = build_context + stages = [] + strategies = build_strategies(stages) + strategies.fetch(:project_executor).stubs(:run_waiting).with { |cmd, args:, context:| + stages << [:project_tail, cmd, args, context] + true } + executor = Dev::OverriddenExecutor.new(**strategies) + + When "executing the overridden command" + executor.execute(command, args: ["--fast"], context: context) + + Then "builtin super() ran first, and the tail was run_waiting with the exact project half" + stages == [ + [:builtin_stage, command.builtin, ["--fast"], context], + [:project_tail, command.project, ["--fast"], context], + ] + end + + test "a non-stamping slot runs the builtin stage first, then the exec tail-call" do + Given "a non-stamping overridden slot over recording strategies" + command = build_command(stamps: false) + context = build_context + stages = [] + strategies = build_strategies(stages) + strategies.fetch(:project_executor).stubs(:exec_into).with { |cmd, args:, context:| + stages << [:project_tail, cmd, args, context] + true } + executor = Dev::OverriddenExecutor.new(**strategies) + + When "executing the overridden command" + executor.execute(command, args: [], context: context) + + Then "builtin super() ran first, and the tail was exec_into with the exact project half" + stages == [ + [:builtin_stage, command.builtin, [], context], + [:project_tail, command.project, [], context], + ] + end +end diff --git a/test/dev/project_executor_test.rb b/test/dev/project_executor_test.rb new file mode 100644 index 0000000..2697bd4 --- /dev/null +++ b/test/dev/project_executor_test.rb @@ -0,0 +1,154 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/project_executor" +require "dev/command" +require "shadowenv_ruby" +require "fileutils" +require "pathname" +require "tmpdir" + +# The one suite that exercises the Kernel.exec / Kernel.system process +# boundary: exec_into's tail-call shape, run_waiting's spawn-and-wait +# shape, and the failure paths of each. +transform!(RSpock::AST::Transformation) +class Dev::ProjectExecutorTest < Minitest::Test + include SorbetHelper + + def build_context(project_root) + ui = typed_mock(Dev::Cli::Ui) + ui.stubs(:print_header) + Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: project_root) + end + + test "exec_into keeps the exec tail-call, never spawn-and-wait" do + Given "a project command, pinned to an empty project root" + original_cwd = Dir.pwd + root = Pathname.new(Dir.mktmpdir("project-executor-exec-")) + command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) + executor = Dev::ProjectExecutor.new + # Provisioning's shell-out boundary; llvm/python provisioning no-op on + # an empty project root. + ShadowenvRuby.stubs(:ensure!) + + When "exec_into runs (the stubbed exec returns, so the honesty guard raises)" + error = nil + begin + executor.exec_into(command, args: [], context: build_context(root)) + rescue Dev::ProjectExecutor::ExecReturnedError => e + error = e + end + + Then "the command exec-replaced the process, never spawn-and-wait" + 1 * Kernel.exec(anything, "shadowenv", "exec", "--", "sh", "-c", includes("./bin/test.sh")) + 0 * Kernel.system(any_parameters) + !error.nil? + + Cleanup + Dir.chdir(original_cwd) + FileUtils.rm_rf(root) + end + + test "exec_into forwards its args into the child's shell command" do + Given "a project command executed with args" + original_cwd = Dir.pwd + root = Pathname.new(Dir.mktmpdir("project-executor-args-")) + command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) + executor = Dev::ProjectExecutor.new + ShadowenvRuby.stubs(:ensure!) + + When "exec_into runs with args" + begin + executor.exec_into(command, args: ["--fast", "spec/a"], context: build_context(root)) + rescue Dev::ProjectExecutor::ExecReturnedError + # Expected under a stubbed exec boundary; the argv assertion below is + # the point of this test. + end + + Then "the args are shell-joined onto the run string" + 1 * Kernel.exec(anything, "shadowenv", "exec", "--", "sh", "-c", includes("./bin/test.sh --fast spec/a")) + + Cleanup + Dir.chdir(original_cwd) + FileUtils.rm_rf(root) + end + + test "a faked-out exec boundary raises ExecReturnedError" do + Given "an exec boundary that returns control instead of replacing the process" + original_cwd = Dir.pwd + root = Pathname.new(Dir.mktmpdir("project-executor-exec-returned-")) + command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) + executor = Dev::ProjectExecutor.new + ShadowenvRuby.stubs(:ensure!) + Kernel.stubs(:exec) + + When "exec_into runs" + executor.exec_into(command, args: [], context: build_context(root)) + + Then "the never-returns contract raises" + raises Dev::ProjectExecutor::ExecReturnedError + + Cleanup + Dir.chdir(original_cwd) + FileUtils.rm_rf(root) + end + + test "run_waiting spawns and waits, returning control on success" do + Given "a project command over a succeeding child, pinned to an empty project root" + original_cwd = Dir.pwd + root = Pathname.new(Dir.mktmpdir("project-executor-wait-")) + command = Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false) + executor = Dev::ProjectExecutor.new + ShadowenvRuby.stubs(:ensure!) + # The wait-mode execution boundary is Kernel.system; record its argv. + child_argvs = [] + Kernel.stubs(:system).with { |*argv| + child_argvs << argv + true }.returns(true) + + When "run_waiting runs" + executor.run_waiting(command, args: [], context: build_context(root)) + + Then "the child was a waited spawn of the shell wrapper, never exec-replace" + child_argvs.size == 1 + child_argvs.fetch(0)[1..5] == ["shadowenv", "exec", "--", "sh", "-c"] + child_argvs.fetch(0).fetch(6).include?("./bin/up.rb") + 0 * Kernel.exec(any_parameters) + + Cleanup + Dir.chdir(original_cwd) + FileUtils.rm_rf(root) + end + + test "a failing waited child raises CommandFailedError with the child's status" do + Given "a project command whose child exits 7" + original_cwd = Dir.pwd + root = Pathname.new(Dir.mktmpdir("project-executor-wait-fail-")) + command = Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false) + executor = Dev::ProjectExecutor.new + ShadowenvRuby.stubs(:ensure!) + Kernel.stubs(:system).returns(false) + # Kernel.system is stubbed, so wait on a real child here to leave the + # thread-local $? at exit status 7 — what a real failed child would set. + Process.wait(Process.spawn("sh", "-c", "exit 7")) + # Guard: a regression to exec-replace would otherwise replace the test + # process itself (Kernel.system above is stubbed, Kernel.exec is real). + Kernel.expects(:exec).never + + When "run_waiting runs" + error = nil + begin + executor.run_waiting(command, args: [], context: build_context(root)) + rescue Dev::CommandRunner::CommandFailedError => e + error = e + end + + Then "the child's exit status rides the error" + error.exit_status == 7 + + Cleanup + Dir.chdir(original_cwd) + FileUtils.rm_rf(root) + end +end From 2e05625698a25d76cb495ef3919d267153a7d1c8 Mon Sep 17 00:00:00 2001 From: "d3mlabs-ai-flow[bot]" <305891656+d3mlabs-ai-flow[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:36:36 -0400 Subject: [PATCH 2/7] ai-flow /build: let's fix the fake classes, put them within the test class Co-authored-by: JPDuchesne <2636122+JPDuchesne@users.noreply.github.com> --- test/dev/builtin_executor_test.rb | 36 +++++++++--------- test/dev/command_executor_test.rb | 22 +++++------ test/dev/command_service_test.rb | 56 ++++++++++++++-------------- test/dev/overridden_executor_test.rb | 30 +++++++-------- 4 files changed, 72 insertions(+), 72 deletions(-) diff --git a/test/dev/builtin_executor_test.rb b/test/dev/builtin_executor_test.rb index 6a90c21..d7a6ae6 100644 --- a/test/dev/builtin_executor_test.rb +++ b/test/dev/builtin_executor_test.rb @@ -6,27 +6,27 @@ require "dev/command" require "pathname" -# Builtin fake whose body records its invocations, so the test can assert -# the in-process delegation passed args and context through untouched. -class BuiltinExecutorFakeBuiltin < Dev::BuiltinCommand - attr_reader :calls - - def initialize - @calls = [] - super() - end - - def desc = "a builtin" - - def call(args:, context:) - @calls << [args, context] - end -end unless defined?(BuiltinExecutorFakeBuiltin) - transform!(RSpock::AST::Transformation) class Dev::BuiltinExecutorTest < Minitest::Test include SorbetHelper + # Builtin fake whose body records its invocations, so the test can assert + # the in-process delegation passed args and context through untouched. + class FakeBuiltin < Dev::BuiltinCommand + attr_reader :calls + + def initialize + @calls = [] + super() + end + + def desc = "a builtin" + + def call(args:, context:) + @calls << [args, context] + end + end + def build_context ui = typed_mock(Dev::Cli::Ui) Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/builtin-executor")) @@ -34,7 +34,7 @@ def build_context test "execute runs the builtin's Ruby body in-process with args and context" do Given "a builtin fake and the executor" - builtin = BuiltinExecutorFakeBuiltin.new + builtin = FakeBuiltin.new executor = Dev::BuiltinExecutor.new context = build_context diff --git a/test/dev/command_executor_test.rb b/test/dev/command_executor_test.rb index fd13ff0..c43eb3e 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -6,19 +6,19 @@ require "dev/command" require "pathname" -# Minimal builtin fake: the composite only dispatches on the variant's -# type, so the fake carries no behavior — the strategy mock receives it -# untouched. -class DispatchFakeBuiltin < Dev::BuiltinCommand - def desc = "a builtin" - - def call(args:, context:); end -end unless defined?(DispatchFakeBuiltin) - transform!(RSpock::AST::Transformation) class Dev::CommandExecutorTest < Minitest::Test include SorbetHelper + # Minimal builtin fake: the composite only dispatches on the variant's + # type, so the fake carries no behavior — the strategy mock receives it + # untouched. + class FakeBuiltin < Dev::BuiltinCommand + def desc = "a builtin" + + def call(args:, context:); end + end + def build_context ui = typed_mock(Dev::Cli::Ui) Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: Pathname.new("/tmp/executor-test")) @@ -36,7 +36,7 @@ def build_strategies test "a builtin command dispatches to the builtin strategy with exact args" do Given "a composite whose builtin strategy expects the dispatch" - command = DispatchFakeBuiltin.new + command = FakeBuiltin.new context = build_context strategies = build_strategies strategies.fetch(:builtin_executor) @@ -69,7 +69,7 @@ def build_strategies test "an overridden command dispatches to the overridden strategy" do Given "a composite whose overridden strategy expects the dispatch" command = Dev::OverriddenCommand.new( - builtin: DispatchFakeBuiltin.new, + builtin: FakeBuiltin.new, project: Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false), ) context = build_context diff --git a/test/dev/command_service_test.rb b/test/dev/command_service_test.rb index 752e119..f970169 100644 --- a/test/dev/command_service_test.rb +++ b/test/dev/command_service_test.rb @@ -6,32 +6,32 @@ require "dev/command" require "pathname" -# Builtin fake whose traits drive the service's guard/stamp decisions and -# whose call records the dispatch. -class ServiceFakeBuiltin < Dev::BuiltinCommand - attr_reader :calls - - def initialize(staleness_exempt: false, stamps: false) - super() - @staleness_exempt = staleness_exempt - @stamps = stamps - @calls = [] - end +transform!(RSpock::AST::Transformation) +class Dev::CommandServiceTest < Minitest::Test + include SorbetHelper - def desc = "a builtin" + # Builtin fake whose traits drive the service's guard/stamp decisions and + # whose call records the dispatch. + class FakeBuiltin < Dev::BuiltinCommand + attr_reader :calls - def staleness_exempt? = @staleness_exempt + def initialize(staleness_exempt: false, stamps: false) + super() + @staleness_exempt = staleness_exempt + @stamps = stamps + @calls = [] + end - def stamps? = @stamps + def desc = "a builtin" - def call(args:, context:) - @calls << [args, context] - end -end unless defined?(ServiceFakeBuiltin) + def staleness_exempt? = @staleness_exempt -transform!(RSpock::AST::Transformation) -class Dev::CommandServiceTest < Minitest::Test - include SorbetHelper + def stamps? = @stamps + + def call(args:, context:) + @calls << [args, context] + end + end def build_service(builtins:, dependency_service:, executor: build_executor) Dev::CommandService.new( @@ -71,7 +71,7 @@ def fake_dependency_service test "execute fetches the command, dispatches it, and passes args and context through" do Given "a service over one builtin" - builtin = ServiceFakeBuiltin.new(staleness_exempt: true) + builtin = FakeBuiltin.new(staleness_exempt: true) service = build_service(builtins: { "deps" => builtin }, dependency_service: fake_dependency_service) context = fake_context @@ -98,7 +98,7 @@ def fake_dependency_service dependency_service = typed_mock(Dev::DependencyService) dependency_service.expects(:guard!).once service = build_service( - builtins: { "build" => ServiceFakeBuiltin.new(staleness_exempt: false) }, + builtins: { "build" => FakeBuiltin.new(staleness_exempt: false) }, dependency_service: dependency_service, ) @@ -115,7 +115,7 @@ def fake_dependency_service dependency_service.expects(:guard!).never dependency_service.stubs(:lock!) service = build_service( - builtins: { "update-deps" => ServiceFakeBuiltin.new(staleness_exempt: true) }, + builtins: { "update-deps" => FakeBuiltin.new(staleness_exempt: true) }, dependency_service: dependency_service, ) @@ -132,7 +132,7 @@ def fake_dependency_service dependency_service.stubs(:guard!) dependency_service.expects(:lock!).once service = build_service( - builtins: { "install-deps" => ServiceFakeBuiltin.new(staleness_exempt: true, stamps: true) }, + builtins: { "install-deps" => FakeBuiltin.new(staleness_exempt: true, stamps: true) }, dependency_service: dependency_service, ) @@ -149,7 +149,7 @@ def fake_dependency_service dependency_service.stubs(:guard!) dependency_service.expects(:lock!).never service = build_service( - builtins: { "deps" => ServiceFakeBuiltin.new }, + builtins: { "deps" => FakeBuiltin.new }, dependency_service: dependency_service, ) @@ -168,7 +168,7 @@ def fake_dependency_service executor = typed_mock(Dev::CommandExecutor) executor.stubs(:execute).raises(Dev::CommandRunner::CommandFailedError.new(exit_status: 7)) service = build_service( - builtins: { "up" => ServiceFakeBuiltin.new(staleness_exempt: true, stamps: true) }, + builtins: { "up" => FakeBuiltin.new(staleness_exempt: true, stamps: true) }, dependency_service: dependency_service, executor: executor, ) @@ -182,7 +182,7 @@ def fake_dependency_service test "visible_commands serves the repository's usage view" do Given "a service over one visible builtin" - builtin = ServiceFakeBuiltin.new + builtin = FakeBuiltin.new service = build_service(builtins: { "deps" => builtin }, dependency_service: fake_dependency_service) Expect "the usage view flows through the service (the onion rule)" diff --git a/test/dev/overridden_executor_test.rb b/test/dev/overridden_executor_test.rb index bfd7a84..16f2311 100644 --- a/test/dev/overridden_executor_test.rb +++ b/test/dev/overridden_executor_test.rb @@ -6,24 +6,24 @@ require "dev/command" require "pathname" -# Builtin fake whose stamping trait drives the tail-message choice (the -# OverriddenCommand delegates stamps? to its builtin slot). -class OverriddenExecutorFakeBuiltin < Dev::BuiltinCommand - def initialize(stamps:) - @stamps = stamps - super() - end +transform!(RSpock::AST::Transformation) +class Dev::OverriddenExecutorTest < Minitest::Test + include SorbetHelper - def desc = "a builtin" + # Builtin fake whose stamping trait drives the tail-message choice (the + # OverriddenCommand delegates stamps? to its builtin slot). + class FakeBuiltin < Dev::BuiltinCommand + def initialize(stamps:) + @stamps = stamps + super() + end - def stamps? = @stamps + def desc = "a builtin" - def call(args:, context:); end -end unless defined?(OverriddenExecutorFakeBuiltin) + def stamps? = @stamps -transform!(RSpock::AST::Transformation) -class Dev::OverriddenExecutorTest < Minitest::Test - include SorbetHelper + def call(args:, context:); end + end def build_context ui = typed_mock(Dev::Cli::Ui) @@ -32,7 +32,7 @@ def build_context def build_command(stamps:) Dev::OverriddenCommand.new( - builtin: OverriddenExecutorFakeBuiltin.new(stamps: stamps), + builtin: FakeBuiltin.new(stamps: stamps), project: Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false), ) end From c7ae57a6d6a198677a61003962a900210111f886 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 19 Aug 2026 10:16:18 -0400 Subject: [PATCH 3/7] Add Category trait to the Command hierarchy Every command declares which usage section it lists under (Lifecycle, Development flow, or project commands). Abstract rather than defaulted so no builtin lands in a section silently; OverriddenCommand takes the slot's category, consistent with its guard/stamp trait delegation. Co-authored-by: Cursor --- src/dev/builtins/cache_command.rb | 3 +++ src/dev/builtins/cd_command.rb | 3 +++ src/dev/builtins/check_command.rb | 3 +++ src/dev/builtins/clone_command.rb | 3 +++ src/dev/builtins/cred_command.rb | 3 +++ src/dev/builtins/deps_command.rb | 3 +++ src/dev/builtins/install_deps_command.rb | 3 +++ src/dev/builtins/learnings_command.rb | 3 +++ src/dev/builtins/plan_command.rb | 3 +++ src/dev/builtins/provide_image_command.rb | 3 +++ src/dev/builtins/reset_container_command.rb | 3 +++ src/dev/builtins/runner_setup_command.rb | 3 +++ src/dev/builtins/up_command.rb | 3 +++ src/dev/builtins/update_deps_command.rb | 3 +++ src/dev/command.rb | 26 +++++++++++++++++++++ test/dev/command_test.rb | 24 +++++++++++++++++-- 16 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/dev/builtins/cache_command.rb b/src/dev/builtins/cache_command.rb index 8925dca..553b0fa 100644 --- a/src/dev/builtins/cache_command.rb +++ b/src/dev/builtins/cache_command.rb @@ -32,6 +32,9 @@ def initialize( sig { override.returns(String) } def desc = "Manage host caches (e.g. cache gc --keep 2)" + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) subcommand, *rest = args diff --git a/src/dev/builtins/cd_command.rb b/src/dev/builtins/cd_command.rb index 3325d9d..0cf8207 100644 --- a/src/dev/builtins/cd_command.rb +++ b/src/dev/builtins/cd_command.rb @@ -21,6 +21,9 @@ def initialize(accessor: Dev::Cd::Accessor.new) sig { override.returns(String) } def desc = "Jump to a checkout under $DEV_CD_ROOT (default ~/src) by fuzzy name" + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) @accessor.run(args) diff --git a/src/dev/builtins/check_command.rb b/src/dev/builtins/check_command.rb index 7209424..0f43abe 100644 --- a/src/dev/builtins/check_command.rb +++ b/src/dev/builtins/check_command.rb @@ -20,6 +20,9 @@ def initialize(dependency_service:) sig { override.returns(String) } def desc = "Check dependency state freshness (manifest vs lockfiles vs installed)" + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + # check IS the explicit staleness inspection — guarding before it # would report the same thing twice. sig { override.returns(T::Boolean) } diff --git a/src/dev/builtins/clone_command.rb b/src/dev/builtins/clone_command.rb index 5e41dcf..713ad85 100644 --- a/src/dev/builtins/clone_command.rb +++ b/src/dev/builtins/clone_command.rb @@ -21,6 +21,9 @@ def initialize(accessor: Dev::Clone::Accessor.new) sig { override.returns(String) } def desc = "Clone a GitHub repo (via gh auth) into $DEV_CD_ROOT (default ~/src), org defaults to d3mlabs" + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) @accessor.run(args) diff --git a/src/dev/builtins/cred_command.rb b/src/dev/builtins/cred_command.rb index 9449112..e419a19 100644 --- a/src/dev/builtins/cred_command.rb +++ b/src/dev/builtins/cred_command.rb @@ -21,6 +21,9 @@ def initialize(accessor: Dev::CredentialAccessor.new) sig { override.returns(String) } def desc = "Resolve a stored credential (e.g. cred get )" + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) @accessor.run(args) diff --git a/src/dev/builtins/deps_command.rb b/src/dev/builtins/deps_command.rb index 766af33..84a1ca4 100644 --- a/src/dev/builtins/deps_command.rb +++ b/src/dev/builtins/deps_command.rb @@ -36,6 +36,9 @@ def initialize( sig { override.returns(String) } def desc = "Inspect locked dependencies (e.g. deps path ficsit )" + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) @accessor_factory.call(context.project_root).run(args) diff --git a/src/dev/builtins/install_deps_command.rb b/src/dev/builtins/install_deps_command.rb index f101969..34143bc 100644 --- a/src/dev/builtins/install_deps_command.rb +++ b/src/dev/builtins/install_deps_command.rb @@ -59,6 +59,9 @@ def initialize( sig { override.returns(String) } def desc = "Install locked dependencies handled on the host (e.g. gh releases)" + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + # install-deps IS the remediation for a stale install — never nag # before it. sig { override.returns(T::Boolean) } diff --git a/src/dev/builtins/learnings_command.rb b/src/dev/builtins/learnings_command.rb index ce8456a..6b35a0a 100644 --- a/src/dev/builtins/learnings_command.rb +++ b/src/dev/builtins/learnings_command.rb @@ -30,6 +30,9 @@ def desc "init: scaffold the index)" end + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) @accessor_factory.call(context.project_root).run(args) diff --git a/src/dev/builtins/plan_command.rb b/src/dev/builtins/plan_command.rb index 1327f73..41d4c9e 100644 --- a/src/dev/builtins/plan_command.rb +++ b/src/dev/builtins/plan_command.rb @@ -27,6 +27,9 @@ def initialize(accessor_factory: ->(project_root) { Dev::Plan::Accessor.new(proj sig { override.returns(String) } def desc = "Sync Cursor plans with GitHub issues (new/link/pull/push/status)" + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + # plan never touches dependencies and runs headlessly from Cursor # hooks, where a staleness warning would only add noise. sig { override.returns(T::Boolean) } diff --git a/src/dev/builtins/provide_image_command.rb b/src/dev/builtins/provide_image_command.rb index 5d1e09a..2f8f667 100644 --- a/src/dev/builtins/provide_image_command.rb +++ b/src/dev/builtins/provide_image_command.rb @@ -19,6 +19,9 @@ class ProvideImageCommand < BuiltinCommand sig { override.returns(String) } def desc = "Resolve the build container image (local/pull/build) and print its tag" + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + # Hidden: workflow plumbing, not a developer intent command. sig { override.returns(T::Boolean) } def hidden? = true diff --git a/src/dev/builtins/reset_container_command.rb b/src/dev/builtins/reset_container_command.rb index f36842f..26edd89 100644 --- a/src/dev/builtins/reset_container_command.rb +++ b/src/dev/builtins/reset_container_command.rb @@ -14,6 +14,9 @@ class ResetContainerCommand < BuiltinCommand sig { override.returns(String) } def desc = "Remove the persistent build container (clears its incremental cache)" + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) cfg = T.must(context.build_container) diff --git a/src/dev/builtins/runner_setup_command.rb b/src/dev/builtins/runner_setup_command.rb index 2d4df5d..6db1fbe 100644 --- a/src/dev/builtins/runner_setup_command.rb +++ b/src/dev/builtins/runner_setup_command.rb @@ -46,6 +46,9 @@ def desc "Register this host as a self-hosted GitHub Actions runner (repo-scoped, or org-wide with --org)" end + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + sig { override.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:) cfg = context.runner diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index 71b244b..dd6a1ab 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -31,6 +31,9 @@ def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new sig { override.returns(String) } def desc = "Install locked dependencies, then run the project's up command (if defined)" + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + # up IS the staleness remediation — never nag before it. sig { override.returns(T::Boolean) } def staleness_exempt? = true diff --git a/src/dev/builtins/update_deps_command.rb b/src/dev/builtins/update_deps_command.rb index 0127ae8..4edc18c 100644 --- a/src/dev/builtins/update_deps_command.rb +++ b/src/dev/builtins/update_deps_command.rb @@ -20,6 +20,9 @@ class UpdateDepsCommand < BuiltinCommand sig { override.returns(String) } def desc = "Resolve dependency constraints and write lockfiles" + sig { override.returns(Command::Category) } + def category = Command::Category::Lifecycle + # update-deps IS the remediation for a stale manifest — nagging before # it would block the very fix being run. sig { override.returns(T::Boolean) } diff --git a/src/dev/command.rb b/src/dev/command.rb index 9e5ae59..d4523c2 100644 --- a/src/dev/command.rb +++ b/src/dev/command.rb @@ -34,9 +34,27 @@ module Command abstract! sealed! + # The usage sections `dev --help` renders. Every command declares its + # group explicitly (the trait is abstract, not defaulted) so nothing + # lands in a section silently. + class Category < T::Enum + enums do + # Environment provisioning and dependency state (up, check, ...). + Lifecycle = new + # Day-to-day development tooling (cd, plan, help, ...). + Workflow = new + # Commands the project defines in dev.yml. + Project = new + end + end + sig { abstract.returns(String) } def desc; end + # The usage section this command lists under. + sig { abstract.returns(Category) } + def category; end + # Whether this command is callable but omitted from `dev`/`dev --help` # usage. Used for internal plumbing (e.g. build primitives) a project # keeps invocable without advertising it. Visible by default. @@ -117,6 +135,9 @@ def initialize(run:, desc: "(no description)", repl: false, container: true, hid sig(:final) { override.returns(T::Boolean) } def hidden? = @hidden + sig(:final) { override.returns(Category) } + def category = Category::Project + sig(:final) { params(other: Object).returns(T::Boolean) } def ==(other) return false unless other.is_a?(ProjectCommand) @@ -175,5 +196,10 @@ def staleness_exempt? = @builtin.staleness_exempt? sig(:final) { override.returns(T::Boolean) } def stamps? = @builtin.stamps? + + # The usage section belongs to the slot too: an overriding `up:` still + # lists under Lifecycle, with the project's description. + sig(:final) { override.returns(Category) } + def category = @builtin.category end end diff --git a/test/dev/command_test.rb b/test/dev/command_test.rb index 2ad5ef7..e85db1d 100644 --- a/test/dev/command_test.rb +++ b/test/dev/command_test.rb @@ -7,16 +7,18 @@ # A minimal builtin for exercising the trait defaults, the hierarchy's # open edge, and the OverriddenCommand composition. class FakeBuiltin < Dev::BuiltinCommand - def initialize(desc: "fake builtin", hidden: false, staleness_exempt: false, stamps: false, &body) + def initialize(desc: "fake builtin", hidden: false, staleness_exempt: false, stamps: false, + category: Dev::Command::Category::Workflow, &body) super() @desc = desc @hidden = hidden @staleness_exempt = staleness_exempt @stamps = stamps + @category = category @body = body end - attr_reader :desc + attr_reader :desc, :category def hidden? = @hidden @@ -170,6 +172,24 @@ def call(args:, context:); end cmd.stamps? end + test "a ProjectCommand's category is the project group" do + Given "a plain ProjectCommand" + cmd = Dev::ProjectCommand.new(run: "./bin/test.sh") + + Expect "it lists under the project commands section" + cmd.category == Dev::Command::Category::Project + end + + test "an OverriddenCommand takes its category from the builtin slot" do + Given "a lifecycle builtin slot overridden by a project command" + builtin = FakeBuiltin.new(category: Dev::Command::Category::Lifecycle) + project = Dev::ProjectCommand.new(run: "./bin/up.sh", desc: "project up") + cmd = Dev::OverriddenCommand.new(builtin: builtin, project: project) + + Expect "the slot's group holds: an overriding up: still lists under Lifecycle" + cmd.category == Dev::Command::Category::Lifecycle + end + test "an OverriddenCommand exposes its typed halves" do Given "an overridden command" builtin = FakeBuiltin.new From a78ba14679058bbca503b8b41c0107832eb1b8b8 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 19 Aug 2026 10:17:43 -0400 Subject: [PATCH 4/7] Add the help builtin dev help renders the usage listing as a first-class command. It lists the catalog that contains it, so the listing arrives as a commands provider resolved at call time; the composition root closes that self-reference. Staleness-exempt: help is how the remediation commands get discovered. Co-authored-by: Cursor --- src/dev/builtins.rb | 1 + src/dev/builtins/help_command.rb | 53 +++++++++++++++++++ test/dev/builtins/help_command_test.rb | 71 ++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 src/dev/builtins/help_command.rb create mode 100644 test/dev/builtins/help_command_test.rb diff --git a/src/dev/builtins.rb b/src/dev/builtins.rb index 0eb0ede..72ce454 100644 --- a/src/dev/builtins.rb +++ b/src/dev/builtins.rb @@ -17,6 +17,7 @@ module Builtins; end require_relative "builtins/clone_command" require_relative "builtins/cred_command" require_relative "builtins/deps_command" +require_relative "builtins/help_command" require_relative "builtins/install_deps_command" require_relative "builtins/learnings_command" require_relative "builtins/plan_command" diff --git a/src/dev/builtins/help_command.rb b/src/dev/builtins/help_command.rb new file mode 100644 index 0000000..52058df --- /dev/null +++ b/src/dev/builtins/help_command.rb @@ -0,0 +1,53 @@ +# typed: strict +# frozen_string_literal: true + +require "stringio" + +require "dev/cli/usage_printer" +require "dev/command" + +module Dev + module Builtins + # `dev help` (also routed from bare `dev`, `--help`, and `-h`): render + # the grouped usage listing. Help lists the very catalog that contains + # it, so the listing arrives as a provider resolved at call time — the + # composition root closes the self-reference, not this class. + class HelpCommand < BuiltinCommand + extend T::Sig + + CommandsProvider = T.type_alias { T.proc.returns(T::Hash[String, Command]) } + + sig do + params( + project_name: String, + usage_printer: Cli::UsagePrinter, + out: T.any(IO, StringIO), + commands_provider: CommandsProvider, + ).void + end + def initialize(project_name:, usage_printer:, out:, commands_provider:) + super() + @project_name = T.let(project_name, String) + @usage_printer = T.let(usage_printer, Cli::UsagePrinter) + @out = T.let(out, T.any(IO, StringIO)) + @commands_provider = T.let(commands_provider, CommandsProvider) + end + + sig { override.returns(String) } + def desc = "Show this usage" + + sig { override.returns(Command::Category) } + def category = Command::Category::Workflow + + # Help must work while the dependency state is stale — it is how the + # remediation commands get discovered in the first place. + sig { override.returns(T::Boolean) } + def staleness_exempt? = true + + sig { override.params(args: T::Array[String], context: ExecutionContext).void } + def call(args:, context:) + @usage_printer.print(project_name: @project_name, commands: @commands_provider.call, out: @out) + end + end + end +end diff --git a/test/dev/builtins/help_command_test.rb b/test/dev/builtins/help_command_test.rb new file mode 100644 index 0000000..8ffbeff --- /dev/null +++ b/test/dev/builtins/help_command_test.rb @@ -0,0 +1,71 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/builtins/help_command" +require "pathname" +require "stringio" + +transform!(RSpock::AST::Transformation) +class Dev::Builtins::HelpCommandTest < Minitest::Test + include SorbetHelper + + test "traits: visible, staleness-exempt (help must work while stale), never stamps, workflow group" do + Given "the builtin" + command = build_help + + Expect "the declarative traits" + command.hidden? == false + command.staleness_exempt? == true + command.stamps? == false + command.category == Dev::Command::Category::Workflow + command.desc == "Show this usage" + end + + test "call renders usage through the printer with the provider's listing" do + Given "a printer expecting the provider's commands" + commands = { "test" => Dev::ProjectCommand.new(run: "rspec", desc: "Run tests") } + out = StringIO.new + usage_printer = typed_mock(Dev::Cli::UsagePrinter) + usage_printer.expects(:print).with(project_name: "myproject", commands: commands, out: out).once + command = build_help( + project_name: "myproject", usage_printer: usage_printer, out: out, + commands_provider: -> { commands }, + ) + + When "running help" + command.call(args: [], context: build_context) + + Then "the printer expectation holds" + true + end + + test "the listing is consulted at call time, not construction time" do + Given "a provider over a catalog assigned only after help is constructed" + catalog = nil + printed = [] + usage_printer = typed_mock(Dev::Cli::UsagePrinter) + usage_printer.stubs(:print).with { |commands:, **| printed << commands } + command = build_help(usage_printer: usage_printer, commands_provider: -> { catalog }) + catalog = { "up" => Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup") } + + When "running help" + command.call(args: [], context: build_context) + + Then "the late-assigned catalog is what renders" + printed.fetch(0) == catalog + end + + private + + def build_help(project_name: "testproject", usage_printer: typed_mock(Dev::Cli::UsagePrinter), + out: StringIO.new, commands_provider: -> { {} }) + Dev::Builtins::HelpCommand.new(project_name:, usage_printer:, out:, commands_provider:) + end + + def build_context + Dev::ExecutionContext.new( + ui: typed_mock(Dev::Cli::Ui), ruby_version: "4.0.1", project_root: Pathname.new("/tmp/help-test"), + ) + end +end From 2ac941ae28071648a80fc1fd3e6eaa702ba8989f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 19 Aug 2026 10:26:03 -0400 Subject: [PATCH 5/7] Route help through the command path; group and eager-load usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare dev, --help, and -h now route to the help builtin like any command (dev help works too, and a project help: override composes via the usual slot mechanics). UsagePrinter renders the listing as Category sections — project commands, Lifecycle, Development flow — alphabetized so the output is deterministic regardless of registration order. With help inside the command path, the --help lazy-toolchain deferral is dropped: the toolchain pass over dependencies.rb runs once per invocation, unconditionally, and the composition root collapses to a single pass in Runner#run (kept there, not the constructor, so dependencies.rb errors still flow through the exit_for mapping). ui/out are per-process collaborators and move to Runner's constructor; the help builtin's self-referential listing is closed at the root with a call-time provider. Co-authored-by: Cursor --- bin/dev | 2 +- src/dev.rb | 2 +- src/dev/cli/usage_printer.rb | 63 +++++++--- src/dev/runner.rb | 102 ++++++++++------ test/dev/builtin_executor_test.rb | 2 + test/dev/cli/usage_printer_test.rb | 111 ++++++++++++++---- test/dev/command_executor_test.rb | 2 + test/dev/command_repository_test.rb | 2 + test/dev/command_service_test.rb | 2 + test/dev/overridden_executor_test.rb | 2 + test/dev/runner_test.rb | 166 +++++++++++++-------------- 11 files changed, 301 insertions(+), 155 deletions(-) diff --git a/bin/dev b/bin/dev index dee0109..3691f0d 100755 --- a/bin/dev +++ b/bin/dev @@ -56,7 +56,7 @@ else end begin - Dev::Runner.new.run(ARGV, out: $stdout, ui: ui) + Dev::Runner.new(ui: ui).run(ARGV) rescue Dev::DevYamlNotFoundError warn "dev: no dev.yml found in this directory or any parent." warn "Run dev from inside a project that defines a dev.yml." diff --git a/src/dev.rb b/src/dev.rb index 89f1dd4..2de83ee 100644 --- a/src/dev.rb +++ b/src/dev.rb @@ -5,7 +5,7 @@ require "sorbet-runtime" # Dev CLI: find repo with dev.yml, run declared commands (optionally in a CLI::UI Frame). -# Entry point: Dev::Runner.new.run(ARGV) +# Entry point: Dev::Runner.new(ui:).run(ARGV) module Dev DEV_YAML_FILENAME = "dev.yml" diff --git a/src/dev/cli/usage_printer.rb b/src/dev/cli/usage_printer.rb index e8a2aea..32729b5 100644 --- a/src/dev/cli/usage_printer.rb +++ b/src/dev/cli/usage_printer.rb @@ -6,38 +6,71 @@ module Dev module Cli - # The `dev` / `dev --help` usage view. Consumes the visible commands the - # CommandService serves (never the repository) and renders the flat - # listing; grouped sections (a category on the builtin base) would slot - # in here when the usage-groups work lands. + # The usage view the help builtin renders. Consumes the visible commands + # the CommandService serves (never the repository) and renders them as + # sections keyed by each command's Category trait: the project's own + # commands first, then the Lifecycle and Development flow builtins. + # Alphabetical within a section, so the listing is deterministic + # regardless of registration order. class UsagePrinter extend T::Sig - sig { params(argv: T::Array[String]).returns(T::Boolean) } - def show_usage?(argv) - argv.empty? || argv == ["--help"] || argv == ["-h"] - end - # @param project_name [String] the dev.yml `name:` - # @param commands [Hash{String => Dev::Command}] visible commands, in - # listing order + # @param commands [Hash{String => Dev::Command}] visible commands # @param out [IO, StringIO] # @return [void] sig { params(project_name: String, commands: T::Hash[String, Command], out: T.any(IO, StringIO)).void } def print(project_name:, commands:, out:) + sections = commands.group_by { |_name, command| command.category } + out.puts "Usage: dev [args...]" out.puts "" out.puts "Commands for #{project_name}:" - if commands.empty? + project_commands = sections.fetch(Command::Category::Project, []) + if project_commands.empty? out.puts " (no commands defined)" else - commands.each do |cmd_name, command| - out.puts " #{cmd_name.ljust(12)} #{command.desc}" - end + print_commands(project_commands, out) end + print_section("Lifecycle", sections.fetch(Command::Category::Lifecycle, []), out) + print_section("Development flow", sections.fetch(Command::Category::Workflow, []), out) out.puts "" out.puts "Examples: dev up dev up -v dev update-deps dev test" end + + private + + # Render one builtin section; sections with no commands are omitted + # entirely (some builtins are config-gated, e.g. reset-container). + # + # @param heading [String] + # @param commands [Array<[String, Dev::Command]>] + # @param out [IO, StringIO] + # @return [void] + sig do + params( + heading: String, + commands: T::Array[[String, Command]], + out: T.any(IO, StringIO), + ).void + end + def print_section(heading, commands, out) + return if commands.empty? + + out.puts "" + out.puts "#{heading}:" + print_commands(commands, out) + end + + # @param commands [Array<[String, Dev::Command]>] + # @param out [IO, StringIO] + # @return [void] + sig { params(commands: T::Array[[String, Command]], out: T.any(IO, StringIO)).void } + def print_commands(commands, out) + commands.sort_by { |name, _command| name }.each do |name, command| + out.puts " #{name.ljust(12)} #{command.desc}" + end + end end end end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index a452702..ad8c760 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -23,64 +23,86 @@ module Dev # The application service behind bin/dev, and the composition root of the - # command onion: usage check, argv/context assembly, one call into - # CommandService, and the rescue-to-exit mapping at the CLI boundary. + # command onion: route argv to a command name (bare/--help/-h mean help), + # assemble the ExecutionContext, wire the service graph, make one call + # into CommandService, and map rescues to exits at the CLI boundary. class Runner extend T::Sig sig do params( + ui: Dev::Cli::Ui, + out: T.any(IO, StringIO), dev_yaml_path: Pathname, manifest_loader: ProjectManifestLoader, command_service: T.nilable(CommandService), ).void end def initialize( + ui:, + out: $stdout, dev_yaml_path: Dev.dev_yaml_file, manifest_loader: ProjectManifestLoader.new, command_service: nil ) + @ui = T.let(ui, Dev::Cli::Ui) + @out = T.let(out, T.any(IO, StringIO)) @manifest_loader = T.let(manifest_loader, ProjectManifestLoader) - # The dev.yml side loads eagerly — usage needs the command list. The - # dependencies.rb side waits for #run (see the toolchain pass there). @manifest = T.let(manifest_loader.load(dev_yaml_path), ProjectManifest) - @command_service = T.let(command_service || build_command_service(@manifest), CommandService) - @usage_printer = T.let(Cli::UsagePrinter.new, Cli::UsagePrinter) + @command_service = T.let(command_service, T.nilable(CommandService)) end # Runs the dev command specified by the given argv. # + # Composition happens here rather than in the constructor so that + # everything — including the toolchain pass over dependencies.rb, which + # is arbitrary project Ruby — stays inside the exit_for error mapping. + # # @param argv [Array[String]] The argv to run the command with. - # @param ui [Dev::Cli::Ui] CLI UI implementation for framing and formatting. - # @param out [IO, StringIO] Stream for usage output (default: $stdout). # @return [void] - sig { params(argv: T::Array[String], ui: Dev::Cli::Ui, out: T.any(IO, StringIO)).void } - def run(argv, ui:, out: $stdout) - if @usage_printer.show_usage?(argv) - @usage_printer.print(project_name: @manifest.name, commands: @command_service.visible_commands, out:) - return - end + sig { params(argv: T::Array[String]).void } + def run(argv) + cmd_name, args = route(argv) + context = build_context + service = @command_service || build_command_service(@manifest, context) + service.execute(cmd_name, args:, context:) + rescue StandardError => e + exit_for(e) + end + + private + + # Split argv into a command name and its arguments. Bare `dev` and the + # conventional flags route to the help builtin; every other spelling + # (including `dev help`) is a regular command lookup. + # + # @param argv [Array] + # @return [(String, Array)] + sig { params(argv: T::Array[String]).returns([String, T::Array[String]]) } + def route(argv) + return ["help", []] if argv.empty? || argv == ["--help"] || argv == ["-h"] - args = T.let(argv.dup, T::Array[String]) - cmd_name = T.must(args.shift) - # The toolchain pass runs here, after the usage check — `dev --help` - # never loads the deps manifest (dependencies.rb is arbitrary Ruby). + args = argv.dup + [T.must(args.shift), args] + end + + # Assemble the per-run ExecutionContext. The toolchain pass over + # dependencies.rb runs unconditionally here, once per invocation. + # + # @return [ExecutionContext] + sig { returns(ExecutionContext) } + def build_context manifest = @manifest_loader.with_toolchain(@manifest, project_root: Dev.target_project_root) - context = ExecutionContext.new( - ui:, + ExecutionContext.new( + ui: @ui, ruby_version: ShadowenvRuby.resolve_ruby_version(manifest.declared_ruby_version), python_version: manifest.declared_python_version, project_root: Dev.target_project_root, build_container: manifest.build_container, runner: manifest.runner, ) - @command_service.execute(cmd_name, args:, context:) - rescue StandardError => e - exit_for(e) end - private - # The rescue-to-exit mapping of the CLI boundary, in one place — the # counterpart of bin/dev's DevYamlNotFoundError handling. Errors keep # their native namespaces all the way up here (no service-layer @@ -115,20 +137,33 @@ def exit_for(error) # container. # # @param manifest [ProjectManifest] + # @param context [ExecutionContext] # @return [CommandService] - sig { params(manifest: ProjectManifest).returns(CommandService) } - def build_command_service(manifest) + sig { params(manifest: ProjectManifest, context: ExecutionContext).returns(CommandService) } + def build_command_service(manifest, context) dependency_service = DependencyService.new( staleness: Dev::Deps::Staleness.new(project_root: Dev.target_project_root), ) - CommandService.new( + # Help lists the catalog the service serves, and the service's + # repository contains help — a self-reference by construction. The + # provider captures the `service` local assigned below and + # dereferences it only at call time, when it exists. + service = T.let(nil, T.nilable(CommandService)) + help = Builtins::HelpCommand.new( + project_name: manifest.name, + usage_printer: Cli::UsagePrinter.new, + out: @out, + commands_provider: -> { T.must(service).visible_commands }, + ) + service = CommandService.new( repository: CommandRepository.new( - builtins: build_builtins(manifest, dependency_service), + builtins: build_builtins(manifest, dependency_service, help:), project_commands: manifest.commands, ), executor: build_executor, dependency_service: dependency_service, ) + service end # Wire the executor composite: one BuiltinExecutor and one @@ -149,14 +184,17 @@ def build_executor # @param manifest [ProjectManifest] # @param dependency_service [DependencyService] - # @return [Hash{String => BuiltinCommand}] the builtin set, in listing order + # @param help [Builtins::HelpCommand] built by the caller, which owns + # the listing self-reference + # @return [Hash{String => BuiltinCommand}] the builtin set sig do - params(manifest: ProjectManifest, dependency_service: DependencyService) + params(manifest: ProjectManifest, dependency_service: DependencyService, help: Builtins::HelpCommand) .returns(T::Hash[String, BuiltinCommand]) end - def build_builtins(manifest, dependency_service) + def build_builtins(manifest, dependency_service, help:) install_deps = Builtins::InstallDepsCommand.new builtins = T.let({ + "help" => help, "update-deps" => Builtins::UpdateDepsCommand.new, "install-deps" => install_deps, # `up` composes the same install the install-deps builtin runs. diff --git a/test/dev/builtin_executor_test.rb b/test/dev/builtin_executor_test.rb index d7a6ae6..1af163e 100644 --- a/test/dev/builtin_executor_test.rb +++ b/test/dev/builtin_executor_test.rb @@ -22,6 +22,8 @@ def initialize def desc = "a builtin" + def category = Dev::Command::Category::Workflow + def call(args:, context:) @calls << [args, context] end diff --git a/test/dev/cli/usage_printer_test.rb b/test/dev/cli/usage_printer_test.rb index 64d2fa3..1edf143 100644 --- a/test/dev/cli/usage_printer_test.rb +++ b/test/dev/cli/usage_printer_test.rb @@ -8,23 +8,34 @@ transform!(RSpock::AST::Transformation) class Dev::Cli::UsagePrinterTest < Minitest::Test - test "show_usage? triggers on empty argv and both help flags only" do - Given "the printer" - printer = Dev::Cli::UsagePrinter.new + # Minimal builtin fake: the printer only reads desc and category, so the + # fake carries just those traits. + class FakeBuiltin < Dev::BuiltinCommand + def initialize(desc:, category:) + super() + @desc = desc + @category = category + end + + attr_reader :desc, :category + + def call(args:, context:); end + end - Expect "the usage triggers" - printer.show_usage?([]) == true - printer.show_usage?(["--help"]) == true - printer.show_usage?(["-h"]) == true - printer.show_usage?(["test"]) == false - printer.show_usage?(["test", "--help"]) == false + def lifecycle_builtin(desc: "a lifecycle builtin") + FakeBuiltin.new(desc: desc, category: Dev::Command::Category::Lifecycle) end - test "print lists each visible command with its description" do - Given "two commands to advertise" + def workflow_builtin(desc: "a workflow builtin") + FakeBuiltin.new(desc: desc, category: Dev::Command::Category::Workflow) + end + + test "print renders the three sections in fixed order with their commands" do + Given "one command per usage group" printer = Dev::Cli::UsagePrinter.new commands = { - "up" => Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup"), + "up" => lifecycle_builtin(desc: "Provision"), + "help" => workflow_builtin(desc: "Show this usage"), "test" => Dev::ProjectCommand.new(run: "rspec", desc: "Run tests"), } out = StringIO.new @@ -32,25 +43,81 @@ class Dev::Cli::UsagePrinterTest < Minitest::Test When "printing usage" printer.print(project_name: "myproject", commands: commands, out: out) - Then "the header, both commands, and the examples line all render" + Then "each command renders under its section, sections in fixed order" + lines = out.string.lines.map(&:chomp) + lines.index("Commands for myproject:") < lines.index(" test Run tests") + lines.index("Lifecycle:") < lines.index(" up Provision") + lines.index("Development flow:") < lines.index(" help Show this usage") + lines.index("Commands for myproject:") < lines.index("Lifecycle:") + lines.index("Lifecycle:") < lines.index("Development flow:") out.string.include?("Usage: dev [args...]") - out.string.include?("Commands for myproject:") - out.string.include?("up") - out.string.include?("Setup") - out.string.include?("test") - out.string.include?("Run tests") out.string.include?("Examples:") end - test "print reports a project with no commands" do - Given "an empty command set" + test "commands list alphabetically within a section" do + Given "lifecycle commands registered out of alphabetical order" + printer = Dev::Cli::UsagePrinter.new + commands = { + "update-deps" => lifecycle_builtin, + "check" => lifecycle_builtin, + "install-deps" => lifecycle_builtin, + } + out = StringIO.new + + When "printing usage" + printer.print(project_name: "myproject", commands: commands, out: out) + + Then "the section lists them alphabetically" + lines = out.string.lines.map(&:chomp) + lines.index(" check a lifecycle builtin") < + lines.index(" install-deps a lifecycle builtin") + lines.index(" install-deps a lifecycle builtin") < + lines.index(" update-deps a lifecycle builtin") + end + + test "print reports a project with no commands of its own" do + Given "a command set with only builtins" printer = Dev::Cli::UsagePrinter.new + commands = { "help" => workflow_builtin } out = StringIO.new When "printing usage" - printer.print(project_name: "bareproject", commands: {}, out: out) + printer.print(project_name: "bareproject", commands: commands, out: out) - Then "the empty state is explicit" + Then "the project section's empty state is explicit" + out.string.include?("Commands for bareproject:") out.string.include?("(no commands defined)") end + + test "builtin sections without commands are omitted" do + Given "a command set with no lifecycle commands" + printer = Dev::Cli::UsagePrinter.new + commands = { "test" => Dev::ProjectCommand.new(run: "rspec", desc: "Run tests") } + out = StringIO.new + + When "printing usage" + printer.print(project_name: "myproject", commands: commands, out: out) + + Then "no empty section headers render" + !out.string.include?("Lifecycle:") + !out.string.include?("Development flow:") + end + + test "an overridden slot lists under the builtin's section with the project's desc" do + Given "a lifecycle slot overridden by a project command" + printer = Dev::Cli::UsagePrinter.new + overridden = Dev::OverriddenCommand.new( + builtin: lifecycle_builtin(desc: "builtin up"), + project: Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Project setup"), + ) + out = StringIO.new + + When "printing usage" + printer.print(project_name: "myproject", commands: { "up" => overridden }, out: out) + + Then "the slot renders under Lifecycle with the override's description" + lines = out.string.lines.map(&:chomp) + lines.index("Lifecycle:") < lines.index(" up Project setup") + !out.string.include?("builtin up") + end end diff --git a/test/dev/command_executor_test.rb b/test/dev/command_executor_test.rb index c43eb3e..582cd0f 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -16,6 +16,8 @@ class Dev::CommandExecutorTest < Minitest::Test class FakeBuiltin < Dev::BuiltinCommand def desc = "a builtin" + def category = Dev::Command::Category::Workflow + def call(args:, context:); end end diff --git a/test/dev/command_repository_test.rb b/test/dev/command_repository_test.rb index 084e9ac..f548d04 100644 --- a/test/dev/command_repository_test.rb +++ b/test/dev/command_repository_test.rb @@ -17,6 +17,8 @@ def initialize(desc: "a builtin", hidden: false) def hidden? = @hidden + def category = Dev::Command::Category::Workflow + def call(args:, context:); end end unless defined?(RepositoryFakeBuiltin) diff --git a/test/dev/command_service_test.rb b/test/dev/command_service_test.rb index f970169..ac288fb 100644 --- a/test/dev/command_service_test.rb +++ b/test/dev/command_service_test.rb @@ -24,6 +24,8 @@ def initialize(staleness_exempt: false, stamps: false) def desc = "a builtin" + def category = Dev::Command::Category::Workflow + def staleness_exempt? = @staleness_exempt def stamps? = @stamps diff --git a/test/dev/overridden_executor_test.rb b/test/dev/overridden_executor_test.rb index 16f2311..2828834 100644 --- a/test/dev/overridden_executor_test.rb +++ b/test/dev/overridden_executor_test.rb @@ -20,6 +20,8 @@ def initialize(stamps:) def desc = "a builtin" + def category = Dev::Command::Category::Workflow + def stamps? = @stamps def call(args:, context:); end diff --git a/test/dev/runner_test.rb b/test/dev/runner_test.rb index 075856d..62c1991 100644 --- a/test/dev/runner_test.rb +++ b/test/dev/runner_test.rb @@ -17,11 +17,11 @@ class RunnerTest < Minitest::Test test "run with empty argv prints usage" do Given "a Runner with a dev.yml" - runner = build_runner(commands: { "up" => { "run" => "./bin/up.rb", "desc" => "Setup" } }) out = StringIO.new + runner = build_runner(commands: { "up" => { "run" => "./bin/up.rb", "desc" => "Setup" } }, out: out) When "we run with empty argv" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "usage is printed" out.string.include?("Usage: dev [args...]") @@ -32,11 +32,11 @@ class RunnerTest < Minitest::Test test "run with --help prints usage" do Given "a Runner" - runner = build_runner out = StringIO.new + runner = build_runner(out: out) When "we run with --help" - runner.run(["--help"], ui: fake_ui, out: out) + runner.run(["--help"]) Then "usage is printed" out.string.include?("Usage: dev [args...]") @@ -44,46 +44,53 @@ class RunnerTest < Minitest::Test test "run with -h prints usage" do Given "a Runner" - runner = build_runner out = StringIO.new + runner = build_runner(out: out) When "we run with -h" - runner.run(["-h"], ui: fake_ui, out: out) + runner.run(["-h"]) Then "usage is printed" out.string.include?("Usage: dev [args...]") end - test "usage never loads the deps manifest (dev --help stays lazy)" do - Given "a Runner whose project root carries a booby-trapped dependencies.rb" - root = Pathname.new(Dir.mktmpdir("runner-usage-lazy-")) - File.write(root / "dependencies.rb", "raise 'usage must not load me'\n") - Dev.stubs(:target_project_root).returns(root) - runner = build_runner + test "help is a command: dev help prints usage and lists itself" do + Given "a Runner" out = StringIO.new + runner = build_runner(out: out) - When "we print usage" - runner.run([], ui: fake_ui, out: out) + When "we run the help command by name" + runner.run(["help"]) - Then "the usage rendered without touching dependencies.rb" + Then "usage is printed with help in the development flow section" out.string.include?("Usage: dev [args...]") + out.string.include?("help") + out.string.include?("Show this usage") + end - Cleanup - FileUtils.rm_rf(root) + test "usage renders the grouped sections" do + Given "a Runner with a project command" + out = StringIO.new + runner = build_runner(commands: { "test" => { "run" => "rspec", "desc" => "Run tests" } }, out: out) + + When "we print usage" + runner.run([]) + + Then "the three sections render in order" + lines = out.string.lines.map(&:chomp) + lines.index("Commands for testproject:") < lines.index("Lifecycle:") + lines.index("Lifecycle:") < lines.index("Development flow:") end test "run with unknown command prints error to stderr and exits 1" do - Given "a Runner pinned to an empty project root" - root = Pathname.new(Dir.mktmpdir("runner-unknown-")) - Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") + Given "a Runner" runner = build_runner old_stderr = $stderr $stderr = StringIO.new Kernel.expects(:exit).with(1).once When "we run an unknown command" - runner.run(["nonexistent"], ui: fake_ui) + runner.run(["nonexistent"]) Then "error mentions the command name" $stderr.string.include?("nonexistent") @@ -91,16 +98,15 @@ class RunnerTest < Minitest::Test Cleanup $stderr = old_stderr - FileUtils.rm_rf(root) end test "usage includes built-in update-deps command" do Given "a Runner with no project commands" - runner = build_runner(commands: {}) out = StringIO.new + runner = build_runner(commands: {}, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "update-deps is listed" out.string.include?("update-deps") @@ -109,14 +115,14 @@ class RunnerTest < Minitest::Test test "usage includes both built-in and project commands" do Given "a Runner with project commands" + out = StringIO.new runner = build_runner(commands: { "test" => { "run" => "rspec", "desc" => "Run tests" }, "up" => { "run" => "./bin/up.rb", "desc" => "Setup" }, - }) - out = StringIO.new + }, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "all commands appear" out.string.include?("update-deps") @@ -126,24 +132,24 @@ class RunnerTest < Minitest::Test test "up is a builtin even when the project defines no up command" do Given "a Runner with no project commands" - runner = build_runner(commands: {}) out = StringIO.new + runner = build_runner(commands: {}, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "up is listed as the builtin dependency install" out.string.include?("up") out.string.include?("Install locked dependencies, then run the project's up command") end - test "a project up command keeps the builtin slot's position with its own desc" do + test "a project up command keeps the builtin slot's section with its own desc" do Given "a Runner whose dev.yml overrides up" - runner = build_runner(commands: { "up" => { "run" => "./bin/up.rb", "desc" => "Project setup" } }) out = StringIO.new + runner = build_runner(commands: { "up" => { "run" => "./bin/up.rb", "desc" => "Project setup" } }, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "the override's description wins" out.string.include?("Project setup") @@ -152,11 +158,11 @@ class RunnerTest < Minitest::Test test "usage includes the cd builtin" do Given "a Runner with no project commands" - runner = build_runner(commands: {}) out = StringIO.new + runner = build_runner(commands: {}, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "cd is listed" out.string.include?("cd") @@ -165,11 +171,11 @@ class RunnerTest < Minitest::Test test "usage includes the clone builtin" do Given "a Runner with no project commands" - runner = build_runner(commands: {}) out = StringIO.new + runner = build_runner(commands: {}, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "clone is listed" out.string.include?("clone") @@ -178,16 +184,17 @@ class RunnerTest < Minitest::Test test "usage includes reset-container when the build container persists" do Given "a Runner whose build container opts into persist" + out = StringIO.new runner = build_runner( commands: {}, build: { "container" => { "image" => "myapp-linux", "registry" => "myregistry", "persist" => true, } }, + out: out, ) - out = StringIO.new When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "the teardown command is listed" out.string.include?("reset-container") @@ -195,36 +202,35 @@ class RunnerTest < Minitest::Test test "reset-container is not registered without persist" do Given "a Runner with a non-persistent build container" + out = StringIO.new runner = build_runner( commands: {}, build: { "container" => { "image" => "myapp-linux", "registry" => "myregistry" } }, + out: out, ) - out = StringIO.new When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "no teardown command is listed" !out.string.include?("reset-container") end test "provide-image is registered (but hidden) when a build container is configured" do - Given "a Runner with a build container, pinned to an empty project root" - root = Pathname.new(Dir.mktmpdir("runner-provide-image-")) - Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") + Given "a Runner with a build container" + usage = StringIO.new runner = build_runner( commands: {}, build: { "container" => { "image" => "myapp-linux", "registry" => "myregistry" } }, + out: usage, ) BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") - usage = StringIO.new old_stdout = $stdout $stdout = StringIO.new When "we print usage and then invoke the command anyway" - runner.run([], ui: fake_ui, out: usage) - runner.run(["provide-image"], ui: fake_ui) + runner.run([]) + runner.run(["provide-image"]) Then "the command is callable but omitted from usage" !usage.string.include?("provide-image") @@ -232,37 +238,32 @@ class RunnerTest < Minitest::Test Cleanup $stdout = old_stdout - FileUtils.rm_rf(root) end test "provide-image is not registered without a build container" do - Given "a Runner without a build container, pinned to an empty project root" - root = Pathname.new(Dir.mktmpdir("runner-no-provide-image-")) - Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") + Given "a Runner without a build container" runner = build_runner(commands: {}) old_stderr = $stderr $stderr = StringIO.new Kernel.expects(:exit).with(1).once When "we invoke the absent command" - runner.run(["provide-image"], ui: fake_ui) + runner.run(["provide-image"]) Then "it is not found" $stderr.string.include?("provide-image") Cleanup $stderr = old_stderr - FileUtils.rm_rf(root) end test "usage includes runner-setup when a runner block is declared" do Given "a Runner whose dev.yml declares a runner block" - runner = build_runner(commands: {}, runner: { "labels" => "ue-engine" }) out = StringIO.new + runner = build_runner(commands: {}, runner: { "labels" => "ue-engine" }, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "the runner-setup command is listed" out.string.include?("runner-setup") @@ -270,11 +271,11 @@ class RunnerTest < Minitest::Test test "runner-setup is not registered without a runner block" do Given "a Runner with no runner block" - runner = build_runner(commands: {}) out = StringIO.new + runner = build_runner(commands: {}, out: out) When "we print usage" - runner.run([], ui: fake_ui, out: out) + runner.run([]) Then "no runner-setup command is listed" !out.string.include?("runner-setup") @@ -290,18 +291,17 @@ class RunnerTest < Minitest::Test python "3.12" end RUBY - Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).with("9.9.9").returns("9.9.9") contexts = [] command_service = typed_mock(Dev::CommandService) command_service.stubs(:execute).with { |cmd_name, args:, context:| contexts << [cmd_name, args, context] true } - runner = build_runner(commands: {}, command_service: command_service) ui = fake_ui + runner = build_runner(commands: {}, command_service: command_service, ui: ui, root: root) + ShadowenvRuby.stubs(:resolve_ruby_version).with("9.9.9").returns("9.9.9") When "we run a command with args" - runner.run(["test", "--fast"], ui: ui) + runner.run(["test", "--fast"]) Then "the service got the name, args, and a fully-assembled context" cmd_name, args, context = contexts.fetch(0) @@ -318,29 +318,20 @@ class RunnerTest < Minitest::Test test "a failed waited child exits with the child's status" do Given "a Runner whose service raises the child's failure" - root = Pathname.new(Dir.mktmpdir("runner-exit-map-")) - Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") command_service = typed_mock(Dev::CommandService) command_service.stubs(:execute).raises(Dev::CommandRunner::CommandFailedError.new(exit_status: 7)) runner = build_runner(commands: {}, command_service: command_service) Kernel.expects(:exit).with(7).once When "we run the command" - runner.run(["up"], ui: fake_ui) + runner.run(["up"]) Then "the expectation on the exit mapping holds" true - - Cleanup - FileUtils.rm_rf(root) end test "an ArgumentError is reported as a clean dev error with exit 1" do Given "a Runner whose service raises a usage error" - root = Pathname.new(Dir.mktmpdir("runner-argerror-")) - Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") command_service = typed_mock(Dev::CommandService) command_service.stubs(:execute).raises(ArgumentError.new("usage: dev cache gc [--keep N]")) runner = build_runner(commands: {}, command_service: command_service) @@ -349,38 +340,40 @@ class RunnerTest < Minitest::Test Kernel.expects(:exit).with(1).once When "we run the command" - runner.run(["cache"], ui: fake_ui) + runner.run(["cache"]) Then "the message reaches stderr under the dev: prefix" $stderr.string.include?("dev: usage: dev cache gc [--keep N]") Cleanup $stderr = old_stderr - FileUtils.rm_rf(root) end test "an unmapped error is a dev bug and re-raises with its backtrace" do Given "a Runner whose service raises an unmapped error class" - root = Pathname.new(Dir.mktmpdir("runner-unmapped-")) - Dev.stubs(:target_project_root).returns(root) - ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") command_service = typed_mock(Dev::CommandService) command_service.stubs(:execute).raises(Dev::Deps::Cache::CacheMissError.new("no entry")) runner = build_runner(commands: {}, command_service: command_service) When "we run the command" - runner.run(["deps"], ui: fake_ui) + runner.run(["deps"]) Then raises Dev::Deps::Cache::CacheMissError - - Cleanup - FileUtils.rm_rf(root) end private - def build_runner(name: "testproject", commands: {}, build: nil, runner: nil, command_service: nil) + # Every run builds an ExecutionContext (the toolchain pass is eager now), + # so the helper always pins the project root to a temp dir and stubs the + # ruby resolution; tests needing specific toolchain behavior re-stub after + # (mocha matches the latest stub first) or pass their own root. + def build_runner(name: "testproject", commands: {}, build: nil, runner: nil, command_service: nil, + ui: fake_ui, out: StringIO.new, root: nil) + root ||= (@tmp_roots ||= []).push(Pathname.new(Dir.mktmpdir("runner-test-"))).fetch(-1) + Dev.stubs(:target_project_root).returns(root) + ShadowenvRuby.stubs(:resolve_ruby_version).returns("4.0.1") + yaml = { "name" => name, "commands" => commands } yaml["build"] = build if build yaml["runner"] = runner if runner @@ -388,7 +381,12 @@ def build_runner(name: "testproject", commands: {}, build: nil, runner: nil, com tmp.write(YAML.dump(yaml)) tmp.flush - Dev::Runner.new(dev_yaml_path: Pathname.new(tmp.path), command_service: command_service) + Dev::Runner.new(dev_yaml_path: Pathname.new(tmp.path), ui: ui, out: out, command_service: command_service) + end + + def teardown + @tmp_roots&.each { |root| FileUtils.rm_rf(root) } + super end def fake_ui From 3ad03c335d936cba68f88b25172b4bec4fd9686f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 19 Aug 2026 10:30:22 -0400 Subject: [PATCH 6/7] Constructor-inject CommandRunner; two messages replace the wait flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommandRunner's wait: constructor flag was a per-call mode hiding in construction; it becomes two public messages, exec_into and run_waiting, mirroring ProjectExecutor's seam (the flag survives only on the private run_child boundary). With the toolchain pass eager, the runner's collaborators are all known at the composition root, so it is built once from the run's ExecutionContext and injected into ProjectExecutor — the project-side executor messages stop carrying a context they never used. Co-authored-by: Cursor --- src/dev/command_executor.rb | 2 +- src/dev/command_runner.rb | 102 +++++++++++------- src/dev/overridden_executor.rb | 4 +- src/dev/project_executor.rb | 44 +++----- src/dev/runner.rb | 23 ++-- test/dev/command_executor_test.rb | 2 +- test/dev/command_runner_test.rb | 60 +++++------ test/dev/command_service_test.rb | 5 +- test/dev/overridden_executor_test.rb | 12 +-- test/dev/project_executor_test.rb | 151 ++++++--------------------- 10 files changed, 169 insertions(+), 236 deletions(-) diff --git a/src/dev/command_executor.rb b/src/dev/command_executor.rb index 842b6f1..84e6efc 100644 --- a/src/dev/command_executor.rb +++ b/src/dev/command_executor.rb @@ -45,7 +45,7 @@ def execute(command, args:, context:) when BuiltinCommand @builtin_executor.execute(command, args:, context:) when ProjectCommand - @project_executor.exec_into(command, args:, context:) + @project_executor.exec_into(command, args:) when OverriddenCommand @overridden_executor.execute(command, args:, context:) else diff --git a/src/dev/command_runner.rb b/src/dev/command_runner.rb index 0806b20..88332ac 100644 --- a/src/dev/command_runner.rb +++ b/src/dev/command_runner.rb @@ -13,18 +13,19 @@ module Dev # Runs dev commands by handing the process over to the child. Dev prints a - # colored header (command name) and then, by default, exec-replaces itself: + # colored header (command name) and then runs the shell command: # # - repl commands: the bare shell command (no footer, for interactive # sessions) # - non-repl commands: a shell wrapper that runs the command and prints # ✓ Done / ✗ Failed based on exit code # - # In wait mode (wait: true) the same commands run spawn-and-wait instead of - # exec-replace: the child is waited on, and a failure raises - # CommandFailedError carrying its exit status — so a caller with - # success-contingent post-steps (e.g. Runner's installed stamp) can sequence - # them after execute while preserving the child's exit code (#85). + # The two child-process shapes are two public messages, mirroring + # ProjectExecutor's seam: exec_into exec-replaces the process (the child's + # exit status becomes dev's own), run_waiting spawns and waits, raising + # CommandFailedError with the child's exit status on failure — so a caller + # with success-contingent post-steps (e.g. the installed stamp) can + # sequence them after execute while preserving the child's exit code (#85). # # The child has full terminal access — CLI::UI features (frames, spinners, # prompts) all work natively without any interception. @@ -38,9 +39,9 @@ module Dev class CommandRunner extend T::Sig - # Raised in wait mode when the child command fails, carrying its exit + # Raised by run_waiting when the child command fails, carrying its exit # status so the caller can skip success-contingent post-steps and exit - # with the child's code. (Exec-replace mode never raises — the child's + # with the child's code. (exec_into never raises this — the child's # status becomes the process's own.) class CommandFailedError < StandardError extend T::Sig @@ -59,41 +60,69 @@ def initialize(exit_status:) params( ui: Dev::Cli::Ui, ruby_version: String, + project_root: Pathname, python_version: T.nilable(String), build_container: T.nilable(Dev::BuildContainerConfig), - project_root: Pathname, - wait: T::Boolean, ).void end - def initialize(ui:, ruby_version:, python_version: nil, build_container: nil, project_root: Dev.target_project_root, - wait: false) + def initialize(ui:, ruby_version:, project_root:, python_version: nil, build_container: nil) @ui = T.let(ui, Dev::Cli::Ui) @ruby_version = T.let(ruby_version, String) @python_version = T.let(python_version, T.nilable(String)) @build_container = T.let(build_container, T.nilable(Dev::BuildContainerConfig)) @project_root = T.let(project_root, Pathname) - @wait = T.let(wait, T::Boolean) end + # Hand the process over to the command: exec-replace, the right shape + # for a leaf command (TTY and signal passthrough, no double process + # tree). Never returns in production. + # + # @param cmd [ProjectCommand] + # @param args [Array] argv after the command name + # @return [void] + sig { params(cmd: ProjectCommand, args: T::Array[String]).void } + def exec_into(cmd, args: []) + run(cmd, args:, wait: false) + end + + # Run the command spawn-and-wait, so control returns to the caller's + # success-contingent post-steps. + # + # @param cmd [ProjectCommand] + # @param args [Array] argv after the command name + # @return [void] + # @raise [CommandFailedError] when the child fails sig { params(cmd: ProjectCommand, args: T::Array[String]).void } - def run(cmd, args: []) + def run_waiting(cmd, args: []) + run(cmd, args:, wait: true) + end + + private + + # The shared pipeline behind both public messages; wait picks the + # child-process shape at the run_child boundary. + # + # @param cmd [ProjectCommand] + # @param args [Array] + # @param wait [Boolean] + # @return [void] + sig { params(cmd: ProjectCommand, args: T::Array[String], wait: T::Boolean).void } + def run(cmd, args:, wait:) shell_command = build_shell_command(cmd.run, args) @ui.print_header(shell_command) if use_container?(cmd) - run_in_container(cmd, shell_command) + run_in_container(shell_command, wait:) else ensure_shadowenv_provisioned! if cmd.repl - run_bare(shell_command) + run_bare(shell_command, wait:) else - run_with_status_footer(shell_command) + run_with_status_footer(shell_command, wait:) end end end - private - sig { params(cmd: ProjectCommand).returns(T::Boolean) } def use_container?(cmd) !@build_container.nil? && cmd.container @@ -111,8 +140,8 @@ def publish_image? ENV["DEV_PUBLISH_IMAGE"] == "1" end - sig { params(_cmd: ProjectCommand, shell_command: String).void } - def run_in_container(_cmd, shell_command) + sig { params(shell_command: String, wait: T::Boolean).void } + def run_in_container(shell_command, wait:) config = T.must(@build_container) image_tag = BuildContainer.ensure_image!( config, @@ -125,7 +154,7 @@ def run_in_container(_cmd, shell_command) docker_argv = container_command(config, image_tag, shell_command) Dir.chdir(@project_root) - run_child(docker_argv) + run_child(docker_argv, wait:) end # docker argv for a containerized command: a `docker exec` into the reused @@ -265,22 +294,24 @@ def ensure_llvm_provisioned!(project_root) # session owns the terminal end to end. # # @param shell_command [String] + # @param wait [Boolean] # @return [void] - sig { params(shell_command: String).void } - def run_bare(shell_command) + sig { params(shell_command: String, wait: T::Boolean).void } + def run_bare(shell_command, wait:) Dir.chdir(@project_root) - run_child([child_env, "shadowenv", "exec", "--", "sh", "-c", shell_command]) + run_child([child_env, "shadowenv", "exec", "--", "sh", "-c", shell_command], wait:) end # Runs the command inside a shell wrapper that prints a colored # success/failure footer based on the exit code (and preserves it). # # @param shell_command [String] + # @param wait [Boolean] # @return [void] - sig { params(shell_command: String).void } - def run_with_status_footer(shell_command) + sig { params(shell_command: String, wait: T::Boolean).void } + def run_with_status_footer(shell_command, wait:) Dir.chdir(@project_root) - run_child([child_env, "shadowenv", "exec", "--", "sh", "-c", <<~SH]) + run_child([child_env, "shadowenv", "exec", "--", "sh", "-c", <<~SH], wait:) #{shell_command} __dev_status=$? if [ $__dev_status -eq 0 ]; then @@ -300,17 +331,16 @@ def run_with_status_footer(shell_command) SH end - # Hands the assembled argv (optional env hash first) to the child process. - # Exec-replace by default — the right shape for a leaf command: TTY and - # signal passthrough, no double process tree. In wait mode, spawn-and-wait - # instead, so control returns to the caller's post-execute steps. + # Hands the assembled argv (optional env hash first) to the child + # process: exec-replace for exec_into, spawn-and-wait for run_waiting. # # @param argv [Array] Kernel.exec / Kernel.system argv + # @param wait [Boolean] # @return [void] - # @raise [CommandFailedError] in wait mode, when the child fails - sig { params(argv: T::Array[T.untyped]).void } - def run_child(argv) - return Kernel.exec(*T.unsafe(argv)) unless @wait + # @raise [CommandFailedError] when a waited child fails + sig { params(argv: T::Array[T.untyped], wait: T::Boolean).void } + def run_child(argv, wait:) + return Kernel.exec(*T.unsafe(argv)) unless wait return if Kernel.system(*T.unsafe(argv)) diff --git a/src/dev/overridden_executor.rb b/src/dev/overridden_executor.rb index 7a188c9..0ed45ca 100644 --- a/src/dev/overridden_executor.rb +++ b/src/dev/overridden_executor.rb @@ -38,9 +38,9 @@ def initialize(builtin_executor:, project_executor:) def execute(command, args:, context:) @builtin_executor.execute(command.builtin, args:, context:) if command.stamps? - @project_executor.run_waiting(command.project, args:, context:) + @project_executor.run_waiting(command.project, args:) else - @project_executor.exec_into(command.project, args:, context:) + @project_executor.exec_into(command.project, args:) end end end diff --git a/src/dev/project_executor.rb b/src/dev/project_executor.rb index 1eed4b8..cb8499b 100644 --- a/src/dev/project_executor.rb +++ b/src/dev/project_executor.rb @@ -3,11 +3,10 @@ require_relative "command" require_relative "command_runner" -require_relative "execution_context" module Dev # The process boundary for project commands: the only class that owns the - # CommandRunner/Kernel seam. The two child-process shapes are two precise + # CommandRunner seam. The two child-process shapes are two precise # messages rather than a wait: flag — exec_into hands the process over to # the child (never returns), run_waiting spawns, waits, and raises on # child failure. Callers choose by sending the message they mean. @@ -20,18 +19,24 @@ class ProjectExecutor # keeps exec_into's never-returns contract honest even then. class ExecReturnedError < StandardError; end + # @param command_runner [CommandRunner] built once at the composition + # root from the run's ExecutionContext + sig { params(command_runner: CommandRunner).void } + def initialize(command_runner:) + @command_runner = T.let(command_runner, CommandRunner) + end + # Hand the process over to the project command: exec-replace, the right # shape for a leaf command (TTY and signal passthrough, no double # process tree). The child's exit status becomes the process's own. # # @param command [ProjectCommand] # @param args [Array] argv after the command name - # @param context [ExecutionContext] # @return [void] never returns # @raise [ExecReturnedError] if the exec boundary returns control - sig { params(command: ProjectCommand, args: T::Array[String], context: ExecutionContext).returns(T.noreturn) } - def exec_into(command, args:, context:) - command_runner(context, wait: false).run(command, args:) + sig { params(command: ProjectCommand, args: T::Array[String]).returns(T.noreturn) } + def exec_into(command, args:) + @command_runner.exec_into(command, args:) raise ExecReturnedError, "exec-mode CommandRunner returned instead of replacing the process" end @@ -40,33 +45,12 @@ def exec_into(command, args:, context:) # # @param command [ProjectCommand] # @param args [Array] argv after the command name - # @param context [ExecutionContext] # @return [void] # @raise [CommandRunner::CommandFailedError] when the child fails, # carrying its exit status - sig { params(command: ProjectCommand, args: T::Array[String], context: ExecutionContext).void } - def run_waiting(command, args:, context:) - command_runner(context, wait: true).run(command, args:) - end - - private - - # Assemble the CommandRunner for one run. Built per call because every - # collaborator it needs arrives with the per-call ExecutionContext. - # - # @param context [ExecutionContext] - # @param wait [Boolean] - # @return [CommandRunner] - sig { params(context: ExecutionContext, wait: T::Boolean).returns(CommandRunner) } - def command_runner(context, wait:) - CommandRunner.new( - ui: context.ui, - ruby_version: context.ruby_version, - python_version: context.python_version, - build_container: context.build_container, - project_root: context.project_root, - wait: wait, - ) + sig { params(command: ProjectCommand, args: T::Array[String]).void } + def run_waiting(command, args:) + @command_runner.run_waiting(command, args:) end end end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index ad8c760..25602b1 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -160,21 +160,30 @@ def build_command_service(manifest, context) builtins: build_builtins(manifest, dependency_service, help:), project_commands: manifest.commands, ), - executor: build_executor, + executor: build_executor(context), dependency_service: dependency_service, ) service end - # Wire the executor composite: one BuiltinExecutor and one - # ProjectExecutor, shared with the OverriddenExecutor that composes - # them for the virtual-dispatch arm. + # Wire the executor composite: one CommandRunner (built from the run's + # context, the process boundary's collaborators), one BuiltinExecutor, + # and one ProjectExecutor, shared with the OverriddenExecutor that + # composes them for the virtual-dispatch arm. # + # @param context [ExecutionContext] # @return [CommandExecutor] - sig { returns(CommandExecutor) } - def build_executor + sig { params(context: ExecutionContext).returns(CommandExecutor) } + def build_executor(context) + command_runner = CommandRunner.new( + ui: context.ui, + ruby_version: context.ruby_version, + python_version: context.python_version, + build_container: context.build_container, + project_root: context.project_root, + ) builtin_executor = BuiltinExecutor.new - project_executor = ProjectExecutor.new + project_executor = ProjectExecutor.new(command_runner:) CommandExecutor.new( builtin_executor:, project_executor:, diff --git a/test/dev/command_executor_test.rb b/test/dev/command_executor_test.rb index 582cd0f..3c59565 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -58,7 +58,7 @@ def build_strategies context = build_context strategies = build_strategies strategies.fetch(:project_executor) - .expects(:exec_into).with(command, args: ["--fast", "spec/a"], context: context).once + .expects(:exec_into).with(command, args: ["--fast", "spec/a"]).once executor = Dev::CommandExecutor.new(**strategies) When "executing" diff --git a/test/dev/command_runner_test.rb b/test/dev/command_runner_test.rb index 6a64cff..f3ed86d 100644 --- a/test/dev/command_runner_test.rb +++ b/test/dev/command_runner_test.rb @@ -40,7 +40,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/console", repl: true) When "we run a command" - runner.run(cmd) + runner.exec_into(cmd) Then "the declared Ruby is ensured for the project root" 1 * ShadowenvRuby.ensure!(ruby_version: "4.0.1", project_root: @project_root) @@ -56,7 +56,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/console", repl: true) When "we run the command" - @runner.run(cmd) + @runner.exec_into(cmd) Then "header is printed and process is replaced via exec" 1 * @ui.print_header("./bin/console") @@ -71,7 +71,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/console", repl: true) When "we run the command with extra args" - @runner.run(cmd, args: ["--verbose"]) + @runner.exec_into(cmd, args: ["--verbose"]) Then "header includes args and exec passes them through" 1 * @ui.print_header("./bin/console --verbose") @@ -86,7 +86,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/setup.rb", repl: false) When "we run the command" - @runner.run(cmd) + @runner.exec_into(cmd) Then "header is printed and exec is called with a shell wrapper" 1 * @ui.print_header("./bin/setup.rb") @@ -101,7 +101,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/test.sh", repl: false) When "we run the command" - @runner.run(cmd) + @runner.exec_into(cmd) Then "the shell wrapper includes exit code handling and Done/Failed output" 1 * Kernel.exec(has_entries("GEM_HOME" => nil, "RUBYLIB" => anything), "shadowenv", "exec", "--", "sh", "-c", @@ -116,7 +116,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/test.sh", repl: false) When "we run with args" - @runner.run(cmd, args: ["-v"]) + @runner.exec_into(cmd, args: ["-v"]) Then "header and wrapper both include args" 1 * @ui.print_header("./bin/test.sh -v") @@ -126,16 +126,14 @@ def teardown Dir.chdir(@original_cwd) end - # --- Wait mode (spawn-and-wait for callers with post-execute steps) --- + # --- run_waiting (spawn-and-wait for callers with post-execute steps) --- - test "wait mode spawns and waits instead of exec-replacing the process" do - Given "a wait-mode runner and a non-repl command" - runner = Dev::CommandRunner.new(ui: @ui, ruby_version: "4.0.1", project_root: @project_root, wait: true) - runner.stubs(:ensure_shadowenv_provisioned!) + test "run_waiting spawns and waits instead of exec-replacing the process" do + Given "a non-repl command" cmd = Dev::ProjectCommand.new(run: "./bin/setup.rb", repl: false) - When "we run the command" - runner.run(cmd) + When "we run the command waiting" + @runner.run_waiting(cmd) Then "the command runs as a waited child, never via exec" 1 * Kernel.system(has_entries("GEM_HOME" => nil, "RUBYLIB" => anything), "shadowenv", "exec", "--", "sh", "-c", includes("./bin/setup.rb")) >> true @@ -145,18 +143,16 @@ def teardown Dir.chdir(@original_cwd) end - test "wait mode raises CommandFailedError carrying the child's exit status" do - Given "a wait-mode runner whose child exits 7" - runner = Dev::CommandRunner.new(ui: @ui, ruby_version: "4.0.1", project_root: @project_root, wait: true) - runner.stubs(:ensure_shadowenv_provisioned!) + test "run_waiting raises CommandFailedError carrying the child's exit status" do + Given "a child that exits 7" cmd = Dev::ProjectCommand.new(run: "./bin/setup.rb", repl: false) Kernel.stubs(:system).returns(false) # Kernel.system is stubbed, so wait on a real child here to leave the # thread-local $? at exit status 7 — what a real failed child would set. Process.wait(Process.spawn("sh", "-c", "exit 7")) - When "we run the command" - error = assert_raises(Dev::CommandRunner::CommandFailedError) { runner.run(cmd) } + When "we run the command waiting" + error = assert_raises(Dev::CommandRunner::CommandFailedError) { @runner.run_waiting(cmd) } Then "the error carries the child's exit status" error.exit_status == 7 @@ -165,17 +161,17 @@ def teardown Dir.chdir(@original_cwd) end - test "wait mode runs the containerized command spawn-and-wait" do - Given "a wait-mode runner with a build container" + test "run_waiting runs the containerized command spawn-and-wait" do + Given "a runner with a build container" config = Dev::BuildContainerConfig.new(image: "myapp-linux", registry: "myregistry") - runner = Dev::CommandRunner.new(ui: @ui, ruby_version: "4.0.1", build_container: config, project_root: @project_root, wait: true) + runner = Dev::CommandRunner.new(ui: @ui, ruby_version: "4.0.1", build_container: config, project_root: @project_root) cmd = Dev::ProjectCommand.new(run: "./bin/up.sh", repl: false) - When "the image resolves and we run the command" + When "the image resolves and we run the command waiting" BuildContainer.stubs(:ensure_image!).returns("myregistry/myapp-linux:content-abc123") BuildContainer.stubs(:docker_run_command) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/up.sh"]) - runner.run(cmd) + runner.run_waiting(cmd) Then "docker runs as a waited child, never via exec" 1 * Kernel.system("docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/up.sh") >> true @@ -200,7 +196,7 @@ def teardown BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/build.sh", volumes: [], env: {}) .returns(["docker", "run", "--rm", "-v", "#{@project_root}:/project", "-w", "/project", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) - runner.run(cmd) + runner.exec_into(cmd) Then "exec is called with the docker run command" 1 * Kernel.exec("docker", "run", "--rm", "-v", "#{@project_root}:/project", "-w", "/project", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh") @@ -224,7 +220,7 @@ def teardown .with("dev-myapp-linux-content-abc123", shell_cmd: "./bin/build.sh", env: {}) .returns(["docker", "exec", "-w", "/project", "dev-myapp-linux-content-abc123", "sh", "-c", "./bin/build.sh"]) BuildContainer.expects(:docker_run_command).never - runner.run(cmd) + runner.exec_into(cmd) Then "exec is called with the docker exec command, not docker run" 1 * Kernel.exec("docker", "exec", "-w", "/project", "dev-myapp-linux-content-abc123", "sh", "-c", "./bin/build.sh") @@ -241,7 +237,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/deploy.sh", repl: false, container: false) When "we run the command" - runner.run(cmd) + runner.exec_into(cmd) Then "exec uses shadowenv, not docker" 1 * Kernel.exec(has_entries("GEM_HOME" => nil, "RUBYLIB" => anything), "shadowenv", "exec", "--", "sh", "-c", includes("./bin/deploy.sh")) @@ -257,7 +253,7 @@ def teardown cmd = Dev::ProjectCommand.new(run: "./bin/build.sh", repl: false) When "we run the command" - runner.run(cmd) + runner.exec_into(cmd) Then "exec uses shadowenv" 1 * Kernel.exec(has_entries("GEM_HOME" => nil, "RUBYLIB" => anything), "shadowenv", "exec", "--", "sh", "-c", includes("./bin/build.sh")) @@ -279,7 +275,7 @@ def teardown BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/test.sh --verbose", volumes: [], env: {}) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/test.sh --verbose"]) - runner.run(cmd, args: ["--verbose"]) + runner.exec_into(cmd, args: ["--verbose"]) Then "the args are included in the shell command passed to docker" 1 * @ui.print_header("./bin/test.sh --verbose") @@ -304,7 +300,7 @@ def teardown BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/build.sh", volumes: [], env: { "WWISE_TOKEN" => "tok-123" }) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) - runner.run(cmd) + runner.exec_into(cmd) Then "the ENV value is passed through to docker run" 1 * Kernel.exec("docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh") @@ -326,7 +322,7 @@ def teardown .with(config, project_root: @project_root, push: false, publish: true, build_args_provider: instance_of(Proc), secrets_provider: instance_of(Proc)) .returns("myregistry/myapp-linux:content-abc123") BuildContainer.stubs(:docker_run_command).returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) - runner.run(cmd) + runner.exec_into(cmd) Then "ensure_image! is asked to publish the resolved image" 1 * Kernel.exec("docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh") @@ -352,7 +348,7 @@ def teardown BuildContainer.expects(:docker_run_command) .with("myregistry/myapp-linux:content-abc123", project_root: @project_root, shell_cmd: "./bin/build.sh", volumes: [], env: {}) .returns(["docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh"]) - runner.run(cmd) + runner.exec_into(cmd) Then "no env is injected and the command still runs" 1 * Kernel.exec("docker", "run", "--rm", "myregistry/myapp-linux:content-abc123", "sh", "-c", "./bin/build.sh") diff --git a/test/dev/command_service_test.rb b/test/dev/command_service_test.rb index ac288fb..9f18b09 100644 --- a/test/dev/command_service_test.rb +++ b/test/dev/command_service_test.rb @@ -43,10 +43,11 @@ def build_service(builtins:, dependency_service:, executor: build_executor) ) end - # A real composite (these tests only dispatch builtins, in-process). + # A real composite (these tests only dispatch builtins, in-process; the + # project seam's runner is a strict mock that must stay silent). def build_executor builtin_executor = Dev::BuiltinExecutor.new - project_executor = Dev::ProjectExecutor.new + project_executor = Dev::ProjectExecutor.new(command_runner: typed_mock(Dev::CommandRunner)) Dev::CommandExecutor.new( builtin_executor: builtin_executor, project_executor: project_executor, diff --git a/test/dev/overridden_executor_test.rb b/test/dev/overridden_executor_test.rb index 2828834..41025cc 100644 --- a/test/dev/overridden_executor_test.rb +++ b/test/dev/overridden_executor_test.rb @@ -60,8 +60,8 @@ def build_strategies(stages) context = build_context stages = [] strategies = build_strategies(stages) - strategies.fetch(:project_executor).stubs(:run_waiting).with { |cmd, args:, context:| - stages << [:project_tail, cmd, args, context] + strategies.fetch(:project_executor).stubs(:run_waiting).with { |cmd, args:| + stages << [:project_tail, cmd, args] true } executor = Dev::OverriddenExecutor.new(**strategies) @@ -71,7 +71,7 @@ def build_strategies(stages) Then "builtin super() ran first, and the tail was run_waiting with the exact project half" stages == [ [:builtin_stage, command.builtin, ["--fast"], context], - [:project_tail, command.project, ["--fast"], context], + [:project_tail, command.project, ["--fast"]], ] end @@ -81,8 +81,8 @@ def build_strategies(stages) context = build_context stages = [] strategies = build_strategies(stages) - strategies.fetch(:project_executor).stubs(:exec_into).with { |cmd, args:, context:| - stages << [:project_tail, cmd, args, context] + strategies.fetch(:project_executor).stubs(:exec_into).with { |cmd, args:| + stages << [:project_tail, cmd, args] true } executor = Dev::OverriddenExecutor.new(**strategies) @@ -92,7 +92,7 @@ def build_strategies(stages) Then "builtin super() ran first, and the tail was exec_into with the exact project half" stages == [ [:builtin_stage, command.builtin, [], context], - [:project_tail, command.project, [], context], + [:project_tail, command.project, []], ] end end diff --git a/test/dev/project_executor_test.rb b/test/dev/project_executor_test.rb index 2697bd4..17b4752 100644 --- a/test/dev/project_executor_test.rb +++ b/test/dev/project_executor_test.rb @@ -4,151 +4,64 @@ require "test_helper" require "dev/project_executor" require "dev/command" -require "shadowenv_ruby" -require "fileutils" -require "pathname" -require "tmpdir" -# The one suite that exercises the Kernel.exec / Kernel.system process -# boundary: exec_into's tail-call shape, run_waiting's spawn-and-wait -# shape, and the failure paths of each. +# The strategy is a thin seam over the injected CommandRunner: each public +# message delegates to the runner's same-named message, and exec_into adds +# the never-returns honesty guard. The Kernel.exec / Kernel.system process +# shapes themselves are CommandRunner's contract (see command_runner_test). transform!(RSpock::AST::Transformation) class Dev::ProjectExecutorTest < Minitest::Test include SorbetHelper - def build_context(project_root) - ui = typed_mock(Dev::Cli::Ui) - ui.stubs(:print_header) - Dev::ExecutionContext.new(ui: ui, ruby_version: "4.0.1", project_root: project_root) + def build_command + Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) end - test "exec_into keeps the exec tail-call, never spawn-and-wait" do - Given "a project command, pinned to an empty project root" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("project-executor-exec-")) - command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) - executor = Dev::ProjectExecutor.new - # Provisioning's shell-out boundary; llvm/python provisioning no-op on - # an empty project root. - ShadowenvRuby.stubs(:ensure!) + test "exec_into hands the command to the runner's exec message with exact args" do + Given "an executor over a runner expecting the exec message" + command = build_command + command_runner = typed_mock(Dev::CommandRunner) + command_runner.expects(:exec_into).with(command, args: ["--fast", "spec/a"]).once + command_runner.expects(:run_waiting).never + executor = Dev::ProjectExecutor.new(command_runner: command_runner) - When "exec_into runs (the stubbed exec returns, so the honesty guard raises)" + When "exec_into runs (the mocked runner returns, so the honesty guard raises)" error = nil begin - executor.exec_into(command, args: [], context: build_context(root)) + executor.exec_into(command, args: ["--fast", "spec/a"]) rescue Dev::ProjectExecutor::ExecReturnedError => e error = e end - Then "the command exec-replaced the process, never spawn-and-wait" - 1 * Kernel.exec(anything, "shadowenv", "exec", "--", "sh", "-c", includes("./bin/test.sh")) - 0 * Kernel.system(any_parameters) + Then "the runner got the exec message, never the waiting one" !error.nil? - - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) - end - - test "exec_into forwards its args into the child's shell command" do - Given "a project command executed with args" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("project-executor-args-")) - command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) - executor = Dev::ProjectExecutor.new - ShadowenvRuby.stubs(:ensure!) - - When "exec_into runs with args" - begin - executor.exec_into(command, args: ["--fast", "spec/a"], context: build_context(root)) - rescue Dev::ProjectExecutor::ExecReturnedError - # Expected under a stubbed exec boundary; the argv assertion below is - # the point of this test. - end - - Then "the args are shell-joined onto the run string" - 1 * Kernel.exec(anything, "shadowenv", "exec", "--", "sh", "-c", includes("./bin/test.sh --fast spec/a")) - - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) end - test "a faked-out exec boundary raises ExecReturnedError" do - Given "an exec boundary that returns control instead of replacing the process" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("project-executor-exec-returned-")) - command = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) - executor = Dev::ProjectExecutor.new - ShadowenvRuby.stubs(:ensure!) - Kernel.stubs(:exec) + test "a returning exec boundary raises ExecReturnedError" do + Given "a runner whose exec message returns control instead of replacing the process" + command_runner = typed_mock(Dev::CommandRunner) + command_runner.stubs(:exec_into) + executor = Dev::ProjectExecutor.new(command_runner: command_runner) When "exec_into runs" - executor.exec_into(command, args: [], context: build_context(root)) + executor.exec_into(build_command, args: []) Then "the never-returns contract raises" raises Dev::ProjectExecutor::ExecReturnedError - - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) - end - - test "run_waiting spawns and waits, returning control on success" do - Given "a project command over a succeeding child, pinned to an empty project root" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("project-executor-wait-")) - command = Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false) - executor = Dev::ProjectExecutor.new - ShadowenvRuby.stubs(:ensure!) - # The wait-mode execution boundary is Kernel.system; record its argv. - child_argvs = [] - Kernel.stubs(:system).with { |*argv| - child_argvs << argv - true }.returns(true) - - When "run_waiting runs" - executor.run_waiting(command, args: [], context: build_context(root)) - - Then "the child was a waited spawn of the shell wrapper, never exec-replace" - child_argvs.size == 1 - child_argvs.fetch(0)[1..5] == ["shadowenv", "exec", "--", "sh", "-c"] - child_argvs.fetch(0).fetch(6).include?("./bin/up.rb") - 0 * Kernel.exec(any_parameters) - - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) end - test "a failing waited child raises CommandFailedError with the child's status" do - Given "a project command whose child exits 7" - original_cwd = Dir.pwd - root = Pathname.new(Dir.mktmpdir("project-executor-wait-fail-")) - command = Dev::ProjectCommand.new(run: "./bin/up.rb", desc: "Setup", container: false) - executor = Dev::ProjectExecutor.new - ShadowenvRuby.stubs(:ensure!) - Kernel.stubs(:system).returns(false) - # Kernel.system is stubbed, so wait on a real child here to leave the - # thread-local $? at exit status 7 — what a real failed child would set. - Process.wait(Process.spawn("sh", "-c", "exit 7")) - # Guard: a regression to exec-replace would otherwise replace the test - # process itself (Kernel.system above is stubbed, Kernel.exec is real). - Kernel.expects(:exec).never + test "run_waiting hands the command to the runner's waiting message and returns control" do + Given "an executor over a runner expecting the waiting message" + command = build_command + command_runner = typed_mock(Dev::CommandRunner) + command_runner.expects(:run_waiting).with(command, args: ["-v"]).once + command_runner.expects(:exec_into).never + executor = Dev::ProjectExecutor.new(command_runner: command_runner) When "run_waiting runs" - error = nil - begin - executor.run_waiting(command, args: [], context: build_context(root)) - rescue Dev::CommandRunner::CommandFailedError => e - error = e - end - - Then "the child's exit status rides the error" - error.exit_status == 7 + executor.run_waiting(command, args: ["-v"]) - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) + Then "control returned for the caller's post-execute steps" + true end end From 4477c6b33434639f67e7bbd46b2a1a8b9822025a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Date: Wed, 19 Aug 2026 10:31:02 -0400 Subject: [PATCH 7/7] Update the manifest-loader contract note for the eager toolchain pass with_toolchain now runs once per invocation (help included); the lazy --help carve-out is gone, so the comment states the real property: dependencies.rb is a declaration file and must stay cheap. Co-authored-by: Cursor --- src/dev/project_manifest_loader.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/dev/project_manifest_loader.rb b/src/dev/project_manifest_loader.rb index 88f27d2..a8aaab2 100644 --- a/src/dev/project_manifest_loader.rb +++ b/src/dev/project_manifest_loader.rb @@ -13,10 +13,11 @@ module Dev # The boundary coercion for a project's declaration files. Two passes, # each reading its file exactly once: # - # - #load parses dev.yml eagerly (usage needs the command list) and - # rejects the removed `ruby:` key at parse time. - # - #with_toolchain loads dependencies.rb — arbitrary Ruby, so it runs - # only once a command actually runs, never for `dev --help`. + # - #load parses dev.yml (Runner construction) and rejects the removed + # `ruby:` key at parse time. + # - #with_toolchain loads dependencies.rb, once per invocation as the + # ExecutionContext is assembled — help included, so dependencies.rb + # must stay cheap and side-effect-free (it is a declaration file). # # Stateless: reusable across manifests, inputs arrive as method arguments. class ProjectManifestLoader