rigortype 0.2.9 → 0.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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/handbook/03-narrowing.md +2 -1
  4. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  5. data/docs/handbook/06-classes.md +18 -9
  6. data/docs/handbook/08-understanding-errors.md +1 -0
  7. data/docs/handbook/11-sig-gen.md +11 -1
  8. data/docs/handbook/appendix-mypy.md +4 -3
  9. data/docs/install.md +14 -6
  10. data/docs/manual/01-installation.md +98 -4
  11. data/docs/manual/02-cli-reference.md +49 -12
  12. data/docs/manual/03-configuration.md +34 -2
  13. data/docs/manual/04-diagnostics.md +23 -0
  14. data/docs/manual/07-plugins.md +4 -4
  15. data/docs/manual/09-editor-integration.md +4 -4
  16. data/docs/manual/11-ci.md +51 -0
  17. data/docs/manual/12-caching.md +65 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  20. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  21. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  23. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  24. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  28. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  29. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  30. data/lib/rigor/analysis/check_rules.rb +557 -95
  31. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/incremental_session.rb +456 -51
  34. data/lib/rigor/analysis/path_expansion.rb +42 -0
  35. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  36. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  37. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  38. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  43. data/lib/rigor/analysis/runner.rb +343 -68
  44. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  45. data/lib/rigor/analysis/worker_session.rb +30 -2
  46. data/lib/rigor/bleeding_edge.rb +37 -8
  47. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  48. data/lib/rigor/cache/descriptor.rb +88 -28
  49. data/lib/rigor/cache/file_digest.rb +158 -0
  50. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  51. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  52. data/lib/rigor/cache/store.rb +33 -3
  53. data/lib/rigor/ci_detector.rb +90 -0
  54. data/lib/rigor/cli/annotate_command.rb +15 -9
  55. data/lib/rigor/cli/baseline_command.rb +4 -1
  56. data/lib/rigor/cli/check_command.rb +212 -27
  57. data/lib/rigor/cli/coverage_command.rb +6 -1
  58. data/lib/rigor/cli/docs_command.rb +0 -30
  59. data/lib/rigor/cli/doctor_command.rb +163 -23
  60. data/lib/rigor/cli/lsp_command.rb +5 -0
  61. data/lib/rigor/cli/mcp_command.rb +5 -0
  62. data/lib/rigor/cli/plugins_command.rb +36 -6
  63. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  64. data/lib/rigor/cli/probe_environment.rb +85 -0
  65. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  66. data/lib/rigor/cli/skill_command.rb +1 -32
  67. data/lib/rigor/cli/trace_command.rb +5 -6
  68. data/lib/rigor/cli/type_of_command.rb +9 -8
  69. data/lib/rigor/cli/type_scan_command.rb +8 -8
  70. data/lib/rigor/cli.rb +15 -4
  71. data/lib/rigor/config_audit.rb +48 -2
  72. data/lib/rigor/configuration/severity_profile.rb +31 -0
  73. data/lib/rigor/configuration.rb +156 -8
  74. data/lib/rigor/environment/default_libraries.rb +35 -0
  75. data/lib/rigor/environment/rbs_loader.rb +164 -5
  76. data/lib/rigor/environment.rb +19 -27
  77. data/lib/rigor/inference/budget_trace.rb +77 -2
  78. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  79. data/lib/rigor/inference/def_handle.rb +23 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  81. data/lib/rigor/inference/def_return_typer.rb +2 -1
  82. data/lib/rigor/inference/expression_typer.rb +361 -113
  83. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  86. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  87. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  88. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  89. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  91. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  92. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  93. data/lib/rigor/inference/mutation_widening.rb +42 -8
  94. data/lib/rigor/inference/narrowing.rb +127 -17
  95. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  97. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  98. data/lib/rigor/inference/scope_indexer.rb +451 -36
  99. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  100. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  101. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  102. data/lib/rigor/inference/void_origin.rb +25 -0
  103. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  104. data/lib/rigor/language_server/completion_provider.rb +2 -1
  105. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  106. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  107. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  108. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  109. data/lib/rigor/plugin/base.rb +9 -22
  110. data/lib/rigor/plugin/inflector.rb +12 -3
  111. data/lib/rigor/plugin/io_boundary.rb +5 -1
  112. data/lib/rigor/plugin/isolation.rb +81 -11
  113. data/lib/rigor/plugin/load_error.rb +10 -1
  114. data/lib/rigor/plugin/loader.rb +96 -14
  115. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  116. data/lib/rigor/plugin/registry.rb +32 -23
  117. data/lib/rigor/plugin.rb +26 -0
  118. data/lib/rigor/protection/mutator.rb +3 -2
  119. data/lib/rigor/reflection.rb +64 -0
  120. data/lib/rigor/runtime/jit.rb +128 -0
  121. data/lib/rigor/scope/discovery_index.rb +12 -2
  122. data/lib/rigor/scope.rb +122 -28
  123. data/lib/rigor/sig_gen/classification.rb +5 -1
  124. data/lib/rigor/sig_gen/generator.rb +38 -6
  125. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  126. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  127. data/lib/rigor/sig_gen/renderer.rb +10 -0
  128. data/lib/rigor/sig_gen/write_result.rb +9 -4
  129. data/lib/rigor/sig_gen/writer.rb +24 -2
  130. data/lib/rigor/source/node_children.rb +116 -0
  131. data/lib/rigor/source/node_locator.rb +3 -1
  132. data/lib/rigor/source/node_walker.rb +4 -2
  133. data/lib/rigor/source.rb +1 -0
  134. data/lib/rigor/type/difference.rb +28 -24
  135. data/lib/rigor/type/hash_shape.rb +34 -10
  136. data/lib/rigor/version.rb +1 -1
  137. data/lib/rigortype.rb +24 -0
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  139. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  141. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  142. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  143. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  146. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  147. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  148. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  149. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  150. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  151. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  152. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  153. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  154. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  157. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  158. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  159. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  160. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  162. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  164. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  166. data/sig/prism_node_children.rbs +9 -0
  167. data/sig/rigor/inference.rbs +7 -0
  168. data/sig/rigor/plugin/base.rbs +2 -3
  169. data/sig/rigor/reflection.rbs +1 -0
  170. data/sig/rigor/scope.rbs +12 -1
  171. data/skills/rigor-ci-setup/SKILL.md +10 -0
  172. data/skills/rigor-editor-setup/SKILL.md +11 -0
  173. data/skills/rigor-next-steps/SKILL.md +23 -2
  174. data/skills/rigor-plugin-author/SKILL.md +3 -3
  175. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  176. data/skills/rigor-plugin-review/SKILL.md +2 -2
  177. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  178. data/skills/rigor-project-init/SKILL.md +29 -3
  179. data/skills/rigor-project-init/references/01-detect.md +1 -1
  180. data/skills/rigor-project-init/references/02-configure.md +39 -5
  181. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  182. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  183. metadata +42 -24
  184. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -63,12 +63,28 @@ module Rigor
63
63
  produces: [:dry_type_aliases]
64
64
  )
65
65
 
66
- # Walks every project file once during `prepare(services)` to build the alias table, then publishes
67
- # via the ADR-9 fact store. The walk is bounded by the configured `paths:` surface; each file's
68
- # parse error degrades to "no contribution" without polluting the user-visible diagnostic stream.
66
+ # Cached alias-table producer (ADR-60 WD3 record-and-validate). The scan Prism-parses every `.rb` file
67
+ # under the project's `paths:` tree to find `include Dry.Types()` declarations an expensive pass that
68
+ # ran on EVERY invocation (cold and warm) because `#prepare` called {AliasScanner.scan} directly, with
69
+ # no cache. It now rides `cache_for`: a warm run re-globs + re-digests the watched tree (cheap SHA over
70
+ # file bytes, no AST build) and reuses the cached table instead of re-parsing. This is the dominant win
71
+ # even for a project that ships no dry-types module — the parse still has to run to prove that, and now
72
+ # the empty result is cached too.
73
+ #
74
+ # `watch:` covers exactly the files the scan reads: one glob per project `paths:` entry, co-extensive
75
+ # with {#scannable_paths}. A {Cache::Descriptor::GlobEntry} digests every file matching its glob, so a
76
+ # content edit, a file addition, and a file removal anywhere under those paths all move the digest and
77
+ # invalidate the entry. The scan reads no file individually through the `IoBoundary`, so the evaluated
78
+ # `watch:` globs are the entire dependency descriptor — and they fully cover the scan's input.
79
+ producer :dry_type_aliases, watch: -> { alias_watch_globs } do |_params|
80
+ AliasScanner.scan(paths: scannable_paths)
81
+ end
82
+
83
+ # Builds the (cached) alias table and publishes it via the ADR-9 fact store. `producer_value` runs the
84
+ # producer through `cache_for` and memoises the result — including a rescued nil — for the run.
69
85
  def prepare(services)
70
- aliases = AliasScanner.scan(paths: scannable_paths(services))
71
- return if aliases.empty?
86
+ aliases = producer_value(:dry_type_aliases)
87
+ return if aliases.nil? || aliases.empty?
72
88
 
73
89
  services.fact_store.publish(
74
90
  plugin_id: manifest.id,
@@ -86,7 +102,7 @@ module Rigor
86
102
  # Resolves the project's `paths:` to a flat list of `.rb` files the scanner walks. Mirrors
87
103
  # `Analysis::Runner`'s `expand_paths` floor; we don't need the runner's full exclude/sort surface
88
104
  # because the alias table is a union — any duplicate scan is a no-op.
89
- def scannable_paths(services)
105
+ def scannable_paths
90
106
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
91
107
  if File.directory?(entry)
92
108
  Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
@@ -97,6 +113,22 @@ module Rigor
97
113
  end
98
114
  end.uniq.freeze
99
115
  end
116
+
117
+ # The `watch:` glob tuples covering the scan's entire input — one `[root, pattern]` per project `paths:`
118
+ # entry. A directory entry watches its whole `**/*.rb` subtree; a single-file `.rb` entry watches
119
+ # exactly that file (`[dir, basename]`). Roots are expanded to absolute paths by
120
+ # `Plugin::Base#watch_glob_entries`, so a relative CLI path (`app/models`) re-validates against the same
121
+ # tree from the project root on the next run. Co-extensive with {#scannable_paths}: every `.rb` the scan
122
+ # would read is watched, so any edit / addition / removal under those paths invalidates the cache.
123
+ def alias_watch_globs
124
+ services.configuration.paths.filter_map do |entry|
125
+ if File.directory?(entry)
126
+ [entry, "**/*.rb"]
127
+ elsif File.file?(entry) && entry.end_with?(".rb")
128
+ [File.dirname(entry), File.basename(entry)]
129
+ end
130
+ end
131
+ end
100
132
  end
101
133
 
102
134
  Rigor::Plugin.register(DryTypes)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -86,7 +88,7 @@ module Rigor
86
88
  visit_factory(node, &)
87
89
  return
88
90
  end
89
- node.compact_child_nodes.each { |child| walk_for_factories(child, &) }
91
+ node.rigor_each_child { |child| walk_for_factories(child, &) }
90
92
  end
91
93
 
92
94
  def factory_call?(node)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -65,7 +67,7 @@ module Rigor
65
67
  case node
66
68
  when Prism::DefNode then defs << node
67
69
  when Prism::ClassNode, Prism::ModuleNode then nil # nested scopes own their own defs
68
- else node.compact_child_nodes.each { |child| collect_direct_defs(child, defs) }
70
+ else node.rigor_each_child { |child| collect_direct_defs(child, defs) }
69
71
  end
70
72
  end
71
73
 
@@ -122,6 +122,16 @@ module Rigor
122
122
  carried_type(receiver_type, call_node.name)
123
123
  end
124
124
 
125
+ # ADR-88 WD1 — the `dynamic_return` contribution is a PURE STRUCTURAL projection of the receiver's own
126
+ # carrier type args (`Result[T, E]#unwrap → T`), computed per call from the receiver type the engine
127
+ # already resolved; it reads no cross-file catalog. (The `Enum` nested-class emission rides the ADR-16
128
+ # macro substrate / synthetic-method scanner, a separate pre-pass, not this `dynamic_return`.) A stable
129
+ # sentinel declares "no cross-file fact surface", keeping the plugin incremental-capable; the
130
+ # `--verify-incremental` gate backstops the claim.
131
+ def incremental_state_fingerprint
132
+ "structural-carriers"
133
+ end
134
+
125
135
  private
126
136
 
127
137
  # @return [Rigor::Type, nil] the receiver's inferred type, or nil when the engine raises on a synthetic
@@ -62,6 +62,17 @@ module Rigor
62
62
  narrowing_facts methods: AssertionAnalyzer::SUPPORTED_METHODS do |call_node, scope|
63
63
  AssertionAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
64
64
  end
65
+
66
+ # ADR-88 WD1 — the narrowing facts this plugin contributes are derived purely from each assertion call's
67
+ # own AST at its call site (`assert_kind_of(String, x)` ⇒ `x` is `String` on the continuation); there is
68
+ # no cross-file catalog a cached diagnostic could depend on. A change to a file's assertions changes that
69
+ # file's own content (re-analysed by the incremental graph already). A stable sentinel declares "no
70
+ # cross-file fact surface", keeping the plugin incremental-capable (a contributing plugin with NO
71
+ # fact / producer / hook makes the incremental snapshot un-reusable every run); `--verify-incremental`
72
+ # backstops the claim.
73
+ def incremental_state_fingerprint
74
+ "per-file-assertions"
75
+ end
65
76
  end
66
77
 
67
78
  Rigor::Plugin.register(Minitest)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  require_relative "policy_index"
@@ -69,7 +71,7 @@ module Rigor
69
71
  when Prism::ClassNode then visit_class(node, lexical_path, &)
70
72
  when Prism::ModuleNode then visit_module(node, lexical_path, &)
71
73
  else
72
- node.compact_child_nodes.each { |child| walk_for_policies(child, lexical_path, &) }
74
+ node.rigor_each_child { |child| walk_for_policies(child, lexical_path, &) }
73
75
  end
74
76
  end
75
77
 
@@ -1,19 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Convenience entry point. `require "rigor-rails"` requires every Tier 1+2 Rails ecosystem plugin in one go,
4
- # so projects that prefer a single require statement (some `spec_helper` patterns, ad-hoc scripts) do not
5
- # have to list seven require lines.
3
+ # The Tier 1+2 Rails set, as a single require. Requiring this file registers every member class with
4
+ # `Rigor::Plugin`; the loader's lookup phase then finds them by id when `.rigor.yml` enumerates them.
6
5
  #
7
- # Note: requiring this entry point does NOT mark every plugin as active. The Rigor plugin loader walks
8
- # `.rigor.yml`'s `plugins:` list and instantiates only the plugins enumerated there. This is per ADR-12
9
- # WD1's "Gemfile-convenience meta-gem" pattern users still control which plugins participate in analysis
10
- # via `.rigor.yml`.
6
+ # UNWIRED this entry point has no supported use today, and is kept deliberately (ADR-96 WD5) rather
7
+ # than removed. It was ADR-12 WD1's "Gemfile-convenience meta-gem": each plugin was to be its own gem, and
8
+ # `gem "rigor-rails"` was to pull the Rails set into a project's Gemfile in one line. Commit `9769f5fa`
9
+ # dropped the per-plugin gemspecs and ADR-31 settled distribution on the single bundled `rigortype` gem, so
10
+ # there is no `rigor-rails` gem to add to a Gemfile — and `plugins: [rigor-rails]` is rejected by the
11
+ # loader, which requires the members to be enumerated (or one selected with an explicit `id:`).
11
12
  #
12
- # Sub-plugins ARE registered with `Rigor::Plugin` when this file loads (each gem's entry point side-effects
13
- # a `Plugin.register` call); the loader's lookup phase finds them by id when listed.
14
- #
15
- # Adding the gem to a project's Gemfile without listing any plugin in `.rigor.yml` is harmless: the requires
16
- # happen on `Bundler.require`, but no plugin's `init` / `prepare` / hooks run.
13
+ # ADR-96 WD3 proposes the role this file would fill: `plugins: [rigor-rails]` activating those members
14
+ # whose `target_gems:` are actually in the project's `Gemfile.lock`. If that is rejected, ADR-60 WD1's
15
+ # never-wired-surface criterion applies and this directory goes.
17
16
 
18
17
  require "rigor-rails-routes"
19
18
  require "rigor-rails-i18n"
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -106,7 +108,7 @@ module Rigor
106
108
  end
107
109
  end
108
110
 
109
- node.compact_child_nodes.each { |child| walk(child, prefix_path, declarations, superclasses) }
111
+ node.rigor_each_child { |child| walk(child, prefix_path, declarations, superclasses) }
110
112
  end
111
113
 
112
114
  def record_class(node, qualified, declarations, superclasses)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -87,7 +89,7 @@ module Rigor
87
89
  def visit_program_children(node)
88
90
  return unless node.respond_to?(:compact_child_nodes)
89
91
 
90
- node.compact_child_nodes.each do |child|
92
+ node.rigor_each_child do |child|
91
93
  case child
92
94
  when Prism::ModuleNode, Prism::ClassNode
93
95
  visit_module_or_class(child)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -371,7 +373,7 @@ module Rigor
371
373
  when Prism::CallNode
372
374
  interpret_call(node, context)
373
375
  else
374
- node.compact_child_nodes.each { |child| interpret(child, context) }
376
+ node.rigor_each_child { |child| interpret(child, context) }
375
377
  end
376
378
  end
377
379
 
@@ -526,7 +528,7 @@ module Rigor
526
528
  return unless node.is_a?(Prism::Node)
527
529
 
528
530
  yield node
529
- node.compact_child_nodes.each { |child| walk_for_alias_pattern(child, &) }
531
+ node.rigor_each_child { |child| walk_for_alias_pattern(child, &) }
530
532
  end
531
533
 
532
534
  def first_block_parameter_name(block_node)
@@ -545,7 +547,7 @@ module Rigor
545
547
  body = node.block&.body
546
548
  return if body.nil?
547
549
 
548
- body.compact_child_nodes.each { |child| interpret(child, context) }
550
+ body.rigor_each_child { |child| interpret(child, context) }
549
551
  end
550
552
 
551
553
  def handle_namespace(node, context)
@@ -625,7 +627,7 @@ module Rigor
625
627
  return [] if body.nil?
626
628
 
627
629
  skips = []
628
- body.compact_child_nodes.each do |child|
630
+ body.rigor_each_child do |child|
629
631
  next unless child.is_a?(Prism::CallNode) && child.name == :skip_controllers
630
632
  next if child.receiver
631
633
 
@@ -748,7 +750,7 @@ module Rigor
748
750
  body = context.concern_body(concern_name)
749
751
  next if body.nil?
750
752
 
751
- body.compact_child_nodes.each { |child| interpret(child, context) }
753
+ body.rigor_each_child { |child| interpret(child, context) }
752
754
  end
753
755
  end
754
756
 
@@ -8,9 +8,10 @@ require "rigor/plugin"
8
8
  # name: T`, `# @rbs return: T`, attribute `#:`, …) and contributes the result to the analysis environment
9
9
  # through the `source_rbs_synthesizer:` manifest hook.
10
10
  #
11
- # By default (WD2) only files starting with the upstream `# rbs_inline: enabled` magic comment are
12
- # processed; a host context can flip this off by setting `require_magic_comment: false` in the plugin
13
- # config (WD10).
11
+ # Since ADR-93 WD1 the default is `require_magic_comment: false`: a file is processed whenever it actually
12
+ # carries an annotation (see {Synthesizer#annotated?}), with only the upstream `# rbs_inline: disabled`
13
+ # directive opting a file out. Set `require_magic_comment: true` to restore the old ADR-32 WD2 gate that
14
+ # processed only files opening with `# rbs_inline: enabled`.
14
15
  module Rigor
15
16
  module Plugin
16
17
  # The plugin gem requires `rbs/inline` at load time; without the upstream library the synthesizer can't
@@ -35,9 +36,24 @@ module Rigor
35
36
  # env-build time. Returns the synthesised RBS source as a String, or `nil` when the file contributes
36
37
  # nothing (no magic comment in the default mode, empty annotation set, parse error per WD6).
37
38
  class Synthesizer
38
- # @param require_magic_comment [Boolean] when `true` (the default, WD2), only files with
39
- # `# rbs_inline: enabled` at the top are processed. When `false` (WD10 host-context override),
40
- # every file is treated as if it carried the magic comment.
39
+ # An RDoc directive comment: `#:` followed by a bare word (RDoc allows a hyphen, as in `:call-seq:`)
40
+ # and a closing colon. RDoc predates rbs-inline by about two decades and `#:<name>:` is
41
+ # indistinguishable from an rbs-inline `#: <type>` assertion to upstream's parser, which reads the
42
+ # directive name as a type alias — `def f #:nodoc:` becomes `def f: () -> nodoc`, a type nothing
43
+ # declares (upstream issue: https://github.com/soutaro/rbs-inline/issues/248).
44
+ #
45
+ # Matching on shape rather than a name list covers all 17 directives the Ruby docs list
46
+ # (https://docs.ruby-lang.org/ja/latest/library/rdoc.html) with nothing to maintain, and none of
47
+ # `#: () -> void`, `#: String`, `#:Integer`, `#: bool`, `#:my_alias`, `#: { name: String }`,
48
+ # `#: (name: String) -> void`, `#: Integer?`, `#[Integer]` or `# @rbs x: String` matches it: a valid
49
+ # RBS type never starts with a bare lowercase word that is immediately closed by a colon. Only the
50
+ # space-free spelling is affected; `# :nodoc:` never reaches upstream's annotation grammar.
51
+ RDOC_DIRECTIVE_COMMENT = /\A#:[a-z_][\w-]*:/
52
+
53
+ # @param require_magic_comment [Boolean] when `false` (the default since ADR-93 WD1), the magic
54
+ # comment is not required and the file is processed only if it actually carries an annotation — see
55
+ # {#annotated?}. When `true` (the old ADR-32 WD2 gate), only files opening with
56
+ # `# rbs_inline: enabled` are processed, and upstream's opt-in semantics apply verbatim.
41
57
  def initialize(require_magic_comment:)
42
58
  @require_magic_comment = require_magic_comment
43
59
  freeze
@@ -55,6 +71,9 @@ module Rigor
55
71
  return nil if source.empty?
56
72
 
57
73
  result = ::Prism.parse(source)
74
+ _, result = neutralize_rdoc_directives(source, result)
75
+ return nil if !@require_magic_comment && !annotated?(result)
76
+
58
77
  # `opt_in: true` is rbs-inline's "require the magic comment" mode (per upstream parser.rb:62).
59
78
  # The plugin's `require_magic_comment:` config knob maps directly onto it.
60
79
  parsed = ::RBS::Inline::Parser.parse(result, opt_in: @require_magic_comment)
@@ -71,6 +90,56 @@ module Rigor
71
90
  # message, without crashing analysis.
72
91
  [:error, "#{e.class}: #{e.message.to_s.lines.first.to_s.strip}"]
73
92
  end
93
+
94
+ private
95
+
96
+ # True when the file carries at least one rbs-inline annotation. Gates the magic-comment-free mode,
97
+ # and is the difference between "honour annotations wherever they are" and "fabricate signatures for
98
+ # code nobody annotated" — upstream's opt-out mode does the latter, emitting a full
99
+ # `def f: (untyped x) -> untyped` skeleton for EVERY unannotated def. Rigor trusts an accepted
100
+ # signature over body inference, so those skeletons would replace real inferred types with `untyped`:
101
+ # measured on mail (zero annotations) as 26 → 42 diagnostics, i.e. the mode actively fought the
102
+ # analysis on exactly the projects that write no annotations. The spec binds Rigor to treat
103
+ # annotations as type sources "whenever present" (`overview.md` § "Compatibility hierarchy"); it does
104
+ # not ask for untyped shadows of unannotated code.
105
+ #
106
+ # Detection delegates to upstream's own `AnnotationParser` rather than scanning for `#:` / `@rbs`
107
+ # with a regexp: the annotation grammar is upstream's to define (ADR-32 WD3), and this keeps a doc
108
+ # comment that merely mentions `@rbs`, or a URL containing `#:`, from opting a file in. A file with
109
+ # the magic comment keeps upstream's semantics verbatim — the author opted that file in explicitly,
110
+ # skeletons and all, which is what `rbs-inline --output` would generate for it.
111
+ def annotated?(prism_result)
112
+ ::RBS::Inline::AnnotationParser.parse(prism_result.comments)
113
+ .any? { |parsed| parsed.each_annotation.any? }
114
+ end
115
+
116
+ # Rewrite every RDoc directive comment to its spaced spelling (`#:nodoc:` -> `# :nodoc:`) so
117
+ # upstream's annotation grammar never sees it, and re-parse. Two reasons this happens here rather
118
+ # than in {#annotated?}: the directive must not gate a file in, AND it must not reach the synthesis,
119
+ # because one mis-parsed directive takes the whole class down with it. `def f #:nodoc:` renders as
120
+ # `def f: (untyped x) -> nodoc`; `nodoc` resolves to nothing, `RBS::DefinitionBuilder` raises
121
+ # `NoTypeFoundError` for the class, and every real annotation in that class is silently lost —
122
+ # measured on a class whose `#: (String) -> Integer` method fell back to body inference because a
123
+ # sibling method carried `#:nodoc:`. `fileutils.rb` alone carries 29 of these.
124
+ #
125
+ # The rewrite touches comment text only, so it cannot change what Ruby does; it shifts columns
126
+ # within the comment, which nothing downstream reads (the synthesized RBS is fresh text with its own
127
+ # buffer). Prism decides what is a comment, so a `#:nodoc:` inside a string literal stays put. Files
128
+ # without a directive re-parse nothing.
129
+ def neutralize_rdoc_directives(source, prism_result)
130
+ offsets = prism_result.comments.filter_map do |comment|
131
+ location = comment.location
132
+ # `start_character_offset`, not `start_offset`: the latter counts BYTES while `String#insert`
133
+ # indexes CHARACTERS, so on a file with any multi-byte content the space lands mid-word and
134
+ # rewrites `#:nodoc:` to `#:n odoc:` — still a directive to upstream, and now a corrupted one.
135
+ location.start_character_offset if RDOC_DIRECTIVE_COMMENT.match?(location.slice)
136
+ end
137
+ return [source, prism_result] if offsets.empty?
138
+
139
+ # Back to front, so each insertion leaves the earlier offsets valid.
140
+ rewritten = offsets.sort.reverse.inject(source.dup) { |acc, offset| acc.insert(offset + 1, " ") }
141
+ [rewritten, ::Prism.parse(rewritten)]
142
+ end
74
143
  end
75
144
 
76
145
  manifest(
@@ -87,11 +156,16 @@ module Rigor
87
156
  # override (returned by `#manifest`, which `Plugin::Registry#source_rbs_synthesizers` consults via
88
157
  # `plugin.manifest.source_rbs_synthesizer`).
89
158
  #
90
- # ADR-32 WD10 — `require_magic_comment` defaults to `true`. Setting it to `false` in `.rigor.yml`
91
- # flips the synthesizer into "process every file" mode.
159
+ # ADR-93 WD1 — `require_magic_comment` defaults to `false`: annotations are official type sources
160
+ # "always parsed whenever present" per the binding spec (overview.md § Compatibility hierarchy), so
161
+ # a file is processed when it actually carries an annotation, with the magic comment not required.
162
+ # The magic-comment-free mode is annotation-presence-gated (see {Synthesizer#annotated?}), so an
163
+ # unannotated file contributes nothing and the flip cannot regress it. Set `require_magic_comment:
164
+ # true` in `.rigor.yml` to restore the ADR-32 opt-in behaviour; `# rbs_inline: disabled` remains the
165
+ # per-file opt-out either way (honoured by upstream unconditionally).
92
166
  def initialize(services:, config: {})
93
167
  super
94
- @require_magic_comment = config.fetch("require_magic_comment", true) ? true : false
168
+ @require_magic_comment = config.fetch("require_magic_comment", false) ? true : false
95
169
  @synthesizer = Synthesizer.new(require_magic_comment: @require_magic_comment)
96
170
  # Build the per-instance manifest eagerly (before `freeze`) so the registry's repeated reads
97
171
  # return the same object and we don't need to mutate a frozen instance later.
@@ -3,16 +3,20 @@
3
3
  # rigor-rbs-inline — ingests rbs-inline-shaped comments (`# @rbs name: T`, `#: () -> T`, `# @rbs return: T`,
4
4
  # attribute `#:`, ivars, generics, override, …) as RBS contributions to the analysis environment.
5
5
  #
6
- # Per ADR-32 the plugin gates per file on the upstream `# rbs_inline: enabled` magic comment by default. A
7
- # host context that owns the entire analysis scope (e.g. the ADR-29 browser playground) can set
8
- # `require_magic_comment: false` in the plugin config to skip the gate — every file the synthesizer sees is
9
- # treated as if it carried the magic comment.
6
+ # Since ADR-93 WD1 the plugin defaults to `require_magic_comment: false`: annotations are official type
7
+ # sources wherever present, gated only on a file actually carrying one, with `# rbs_inline: disabled` the
8
+ # per-file opt-out. ADR-93 WD2 default-wires this plugin from `Configuration.load` when the upstream
9
+ # `rbs-inline` library is resolvable, so most projects need no `plugins:` entry at all. Restore the old
10
+ # ADR-32 magic-comment gate, or opt out of the auto-wire, through config:
10
11
  #
11
12
  # # .rigor.yml
12
13
  # plugins:
13
14
  # - id: rigor-rbs-inline
14
15
  # config:
15
- # require_magic_comment: false # default true
16
+ # require_magic_comment: true # default false — restore the ADR-32 magic-comment gate
17
+ # # or disable the auto-wired default entirely:
18
+ # # - gem: rigor-rbs-inline
19
+ # # enabled: false
16
20
  #
17
21
  # The plugin depends on the upstream `rbs-inline` gem; Rigor's core `rigortype` gemspec stays zero-dep per
18
22
  # ADR-0 / ADR-32 WD1.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -89,7 +91,7 @@ module Rigor
89
91
  return
90
92
  end
91
93
 
92
- node.compact_child_nodes.each do |child|
94
+ node.rigor_each_child do |child|
93
95
  collect(child, anchor: anchor, accumulator: accumulator)
94
96
  end
95
97
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -70,7 +72,7 @@ module Rigor
70
72
  if rspec_describe_call?(node)
71
73
  scopes << build_scope(node, kind: :rspec_describe)
72
74
  else
73
- node.compact_child_nodes.each { |child| walk_top_level(child, scopes) }
75
+ node.rigor_each_child { |child| walk_top_level(child, scopes) }
74
76
  end
75
77
  end
76
78
 
@@ -97,6 +97,17 @@ module Rigor
97
97
  let_binding_return_type(call_node, scope)
98
98
  end
99
99
 
100
+ # ADR-88 WD1 — both contributions are per-file: the matcher narrowing facts come from the call's own AST,
101
+ # and the `let`-binding return types come from each spec file's OWN `describe`/`let` structure (`let` is
102
+ # file-scoped). A spec file's edit changes its own content (re-analysed already). The one cross-file input
103
+ # — the optional factorybot `:factory_index` fact this plugin consumes — is itself an ADR-9 publication
104
+ # captured in the incremental fact-surface digest via factorybot's own channel, so it need not appear
105
+ # here. A stable sentinel declares "no OWN cross-file fact surface", keeping the plugin
106
+ # incremental-capable; `--verify-incremental` backstops the claim.
107
+ def incremental_state_fingerprint
108
+ "per-file-lets"
109
+ end
110
+
100
111
  private
101
112
 
102
113
  # The `file_methods:` gate set — every `let` / `subject` name the file declares anywhere. A safe
@@ -43,7 +43,10 @@ module Rigor
43
43
 
44
44
  require "rack/utils"
45
45
  @rack_status_symbols = Rack::Utils::SYMBOL_TO_STATUS_CODE.keys.to_set.freeze
46
- rescue LoadError, StandardError
46
+ rescue ::LoadError, ::StandardError
47
+ # `::`-qualified: in this lexical scope a bare `LoadError` resolves to `Rigor::Plugin::LoadError`
48
+ # (the plugin-loading error, a StandardError), so the real `::LoadError` (a ScriptError) from a
49
+ # failed require would escape instead of declining.
47
50
  @rack_status_symbols = nil
48
51
  end
49
52
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  require_relative "worker_index"
@@ -67,7 +69,7 @@ module Rigor
67
69
  when Prism::ClassNode then visit_class(node, lexical_path, &)
68
70
  when Prism::ModuleNode then visit_module(node, lexical_path, &)
69
71
  else
70
- node.compact_child_nodes.each { |child| walk_for_workers(child, lexical_path, &) }
72
+ node.rigor_each_child { |child| walk_for_workers(child, lexical_path, &) }
71
73
  end
72
74
  end
73
75
 
@@ -133,7 +135,7 @@ module Rigor
133
135
  def lookup_perform_def(body)
134
136
  return nil if body.nil?
135
137
 
136
- body.compact_child_nodes.each do |node|
138
+ body.rigor_each_child do |node|
137
139
  next unless node.is_a?(Prism::DefNode) && node.name == :perform
138
140
  next if node.receiver.is_a?(Prism::SelfNode)
139
141
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  require_relative "method_signature"
@@ -58,7 +60,7 @@ module Rigor
58
60
  # A `def` not preceded by a `sig` is fine; we just don't record anything for it. The
59
61
  # interesting case is in `walk_statements`, which pairs sig+def.
60
62
  else
61
- node.compact_child_nodes.each do |child|
63
+ node.rigor_each_child do |child|
62
64
  walk_node(child, state, lexical_path: lexical_path, in_singleton_class: in_singleton_class)
63
65
  end
64
66
  end