diff --git a/src/dev/builtins.rb b/src/dev/builtins.rb index e3c3c8c..0eb0ede 100644 --- a/src/dev/builtins.rb +++ b/src/dev/builtins.rb @@ -2,11 +2,12 @@ # frozen_string_literal: true module Dev - # One class per builtin dev command. Each exposes call(args:, context:) - # with its collaborators constructor-injected; per-call values stay - # method-side. The composition root (Runner) decides which builtins exist - # for a given project (config-gated: runner-setup only with a `runner:` - # block, provide-image/reset-container only with a build container). + # One class per builtin dev command, each subclassing BuiltinCommand — + # the sealed Command hierarchy's declared open edge — with its + # collaborators constructor-injected; per-call values stay method-side. + # The composition root (Runner) decides which builtins exist for a given + # project (config-gated: runner-setup only with a `runner:` block, + # provide-image/reset-container only with a build container). module Builtins; end end diff --git a/src/dev/builtins/cache_command.rb b/src/dev/builtins/cache_command.rb index f130fe7..8925dca 100644 --- a/src/dev/builtins/cache_command.rb +++ b/src/dev/builtins/cache_command.rb @@ -24,6 +24,7 @@ def initialize( cache_gc_factory: ->(lockfile) { Dev::Deps::CacheGc.new(lockfile:) }, flag_parser: Cli::FlagParser.new ) + super() @cache_gc_factory = T.let(cache_gc_factory, CacheGcFactory) @flag_parser = T.let(flag_parser, Cli::FlagParser) end diff --git a/src/dev/builtins/cd_command.rb b/src/dev/builtins/cd_command.rb index 65f2b2f..3325d9d 100644 --- a/src/dev/builtins/cd_command.rb +++ b/src/dev/builtins/cd_command.rb @@ -14,6 +14,7 @@ class CdCommand < BuiltinCommand sig { params(accessor: Dev::Cd::Accessor).void } def initialize(accessor: Dev::Cd::Accessor.new) + super() @accessor = T.let(accessor, Dev::Cd::Accessor) end diff --git a/src/dev/builtins/check_command.rb b/src/dev/builtins/check_command.rb index 010e2e6..7209424 100644 --- a/src/dev/builtins/check_command.rb +++ b/src/dev/builtins/check_command.rb @@ -13,6 +13,7 @@ class CheckCommand < BuiltinCommand sig { params(dependency_service: DependencyService).void } def initialize(dependency_service:) + super() @dependency_service = T.let(dependency_service, DependencyService) end diff --git a/src/dev/builtins/clone_command.rb b/src/dev/builtins/clone_command.rb index 368ad38..5e41dcf 100644 --- a/src/dev/builtins/clone_command.rb +++ b/src/dev/builtins/clone_command.rb @@ -14,6 +14,7 @@ class CloneCommand < BuiltinCommand sig { params(accessor: Dev::Clone::Accessor).void } def initialize(accessor: Dev::Clone::Accessor.new) + super() @accessor = T.let(accessor, Dev::Clone::Accessor) end diff --git a/src/dev/builtins/cred_command.rb b/src/dev/builtins/cred_command.rb index 3b33161..9449112 100644 --- a/src/dev/builtins/cred_command.rb +++ b/src/dev/builtins/cred_command.rb @@ -14,6 +14,7 @@ class CredCommand < BuiltinCommand sig { params(accessor: Dev::CredentialAccessor).void } def initialize(accessor: Dev::CredentialAccessor.new) + super() @accessor = T.let(accessor, Dev::CredentialAccessor) end diff --git a/src/dev/builtins/deps_command.rb b/src/dev/builtins/deps_command.rb index ee4ecfd..766af33 100644 --- a/src/dev/builtins/deps_command.rb +++ b/src/dev/builtins/deps_command.rb @@ -29,6 +29,7 @@ def initialize( ) } ) + super() @accessor_factory = T.let(accessor_factory, AccessorFactory) end diff --git a/src/dev/builtins/install_deps_command.rb b/src/dev/builtins/install_deps_command.rb index 7aa6fe0..f101969 100644 --- a/src/dev/builtins/install_deps_command.rb +++ b/src/dev/builtins/install_deps_command.rb @@ -50,6 +50,7 @@ def initialize( gem_skill_linker_factory: ->(project_root) { Dev::Deps::GemSkillLinker.new(project_root:) }, synchronizer: Dev::Learnings::Synchronizer.for ) + super() @installer_factory = T.let(installer_factory, InstallerFactory) @gem_skill_linker_factory = T.let(gem_skill_linker_factory, GemSkillLinkerFactory) @synchronizer = T.let(synchronizer, T.untyped) diff --git a/src/dev/builtins/learnings_command.rb b/src/dev/builtins/learnings_command.rb index 44c08b4..ce8456a 100644 --- a/src/dev/builtins/learnings_command.rb +++ b/src/dev/builtins/learnings_command.rb @@ -20,6 +20,7 @@ class LearningsCommand < BuiltinCommand sig { params(accessor_factory: AccessorFactory).void } def initialize(accessor_factory: ->(project_root) { Dev::Learnings::Accessor.new(project_root:) }) + super() @accessor_factory = T.let(accessor_factory, AccessorFactory) end diff --git a/src/dev/builtins/plan_command.rb b/src/dev/builtins/plan_command.rb index 4109640..1327f73 100644 --- a/src/dev/builtins/plan_command.rb +++ b/src/dev/builtins/plan_command.rb @@ -20,6 +20,7 @@ class PlanCommand < BuiltinCommand sig { params(accessor_factory: AccessorFactory).void } def initialize(accessor_factory: ->(project_root) { Dev::Plan::Accessor.new(project_root:) }) + super() @accessor_factory = T.let(accessor_factory, AccessorFactory) end diff --git a/src/dev/builtins/runner_setup_command.rb b/src/dev/builtins/runner_setup_command.rb index 925be32..2d4df5d 100644 --- a/src/dev/builtins/runner_setup_command.rb +++ b/src/dev/builtins/runner_setup_command.rb @@ -36,6 +36,7 @@ def initialize( runner_setup_factory: ->(config, repo, org) { Dev::RunnerSetup.new(config:, repo:, org:) }, flag_parser: Cli::FlagParser.new ) + super() @runner_setup_factory = T.let(runner_setup_factory, RunnerSetupFactory) @flag_parser = T.let(flag_parser, Cli::FlagParser) end diff --git a/src/dev/builtins/up_command.rb b/src/dev/builtins/up_command.rb index 0271382..71b244b 100644 --- a/src/dev/builtins/up_command.rb +++ b/src/dev/builtins/up_command.rb @@ -23,6 +23,7 @@ class UpCommand < BuiltinCommand ).void end def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new) + super() @install_deps_command = T.let(install_deps_command, InstallDepsCommand) @hook_installer = T.let(hook_installer, Dev::Cd::HookInstaller) end diff --git a/src/dev/command.rb b/src/dev/command.rb index 79178fc..9e5ae59 100644 --- a/src/dev/command.rb +++ b/src/dev/command.rb @@ -4,20 +4,31 @@ require_relative "execution_context" module Dev - # Sealed command data hierarchy. A command is one of exactly three shapes: + # Sealed command hierarchy. A command is one of exactly three shapes: # - # - BuiltinCommand: a Ruby body dev ships (abstract here; concretes live - # under src/dev/builtins/) + # - BuiltinCommand: a Ruby body dev ships (an abstract class, the + # hierarchy's one declared open edge; subclasses live under + # src/dev/builtins/) # - ProjectCommand: pure data parsed from a dev.yml `commands:` entry # - OverriddenCommand: a project command occupying a builtin's slot (the # builtin runs first, like a hardcoded super()) # - # Sealing makes any other nesting unrepresentable: CommandExecutor - # dispatches exhaustively over these three variants (case + T.absurd). - # Sorbet requires the direct subclasses of a sealed class beside it, which - # is why the whole hierarchy shares this file; BuiltinCommand is abstract - # but deliberately NOT sealed, so its concretes get their own files. - class Command + # Sealing makes a fourth variant unrepresentable: CommandExecutor + # dispatches exhaustively over these three (case + T.absurd), and Sorbet + # requires a sealed module's direct heirs beside it, which is why the + # hierarchy shares this file. + # + # Command is a module rather than a class deliberately. A sealed class's + # runtime `inherited` hook rides down the singleton chain to every + # descendant, so builtins subclassing an abstract BuiltinCommand class + # raise at definition time unless sorbet-runtime internals are faked open + # (the ivar pokes this file used to carry). A sealed module's `included` + # hook fires only for its direct includers — the three heirs below — + # because `include` never transfers singleton methods, so subclassing + # BuiltinCommand is an honest open edge with nothing to suppress. Descent + # is closed everywhere it is not explicitly declared: the two data leaves + # are final!. + module Command extend T::Sig extend T::Helpers abstract! @@ -46,53 +57,56 @@ def staleness_exempt? = false def stamps? = false end - # Built-in command that executes Ruby code. Concretes live under - # src/dev/builtins/, one class per builtin, with collaborators injected - # through their constructors; per-call values arrive through #call. - class BuiltinCommand < Command + # Built-in command that executes Ruby code: the hierarchy's declared open + # edge. Subclasses live under src/dev/builtins/, one class per builtin, + # with collaborators injected through their constructors; per-call values + # arrive through #call. Test fakes subclass it the same way. + # + # A class rather than a module because Sorbet flattens module mixins: + # were this a module, every includer would gain sealed Command as a + # direct mixin in the symbol table and fail the same-file check + # statically. A superclass edge is not flattened, so subclasses inherit + # Command's membership without re-including it — legal statically, and + # invisible to the seal's runtime hooks. + class BuiltinCommand extend T::Sig extend T::Helpers + include Command abstract! sig { abstract.params(args: T::Array[String], context: ExecutionContext).void } def call(args:, context:); end end - # Statically, `sealed!` binds only Command's direct subclasses, so - # BuiltinCommand concretes may live in their own files — but sorbet-runtime's - # inherited hook also rides down to BuiltinCommand's subclasses and would - # reject them for lacking a sealed declaration. Registering an empty - # decl-file prefix marks BuiltinCommand as the hierarchy's deliberately open - # edge: the hook accepts subclasses from any file (src/dev/builtins/, test - # fakes), matching the static rule. - BuiltinCommand.instance_variable_set(:@sorbet_sealed_module_decl_file, "") - BuiltinCommand.instance_variable_set(:@sorbet_sealed_module_all_subclasses, []) - # Project command from a dev.yml `commands:` entry. Pure data: the run # string, optional description, repl flag, and container opt-out. When # build.container is declared, commands run inside the container by # default unless container: false. - class ProjectCommand < Command + class ProjectCommand extend T::Sig + extend T::Helpers + include Command + final! - sig { returns(String) } + sig(:final) { returns(String) } attr_reader :run - sig { override.returns(String) } + sig(:final) { override.returns(String) } attr_reader :desc - sig { returns(T::Boolean) } + sig(:final) { returns(T::Boolean) } attr_reader :repl # Whether this command should run inside the build container (when one is # configured). Defaults to true; set to false via `container: false` in dev.yml. - sig { returns(T::Boolean) } + sig(:final) { returns(T::Boolean) } attr_reader :container - sig do + sig(:final) do params(run: String, desc: String, repl: T::Boolean, container: T::Boolean, hidden: T::Boolean).void end def initialize(run:, desc: "(no description)", repl: false, container: true, hidden: false) + super() @run = T.let(run, String) @desc = T.let(desc, String) @repl = T.let(repl, T::Boolean) @@ -100,10 +114,10 @@ def initialize(run:, desc: "(no description)", repl: false, container: true, hid @hidden = T.let(hidden, T::Boolean) end - sig { override.returns(T::Boolean) } + sig(:final) { override.returns(T::Boolean) } def hidden? = @hidden - sig { params(other: Object).returns(T::Boolean) } + sig(:final) { params(other: Object).returns(T::Boolean) } def ==(other) return false unless other.is_a?(ProjectCommand) @@ -111,12 +125,12 @@ def ==(other) @container == other.container && @hidden == other.hidden? end - sig { params(other: Object).returns(T::Boolean) } + sig(:final) { params(other: Object).returns(T::Boolean) } def eql?(other) self == other end - sig { returns(Integer) } + sig(:final) { returns(Integer) } def hash [@run, @desc, @repl, @container, @hidden].hash end @@ -126,36 +140,40 @@ def hash # dispatch: the override owns the slot, and its implementation calls # super() at the top — CommandExecutor runs the builtin body first, then # the project command. - class OverriddenCommand < Command + class OverriddenCommand extend T::Sig + extend T::Helpers + include Command + final! - sig { returns(BuiltinCommand) } + sig(:final) { returns(BuiltinCommand) } attr_reader :builtin - sig { returns(ProjectCommand) } + sig(:final) { returns(ProjectCommand) } attr_reader :project - sig { params(builtin: BuiltinCommand, project: ProjectCommand).void } + sig(:final) { params(builtin: BuiltinCommand, project: ProjectCommand).void } def initialize(builtin:, project:) + super() @builtin = T.let(builtin, BuiltinCommand) @project = T.let(project, ProjectCommand) end # The override owns the slot, so its description wins — a project `up:` # shows its own desc in usage, not the generic builtin one. - sig { override.returns(String) } + sig(:final) { override.returns(String) } def desc = @project.desc - sig { override.returns(T::Boolean) } + sig(:final) { override.returns(T::Boolean) } def hidden? = @project.hidden? # Guard and stamp traits belong to the slot, not the override: a project # `up:` still is the provisioning command, so it inherits the builtin's # exemption and stamping behavior. - sig { override.returns(T::Boolean) } + sig(:final) { override.returns(T::Boolean) } def staleness_exempt? = @builtin.staleness_exempt? - sig { override.returns(T::Boolean) } + sig(:final) { override.returns(T::Boolean) } def stamps? = @builtin.stamps? end end diff --git a/src/dev/command_repository.rb b/src/dev/command_repository.rb index c35fad0..eea700c 100644 --- a/src/dev/command_repository.rb +++ b/src/dev/command_repository.rb @@ -10,8 +10,8 @@ module Dev # builtin's slot — their OverriddenCommand composition. Data in, never a # path, never a parse. # - # Onion rule: CommandService is the only production consumer; the constant - # is private and construction is confined to the composition root. + # Onion rule: CommandService is the only production consumer, and + # construction is confined to the composition root. class CommandRepository extend T::Sig @@ -77,6 +77,4 @@ def assemble(builtins, project_commands) commands end end - - private_constant :CommandRepository end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index d3fe3c2..3dc9aed 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -105,10 +105,11 @@ def exit_for(error) end end - # The composition root: the one place the repository (private to this - # onion) and the builtin set are constructed. Which builtins exist is - # config-gated here — runner-setup only with a `runner:` block, - # provide-image/reset-container only with a build container. + # The composition root: the one place the repository (consumed only by + # CommandService, the onion rule) and the builtin set are constructed. + # Which builtins exist is config-gated here — runner-setup only with a + # `runner:` block, provide-image/reset-container only with a build + # container. # # @param manifest [ProjectManifest] # @return [CommandService] diff --git a/test/dev/command_executor_test.rb b/test/dev/command_executor_test.rb index cc01e1e..b3b0848 100644 --- a/test/dev/command_executor_test.rb +++ b/test/dev/command_executor_test.rb @@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand attr_reader :calls def initialize(stamps: false, &body) + super() @stamps = stamps @calls = [] @body = body - super() end def desc = "a builtin" @@ -51,7 +51,7 @@ def build_context(project_root) When "executing" executor.execute(builtin, args: ["--verbose"], context: context) - Then "the body received args and context; no child process was involved" + Then "the builtin received args and context; no child process was involved" builtin.calls == [[["--verbose"], context]] Cleanup diff --git a/test/dev/command_repository_test.rb b/test/dev/command_repository_test.rb index 8382464..084e9ac 100644 --- a/test/dev/command_repository_test.rb +++ b/test/dev/command_repository_test.rb @@ -5,19 +5,12 @@ require "dev/command_repository" require "dev/command" -# The repository is service-private in production (private_constant, onion -# rule); tests alias it through const_get rather than weakening the privacy. -CommandRepositoryUnderTest = Dev.const_get(:CommandRepository) unless defined?(CommandRepositoryUnderTest) -unless defined?(CommandNotFoundErrorUnderTest) - CommandNotFoundErrorUnderTest = CommandRepositoryUnderTest.const_get(:CommandNotFoundError) -end - # A named no-op builtin for assembly assertions. class RepositoryFakeBuiltin < Dev::BuiltinCommand def initialize(desc: "a builtin", hidden: false) + super() @desc = desc @hidden = hidden - super() end attr_reader :desc @@ -29,10 +22,14 @@ def call(args:, context:); end transform!(RSpock::AST::Transformation) class Dev::CommandRepositoryTest < Minitest::Test + def build_builtin(desc: "a builtin", hidden: false) + RepositoryFakeBuiltin.new(desc: desc, hidden: hidden) + end + test "fetch returns a builtin-only command as the builtin" do Given "a repository with one builtin and no project commands" - builtin = RepositoryFakeBuiltin.new(desc: "resolve deps") - repository = CommandRepositoryUnderTest.new(builtins: { "update-deps" => builtin }, project_commands: {}) + builtin = build_builtin(desc: "resolve deps") + repository = Dev::CommandRepository.new(builtins: { "update-deps" => builtin }, project_commands: {}) Expect "the builtin occupies its slot" repository.fetch("update-deps") == builtin @@ -41,7 +38,7 @@ class Dev::CommandRepositoryTest < Minitest::Test test "fetch returns a project-only command as the ProjectCommand" do Given "a repository with one project command and no builtins" project = Dev::ProjectCommand.new(run: "./bin/test.sh", desc: "Run tests") - repository = CommandRepositoryUnderTest.new(builtins: {}, project_commands: { "test" => project }) + repository = Dev::CommandRepository.new(builtins: {}, project_commands: { "test" => project }) Expect "the project command occupies its slot" repository.fetch("test") == project @@ -49,9 +46,9 @@ class Dev::CommandRepositoryTest < Minitest::Test test "a project command on a builtin's name composes into an OverriddenCommand" do Given "a repository where a project up: collides with the up builtin" - builtin = RepositoryFakeBuiltin.new(desc: "built-in up") + builtin = build_builtin(desc: "built-in up") project = Dev::ProjectCommand.new(run: "./bin/up.sh", desc: "project up") - repository = CommandRepositoryUnderTest.new( + repository = Dev::CommandRepository.new( builtins: { "up" => builtin }, project_commands: { "up" => project }, ) @@ -68,20 +65,20 @@ class Dev::CommandRepositoryTest < Minitest::Test test "fetch raises CommandNotFoundError for an unknown name" do Given "an empty repository" - repository = CommandRepositoryUnderTest.new(builtins: {}, project_commands: {}) + repository = Dev::CommandRepository.new(builtins: {}, project_commands: {}) When "fetching a nonexistent command" repository.fetch("nope") Then - raises CommandNotFoundErrorUnderTest + raises Dev::CommandRepository::CommandNotFoundError end test "visible_commands lists builtins then project commands, overrides in the builtin's position" do Given "a repository with a builtin, a project command, and an override" - builtin = RepositoryFakeBuiltin.new(desc: "built-in up") - repository = CommandRepositoryUnderTest.new( - builtins: { "update-deps" => RepositoryFakeBuiltin.new(desc: "resolve"), "up" => builtin }, + builtin = build_builtin(desc: "built-in up") + repository = Dev::CommandRepository.new( + builtins: { "update-deps" => build_builtin(desc: "resolve"), "up" => builtin }, project_commands: { "up" => Dev::ProjectCommand.new(run: "./bin/up.sh", desc: "project up"), "test" => Dev::ProjectCommand.new(run: "rspec", desc: "Run tests"), @@ -98,9 +95,9 @@ class Dev::CommandRepositoryTest < Minitest::Test test "visible_commands omits hidden commands but fetch still resolves them" do Given "a repository with a hidden builtin" - hidden = RepositoryFakeBuiltin.new(desc: "plumbing", hidden: true) - repository = CommandRepositoryUnderTest.new( - builtins: { "provide-image" => hidden, "up" => RepositoryFakeBuiltin.new }, + hidden = build_builtin(desc: "plumbing", hidden: true) + repository = Dev::CommandRepository.new( + builtins: { "provide-image" => hidden, "up" => build_builtin }, project_commands: {}, ) diff --git a/test/dev/command_service_test.rb b/test/dev/command_service_test.rb index 1df5aa9..8e178bb 100644 --- a/test/dev/command_service_test.rb +++ b/test/dev/command_service_test.rb @@ -12,10 +12,10 @@ class ServiceFakeBuiltin < Dev::BuiltinCommand attr_reader :calls def initialize(staleness_exempt: false, stamps: false) + super() @staleness_exempt = staleness_exempt @stamps = stamps @calls = [] - super() end def desc = "a builtin" @@ -33,12 +33,9 @@ def call(args:, context:) class Dev::CommandServiceTest < Minitest::Test include SorbetHelper - CommandRepositoryClass = Dev.const_get(:CommandRepository) - CommandNotFoundErrorClass = CommandRepositoryClass.const_get(:CommandNotFoundError) - def build_service(builtins:, dependency_service:, executor: Dev::CommandExecutor.new) Dev::CommandService.new( - repository: CommandRepositoryClass.new(builtins: builtins, project_commands: {}), + repository: Dev::CommandRepository.new(builtins: builtins, project_commands: {}), executor: executor, dependency_service: dependency_service, ) @@ -68,7 +65,7 @@ def fake_dependency_service When "executing the command" service.execute("deps", args: ["path", "xcode"], context: context) - Then "the builtin body ran once with the args and context" + Then "the builtin ran once with the args and context" builtin.calls == [[["path", "xcode"], context]] end @@ -80,7 +77,7 @@ def fake_dependency_service service.execute("nonexistent", args: [], context: fake_context) Then "the error bubbles under its native namespace" - raises CommandNotFoundErrorClass + raises Dev::CommandRepository::CommandNotFoundError end test "execute guards staleness before a non-exempt command" do @@ -175,7 +172,7 @@ def fake_dependency_service builtin = ServiceFakeBuiltin.new service = build_service(builtins: { "deps" => builtin }, dependency_service: fake_dependency_service) - Expect "the usage view flows through the service (the repository stays private)" + Expect "the usage view flows through the service (the onion rule)" service.visible_commands == { "deps" => builtin } end end diff --git a/test/dev/command_test.rb b/test/dev/command_test.rb index d4abf4e..2ad5ef7 100644 --- a/test/dev/command_test.rb +++ b/test/dev/command_test.rb @@ -4,19 +4,22 @@ require "test_helper" require "dev/command" -# A minimal concrete builtin for exercising the abstract base's defaults -# and the OverriddenCommand composition. -class FakeBuiltinCommand < Dev::BuiltinCommand - def initialize(desc: "fake builtin", staleness_exempt: false, stamps: false, &body) +# 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) + super() @desc = desc + @hidden = hidden @staleness_exempt = staleness_exempt @stamps = stamps @body = body - super() end attr_reader :desc + def hidden? = @hidden + def staleness_exempt? = @staleness_exempt def stamps? = @stamps @@ -24,11 +27,12 @@ def stamps? = @stamps def call(args:, context:) @body&.call(args, context) end -end unless defined?(FakeBuiltinCommand) +end unless defined?(FakeBuiltin) transform!(RSpock::AST::Transformation) class CommandTest < Minitest::Test extend T::Sig + include SorbetHelper test "initialize with only run uses default desc and repl" do Given "we build a ProjectCommand with only run" @@ -96,9 +100,57 @@ class CommandTest < Minitest::Test cmd.hidden? end + test "a builtin without trait overrides gets the Command defaults" do + Given "a builtin defining only desc and call" + builtin = Class.new(Dev::BuiltinCommand) do + def desc = "minimal" + + def call(args:, context:); end + end.new + + Expect "the Command trait defaults hold" + !builtin.hidden? + !builtin.staleness_exempt? + !builtin.stamps? + end + + test "subclassing BuiltinCommand is the hierarchy's declared open edge" do + Given "a builtin subclass" + builtin = FakeBuiltin.new(desc: "open edge") + + Expect "it enters the sealed hierarchy through the BuiltinCommand variant" + builtin.is_a?(Dev::BuiltinCommand) + builtin.is_a?(Dev::Command) + builtin.desc == "open edge" + end + + test "including Command directly raises: the seal admits only its three declared variants" do + When "including the sealed module outside its declaring file" + Class.new { include Dev::Command } + + Then "sorbet-runtime rejects the include" + raises RuntimeError + end + + test "ProjectCommand is final: subclassing raises, keeping descent closed" do + When "declaring a subclass of the data leaf" + Class.new(Dev::ProjectCommand) + + Then "sorbet-runtime rejects the open edge" + raises RuntimeError + end + + test "OverriddenCommand is final: subclassing raises, keeping descent closed" do + When "declaring a subclass of the data leaf" + Class.new(Dev::OverriddenCommand) + + Then "sorbet-runtime rejects the open edge" + raises RuntimeError + end + test "an OverriddenCommand takes desc and hidden from the project override" do Given "a builtin slot overridden by a hidden project command" - builtin = FakeBuiltinCommand.new(desc: "builtin up") + builtin = FakeBuiltin.new(desc: "builtin up") project = Dev::ProjectCommand.new(run: "./bin/up.sh", desc: "project up", hidden: true) cmd = Dev::OverriddenCommand.new(builtin: builtin, project: project) @@ -109,7 +161,7 @@ class CommandTest < Minitest::Test test "an OverriddenCommand takes guard and stamp traits from the builtin slot" do Given "a stamping, staleness-exempt builtin slot overridden by a project command" - builtin = FakeBuiltinCommand.new(staleness_exempt: true, stamps: true) + builtin = FakeBuiltin.new(staleness_exempt: true, stamps: true) project = Dev::ProjectCommand.new(run: "./bin/up.sh", desc: "project up") cmd = Dev::OverriddenCommand.new(builtin: builtin, project: project) @@ -120,7 +172,7 @@ class CommandTest < Minitest::Test test "an OverriddenCommand exposes its typed halves" do Given "an overridden command" - builtin = FakeBuiltinCommand.new + builtin = FakeBuiltin.new project = Dev::ProjectCommand.new(run: "./bin/up.sh") cmd = Dev::OverriddenCommand.new(builtin: builtin, project: project)