Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/dev/builtins.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cache_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cd_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/check_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/clone_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/cred_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ def initialize(
)
}
)
super()
@accessor_factory = T.let(accessor_factory, AccessorFactory)
end

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/learnings_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/plan_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/dev/builtins/up_command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
100 changes: 59 additions & 41 deletions src/dev/command.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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!
Expand DownExpand Up@@ -46,77 +57,80 @@ 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)
@container = T.let(container, T::Boolean)
@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)

@run == other.run && @desc == other.desc && @repl == other.repl &&
@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
Expand All@@ -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
6 changes: 2 additions & 4 deletions src/dev/command_repository.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -77,6 +77,4 @@ def assemble(builtins, project_commands)
commands
end
end

private_constant :CommandRepository
end
9 changes: 5 additions & 4 deletions src/dev/runner.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -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]
Expand Down
4 changes: 2 additions & 2 deletions test/dev/command_executor_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,10 +15,10 @@ class ExecutorFakeBuiltin < Dev::BuiltinCommand
attr_reader :calls

def initialize(stamps: false, &body)
super()
@stamps = stamps
@calls = []
@body = body
super()

@JPDuchesneJPDuchesneAug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask what's with this line change?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/ask

The removed super() was a no-op, so the diff is dead-code cleanup in the test fake. ExecutorFakeBuiltin subclasses Dev::BuiltinCommand, and that base class (see src/dev/command.rb) defines no initialize of its own — it's an abstract class that only declares the abstract call method, and Command is a plain module with no constructor either. So super() fell through to Object#initialize, which takes no arguments and does nothing; the line initialized nothing and its presence wrongly implied the base class had constructor behavior to run. Removing it also matches how the real builtins under src/dev/builtins/ are written — e.g. CdCommand and CredCommand define initialize without calling super. Behavior of the tests in test/dev/command_executor_test.rb is unchanged. (One caveat: git history access is restricted in this checkout, so I can't confirm whether the line was a leftover from an earlier BuiltinCommand shape — the file's comments mention it previously carried sorbet-runtime workarounds — but as the code stands today the call is provably inert.)

⚙️ workflow run · model: claude-fable-5-high

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b8ed631: the super() calls are restored — and the convention is now applied uniformly. The bot's replies were right that the calls are inert today, but that misses the point of the rule: an explicit super() means future base-class constructor state either runs automatically (defaulted params) or fails loudly at first instantiation (required params), instead of silently constructing half-initialized objects. The fakes had actually been the only callers — the 11 real builtins never called super() — so the commit adds it everywhere an initializer derives from the hierarchy: both data leaves, all builtins, and the fakes.

end

def desc = "a builtin"
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading