cli-kit 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/release.yml +20 -0
  3. data/.github/workflows/release.yml +52 -0
  4. data/.github/workflows/ruby.yml +1 -1
  5. data/.rubocop.sorbet.yml +1 -0
  6. data/.rubocop.yml +2 -2
  7. data/Gemfile +1 -1
  8. data/Gemfile.lock +23 -24
  9. data/Rakefile +0 -1
  10. data/bin/testunit +0 -1
  11. data/cli-kit.gemspec +2 -1
  12. data/examples/minimal/example.rb +1 -1
  13. data/examples/single-file/example.rb +4 -3
  14. data/gen/lib/gen/commands/help.rb +1 -3
  15. data/gen/lib/gen/commands/new.rb +2 -6
  16. data/gen/lib/gen/commands.rb +1 -7
  17. data/gen/lib/gen/entry_point.rb +1 -5
  18. data/gen/lib/gen/generator.rb +15 -19
  19. data/gen/lib/gen/help.rb +3 -7
  20. data/lib/cli/kit/args/definition.rb +37 -95
  21. data/lib/cli/kit/args/evaluation.rb +40 -59
  22. data/lib/cli/kit/args/parser/node.rb +19 -23
  23. data/lib/cli/kit/args/parser.rb +12 -16
  24. data/lib/cli/kit/args/tokenizer.rb +15 -18
  25. data/lib/cli/kit/base_command.rb +4 -8
  26. data/lib/cli/kit/command_help.rb +24 -27
  27. data/lib/cli/kit/command_registry.rb +40 -36
  28. data/lib/cli/kit/config.rb +210 -17
  29. data/lib/cli/kit/core_ext.rb +118 -8
  30. data/lib/cli/kit/error_handler.rb +49 -39
  31. data/lib/cli/kit/executor.rb +7 -16
  32. data/lib/cli/kit/ini.rb +8 -12
  33. data/lib/cli/kit/levenshtein.rb +7 -5
  34. data/lib/cli/kit/logger.rb +8 -10
  35. data/lib/cli/kit/opts.rb +26 -86
  36. data/lib/cli/kit/parse_args.rb +5 -10
  37. data/lib/cli/kit/resolver.rb +4 -6
  38. data/lib/cli/kit/support/test_helper.rb +10 -5
  39. data/lib/cli/kit/system.rb +25 -114
  40. data/lib/cli/kit/util.rb +15 -31
  41. data/lib/cli/kit/version.rb +1 -1
  42. data/lib/cli/kit.rb +19 -36
  43. metadata +18 -3
  44. data/lib/cli/kit/sorbet_runtime_stub.rb +0 -154
@@ -2,15 +2,132 @@
2
2
 
3
3
  require 'cli/kit'
4
4
  require 'fileutils'
5
+ require 'tempfile'
5
6
 
6
7
  module CLI
7
8
  module Kit
8
9
  class Config
9
- extend T::Sig
10
-
11
10
  XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'
12
11
 
13
- sig { params(tool_name: String).void }
12
+ # Raised when the config file cannot be written (e.g. read-only
13
+ # directory, read-only filesystem, nix/home-manager-managed symlink
14
+ # pointing into +/nix/store+).
15
+ #
16
+ # Inherits from SystemCallError so existing `rescue SystemCallError`
17
+ # handlers around +Config#set+ continue to match. The message contains
18
+ # only the keys that actually changed; unchanged keys (which may
19
+ # include sensitive values such as API tokens) are intentionally
20
+ # excluded so the failure message can never leak secrets through
21
+ # stderr or exception reports.
22
+ class ConfigWriteError < SystemCallError
23
+ class << self
24
+ # Ruby's +SystemCallError.===+ uses errno-based matching,
25
+ # which means +rescue ConfigWriteError+ would otherwise fail
26
+ # to catch a ConfigWriteError instance (it would match
27
+ # +Errno::EACCES+ or +Errno::EPERM+ instead, based on the
28
+ # wrapped errno). Override with plain class-hierarchy
29
+ # matching so +rescue ConfigWriteError+ works as callers
30
+ # expect. +rescue SystemCallError+ still matches via normal
31
+ # inheritance.
32
+ #: (untyped other) -> bool
33
+ def ===(other)
34
+ other.is_a?(self)
35
+ end
36
+
37
+ # +SystemCallError.new+ has a factory-style signature defined
38
+ # in Sorbet's stdlib RBI (+(String | Integer?) ->
39
+ # SystemCallError+) which doesn't match this subclass's
40
+ # four-argument constructor. Override +.new+ here so type
41
+ # checkers and callers see the real signature, and allocate
42
+ # the instance manually to bypass +SystemCallError+'s
43
+ # factory behaviour.
44
+ #: (String config_path, String old_content, String new_content, SystemCallError cause) -> ConfigWriteError
45
+ def new(config_path, old_content, new_content, cause)
46
+ instance = allocate
47
+ instance.__send__(:initialize, config_path, old_content, new_content, cause)
48
+ instance
49
+ end
50
+ end
51
+
52
+ #: String
53
+ attr_reader :config_path
54
+
55
+ #: String
56
+ attr_reader :old_content
57
+
58
+ #: String
59
+ attr_reader :new_content
60
+
61
+ # rubocop:disable Lint/MissingSuper
62
+ # +SystemCallError#initialize+ has a factory-style signature that
63
+ # cannot be invoked from a plain subclass (it expects a matching
64
+ # Errno constant on the class). We bypass it deliberately and
65
+ # initialize via Exception so we just get a message-only
66
+ # exception that +rescue SystemCallError+ still catches via
67
+ # inheritance. +super+ would not work here.
68
+ #: (String config_path, String old_content, String new_content, SystemCallError cause) -> void
69
+ def initialize(config_path, old_content, new_content, cause)
70
+ @config_path = config_path
71
+ @old_content = old_content
72
+ @new_content = new_content
73
+ @wrapped_errno = cause.errno
74
+ message = <<~MSG.rstrip
75
+ Could not write to #{config_path}: #{cause.message}
76
+
77
+ Attempted changes (unchanged keys omitted):
78
+ #{diff}
79
+ MSG
80
+ Exception.instance_method(:initialize).bind(self).call(message)
81
+ end
82
+ # rubocop:enable Lint/MissingSuper
83
+
84
+ # Preserve the original errno from the wrapped Errno so callers
85
+ # that inspect +errno+ continue to see the real underlying code.
86
+ #: -> Integer?
87
+ def errno
88
+ @wrapped_errno
89
+ end
90
+
91
+ # A line-by-line diff of only the sections/keys that changed
92
+ # between +old_content+ and +new_content+. Unchanged keys are
93
+ # omitted so that sensitive values stored elsewhere in the config
94
+ # are never included in the failure message.
95
+ #: -> String
96
+ def diff
97
+ old_ini = CLI::Kit::Ini.new(config: @old_content).tap(&:parse).ini
98
+ new_ini = CLI::Kit::Ini.new(config: @new_content).tap(&:parse).ini
99
+
100
+ lines = []
101
+ (old_ini.keys | new_ini.keys).each do |section|
102
+ old_section = old_ini[section] || {}
103
+ new_section = new_ini[section] || {}
104
+
105
+ changes = []
106
+ (old_section.keys | new_section.keys).each do |key|
107
+ old_val = old_section[key]
108
+ new_val = new_section[key]
109
+ next if old_val == new_val
110
+
111
+ changes << "- #{key} = #{old_val}" if old_val
112
+ changes << "+ #{key} = #{new_val}" if new_val
113
+ end
114
+ next if changes.empty?
115
+
116
+ prefix = if old_section.empty?
117
+ '+ '
118
+ elsif new_section.empty?
119
+ '- '
120
+ else
121
+ ' '
122
+ end
123
+ lines << "#{prefix}#{section}"
124
+ lines.concat(changes)
125
+ end
126
+ lines.join("\n")
127
+ end
128
+ end
129
+
130
+ #: (tool_name: String) -> void
14
131
  def initialize(tool_name:)
15
132
  @tool_name = tool_name
16
133
  end
@@ -28,13 +145,13 @@ module CLI
28
145
  # #### Example Usage
29
146
  # `config.get('name.of.config')`
30
147
  #
31
- sig { params(section: String, name: String, default: T.nilable(String)).returns(T.nilable(String)) }
148
+ #: (String section, String name, ?default: String?) -> String?
32
149
  def get(section, name, default: nil)
33
150
  all_configs.dig("[#{section}]", name) || default
34
151
  end
35
152
 
36
153
  # Coalesce and enforce the value of a config to a boolean
37
- sig { params(section: String, name: String, default: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
154
+ #: (String section, String name, ?default: bool?) -> bool?
38
155
  def get_bool(section, name, default: false)
39
156
  case get(section, name)
40
157
  when 'true'
@@ -58,14 +175,15 @@ module CLI
58
175
  # #### Example Usage
59
176
  # `config.set('section', 'name.of.config', 'value')`
60
177
  #
61
- sig { params(section: String, name: String, value: T.nilable(T.any(String, T::Boolean))).void }
178
+ #: (String section, String name, (String | bool)? value) -> void
62
179
  def set(section, name, value)
63
180
  all_configs["[#{section}]"] ||= {}
181
+ section = all_configs["[#{section}]"] #: as !nil
64
182
  case value
65
183
  when nil
66
- T.must(all_configs["[#{section}]"]).delete(name)
184
+ section.delete(name)
67
185
  else
68
- T.must(all_configs["[#{section}]"])[name] = value.to_s
186
+ section[name] = value.to_s
69
187
  end
70
188
  write_config
71
189
  end
@@ -79,7 +197,7 @@ module CLI
79
197
  # #### Example Usage
80
198
  # `config.unset('section', 'name.of.config')`
81
199
  #
82
- sig { params(section: String, name: String).void }
200
+ #: (String section, String name) -> void
83
201
  def unset(section, name)
84
202
  set(section, name, nil)
85
203
  end
@@ -92,12 +210,12 @@ module CLI
92
210
  # #### Example Usage
93
211
  # `config.get_section('section')`
94
212
  #
95
- sig { params(section: String).returns(T::Hash[String, String]) }
213
+ #: (String section) -> Hash[String, String]
96
214
  def get_section(section)
97
215
  (all_configs["[#{section}]"] || {}).dup
98
216
  end
99
217
 
100
- sig { returns(String) }
218
+ #: -> String
101
219
  def to_s
102
220
  ini.to_s
103
221
  end
@@ -107,7 +225,7 @@ module CLI
107
225
  # if ENV['XDG_CONFIG_HOME'] is not set, we default to ~/.config, e.g.:
108
226
  # ~/.config/tool/config
109
227
  #
110
- sig { returns(String) }
228
+ #: -> String
111
229
  def file
112
230
  config_home = ENV.fetch(XDG_CONFIG_HOME, '~/.config')
113
231
  File.expand_path(File.join(@tool_name, 'config'), config_home)
@@ -115,23 +233,98 @@ module CLI
115
233
 
116
234
  private
117
235
 
118
- sig { returns(T::Hash[String, T::Hash[String, String]]) }
236
+ #: -> Hash[String, Hash[String, String]]
119
237
  def all_configs
120
238
  ini.ini
121
239
  end
122
240
 
123
- sig { returns(CLI::Kit::Ini) }
241
+ #: -> CLI::Kit::Ini
124
242
  def ini
125
243
  @ini ||= CLI::Kit::Ini.new(file).tap(&:parse)
126
244
  end
127
245
 
128
- sig { void }
246
+ #: -> void
129
247
  def write_config
130
248
  all_configs.each do |section, sub_config|
131
249
  all_configs.delete(section) if sub_config.empty?
132
250
  end
133
- FileUtils.mkdir_p(File.dirname(file))
134
- File.write(file, to_s)
251
+
252
+ # Capture +file+ and +to_s+ up front so they are visible (and
253
+ # known to be non-nil) inside the rescue block below.
254
+ config_path = file
255
+ new_content = to_s
256
+
257
+ begin
258
+ # Always write atomically (tmpfile + rename) so readers never
259
+ # observe a partial write. If +config_path+ is a symlink (e.g.
260
+ # a nix/home-manager-managed config), resolve it so the rename
261
+ # replaces the real file at the end of the chain and leaves
262
+ # the managed symlink itself intact.
263
+ target_path = resolved_write_path(config_path)
264
+ target_dir = File.dirname(target_path)
265
+ FileUtils.mkdir_p(target_dir)
266
+ write_config_atomic(target_path, target_dir, new_content)
267
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
268
+ # +EROFS+ (read-only filesystem) is the canonical error raised
269
+ # when the underlying filesystem is mounted read-only — common
270
+ # for nix/home-manager configs that live in +/nix/store+. Wrap
271
+ # it the same way as +EACCES+/+EPERM+ so callers always see
272
+ # the diff and any +rescue ConfigWriteError+ handler matches.
273
+ old_content = read_config_for_diff(config_path)
274
+ raise(ConfigWriteError.new(config_path, old_content, new_content, e))
275
+ end
276
+ end
277
+
278
+ # Write +new_content+ into +config_path+ atomically, via a tmpfile
279
+ # in the same directory followed by rename. Readers never observe
280
+ # a partial write.
281
+ #: (String config_path, String config_dir, String new_content) -> void
282
+ def write_config_atomic(config_path, config_dir, new_content)
283
+ tmpfile = Tempfile.new([File.basename(config_path), '.tmp'], config_dir)
284
+ tmpfile_path = tmpfile.path #: as !nil
285
+ begin
286
+ tmpfile.write(new_content)
287
+ tmpfile.close
288
+ # Tempfile defaults to 0o600. Match the permissions a plain
289
+ # +File.write+ would have produced: preserve the existing
290
+ # mode when the config is being updated, and use the
291
+ # umask-adjusted default (matching +open(2)+ for new files)
292
+ # otherwise. This avoids silently tightening permissions on
293
+ # an existing config and avoids creating new configs with
294
+ # the more restrictive Tempfile default.
295
+ mode = if File.exist?(config_path)
296
+ File.stat(config_path).mode
297
+ else
298
+ 0o666 & ~File.umask
299
+ end
300
+ File.chmod(mode, tmpfile_path)
301
+ File.rename(tmpfile_path, config_path)
302
+ rescue Exception # rubocop:disable Lint/RescueException
303
+ tmpfile.close unless tmpfile.closed?
304
+ File.unlink(tmpfile_path) if File.exist?(tmpfile_path)
305
+ raise
306
+ end
307
+ end
308
+
309
+ #: (String config_path) -> String
310
+ def read_config_for_diff(config_path)
311
+ File.read(config_path)
312
+ rescue SystemCallError
313
+ ''
314
+ end
315
+
316
+ # Resolve +config_path+ through any symlinks so the atomic rename
317
+ # in +write_config_atomic+ replaces the real file at the end of
318
+ # the chain instead of clobbering the symlink with a regular file.
319
+ # For broken symlinks, fall back to +config_path+ itself so we
320
+ # still create a config there.
321
+ #: (String config_path) -> String
322
+ def resolved_write_path(config_path)
323
+ return config_path unless File.symlink?(config_path)
324
+
325
+ File.realpath(config_path)
326
+ rescue Errno::ENOENT
327
+ config_path
135
328
  end
136
329
  end
137
330
  end
@@ -2,29 +2,139 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  class Exception
5
- extend(T::Sig)
6
-
7
5
  # You'd think instance variables @bug and @silent would work here. They
8
6
  # don't. I'm not sure why. If you, the reader, want to take some time to
9
7
  # figure it out, go ahead and refactor to that.
10
8
 
11
- sig { returns(T::Boolean) }
12
- def bug?
13
- true
9
+ # Fault represents who is responsible for an error. It is used to attribute
10
+ # exceptions to the right owner for observability and triage.
11
+ #
12
+ # @abstract
13
+ # @sealed
14
+ class Fault
15
+ # @abstract
16
+ #: -> String
17
+ def to_s = raise NotImplementedError, 'abstract method called'
18
+
19
+ # A bug in the tool itself (dev, tec, cli-kit, etc.).
20
+ class System < Fault
21
+ # @override
22
+ #: -> String
23
+ def to_s = 'system'
24
+ end
25
+ SYSTEM = System.new.freeze #: System
26
+
27
+ # The default fault for exceptions that have not been explicitly attributed.
28
+ # Nothing should reference this constant directly — it exists only as the
29
+ # default return value of Exception#fault. Use rubocop to enforce this.
30
+ #
31
+ # Inherits from System so that `fault.is_a?(System)` and checks like
32
+ # `bug?` (which uses `Fault::System ===`) treat unattributed errors as
33
+ # system errors by default.
34
+ class ImplicitlySystem < System
35
+ # @override
36
+ #: -> String
37
+ def to_s = 'implicitly_system'
38
+ end
39
+ IMPLICITLY_SYSTEM = ImplicitlySystem.new.freeze #: ImplicitlySystem
40
+
41
+ # A problem with the project's configuration (dev.yml, zone.nix, etc.).
42
+ class Project < Fault
43
+ # @override
44
+ #: -> String
45
+ def to_s = 'project'
46
+ end
47
+ PROJECT = Project.new.freeze #: Project
48
+
49
+ # User error: incorrect CLI usage, expired tokens, bad arguments, etc.
50
+ class User < Fault
51
+ # @override
52
+ #: -> String
53
+ def to_s = 'user'
54
+ end
55
+ USER = User.new.freeze #: User
56
+
57
+ # Not a real failure — e.g. user cancellation, interrupt, or conditions
58
+ # wildly out of anyone's control (no disk space). Doesn't require fixing
59
+ # or tracking.
60
+ class Terroir < Fault
61
+ # @override
62
+ #: -> String
63
+ def to_s = 'terroir'
64
+ end
65
+ TERROIR = Terroir.new.freeze #: Terroir
14
66
  end
15
67
 
16
- sig { returns(T::Boolean) }
68
+ #: -> Fault
69
+ def fault = Fault::IMPLICITLY_SYSTEM
70
+
71
+ #: (Fault fault) -> void
72
+ def fault!(fault)
73
+ singleton_class.define_method(:fault) { fault }
74
+ end
75
+
76
+ #: (Fault fault) -> self
77
+ def with_fault(fault)
78
+ fault!(fault)
79
+ self
80
+ end
81
+
82
+ #: -> bool
83
+ def bug? = (Fault::System === fault)
84
+
85
+ #: -> bool
17
86
  def silent?
18
87
  false
19
88
  end
20
89
 
21
- sig { params(bug: T::Boolean).void }
90
+ #: (?bool bug) -> void
22
91
  def bug!(bug = true)
23
92
  singleton_class.define_method(:bug?) { bug }
24
93
  end
25
94
 
26
- sig { params(silent: T::Boolean).void }
95
+ #: (?bool silent) -> void
27
96
  def silent!(silent = true)
28
97
  singleton_class.define_method(:silent?) { silent }
29
98
  end
30
99
  end
100
+
101
+ # -- Custom fault defaults for Ruby stdlib exceptions --------------------------
102
+ #
103
+ # Normally, fault attribution belongs at the raise site (via `fault:` kwarg).
104
+ # These classes are different: Ruby itself raises them (e.g. the kernel sends
105
+ # SIGINT, the OS returns ENOSPC), so there is no raise site we control. We
106
+ # override the default fault at the class level instead.
107
+ #
108
+ # All of these are environmental conditions ("terroir") — nobody's fault,
109
+ # nothing to fix, shouldn't be reported as bugs.
110
+
111
+ class Interrupt # Ctrl-C / SIGINT
112
+ #: -> Exception::Fault
113
+ def fault = Exception::Fault::TERROIR
114
+ end
115
+
116
+ class SignalException # SIGTERM, SIGHUP, etc.
117
+ #: -> Exception::Fault
118
+ def fault = Exception::Fault::TERROIR
119
+ end
120
+
121
+ module Errno
122
+ class ENOSPC # disk full
123
+ #: -> Exception::Fault
124
+ def fault = Exception::Fault::TERROIR
125
+ end
126
+ end
127
+
128
+ module Errno
129
+ class EPIPE # broken pipe
130
+ #: -> Exception::Fault
131
+ def fault = Exception::Fault::TERROIR
132
+ end
133
+ end
134
+
135
+ module Errno
136
+ class EIO # I/O error (e.g. terminal disconnected)
137
+ #: -> Exception::Fault
138
+ def fault = Exception::Fault::TERROIR
139
+ end
140
+ end
@@ -6,23 +6,12 @@ require 'English'
6
6
  module CLI
7
7
  module Kit
8
8
  class ErrorHandler
9
- extend T::Sig
9
+ #: type exception_reporter_or_proc = singleton(ExceptionReporter) | ^() -> singleton(ExceptionReporter)
10
10
 
11
- ExceptionReporterOrProc = T.type_alias do
12
- T.any(T.class_of(ExceptionReporter), T.proc.returns(T.class_of(ExceptionReporter)))
13
- end
14
-
15
- sig { params(override_exception_handler: T.proc.params(arg0: Exception).returns(Integer)).void }
11
+ #: ^(Exception arg0) -> Integer
16
12
  attr_writer :override_exception_handler
17
13
 
18
- sig do
19
- params(
20
- log_file: T.nilable(String),
21
- exception_reporter: ExceptionReporterOrProc,
22
- tool_name: T.nilable(String),
23
- dev_mode: T::Boolean,
24
- ).void
25
- end
14
+ #: (?log_file: String?, ?exception_reporter: exception_reporter_or_proc, ?tool_name: String?, ?dev_mode: bool) -> void
26
15
  def initialize(log_file: nil, exception_reporter: NullExceptionReporter, tool_name: nil, dev_mode: false)
27
16
  @log_file = log_file
28
17
  @exception_reporter_or_proc = exception_reporter
@@ -30,33 +19,28 @@ module CLI
30
19
  @dev_mode = dev_mode
31
20
  end
32
21
 
22
+ # @abstract
33
23
  class ExceptionReporter
34
- extend T::Sig
35
- extend T::Helpers
36
- abstract!
37
-
38
24
  class << self
39
- extend T::Sig
40
-
41
- sig { abstract.params(exception: T.nilable(Exception), logs: T.nilable(String)).void }
42
- def report(exception, logs = nil); end
25
+ # @abstract
26
+ #: (Exception?, ?String?) -> void
27
+ def report(exception, logs = nil)
28
+ raise(NotImplementedError)
29
+ end
43
30
  end
44
31
  end
45
32
 
46
33
  class NullExceptionReporter < ExceptionReporter
47
- extend T::Sig
48
-
49
34
  class << self
50
- extend T::Sig
51
-
52
- sig { override.params(_exception: T.nilable(Exception), _logs: T.nilable(String)).void }
35
+ # @override
36
+ #: (Exception? _exception, ?String? _logs) -> void
53
37
  def report(_exception, _logs = nil)
54
38
  nil
55
39
  end
56
40
  end
57
41
  end
58
42
 
59
- sig { params(block: T.proc.void).returns(Integer) }
43
+ #: { -> void } -> Integer
60
44
  def call(&block)
61
45
  # @at_exit_exception is set if handle_abort decides to submit an error.
62
46
  # $ERROR_INFO is set if we terminate because of a signal.
@@ -64,7 +48,7 @@ module CLI
64
48
  triage_all_exceptions(&block)
65
49
  end
66
50
 
67
- sig { params(error: T.nilable(Exception)).void }
51
+ #: (Exception? error) -> void
68
52
  def report_exception(error)
69
53
  if (notify_with = exception_for_submission(error))
70
54
  logs = nil
@@ -92,7 +76,7 @@ module CLI
92
76
  # they're #bug?
93
77
  #
94
78
  # Returns an exit status for the program.
95
- sig { params(block: T.proc.void).returns(Integer) }
79
+ #: { -> void } -> Integer
96
80
  def triage_all_exceptions(&block)
97
81
  begin
98
82
  block.call
@@ -100,7 +84,7 @@ module CLI
100
84
  rescue Interrupt => e # Ctrl-C
101
85
  # transform message, prevent bugsnag
102
86
  exc = e.exception('Interrupt')
103
- CLI::Kit.raise(exc, bug: false)
87
+ CLI::Kit.raise(exc, fault: Exception::Fault::TERROIR)
104
88
  rescue Errno::ENOSPC => e
105
89
  # transform message, prevent bugsnag
106
90
  message = if @tool_name
@@ -109,7 +93,7 @@ module CLI
109
93
  'Your disk is full - free space is required to operate'
110
94
  end
111
95
  exc = e.exception(message)
112
- CLI::Kit.raise(exc, bug: false)
96
+ CLI::Kit.raise(exc, fault: Exception::Fault::TERROIR)
113
97
  end
114
98
  # If SystemExit was raised, e.g. `exit()`, then
115
99
  # return whatever status is attached to the exception
@@ -130,12 +114,38 @@ module CLI
130
114
  e.bug? ? CLI::Kit::EXIT_BUG : CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
131
115
  end
132
116
 
133
- sig { params(error: T.nilable(Exception)).returns(T.nilable(Exception)) }
134
- def exception_for_submission(error)
135
- # happens on normal non-error termination
136
- return if error.nil?
117
+ #: (Exception? error) -> bool
118
+ def caused_by_benign_signal?(error)
119
+ current = error #: Exception?
120
+ while current
121
+ return true if current.is_a?(SignalException) && SIGNALS_THAT_ARENT_BUGS.include?(current.message)
137
122
 
138
- return unless error.bug?
123
+ current = current.cause
124
+ end
125
+ false
126
+ end
127
+
128
+ # Walks the cause chain looking for anything that should be reported as a
129
+ # bug. A terroir wrapper (e.g. Errno::EPIPE raised by a finalizer) can
130
+ # mask a real bug as its cause, so we can't only inspect the outermost
131
+ # exception. A non-benign signal (e.g. SIGSEGV) also counts as a bug.
132
+ #: (Exception? error) -> bool
133
+ def caused_by_bug?(error)
134
+ current = error #: Exception?
135
+ while current
136
+ return true if current.bug?
137
+ if current.is_a?(SignalException) && !SIGNALS_THAT_ARENT_BUGS.include?(current.message)
138
+ return true
139
+ end
140
+
141
+ current = current.cause
142
+ end
143
+ false
144
+ end
145
+
146
+ #: (Exception? error) -> Exception?
147
+ def exception_for_submission(error)
148
+ return if error.nil? || caused_by_benign_signal?(error) || !caused_by_bug?(error)
139
149
 
140
150
  case error
141
151
  when SignalException
@@ -161,14 +171,14 @@ module CLI
161
171
  end
162
172
  end
163
173
 
164
- sig { params(message: String).void }
174
+ #: (String message) -> void
165
175
  def stderr_puts(message)
166
176
  $stderr.puts(CLI::UI.fmt("{{red:#{message}}}"))
167
177
  rescue Errno::EPIPE, Errno::EIO
168
178
  nil
169
179
  end
170
180
 
171
- sig { returns(T.class_of(ExceptionReporter)) }
181
+ #: -> singleton(ExceptionReporter)
172
182
  def exception_reporter
173
183
  case @exception_reporter_or_proc
174
184
  when Proc
@@ -7,15 +7,13 @@ require 'fileutils'
7
7
  module CLI
8
8
  module Kit
9
9
  class Executor
10
- extend T::Sig
11
-
12
- sig { params(log_file: String).void }
10
+ #: (log_file: String) -> void
13
11
  def initialize(log_file:)
14
12
  FileUtils.mkpath(File.dirname(log_file))
15
13
  @log_file = log_file
16
14
  end
17
15
 
18
- sig { params(command: T.class_of(CLI::Kit::BaseCommand), command_name: String, args: T::Array[String]).void }
16
+ #: (singleton(CLI::Kit::BaseCommand) command, String command_name, Array[String] args) -> void
19
17
  def call(command, command_name, args)
20
18
  with_traps do
21
19
  with_logging do |id|
@@ -36,10 +34,7 @@ module CLI
36
34
 
37
35
  private
38
36
 
39
- sig do
40
- type_parameters(:T).params(block: T.proc.params(id: String).returns(T.type_parameter(:T)))
41
- .returns(T.type_parameter(:T))
42
- end
37
+ #: [T] { (String id) -> T } -> T
43
38
  def with_logging(&block)
44
39
  CLI::UI.log_output_to(@log_file) do
45
40
  CLI::UI::StdoutRouter.with_id(on_streams: [CLI::UI::StdoutRouter.duplicate_output_to].compact) do |id|
@@ -48,18 +43,14 @@ module CLI
48
43
  end
49
44
  end
50
45
 
51
- sig { type_parameters(:T).params(block: T.proc.returns(T.type_parameter(:T))).returns(T.type_parameter(:T)) }
46
+ #: [T] { -> T } -> T
52
47
  def with_traps(&block)
53
48
  twrap('QUIT', method(:quit_handler)) do
54
49
  twrap('INFO', method(:info_handler), &block)
55
50
  end
56
51
  end
57
52
 
58
- sig do
59
- type_parameters(:T)
60
- .params(signal: String, handler: Method, block: T.proc.returns(T.type_parameter(:T)))
61
- .returns(T.type_parameter(:T))
62
- end
53
+ #: [T] (String signal, Method handler) { -> T } -> T
63
54
  def twrap(signal, handler, &block)
64
55
  return yield unless Signal.list.key?(signal)
65
56
 
@@ -78,7 +69,7 @@ module CLI
78
69
  end
79
70
  end
80
71
 
81
- sig { params(_sig: T.untyped).void }
72
+ #: (untyped _sig) -> void
82
73
  def quit_handler(_sig)
83
74
  z = caller
84
75
  CLI::UI.raw do
@@ -88,7 +79,7 @@ module CLI
88
79
  exit(CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG)
89
80
  end
90
81
 
91
- sig { params(_sig: T.untyped).void }
82
+ #: (untyped _sig) -> void
92
83
  def info_handler(_sig)
93
84
  z = caller
94
85
  CLI::UI.raw do