Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract CLI commands to individual classes #351

Merged
merged 46 commits into from
Jun 2, 2023
Merged

Conversation

davidstosik
Copy link
Contributor

@davidstosik davidstosik commented Apr 20, 2023

What are you trying to accomplish?

Packwerk's code around the CLI is currently hard to understand, mixes concepts together, and is overall very difficult to change. (Changing a seemingly simple thing in the CLI would sometimes requires editing many files).

This PR attempts a refactor of the code CLI in order to make it

  1. easier to understand
  2. easier to modify

What approach did you choose and why?

The first approach that we tried with @gmcgibbon involved using Thor, but we realized that using Thor while not breaking public APIs would be overly complicated, and end up trading one type of complexity for another.

In this PR, my approach was mainly to:

  1. separate each CLI command to its own class
  2. avoid introducing behaviour changes in public APIs (the CLI should behave the same, return same results and output same text, public classes should keep the same public methods with the same signature and expected behaviour)
  3. make the CLI open-closed, allowing an external actor (such as a plugin?) to declare new CLI commands without having to monkey-patch any code in Packwerk.

What should reviewers focus on?

I know this looks rather big, but to this PR's defense, about 500 lines additions/deletions are test code lines that moved to a different file.
I left a few comments which you can check below about the things I'm unsure and would like a second opinion about.

If the approach sounds useful to you, you can also check the changes commit by commit, as I have made each commit a logical and justified change, ensuring that each commit also keeps the test suite green.

Type of Change

  • Bugfix
  • New feature
  • Non-breaking change (a change that doesn't alter functionality - i.e., code refactor, configs, etc.)

Checklist

  • I have updated the documentation accordingly.
  • I have added tests to cover my changes.
  • It is safe to rollback this change.

autoload_under "commands" do
autoload :OffenseProgressMarker
end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This does not seem necessary, and was actually interfering with the autoload :Commands that we added above.

Copy link
Member

Choose a reason for hiding this comment

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

👍

Seems it was left behind in #124

true
when nil, "help"
usage
command = args.shift || "help"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If no command was passed, then default to help. Keeps the behaviour as before.

def class_for(command)
class_name = command.sub(" ", "_").underscore.classify + "Command"
if Commands.const_defined?(class_name)
Commands.const_get(class_name) # rubocop:disable Sorbet/ConstantsFromStrings
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried with the registry pattern first, but it required me to require all the files (with Dir[] and a glob for example) preemptively.
Using const_get, we can keep lazy loading.

Copy link
Member

Choose a reason for hiding this comment

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

I think const_get is ok, but we don't need this class_for method, just lean on autoloading to do this work for you. See my other comment. I ran tests and they all pass with the proposed change.

Copy link
Contributor Author

@davidstosik davidstosik May 1, 2023

Choose a reason for hiding this comment

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

Yeah, so my idea was:

  1. I wanted to extract the const_get + rescue to its own method to make the thing more readable (I didn't want to pollute Packwerk::Cli#execute_command with it). It first was a class method on Packwerk::Cli, and then naturally moved to Packwerk::Commands.
  2. Once that was done, I noticed I was using exceptions as control flow whereas there is an appropriate method (#const_defined?) to check if the constant exists without raising NameError, so I used it instead.

I now realized that when I moved the method, I could have omitted the Commands here:

       sig { params(command: String).returns(T.nilable(T.class_of(Commands::BaseCommand))) }
       def class_for(command)
         class_name = command.sub(" ", "_").underscore.classify + "Command"
-        if Commands.const_defined?(class_name)
-          Commands.const_get(class_name) # rubocop:disable Sorbet/ConstantsFromStrings
+        if const_defined?(class_name)
+          const_get(class_name) # rubocop:disable Sorbet/ConstantsFromStrings

Copy link
Member

Choose a reason for hiding this comment

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

I didn't want to pollute Packwerk::Cli#execute_command with it

Yeah but now we have a new public method to support, just for a single use case. Feels to me more appropriate just to hide the implementation in the only place that actually uses it (esp since it's just a couple lines).

I noticed I was using exceptions as control flow

Calling an undefined command is an "exception" though, so feels to me that rescuing here is fine (to avoid exposing an exception to the user). I would consider it "control flow" if we were then doing something other work in the rescue, but we're literally just cleaning up the output.

Copy link
Contributor Author

@davidstosik davidstosik May 1, 2023

Choose a reason for hiding this comment

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

a new public method to support

That's a very fair point! Maybe I can move the method back to Cli, make it private, and switch back to rescuing NameError. I don't have a strong opinion on this bit.

Comment on lines 15 to 20
init - set up packwerk
check - run all checks
update-todo - update package_todo.yml files
validate - verify integrity of packwerk and package configuration
version - output packwerk version
help - display help information about packwerk
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because I kept lazy loading, and commands are not loaded, this help message cannot be generated dynamically.
I'm not super happy about it, does anyone have thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

Maybe we could use the registry pattern just for help messages? So have some registry that commands can call to register their help message, but let the actual command class be autoloaded.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

registry pattern

Yup! See my other comment with a link to a specific commit. That's what I used first. But that would mean we need to eager load CLI commands.

Copy link
Member

Choose a reason for hiding this comment

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

Not necessarily. There could be a registry that can be pre-populated for in-gem commands. For commands added externally, the process would be to register your extension (for the help message) and also create an autoloadable constant, which would not be loaded until the command was actually used.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh, so instead of registering the commands from inside the command class, we could do it outside (in a place that we know gets loaded on init), like so?

Commands.register("update-todo", help: "update package_todo.yml files", aliases: ["update"])

For the gem's core commands, these would all be located in the same location, while plugin gems would have to do that somewhere, where they get loaded (eg. their own lib/packwerk/plugin_name.rb file).

#register could possibly also offer a way to have command and class name be different, like so:

Commands.register("something", help: "does something", classname: "MyCommandClass")

Copy link
Member

Choose a reason for hiding this comment

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

Yeah right exactly 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Additional commits:

  • Go back to using the registry pattern to register CLI command 386ffff
  • Generate the help command output based on registered commands d5c7dbe

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some autoload tests caught me loading more than I should:
image

(Didn't know about bin/rake test:loading.)

So I added one more commit: 721b197 (Move command registration to Cli in order to avoid early loading)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This module is a temporary to keep the behaviour as it was previously, focusing this PR on a refactor that mostly does not change behaviour.

However, the respective behaviours of check and update-todo are expected to diverge:

  • --offenses-formatter does not make much sense in the case of update-todo
  • update-todo does not accept a list of files (either passed as extra arguments or with --packages

When we work on this separation of behaviours, this module will naturally go away.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I removed this because it didn't seem to make sense anymore:

  • it's never returned by methods that are part of the public API
  • it imposes the commands to return their last message wrapped in a Result object, instead of outputting it in the relevant IO object that they know about, and to which they keep outputting during their execution

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test file, and how I had to move all its tests to CheckCommandTest and UpdateCommandTest, represents a +596 −505 diff all by itself, and is what grew this PR to its current +1,063 −777.

There are a few notable changes in each test though. Here's a single test's diff to illustrate those changes:

--- before.txt	2023-04-20 15:35:47.222809962 +0000
+++ after.txt	2023-04-20 15:36:31.176723735 +0000
@@ -1,4 +1,4 @@
-    test "#check does not list stale violations when run on a single file with no violations, even if the containing package has violations" do
+    test "#run does not list stale violations when run on a single file with no violations, even if the containing package has violations" do
       use_template(:minimal)
       file_to_check = "components/source/some/path.rb"
       other_file = "components/source/some/other/path.rb"
@@ -19,27 +19,31 @@
             - #{other_file}
       YML
 
+      RunContext.any_instance.stubs(:process_file).returns([])
+      FilesForProcessing.any_instance.stubs(:files).returns(Set.new([file_to_check]))
+
       out = StringIO.new
-      parse_run = ParseRun.new(
-        relative_file_set: Set.new([file_to_check]),
-        configuration: Configuration.new({ "parallel" => false }),
-        progress_formatter: Formatters::ProgressFormatter.new(out)
+      configuration = Configuration.new({ "parallel" => false })
+      check_command = CheckCommand.new(
+        [],
+        configuration: configuration,
+        out: out,
+        err_out: StringIO.new,
+        progress_formatter: Formatters::ProgressFormatter.new(out),
+        offenses_formatter: configuration.offenses_formatter
       )
-      RunContext.any_instance.stubs(:process_file).returns([])
-      result = parse_run.check
+
+      result = check_command.run
 
       expected_output = <<~EOS
         📦 Packwerk is inspecting 1 file
         \\.
         📦 Finished in \\d+\\.\\d+ seconds
-      EOS
-      assert_match(/#{expected_output}/, out.string)
 
-      expected_message = <<~EOS
         No offenses detected
         No stale violations detected
       EOS
-      assert_equal expected_message, result.message
+      assert_match(/#{expected_output}/, out.string)
 
-      assert result.status
+      assert result
     end

Copy link
Contributor Author

@davidstosik davidstosik Apr 20, 2023

Choose a reason for hiding this comment

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

I'm tempted to rename this file offenses_finder.rb now. 🤔
That's the only logic that's left in there.

Specify files or check the include and exclude glob in the config file.
MSG

true
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It feels like it should return false if the above was an error, but this is the existing behaviour.

Also, we pass an err_out around but it feels like we're not using it as much as we should...

In the spirit of keeping this PR a low impact refactor, I decided not to change those.


sig { override.returns(T::Boolean) }
def run
@err_out.puts(<<~USAGE)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here too, I'm preserving existing behaviour: this help message was output to err_out before, still is.

@davidstosik davidstosik marked this pull request as ready for review April 27, 2023 11:05
@davidstosik davidstosik requested a review from a team as a code owner April 27, 2023 11:05
@davidstosik davidstosik changed the title [WIP] Extract CLI commands to individual classes Extract CLI commands to individual classes Apr 27, 2023
Copy link
Member

@shioyama shioyama left a comment

Choose a reason for hiding this comment

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

Still reading through changes, but made some initial comments. Really great work! ❤️ Way cleaner and more extensible.

autoload_under "commands" do
autoload :OffenseProgressMarker
end

Copy link
Member

Choose a reason for hiding this comment

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

👍

Seems it was left behind in #124

when nil, "help"
usage
command = args.shift || "help"
command_class = Commands.class_for(command)
Copy link
Member

Choose a reason for hiding this comment

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

You can avoid the need for class_for by just const_get-ting the command (converted to class name), and rescuing the possible NameError, like this:

      class_name = command.sub(" ", "_").underscore.classify + "Command"
      Commands.const_get(class_name).new(
        args,
        configuration: @configuration,
        out: @out,
        err_out: @err_out,
        progress_formatter: @progress_formatter,
        offenses_formatter: @offenses_formatter,
      ).run
    rescue NameError
      @err_out.puts("'#{command}' is not a packwerk command. See `packwerk help`.",)

      false
    end

def class_for(command)
class_name = command.sub(" ", "_").underscore.classify + "Command"
if Commands.const_defined?(class_name)
Commands.const_get(class_name) # rubocop:disable Sorbet/ConstantsFromStrings
Copy link
Member

Choose a reason for hiding this comment

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

I think const_get is ok, but we don't need this class_for method, just lean on autoloading to do this work for you. See my other comment. I ran tests and they all pass with the proposed change.

Comment on lines 15 to 20
init - set up packwerk
check - run all checks
update-todo - update package_todo.yml files
validate - verify integrity of packwerk and package configuration
version - output packwerk version
help - display help information about packwerk
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we could use the registry pattern just for help messages? So have some registry that commands can call to register their help message, but let the actual command class be autoloaded.

autoload :CheckCommand
autoload :HelpCommand
autoload :InitCommand
autoload :UpdateCommand, "packwerk/commands/update_todo_command"
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should add some aliasing functionality to make this more natural and explicit? i.e. here we're aliasing update to update_todo, so rather than adding an autoload we could have execute_command look at a list of aliases and "redirect" the command accordingly if an alias exists.

Or do we also need to support the existence of the UpdateComand constant as well? 🤔

Copy link
Contributor Author

@davidstosik davidstosik May 1, 2023

Choose a reason for hiding this comment

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

Or do we also need to support the existence of the UpdateCommand constant as well? 🤔

We don't. It's a class introduced in this PR, but doesn't have to.

My problem is as follows:

  • we use autoloading, so the class is not loaded until we have defined that it's necessary.
  • if we implement a system to declare aliases, then we need it to be run during initialization
  • I would like to avoid having to declare aliases outside of the command class itself (open-closed principle)

I guess the fact that we need these autoload is already an infringement to the open-closed principle though... 🤔

Check this commit to see an early idea I had, where each command was able to declare itself, and its aliases in the process. The caveat was that command classes needed to be eager loaded.

24d4033#diff-c990d324e3c6c514f831ceaa8414cb9450101d4f76e2ac32e80c812f2f455125R10

extend T::Sig
sig { params(name: String, help: String, aliases: T::Array[String]).void }
def register_command(name, help:, aliases: [])
CommandRegistry.register(name, help: help, aliases: aliases)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This just delegates to CommandRegistry. It allows me to keep that class private, while it exposes public methods to be used within Packwerk.

end

Cli.register_command("init", help: "set up packwerk")
Copy link
Member

@gmcgibbon gmcgibbon May 2, 2023

Choose a reason for hiding this comment

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

The help text should be declared on the command class. It makes it easier to change and expand on.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See previous conversation with @shioyama : if I do so (which I'm not against), I'll need to either eager load command classes or at least lazy load them all when the user called for help.

Copy link
Member

Choose a reason for hiding this comment

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

In the case of the help command, I think it is fine to load all commands. Since this is done lazily I don't see a problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sig { returns(T.class_of(Cli::BaseCommand)) }
def command_class
classname = @name.sub(" ", "_").underscore.classify + "Command"
Cli.const_get(classname) # rubocop:disable Sorbet/ConstantsFromStrings
Copy link
Member

Choose a reason for hiding this comment

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

Can you explain your motivation to not use a Packwerk::Cli::Commands module to const_get from? We could fit all command related code in one place (eg. Commands::Registry, Commands::Base, Commands::HelpCommand, etc. It seems safer and more organized.

Copy link
Contributor Author

@davidstosik davidstosik May 5, 2023

Choose a reason for hiding this comment

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

Without the need to auto match a command name with a class name, and with instead an explicit registration of each command, I felt Commands had lost its main purpose, that triggered its creation: hosting all commands and nothing else so that packwerk not-a-command-but-const-with-matching-name-exists would fail beautifully and not weirdly.
With its main purpose lost, I went for the "less code is better" approach.

To me, commands are part of the CLI (command line interface 😬), so using Cli as namespace felt adequate.

Copy link
Member

Choose a reason for hiding this comment

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

Personally, I think the organization of the commands/ namespace makes command specific code easier to reason about, but I don't feel strongly about it. I'm slightly worried about this from a public API perspective.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cli is public, sure, but if I make my constants nested under it private, it shouldn't make a difference?
I'm not antagonizing, just trying to understand where the problem would be exactly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

end

run_context = RunContext.from_configuration(@configuration)
offense_collection = OffenseCollection.new(@configuration.root_path)
Copy link
Member

Choose a reason for hiding this comment

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

These could be private methods.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Quite verbose, but possible: c8b3864

end

sig { returns(T::Hash[Symbol, T.untyped]) }
def parse_options
Copy link
Member

@gmcgibbon gmcgibbon May 2, 2023

Choose a reason for hiding this comment

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

Thinking ahead slightly, it might make sense for the base class to implement def options; raise NotImplementedError; end (or Sorbet equivalent abstract class syntax) and have this be a memoized public method for commands to implement.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That'll come in a next PR where I split the options used by check and update-todo into separate option parsers. This PR felt already big enough.

I did not split in this PR too because splitting will also induce behavior change (I don't want update-todo you use --packages and --offenses-formatter anymore), and I'd like to make sure I get proper review on these bits.


module Packwerk
class Cli
class CommandRegistry
Copy link
Member

@gmcgibbon gmcgibbon May 2, 2023

Choose a reason for hiding this comment

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

The instance of this class is an entry, not a registry, so it doesn't really make sense to me. I would say that a better api would be Commands.all / Commands.register, and then name this class Commands::Info, Commands::Lookup, or Commands::Entry (etc.) so the registry can live on Commands.

Copy link
Contributor Author

@davidstosik davidstosik May 5, 2023

Choose a reason for hiding this comment

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

Yeah, the naming didn't please me. I'll take another look.

Copy link
Contributor Author

@davidstosik davidstosik May 8, 2023

Choose a reason for hiding this comment

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

I renamed CommandRegistry to LazyLoadedCommand (to make the distinction with BaseCommand). d49a7ea

I prefer typing the registry as a T::Array[LazyLoadedCommand] rather than a T::Array[T::Hash[Symbol, T.untyped]] (where each value in the hash would look like { name: "foo", aliases: ["bar"] }).

I guess I could restore the Commands module/namespace anyway and have the registry live there... (But I'd rather each entry be a LazyLoadedCommand than a primitive.)

Copy link
Contributor Author

@davidstosik davidstosik May 8, 2023

Choose a reason for hiding this comment

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

and then name this class Commands::Info, Commands::Lookup

Just understood what you meant here. Could work too. 🤔 Will try.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

end

all_offenses = T.let([], T::Array[Offense])
on_interrupt = T.let(-> { @progress_formatter.interrupted }, T.proc.void)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
on_interrupt = T.let(-> { @progress_formatter.interrupted }, T.proc.void)
on_interrupt = T.let(proc { @progress_formatter.interrupted }, T.proc.void)

These could also be memoized private methods


module Packwerk
module Commands
class LazyLoadedEntry
Copy link
Member

@gmcgibbon gmcgibbon May 24, 2023

Choose a reason for hiding this comment

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

Suggested change
class LazyLoadedEntry
class Entry

Nit: I think LazyLoadedEntry is a little too verbose, but feel free to ignore this. I like the implementation!

end
def initialize(args, configuration:, out:, err_out:, progress_formatter:, offenses_formatter:)
@args = args
@configuration = configuration
Copy link
Member

Choose a reason for hiding this comment

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

These should be assigned by super, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, yeah, I think so, but there was a reason why I did that. Will get back to you when I remember. 😅

Copy link
Contributor Author

@davidstosik davidstosik May 31, 2023

Choose a reason for hiding this comment

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

Ahh, yes, it's a Sorbet problem. If I don't make these assignments in this initialize method (ie if for example I comment these two lines), Sorbet will error with the following:

bin/srb tc                                                                                                                                130 ↵
lib/packwerk/commands/uses_parse_run.rb:29: Use of undeclared variable @configuration https://srb.help/6002
    29 |        @configuration.parallel = @parsed_options[:parallel]
                ^^^^^^^^^^^^^^
  Note:
    Use T.let to declare this variable.
    For more information, see https://sorbet.org/docs/type-annotations

lib/packwerk/commands/uses_parse_run.rb:41: Use of undeclared variable @configuration https://srb.help/6002
    41 |          configuration: @configuration
                                 ^^^^^^^^^^^^^^
  Note:
    Use T.let to declare this variable.
    For more information, see https://sorbet.org/docs/type-annotations

lib/packwerk/commands/uses_parse_run.rb:56: Use of undeclared variable @configuration https://srb.help/6002
    56 |          parallel: @configuration.parallel?,
                            ^^^^^^^^^^^^^^
  Note:
    Use T.let to declare this variable.
    For more information, see https://sorbet.org/docs/type-annotations

lib/packwerk/commands/uses_parse_run.rb:66: Use of undeclared variable @configuration https://srb.help/6002
    66 |          options[:parallel] = T.let(@configuration.parallel?, T::Boolean)
                                             ^^^^^^^^^^^^^^
  Note:
    Use T.let to declare this variable.
    For more information, see https://sorbet.org/docs/type-annotations

lib/packwerk/commands/uses_parse_run.rb:82: Use of undeclared variable @args https://srb.help/6002
    82 |          end.parse!(@args)
                             ^^^^^
  Note:
    Use T.let to declare this variable.
    For more information, see https://sorbet.org/docs/type-annotations
Errors: 5

Here's a minimal reproduction on Sorbet.run:

# typed: strict

class Foo
  extend T::Sig

  sig { params(param: String).void }
  def initialize(param)
    @param = param
  end
end

module Bar
  extend T::Sig
  extend T::Helpers

  requires_ancestor { Foo }

  sig { params(param: String).void }
  def initialize(param)
    # @param = param # This line fixes the Sorbet error on line 22
    super
    @param << " concatenated" # Error "Use of undeclared variable @param"
  end
end

I think using attribute readers as you suggested in another comment could allow us to avoid this repetition. 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Hm, I wonder if this is a bug in Sorbet. If we require the class as an ancestor, it should always expect the initializer to run.

Copy link
Member

Choose a reason for hiding this comment

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

True, I guess it can't know for sure. The workaround is good 👍

@out = out
@err_out = err_out
@progress_formatter = progress_formatter
@offenses_formatter = offenses_formatter
Copy link
Member

Choose a reason for hiding this comment

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

I'm interested in how you feel about using ivars for stdout/stderr/args/formatters? Should we create private/protected/public readers for these ivars? It seems like it would be a nicer public API to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I like the idea. I tend to define accessors for everything usually. I did not do it here because Sorbet requires a level of verbosity I was not super comfortable with. I'll give it a try.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

end
def initialize(args, configuration:, out:, err_out:, progress_formatter:, offenses_formatter:)
super
@_parsed_options = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped]))
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
@_parsed_options = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped]))
@parsed_options = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped]))

I don't think this needs to be underscored. No other ivar is.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I used an underscore because it's a memoization ivar for the parsed_options method, and Sorbet seemed to require this T.let in the initializer. This might not be true though, I'll check.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@@ -44,6 +53,7 @@ def mark_as_failed
def interrupted
@out.puts
@out.puts("Manually interrupted. Violations caught so far are listed below:")
@out.puts
Copy link
Member

Choose a reason for hiding this comment

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

Are these extra lines necessary, or just for readability?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They are not new, they were just moved from somewhere else to keep the tests happy.
If I recall correctly, it used to be here:

@out.puts

The change happened in this commit: 1bed3e8

@davidstosik davidstosik merged commit 0c678b0 into main Jun 2, 2023
@davidstosik davidstosik deleted the sto/command-classes branch June 2, 2023 02:40
@shopify-shipit shopify-shipit bot temporarily deployed to rubygems August 8, 2023 20:48 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants