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/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/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/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/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/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/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/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/src/dev/command_executor.rb b/src/dev/command_executor.rb index 1e15074..84e6efc 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:) 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/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 new file mode 100644 index 0000000..0ed45ca --- /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:) + else + @project_executor.exec_into(command.project, args:) + end + end + end +end diff --git a/src/dev/project_executor.rb b/src/dev/project_executor.rb new file mode 100644 index 0000000..cb8499b --- /dev/null +++ b/src/dev/project_executor.rb @@ -0,0 +1,56 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "command" +require_relative "command_runner" + +module Dev + # The process boundary for project commands: the only class that owns the + # 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. + 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 + + # @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 + # @return [void] never returns + # @raise [ExecReturnedError] if the exec boundary returns control + 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 + + # 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 + # @return [void] + # @raise [CommandRunner::CommandFailedError] when the child fails, + # carrying its exit status + 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/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 diff --git a/src/dev/runner.rb b/src/dev/runner.rb index 3dc9aed..25602b1 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,70 +15,94 @@ 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" 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 = argv.dup + [T.must(args.shift), args] + end - 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). + # 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 @@ -112,32 +137,73 @@ 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: CommandExecutor.new, + executor: build_executor(context), dependency_service: dependency_service, ) + service + end + + # 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 { 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(command_runner:) + 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 + # @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 new file mode 100644 index 0000000..1af163e --- /dev/null +++ b/test/dev/builtin_executor_test.rb @@ -0,0 +1,49 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/builtin_executor" +require "dev/command" +require "pathname" + +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 category = Dev::Command::Category::Workflow + + 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")) + end + + test "execute runs the builtin's Ruby body in-process with args and context" do + Given "a builtin fake and the executor" + builtin = FakeBuiltin.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/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 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 b3b0848..3c59565 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -4,189 +4,86 @@ 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) - super() - @stamps = stamps - @calls = [] - @body = body - end - - def desc = "a builtin" - - def stamps? = @stamps - - def call(args:, context:) - @calls << [args, context] - @body&.call - end -end unless defined?(ExecutorFakeBuiltin) transform!(RSpock::AST::Transformation) class Dev::CommandExecutorTest < Minitest::Test include SorbetHelper - def build_context(project_root) + # 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 category = Dev::Command::Category::Workflow + + def call(args:, context:); end + end + + 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 + + # 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 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) + test "a builtin command dispatches to the builtin strategy with exact args" do + Given "a composite whose builtin strategy expects the dispatch" + command = FakeBuiltin.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) When "executing" - executor.execute(builtin, args: ["--verbose"], context: context) - - Then "the builtin received args and context; no child process was involved" - builtin.calls == [[["--verbose"], context]] + executor.execute(command, args: ["--verbose"], context: context) - Cleanup - FileUtils.rm_rf(root) + Then "the expectation held and no other strategy was consulted" + true 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-")) + 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) - executor = Dev::CommandExecutor.new - # Provisioning's shell-out boundary; llvm/python provisioning no-op on - # an empty project root. - ShadowenvRuby.stubs(:ensure!) + context = build_context + strategies = build_strategies + strategies.fetch(:project_executor) + .expects(:exec_into).with(command, args: ["--fast", "spec/a"]).once + executor = Dev::CommandExecutor.new(**strategies) - When "executing a non-stamping command" - executor.execute(command, args: [], context: build_context(root)) - - 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) - - Cleanup - Dir.chdir(original_cwd) - FileUtils.rm_rf(root) - end + When "executing" + executor.execute(command, args: ["--fast", "spec/a"], context: context) - # 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)) - - 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) - - 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: FakeBuiltin.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_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_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 8e178bb..9f18b09 100644 --- a/test/dev/command_service_test.rb +++ b/test/dev/command_service_test.rb @@ -6,34 +6,36 @@ 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 category = Dev::Command::Category::Workflow -transform!(RSpock::AST::Transformation) -class Dev::CommandServiceTest < Minitest::Test - include SorbetHelper + def staleness_exempt? = @staleness_exempt + + def stamps? = @stamps + + def call(args:, context:) + @calls << [args, context] + end + end - def build_service(builtins:, dependency_service:, executor: Dev::CommandExecutor.new) + def build_service(builtins:, dependency_service:, executor: build_executor) Dev::CommandService.new( repository: Dev::CommandRepository.new(builtins: builtins, project_commands: {}), executor: executor, @@ -41,6 +43,20 @@ def build_service(builtins:, dependency_service:, executor: Dev::CommandExecutor ) end + # 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(command_runner: typed_mock(Dev::CommandRunner)) + 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), @@ -58,7 +74,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 @@ -85,7 +101,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, ) @@ -102,7 +118,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, ) @@ -119,7 +135,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, ) @@ -136,7 +152,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, ) @@ -155,7 +171,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, ) @@ -169,7 +185,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/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 diff --git a/test/dev/overridden_executor_test.rb b/test/dev/overridden_executor_test.rb new file mode 100644 index 0000000..41025cc --- /dev/null +++ b/test/dev/overridden_executor_test.rb @@ -0,0 +1,98 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/overridden_executor" +require "dev/command" +require "pathname" + +transform!(RSpock::AST::Transformation) +class Dev::OverriddenExecutorTest < Minitest::Test + include SorbetHelper + + # 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 desc = "a builtin" + + def category = Dev::Command::Category::Workflow + + def stamps? = @stamps + + 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/overridden-executor")) + end + + def build_command(stamps:) + Dev::OverriddenCommand.new( + builtin: FakeBuiltin.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:| + stages << [:project_tail, cmd, args] + 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"]], + ] + 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:| + stages << [:project_tail, cmd, args] + 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, []], + ] + end +end diff --git a/test/dev/project_executor_test.rb b/test/dev/project_executor_test.rb new file mode 100644 index 0000000..17b4752 --- /dev/null +++ b/test/dev/project_executor_test.rb @@ -0,0 +1,67 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/project_executor" +require "dev/command" + +# 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_command + Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests", container: false) + end + + 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 mocked runner returns, so the honesty guard raises)" + error = nil + begin + executor.exec_into(command, args: ["--fast", "spec/a"]) + rescue Dev::ProjectExecutor::ExecReturnedError => e + error = e + end + + Then "the runner got the exec message, never the waiting one" + !error.nil? + end + + 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(build_command, args: []) + + Then "the never-returns contract raises" + raises Dev::ProjectExecutor::ExecReturnedError + end + + 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" + executor.run_waiting(command, args: ["-v"]) + + Then "control returned for the caller's post-execute steps" + true + end +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