rigortype 0.2.0 → 0.2.2

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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -20
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/docs/handbook/01-getting-started.md +311 -0
  27. data/docs/handbook/02-everyday-types.md +337 -0
  28. data/docs/handbook/03-narrowing.md +359 -0
  29. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  30. data/docs/handbook/05-methods-and-blocks.md +339 -0
  31. data/docs/handbook/06-classes.md +305 -0
  32. data/docs/handbook/07-rbs-and-extended.md +427 -0
  33. data/docs/handbook/08-understanding-errors.md +373 -0
  34. data/docs/handbook/09-plugins.md +241 -0
  35. data/docs/handbook/10-sorbet.md +347 -0
  36. data/docs/handbook/11-sig-gen.md +312 -0
  37. data/docs/handbook/12-lightweight-hkt.md +333 -0
  38. data/docs/handbook/README.md +275 -0
  39. data/docs/handbook/appendix-elixir.md +370 -0
  40. data/docs/handbook/appendix-go.md +399 -0
  41. data/docs/handbook/appendix-java-csharp.md +470 -0
  42. data/docs/handbook/appendix-liskov.md +580 -0
  43. data/docs/handbook/appendix-mypy.md +370 -0
  44. data/docs/handbook/appendix-phpstan.md +338 -0
  45. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  46. data/docs/handbook/appendix-rust.md +446 -0
  47. data/docs/handbook/appendix-steep.md +336 -0
  48. data/docs/handbook/appendix-type-theory.md +1662 -0
  49. data/docs/handbook/appendix-typeprof.md +416 -0
  50. data/docs/handbook/appendix-typescript.md +332 -0
  51. data/docs/install.md +189 -0
  52. data/docs/llms.txt +72 -0
  53. data/docs/manual/01-installation.md +342 -0
  54. data/docs/manual/02-cli-reference.md +557 -0
  55. data/docs/manual/03-configuration.md +152 -0
  56. data/docs/manual/04-diagnostics.md +206 -0
  57. data/docs/manual/05-inspecting-types.md +109 -0
  58. data/docs/manual/06-baseline.md +104 -0
  59. data/docs/manual/07-plugins.md +92 -0
  60. data/docs/manual/08-skills.md +143 -0
  61. data/docs/manual/09-editor-integration.md +245 -0
  62. data/docs/manual/10-mcp-server.md +532 -0
  63. data/docs/manual/11-ci.md +274 -0
  64. data/docs/manual/12-caching.md +116 -0
  65. data/docs/manual/13-troubleshooting.md +120 -0
  66. data/docs/manual/14-rails-quickstart.md +332 -0
  67. data/docs/manual/15-type-protection-coverage.md +204 -0
  68. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  69. data/docs/manual/17-driving-improvement.md +160 -0
  70. data/docs/manual/README.md +87 -0
  71. data/docs/manual/ci-templates/README.md +58 -0
  72. data/docs/manual/plugins/README.md +86 -0
  73. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  74. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  75. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  76. data/docs/manual/plugins/rigor-activejob.md +58 -0
  77. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  78. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  79. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  80. data/docs/manual/plugins/rigor-devise.md +70 -0
  81. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  82. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  83. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  84. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  85. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  86. data/docs/manual/plugins/rigor-graphql.md +89 -0
  87. data/docs/manual/plugins/rigor-hanami.md +83 -0
  88. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  89. data/docs/manual/plugins/rigor-minitest.md +86 -0
  90. data/docs/manual/plugins/rigor-pundit.md +72 -0
  91. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  92. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  93. data/docs/manual/plugins/rigor-rails.md +44 -0
  94. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  95. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  96. data/docs/manual/plugins/rigor-rspec.md +86 -0
  97. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  98. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  99. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  100. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  101. data/docs/manual/plugins/rigor-statesman.md +75 -0
  102. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  103. data/exe/rigor +1 -1
  104. data/lib/rigor/analysis/incremental_session.rb +4 -2
  105. data/lib/rigor/analysis/run_stats.rb +13 -1
  106. data/lib/rigor/analysis/runner.rb +54 -12
  107. data/lib/rigor/cli/check_command.rb +26 -3
  108. data/lib/rigor/cli/coverage_command.rb +67 -92
  109. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  110. data/lib/rigor/cli/docs_command.rb +248 -0
  111. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  112. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  113. data/lib/rigor/cli/skill_command.rb +103 -41
  114. data/lib/rigor/cli/skill_describe.rb +346 -0
  115. data/lib/rigor/cli.rb +25 -3
  116. data/lib/rigor/config_audit.rb +152 -0
  117. data/lib/rigor/configuration.rb +12 -0
  118. data/lib/rigor/environment/rbs_loader.rb +27 -0
  119. data/lib/rigor/environment.rb +49 -1
  120. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +140 -38
  121. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  122. data/lib/rigor/inference/scope_indexer.rb +87 -89
  123. data/lib/rigor/inference/statement_evaluator.rb +27 -0
  124. data/lib/rigor/plugin/isolation.rb +5 -5
  125. data/lib/rigor/plugin/loader.rb +4 -2
  126. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  127. data/lib/rigor/protection/mutation_scanner.rb +98 -38
  128. data/lib/rigor/protection/mutator.rb +21 -0
  129. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  130. data/lib/rigor/signature_path_audit.rb +92 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/skills/rigor-ask/SKILL.md +172 -0
  133. data/skills/rigor-doctor/SKILL.md +87 -0
  134. data/skills/rigor-editor-setup/SKILL.md +114 -0
  135. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  136. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  137. data/skills/rigor-next-steps/SKILL.md +113 -0
  138. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  139. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  140. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  141. data/skills/rigor-upgrade/SKILL.md +79 -0
  142. metadata +120 -1
data/lib/rigor/cli.rb CHANGED
@@ -16,8 +16,10 @@ require_relative "cli/ci_detector"
16
16
  module Rigor
17
17
  # The CLI class is a dispatcher: each `run_*` method delegates to a
18
18
  # command-specific class once the command grows beyond a few lines (see
19
- # {CLI::TypeOfCommand} and {CLI::CheckCommand}).
20
- class CLI
19
+ # {CLI::TypeOfCommand} and {CLI::CheckCommand}). It necessarily grows by
20
+ # one delegator + one help line per command, so — like the command
21
+ # classes it fans out to — it carries an explicit ClassLength exemption.
22
+ class CLI # rubocop:disable Metrics/ClassLength
21
23
  EXIT_USAGE = 64
22
24
 
23
25
  HANDLERS = {
@@ -39,6 +41,8 @@ module Rigor
39
41
  "plugin" => :run_plugin,
40
42
  "playground" => :run_playground,
41
43
  "skill" => :run_skill,
44
+ "describe" => :run_describe,
45
+ "docs" => :run_docs,
42
46
  "show-bleedingedge" => :run_show_bleedingedge
43
47
  }.freeze
44
48
 
@@ -285,6 +289,22 @@ module Rigor
285
289
  CLI::SkillCommand.new(argv: @argv, out: @out, err: @err).run
286
290
  end
287
291
 
292
+ # `rigor describe` — a top-level alias for `rigor skill describe`,
293
+ # the entry point most users reach for first. Surfaced because a
294
+ # bare `rigor describe` is the intuitive guess (the onboarding field
295
+ # trial saw it tried and met "Unknown command").
296
+ def run_describe
297
+ require_relative "cli/skill_command"
298
+
299
+ CLI::SkillCommand.new(argv: ["describe", *@argv], out: @out, err: @err).run
300
+ end
301
+
302
+ def run_docs
303
+ require_relative "cli/docs_command"
304
+
305
+ CLI::DocsCommand.new(argv: @argv, out: @out, err: @err).run
306
+ end
307
+
288
308
  def run_plugin
289
309
  require_relative "cli/plugin_command"
290
310
 
@@ -318,7 +338,9 @@ module Rigor
318
338
  plugins Report activation status of every configured plugin
319
339
  plugin Browse bundled plugin source as worked examples (list/path/print/root)
320
340
  playground Start the browser playground (requires rigor-playground gem)
321
- skill List or print bundled Agent Skills (rigor-project-init, ...)
341
+ describe Recommend the next skill for this project (alias for `skill describe`)
342
+ skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
343
+ docs Print the bundled docs offline (docs <name>, docs --list)
322
344
  show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
323
345
  version Print the Rigor version
324
346
  help Print this help
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "signature_path_audit"
4
+ require_relative "analysis/check_rules"
5
+
6
+ module Rigor
7
+ # Audits a loaded {Configuration} for the class of mistake where a
8
+ # configured value silently resolves to nothing — a typo'd or moved
9
+ # path, an unknown library name, an inert rule id. The shared failure
10
+ # mode is that the loader filters the bad entry without a word, so the
11
+ # only symptom is downstream and confusing: missing signatures turn
12
+ # every call into the types they were meant to cover into a
13
+ # high-confidence `call.undefined-method`, and an unrecognised
14
+ # suppression token leaves the rule firing as if the `disable:` line
15
+ # were never written. This module surfaces each such entry up front so
16
+ # the cause is visible instead of inferred.
17
+ #
18
+ # Every check is held to the same bar that {SignaturePathAudit} set:
19
+ # it mirrors the loader's own acceptance test, so a warning means the
20
+ # loader really did load nothing, and it never fires on a setup that
21
+ # works. In particular the rule-token check only flags a token under a
22
+ # built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*`
23
+ # rule id (whose family Rigor cannot statically enumerate, since a
24
+ # `node_rule` block emits any `rule:` string it likes) is left alone, so
25
+ # disabling a plugin rule is never mistaken for a typo.
26
+ module ConfigAudit
27
+ # One config-level finding. `kind` discriminates the source key
28
+ # (`:signature_path`, `:library`, `:disabled_rule`,
29
+ # `:severity_override`, `:bundler_bundle_path`, `:bundler_lockfile`,
30
+ # `:rbs_collection_lockfile`); `fields` carries the kind-specific
31
+ # structured data merged into {#to_h} for JSON consumers.
32
+ Warning = Data.define(:kind, :message, :fields) do
33
+ def to_h
34
+ { "kind" => kind.to_s, "message" => message }.merge(fields)
35
+ end
36
+ end
37
+
38
+ # @param configuration [Rigor::Configuration]
39
+ # @param project_root [String] the directory the run's relative
40
+ # bundler / collection paths resolve against (the CLI's CWD), used
41
+ # only by the explicit-path checks.
42
+ # @return [Array<Warning>]
43
+ def self.warnings(configuration, project_root: Dir.pwd)
44
+ signature_path_warnings(configuration) +
45
+ library_warnings(configuration) +
46
+ rule_token_warnings(configuration) +
47
+ explicit_path_warnings(configuration, project_root)
48
+ end
49
+
50
+ # `signature_paths:` entries that resolve to nothing — delegated to
51
+ # {SignaturePathAudit}, which mirrors the loader's `directory?` +
52
+ # recursive `**/*.rbs` acceptance test.
53
+ def self.signature_path_warnings(configuration)
54
+ SignaturePathAudit.warnings(configuration.signature_paths).map do |entry|
55
+ Warning.new(
56
+ kind: :signature_path,
57
+ message: entry.message,
58
+ fields: { "path" => entry.path, "status" => entry.status.to_s, "rbs_file_count" => entry.rbs_file_count }
59
+ )
60
+ end
61
+ end
62
+
63
+ # `libraries:` entries RBS does not recognise. Uses the same
64
+ # `RBS::EnvironmentLoader#has_library?` guard the loader filters
65
+ # through ({Environment::RbsLoader} `build_env_for`), so a flagged
66
+ # name is exactly one the loader skipped. Fails soft: if RBS itself
67
+ # raises, no warning rather than a crash.
68
+ def self.library_warnings(configuration)
69
+ configured = Array(configuration.libraries)
70
+ return [] if configured.empty?
71
+
72
+ loader = ::RBS::EnvironmentLoader.new
73
+ configured.reject { |lib| loader.has_library?(library: lib.to_s, version: nil) }.map do |lib|
74
+ Warning.new(
75
+ kind: :library,
76
+ message: "libraries: #{lib.to_s.inspect} is not an available RBS library (no signatures loaded from it)",
77
+ fields: { "name" => lib.to_s }
78
+ )
79
+ end
80
+ rescue StandardError
81
+ []
82
+ end
83
+
84
+ # `disable:` tokens and `severity_overrides:` keys that name no rule.
85
+ # Restricted to tokens under a built-in family so a plugin rule id is
86
+ # never mis-flagged (see the module docstring).
87
+ def self.rule_token_warnings(configuration)
88
+ disable = Array(configuration.disabled_rules).filter_map do |token|
89
+ next unless inert_builtin_token?(token.to_s)
90
+
91
+ Warning.new(
92
+ kind: :disabled_rule,
93
+ message: "disable: #{token.to_s.inspect} is not a recognized rule id; the suppression has no effect",
94
+ fields: { "token" => token.to_s }
95
+ )
96
+ end
97
+ overrides = configuration.severity_overrides.keys.filter_map do |key|
98
+ next unless inert_builtin_token?(key.to_s)
99
+
100
+ Warning.new(
101
+ kind: :severity_override,
102
+ message: "severity_overrides: #{key.to_s.inspect} is not a recognized rule id; the override has no effect",
103
+ fields: { "token" => key.to_s }
104
+ )
105
+ end
106
+ disable + overrides
107
+ end
108
+
109
+ # True when `token` looks like a built-in rule id but matches none —
110
+ # its first segment is a built-in family yet it is neither a bare
111
+ # family wildcard nor a known canonical id. A token whose family is
112
+ # not built-in (a plugin / `rbs_extended.*` rule, or a bare legacy
113
+ # alias) is deliberately NOT flagged: it may resolve at run time, so
114
+ # under-warning is the FP-safe choice.
115
+ def self.inert_builtin_token?(token)
116
+ family = token.split(".").first
117
+ return false unless Analysis::CheckRules::RULE_FAMILIES.include?(family)
118
+ return false if token == family
119
+ return false if Analysis::CheckRules::ALL_RULES.include?(token)
120
+
121
+ true
122
+ end
123
+
124
+ # Explicitly-configured bundler / rbs-collection paths that do not
125
+ # exist. Only the explicit form is audited (a nil value means
126
+ # auto-detection, which finding nothing is normal); messages stay
127
+ # factual about the path rather than guessing the fallback.
128
+ def self.explicit_path_warnings(configuration, project_root)
129
+ warnings = []
130
+ add_missing_dir(warnings, configuration.bundler_bundle_path, project_root,
131
+ :bundler_bundle_path, "bundler.bundle_path")
132
+ add_missing_file(warnings, configuration.bundler_lockfile, project_root,
133
+ :bundler_lockfile, "bundler.lockfile")
134
+ add_missing_file(warnings, configuration.rbs_collection_lockfile, project_root,
135
+ :rbs_collection_lockfile, "rbs_collection.lockfile")
136
+ warnings
137
+ end
138
+
139
+ def self.add_missing_dir(warnings, path, project_root, kind, key)
140
+ return if path.nil? || File.directory?(File.expand_path(path, project_root))
141
+
142
+ warnings << Warning.new(kind: kind, message: "#{key}: #{path.inspect} is not a directory",
143
+ fields: { "path" => path })
144
+ end
145
+
146
+ def self.add_missing_file(warnings, path, project_root, kind, key)
147
+ return if path.nil? || File.file?(File.expand_path(path, project_root))
148
+
149
+ warnings << Warning.new(kind: kind, message: "#{key}: #{path.inspect} does not exist", fields: { "path" => path })
150
+ end
151
+ end
152
+ end
@@ -600,6 +600,18 @@ module Rigor
600
600
  raise ArgumentError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
601
601
 
602
602
  value.to_h do |k, v|
603
+ # YAML 1.1 parses bare `off`/`on`/`no`/`yes`/`true`/`false`
604
+ # as booleans, so a user who wrote `off` (a valid severity)
605
+ # without quotes hands us `false`. Catch the non-Symbol /
606
+ # non-String case before `to_sym` blows up with a backtrace.
607
+ unless v.is_a?(String) || v.is_a?(Symbol)
608
+ hint = v == false ? %( — did you mean the string "off"?) : ""
609
+ raise ArgumentError,
610
+ "severity_overrides[#{k.inspect}] is #{v.inspect}, a YAML boolean#{hint} " \
611
+ "Bare off/on/no/yes/true/false are parsed as booleans; quote the severity " \
612
+ "(e.g. \"off\")."
613
+ end
614
+
603
615
  sym = v.to_sym
604
616
  unless SeverityProfile::VALID_SEVERITIES.include?(sym)
605
617
  raise ArgumentError,
@@ -323,6 +323,33 @@ module Rigor
323
323
  [Pathname(CORE_OVERLAY_SIGS_ROOT)]
324
324
  end
325
325
 
326
+ # Rigor-owned per-gem RBS overlays (`data/gem_overlay/<gem>/`),
327
+ # ADR-72. Unlike the unconditional `core_overlay`, each gem's
328
+ # overlay is loaded ONLY when that gem is locked in the
329
+ # project's Gemfile.lock but ships no RBS of its own —
330
+ # {Environment.for_project} decides eligibility and passes the
331
+ # already-filtered gem-name set here. One directory per gem name
332
+ # keeps the membership check a cheap `File.directory?`.
333
+ GEM_OVERLAY_SIGS_ROOT = File.expand_path(
334
+ "../../../data/gem_overlay",
335
+ __dir__
336
+ ).freeze
337
+
338
+ # @param gem_names [Enumerable<String>] overlay-eligible
339
+ # Gemfile.lock gem names (the caller filters to the
340
+ # `:missing`-coverage, no-conflicting-plugin set).
341
+ # @return [Array<Pathname>] the bundled overlay directory for
342
+ # each gem that ships one; empty when none match or the
343
+ # overlay root is absent.
344
+ def gem_overlay_sig_paths(gem_names)
345
+ return [] unless File.directory?(GEM_OVERLAY_SIGS_ROOT)
346
+
347
+ gem_names.filter_map do |name|
348
+ dir = File.join(GEM_OVERLAY_SIGS_ROOT, name.to_s)
349
+ Pathname(dir) if File.directory?(dir)
350
+ end
351
+ end
352
+
326
353
  def vendored_gem_sig_paths
327
354
  return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)
328
355
 
@@ -65,6 +65,13 @@ module Rigor
65
65
  prism rbs
66
66
  ].freeze
67
67
 
68
+ # ADR-72 — a Gemfile.lock gem name mapped to the opt-in plugin id
69
+ # that ships the SAME core-ext RBS. When that plugin is loaded the
70
+ # auto-overlay for the gem stands down, so the two never both
71
+ # declare the methods (which would raise a duplicate-declaration
72
+ # error). Keyed on the gem name `RbsCoverageReport` reports.
73
+ GEM_OVERLAY_PLUGIN_IDS = { "activesupport" => "activesupport-core-ext" }.freeze
74
+
68
75
  attr_reader :class_registry, :rbs_loader, :plugin_registry, :dependency_source_index,
69
76
  :reporters, :name_scope,
70
77
  :synthetic_method_index, :project_patched_methods
@@ -291,7 +298,24 @@ module Rigor
291
298
  # collection discovery. A duplicate-declaration conflict
292
299
  # degrades through the same O7 failure-memo path.
293
300
  plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
294
- loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
301
+ # ADR-72 Gemfile.lock-gated bundled RBS overlays. For each
302
+ # locked gem that ships no RBS through any resolution path
303
+ # (`:missing`) and has a Rigor-bundled overlay, load that
304
+ # overlay so its core-class extensions resolve (e.g.
305
+ # `Integer#minutes` on a Rails project) — turning a systematic
306
+ # false `call.undefined-method` into a no-op while a project
307
+ # WITHOUT the gem still sees the genuine diagnostic. Appended
308
+ # last so any RBS the project already supplies wins, and skipped
309
+ # for a gem whose opt-in plugin twin is loaded (no duplicate
310
+ # declaration). The paths ride in `loader_signature_paths`, so
311
+ # the env cache descriptor digests them for free.
312
+ overlay_paths = gem_overlay_paths(
313
+ locked: locked, default_libraries: merged_libraries,
314
+ bundle_sig_paths: gem_sig_paths, rbs_collection_paths: collection_paths,
315
+ plugin_registry: plugin_registry
316
+ )
317
+ loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths +
318
+ collection_paths + overlay_paths
295
319
  # ADR-32 WD4 + WD5 — invoke each loaded plugin's
296
320
  # `source_rbs_synthesizer` once per project source file
297
321
  # and collect non-nil `[filename, rbs_source]` pairs.
@@ -346,6 +370,30 @@ module Rigor
346
370
  sig.directory? ? [sig] : []
347
371
  end
348
372
 
373
+ # ADR-72 — resolve the bundled RBS overlay directories to load for
374
+ # this project. A gem is eligible when it is locked in the
375
+ # Gemfile.lock, classified `:missing` by {RbsCoverageReport}
376
+ # (no RBS through default-library / vendored / bundle-`sig/` /
377
+ # rbs-collection), and its conflicting opt-in plugin (if any) is
378
+ # not loaded. Returns `[Pathname]`, deterministically ordered, or
379
+ # `[]` when no lockfile / no eligible gem.
380
+ def gem_overlay_paths(locked:, default_libraries:, bundle_sig_paths:,
381
+ rbs_collection_paths:, plugin_registry:)
382
+ return [] if locked.empty?
383
+
384
+ missing = RbsCoverageReport.classify(
385
+ locked_gems: locked, default_libraries: default_libraries,
386
+ bundle_sig_paths: bundle_sig_paths, rbs_collection_paths: rbs_collection_paths
387
+ ).select { |row| row.source == :missing }.map(&:gem_name)
388
+
389
+ loaded_ids = plugin_registry ? plugin_registry.ids.to_set : Set.new
390
+ eligible = missing.reject do |gem_name|
391
+ plugin_id = GEM_OVERLAY_PLUGIN_IDS[gem_name]
392
+ plugin_id && loaded_ids.include?(plugin_id)
393
+ end.sort
394
+ RbsLoader.gem_overlay_sig_paths(eligible)
395
+ end
396
+
349
397
  # ADR-32 WD4 + WD5 — for each project source file, invoke
350
398
  # every plugin-registered synthesizer once and collect
351
399
  # non-nil returns. The returned array is `[[virtual_filename,
@@ -51,7 +51,15 @@ module Rigor
51
51
  NUMERIC_BINARY = Set[
52
52
  :+, :-, :*, :/, :%, :**, :&, :|, :^, :<<, :>>,
53
53
  :<, :<=, :>, :>=, :==, :!=, :<=>,
54
- :gcd, :lcm, :fdiv, :quo, :ceildiv, :[]
54
+ :gcd, :lcm, :fdiv, :quo, :ceildiv, :[],
55
+ # Integer bit-test predicates (`(self & mask) <=> mask|0`). The
56
+ # catalog marks them `:dispatch` only because a non-Integer mask
57
+ # would route through `to_int`; a concrete Integer literal never
58
+ # does, so the fold is pure here — the sibling of the already-folded
59
+ # bit-reference `:[]`. Integer-only, but Float-safe to list: a Float
60
+ # receiver has no such method, so `invoke_binary` rescues the
61
+ # `NoMethodError` to nil and the RBS tier answers.
62
+ :allbits?, :anybits?, :nobits?
55
63
  ].freeze
56
64
  STRING_BINARY = Set[
57
65
  :+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>,
@@ -86,6 +94,17 @@ module Rigor
86
94
  # delegates to operand `==`); ordering is undefined for Complex.
87
95
  COMPLEX_BINARY = Set[:+, :-, :*, :/, :**].freeze
88
96
 
97
+ # `Set#&` and its alias `Set#intersection` are leaf-pure for a
98
+ # concrete Set operand exactly like their siblings `|` / `-` / `^`
99
+ # (all `:leaf` in the catalog), but the catalog flags
100
+ # `set_i_intersection`'s C body `block_dependent` — it drives Set's
101
+ # own internal iterator — so the catalog tier declines and the
102
+ # intersection alone fails to fold. A concrete Set argument's `each`
103
+ # is the pure core method, so the fold is sound; the hand-rolled
104
+ # allow-list is the right tool, mirroring the bit-test predicates.
105
+ # (The other binary set ops keep folding through the catalog.)
106
+ SET_BINARY = Set[:&, :intersection].freeze
107
+
89
108
  # v0.0.3 C — pure unary catalogue. Each method must:
90
109
  # - take zero arguments,
91
110
  # - have no side effects,
@@ -114,17 +133,35 @@ module Rigor
114
133
  # siblings `:inspect` / `:to_s` remain folded.
115
134
  INTEGER_UNARY = Set[
116
135
  :odd?, :even?, :zero?, :positive?, :negative?,
136
+ # `finite?` / `infinite?` are total on Integer (`true` / `nil`
137
+ # always) and round out the numeric predicate family — the Float
138
+ # sibling already folds them. `nonzero?` returns `self` (non-zero)
139
+ # or `nil`, both foldable Constants.
140
+ :finite?, :infinite?, :nonzero?,
117
141
  :succ, :pred, :next, :abs, :magnitude,
118
142
  :bit_length, :to_s, :to_i, :to_int, :to_f,
119
143
  :floor, :ceil, :round, :truncate, :chr,
120
144
  :inspect, :-@, :+@, :~, :to_r, :to_c
121
145
  ].freeze
122
146
  FLOAT_UNARY = Set[
123
- :zero?, :positive?, :negative?,
124
- :nan?, :finite?, :infinite?,
147
+ :zero?, :positive?, :negative?, :nonzero?,
148
+ :nan?, :finite?, :infinite?, :integer?,
125
149
  :abs, :magnitude, :floor, :ceil, :round, :truncate,
126
150
  :next_float, :prev_float,
127
151
  :to_s, :to_i, :to_int, :to_f, :to_r, :rationalize,
152
+ # `numerator` / `denominator` expose the rational
153
+ # decomposition of the float (`2.5.numerator → 5`,
154
+ # `.denominator → 2`) — pure arithmetic, the Float siblings
155
+ # of the already-folded Rational accessors. The non-finite
156
+ # edges stay sound: `Infinity.numerator → Infinity` /
157
+ # `.denominator → 1` fold to the same value Ruby returns, and
158
+ # `NaN.numerator → NaN` is declined by `foldable_constant_value?`.
159
+ :numerator, :denominator,
160
+ # `arg` / `angle` / `phase` (aliases) return the complex
161
+ # argument of the real number: `0` for `self >= 0`, `Math::PI`
162
+ # for `self < 0`. Pure sign test, deterministic; a NaN
163
+ # receiver yields NaN which `foldable_constant_value?` declines.
164
+ :arg, :angle, :phase,
128
165
  :inspect, :-@, :+@
129
166
  ].freeze
130
167
  STRING_UNARY = Set[
@@ -133,12 +170,21 @@ module Rigor
133
170
  :empty?, :strip, :lstrip, :rstrip, :chomp, :chop, :squeeze,
134
171
  :to_s, :to_str, :to_sym, :intern,
135
172
  :to_i, :to_f, :ord, :chr, :hex, :oct, :succ, :next,
136
- :sum, :inspect
173
+ :sum, :inspect,
174
+ # `shellescape` is the String-receiver twin of the already-folded
175
+ # `Shellwords.escape` — deterministic shell-quoting, no global
176
+ # state. The `shellwords` library is loaded process-wide via
177
+ # `shellwords_folding`, so the method is always defined here.
178
+ :shellescape
137
179
  ].freeze
138
180
  SYMBOL_UNARY = Set[
139
181
  :to_s, :to_sym, :to_proc, :length, :size,
140
182
  :empty?, :upcase, :downcase, :capitalize,
141
- :swapcase, :succ, :next, :inspect
183
+ :swapcase, :succ, :next, :inspect,
184
+ # `name` (the frozen-string accessor), `id2name` (alias of
185
+ # `to_s`), and `intern` (alias of `to_sym`) are pure reads of the
186
+ # symbol's text — siblings of the already-folded `to_s` / `to_sym`.
187
+ :name, :id2name, :intern
142
188
  ].freeze
143
189
  BOOL_UNARY = Set[:!, :to_s, :inspect, :&, :|, :^].freeze
144
190
  NIL_UNARY = Set[:nil?, :!, :to_s, :to_a, :to_h, :inspect].freeze
@@ -377,26 +423,8 @@ module Rigor
377
423
  end
378
424
 
379
425
  def try_fold_unary_set(receiver_values, method_name)
380
- range_lift = try_fold_range_constant_unary(receiver_values, method_name)
381
- return range_lift if range_lift
382
-
383
- string_lift = try_fold_string_array_unary(receiver_values, method_name)
384
- return string_lift if string_lift
385
-
386
- pathname_lift = try_fold_pathname_unary(receiver_values, method_name)
387
- return pathname_lift if pathname_lift
388
-
389
- regexp_lift = try_fold_regexp_array_unary(receiver_values, method_name)
390
- return regexp_lift if regexp_lift
391
-
392
- set_lift = try_fold_set_array_unary(receiver_values, method_name)
393
- return set_lift if set_lift
394
-
395
- integer_lift = try_fold_integer_array_unary(receiver_values, method_name)
396
- return integer_lift if integer_lift
397
-
398
- numeric_lift = try_fold_numeric_array_unary(receiver_values, method_name)
399
- return numeric_lift if numeric_lift
426
+ special = try_fold_unary_special(receiver_values, method_name)
427
+ return special if special
400
428
 
401
429
  # Type-level allow check on every receiver. If one member's
402
430
  # type does not have the method in its allow list (e.g.
@@ -411,6 +439,23 @@ module Rigor
411
439
  end
412
440
  build_constant_type(results, source: receiver_values)
413
441
  end
442
+
443
+ # The carrier-specific unary lifts — Range-to-Tuple, the
444
+ # Array-returning String / Pathname / Regexp / Set / Integer /
445
+ # Numeric folds — that produce a precise structural type before
446
+ # the generic scalar `invoke_unary` path. The first match wins;
447
+ # nil means none applied and the caller falls through to the
448
+ # scalar allow-list path.
449
+ def try_fold_unary_special(receiver_values, method_name)
450
+ try_fold_range_constant_unary(receiver_values, method_name) ||
451
+ try_fold_string_array_unary(receiver_values, method_name) ||
452
+ try_fold_pathname_unary(receiver_values, method_name) ||
453
+ try_fold_pathname_array_unary(receiver_values, method_name) ||
454
+ try_fold_regexp_array_unary(receiver_values, method_name) ||
455
+ try_fold_set_array_unary(receiver_values, method_name) ||
456
+ try_fold_integer_array_unary(receiver_values, method_name) ||
457
+ try_fold_numeric_array_unary(receiver_values, method_name)
458
+ end
414
459
  # v0.0.7 — `Constant<Range>#to_a` and the no-arg
415
460
  # `first` / `last` / `min` / `max` short-circuit through a
416
461
  # Range-specific arm that catalog dispatch cannot reach:
@@ -427,10 +472,13 @@ module Rigor
427
472
  RANGE_FOLD_METHODS = Set[:to_a, :first, :last, :min, :max, :count, :size, :length, :entries, :minmax,
428
473
  :sum].freeze
429
474
  # 1-arg head/tail projections on a `Constant<Range>`. `first(n)` /
430
- # `take(n)` return the first `n` elements, `last(n)` the final `n` —
431
- # each lifts to a per-position `Tuple[Constant[Integer]…]`. The
432
- # no-arg `first` / `last` stay on the unary path (single Integer).
433
- RANGE_FOLD_BINARY_METHODS = Set[:first, :last, :take].freeze
475
+ # `take(n)` return the first `n` elements, `last(n)` the final `n`,
476
+ # and `min(n)` / `max(n)` the n smallest / largest (for an ascending
477
+ # integer range `min(n) == first(n)` and `max(n) == last(n).reverse`)
478
+ # each lifts to a per-position `Tuple[Constant[Integer]…]`. The
479
+ # no-arg `first` / `last` / `min` / `max` stay on the unary path
480
+ # (single Integer endpoint).
481
+ RANGE_FOLD_BINARY_METHODS = Set[:first, :last, :take, :min, :max].freeze
434
482
  RANGE_TO_A_LIMIT = 16
435
483
  private_constant :RANGE_FOLD_METHODS, :RANGE_FOLD_BINARY_METHODS, :RANGE_TO_A_LIMIT
436
484
 
@@ -509,17 +557,31 @@ module Rigor
509
557
 
510
558
  def range_take_tuple(range, method_name, count)
511
559
  return nil unless count.is_a?(Integer) && !count.negative?
512
- # `first(n)`/`last(n)`/`take(n)` materialise at most `min(n, size)`
513
- # elements; cap that count so a huge `n` (or range) never blows up
514
- # the Constant. `Range#size` is O(1) for integer endpoints.
560
+ # `first(n)`/`last(n)`/`take(n)`/`min(n)`/`max(n)` materialise at
561
+ # most `min(n, size)` elements; cap that count so a huge `n` (or
562
+ # range) never blows up the Constant. `Range#size` and the head/
563
+ # tail projections are O(n) for integer endpoints (no full
564
+ # materialisation).
515
565
  return nil if [count, range.size].min > RANGE_TO_A_LIMIT
516
566
 
517
- values = method_name == :last ? range.last(count) : range.first(count)
567
+ values = range_head_tail(range, method_name, count)
518
568
  return Type::Combinator.tuple_of if values.empty?
519
569
 
520
570
  Type::Combinator.tuple_of(*values.map { |v| Type::Combinator.constant_of(v) })
521
571
  end
522
572
 
573
+ # The n elements a head/tail projection selects, in Ruby's order.
574
+ # For an ascending integer range `min(n)` is the leading `n`
575
+ # (`first(n)`) and `max(n)` the trailing `n` reversed (descending),
576
+ # so neither needs the full sort `Array#min`/`#max` would do.
577
+ def range_head_tail(range, method_name, count)
578
+ case method_name
579
+ when :last then range.last(count)
580
+ when :max then range.last(count).reverse
581
+ else range.first(count) # :first, :take, :min
582
+ end
583
+ end
584
+
523
585
  def try_fold_binary_set(receiver_values, method_name, arg_values)
524
586
  range_lift = try_fold_range_constant_binary(receiver_values, method_name, arg_values)
525
587
  return range_lift if range_lift
@@ -542,14 +604,21 @@ module Rigor
542
604
  build_constant_type(results, source: receiver_values + arg_values)
543
605
  end
544
606
  # v0.0.7 — `Constant<String>#chars` / `bytes` / `codepoints` /
545
- # `lines` / `split` (no-arg) return a Ruby Array of foldable
546
- # scalars; `foldable_constant_value?` rejects Array
607
+ # `grapheme_clusters` / `lines` / `split` (no-arg) return a Ruby
608
+ # Array of foldable scalars; `foldable_constant_value?` rejects Array
547
609
  # results, so the standard unary path declines. Lift the
548
610
  # Array to a per-position `Tuple[Constant…]` directly,
549
611
  # capped at `STRING_ARRAY_LIFT_LIMIT` to keep the result
550
612
  # bounded for long strings. (`codepoints` yields per-character
551
- # Integer codepoints, the sibling of the byte-valued `bytes`.)
552
- STRING_ARRAY_UNARY_METHODS = Set[:chars, :bytes, :codepoints, :lines, :split].freeze
613
+ # Integer codepoints, the sibling of the byte-valued `bytes`;
614
+ # `grapheme_clusters` is the extended-grapheme sibling of `chars`.)
615
+ # `shellsplit` is the String-receiver twin of the already-folded
616
+ # `Shellwords.split` — lifts the token Array to a Tuple. Raises
617
+ # `ArgumentError` on unmatched quotes, which `try_fold_string_array_unary`
618
+ # rescues to nil (RBS tier widens). `shellwords` is loaded process-wide
619
+ # via `shellwords_folding`.
620
+ STRING_ARRAY_UNARY_METHODS = Set[:chars, :bytes, :codepoints, :grapheme_clusters,
621
+ :lines, :split, :shellsplit].freeze
553
622
  # `partition` / `rpartition` always return a fixed 3-element
554
623
  # `[head, separator, tail]` Array whose members are substrings of
555
624
  # the receiver (bounded by the input), so they lift to a precise
@@ -613,10 +682,26 @@ module Rigor
613
682
  ].freeze
614
683
  PATHNAME_PURE_BINARY = Set[
615
684
  :+, :join, :sub_ext, :<=>, :==, :eql?, :===,
616
- :relative_path_from
685
+ :relative_path_from,
686
+ # `/` is the exact alias of `+` (`def /(other) = self + other`),
687
+ # the idiomatic path-join operator (`dir / "file"`). `basename`'s
688
+ # 1-arg suffix-stripping form (`path.basename(".rb")` → the stem)
689
+ # is the binary sibling of the already-folded no-arg `basename` —
690
+ # both are pure `@path` string manipulation, no filesystem read.
691
+ :/, :basename
617
692
  ].freeze
618
693
  private_constant :PATHNAME_PURE_UNARY, :PATHNAME_PURE_BINARY
619
694
 
695
+ # `Constant<Pathname>#split` returns the fixed 2-element
696
+ # `[dirname, basename]` pair (both Pathname), the path-string
697
+ # split of `File.split`. Lifted to `Tuple[Constant[Pathname],
698
+ # Constant[Pathname]]`. Filesystem-independent — reads only
699
+ # `@path` — so it is deterministic at fold time, the
700
+ # Array-returning sibling of the scalar `basename` / `dirname`
701
+ # folds (which `try_fold_pathname_unary` already covers).
702
+ PATHNAME_ARRAY_UNARY_METHODS = Set[:split].freeze
703
+ private_constant :PATHNAME_ARRAY_UNARY_METHODS
704
+
620
705
  def try_fold_pathname_unary(receiver_values, method_name)
621
706
  return nil unless PATHNAME_PURE_UNARY.include?(method_name)
622
707
  return nil unless receiver_values.size == 1
@@ -649,6 +734,22 @@ module Rigor
649
734
  nil
650
735
  end
651
736
 
737
+ # `Constant<Pathname>#split` — lift the `[dirname, basename]`
738
+ # Pathname pair to a Tuple[Constant[Pathname], Constant[Pathname]].
739
+ # Pure path-string manipulation (no filesystem read); both
740
+ # elements are Pathname, a foldable Constant class.
741
+ def try_fold_pathname_array_unary(receiver_values, method_name)
742
+ return nil unless PATHNAME_ARRAY_UNARY_METHODS.include?(method_name)
743
+ return nil unless receiver_values.size == 1
744
+
745
+ receiver = receiver_values.first
746
+ return nil unless receiver.is_a?(Pathname)
747
+
748
+ lift_array_result(receiver.split)
749
+ rescue StandardError
750
+ nil
751
+ end
752
+
652
753
  def try_fold_string_array_unary(receiver_values, method_name)
653
754
  return nil unless STRING_ARRAY_UNARY_METHODS.include?(method_name)
654
755
  return nil unless receiver_values.size == 1
@@ -1490,6 +1591,7 @@ module Rigor
1490
1591
  when nil then NIL_BINARY
1491
1592
  when Rational then RATIONAL_BINARY
1492
1593
  when Complex then COMPLEX_BINARY
1594
+ when ::Set then SET_BINARY
1493
1595
  else Set.new
1494
1596
  end
1495
1597
  end