rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -19,6 +19,9 @@ module Rigor
19
19
  # `plugin.<id>`, never to a bare id or `all`. The run-level rows outside the check-rule catalogue
20
20
  # (`rbs.coverage.*`, `dynamic.dependency-source.*`, the plugin loader, `plugin_trust.*`, `pool-degraded`) keep
21
21
  # answering to `severity_overrides:` only, by any spelling.
22
+ # Issue #1985 — `severity_overrides:` follows the same rule: a plugin's row answers to its qualified id or
23
+ # `plugin.<id>`, never to its bare rule or a family wildcard; the plugin loader's rows answer to
24
+ # `plugin_loader.<rule>` first and to their bare `load-error` / `runtime-error` after it.
22
25
  #
23
26
  # Extracted (ADR-87 WD4) so the miss path ({Runner::DiagnosticAggregator}) and the boot-slimming hit path
24
27
  # ({RunCacheProbe}, which never loads the aggregator) apply the IDENTICAL final filter — a warm HIT's
@@ -52,11 +55,19 @@ module Rigor
52
55
  diagnostic.source_family == Diagnostic::DEFAULT_SOURCE_FAMILY && CHECK_RULES.include?(diagnostic.rule)
53
56
  end
54
57
 
58
+ # `plugin.<id>` for a row a plugin emitted, nil for every other row.
59
+ def plugin_family(diagnostic)
60
+ family = diagnostic.source_family.to_s
61
+ family if family.start_with?(CheckRules::PLUGIN_FAMILY_PREFIX)
62
+ end
63
+
55
64
  def stamp(diagnostic, configuration)
56
65
  return diagnostic if diagnostic.rule.nil?
57
66
 
58
67
  resolved = Configuration::SeverityProfile.resolve(
59
68
  rule: diagnostic.rule,
69
+ qualified_rule: diagnostic.qualified_rule,
70
+ plugin_family: plugin_family(diagnostic),
60
71
  authored_severity: diagnostic.severity,
61
72
  profile: configuration.severity_profile,
62
73
  overrides: configuration.severity_overrides,
@@ -20,6 +20,7 @@ require_relative "../inference/method_dispatcher/file_folding"
20
20
  require_relative "check_rules"
21
21
  require_relative "crash_signature"
22
22
  require_relative "dependency_recorder"
23
+ require_relative "run_disclosure_credit"
23
24
  require_relative "dependency_source_inference"
24
25
  require_relative "diagnostic"
25
26
  require_relative "erb_template_detector"
@@ -170,8 +171,18 @@ module Rigor
170
171
  return analyze_with_effects(path) unless @record_dependencies
171
172
 
172
173
  diagnostics = nil
173
- record = DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
174
+ reads = []
175
+ record = nil
176
+ # Issue #2020 — and the run-scoped rows it registered, as `Runner#analyze_file` keeps them.
177
+ disclosures = RunDisclosureCredit.collect do
178
+ record = Plugin::IoBoundary.attributing(reads) do
179
+ DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
180
+ end
181
+ end
174
182
  @file_dependencies[path] = record
183
+ # Issue #1553 — the plugin reads this file's analysis made, as `Runner#analyze_file` keeps them.
184
+ (@analysis_reads ||= {})[path] = reads unless reads.empty?
185
+ (@analysis_disclosures ||= {})[path] = disclosures unless disclosures.empty?
175
186
  diagnostics
176
187
  end
177
188
 
@@ -309,6 +320,18 @@ module Rigor
309
320
  @file_dependencies
310
321
  end
311
322
 
323
+ # Issue #1553 — the plugin reads ({Plugin::IoBoundary.attributing}) each `#analyze` call's file made (empty
324
+ # unless `record_dependencies:` was set). The rows are value objects of frozen Strings, so they marshal back.
325
+ def drain_analysis_reads
326
+ @analysis_reads || {}
327
+ end
328
+
329
+ # Issue #2020 — the run-scoped rows each file's analysis registered, `{path => [record]}`; see
330
+ # {RunDisclosureCredit}. Marshal-clean like {#drain_run_disclosures}.
331
+ def drain_analysis_disclosures
332
+ @analysis_disclosures || {}
333
+ end
334
+
312
335
  # ADR-103 WD12 — the per-file {Effects::FileCollection}s this session collected (empty unless the
313
336
  # configuration carries an `effects:` block). Marshal-clean by construction — frozen Hashes of
314
337
  # Strings, `LabelSet`s of frozen Arrays, and `Data` edges — so the fork pool ships them back to the
@@ -4,6 +4,7 @@ require "digest"
4
4
  require "json"
5
5
  require_relative "../value_semantics"
6
6
  require_relative "file_digest"
7
+ require_relative "../rooted_glob"
7
8
 
8
9
  module Rigor
9
10
  module Cache
@@ -87,7 +88,13 @@ module Rigor
87
88
  # appears, vanishes or changes kind invalidates the warm entry. A
88
89
  # pre-19 entry carries none of them and would validate fresh across exactly those edits, so cached
89
90
  # entries must read as misses once and rebuild carrying the rows.
90
- SCHEMA_VERSION = 19
91
+ # v20: #1977 — the `--incremental` run-result slot records a `:kind` row, not an existence row, per analysis
92
+ # root, `pre_eval:` entry and project signature root. A pre-20 slot's existence row validates fresh across a
93
+ # directory replaced by a file of the same name, so it must read as a miss once and rebuild.
94
+ # v21: #1984 — the visibility walk records each class's singleton slots (`[:singleton, name]`) in the seed
95
+ # bundles' `method_visibilities`. A pre-21 bundle carries none for an unchanged file, so cached bundles must read
96
+ # as misses once and rebuild carrying them.
97
+ SCHEMA_VERSION = 21
91
98
 
92
99
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
93
100
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -325,7 +332,12 @@ module Rigor
325
332
  # tuple moves, so a `git checkout`, a `bundle install` or a CI cache restored over a fresh checkout
326
333
  # keeps the file rows fresh while it would move every glob signature, turning every such run into a
327
334
  # full re-analysis. `:names` also reads no stat at all, so it costs a `Dir.glob` and nothing else.
328
- VALID_MODES = %i[stat names].freeze
335
+ #
336
+ # `:kinds` (#1959) is `:names` plus each match's `File.lstat` file type: the set moves it, and so does a
337
+ # path that keeps its name and changes kind (a file `gen` replaced by a directory `gen/`, a symlinked
338
+ # directory replaced by a real one). It reads one `lstat` per match and no content, so a `touch` or an
339
+ # edit leaves it fresh.
340
+ VALID_MODES = %i[stat names kinds].freeze
329
341
 
330
342
  attr_reader :root, :pattern, :value
331
343
 
@@ -357,13 +369,15 @@ module Rigor
357
369
  # The aggregate signature the entry's `value` carries: SHA-256 over the sorted per-file rows. In the
358
370
  # default (`:stat`) mode a row is the file's `(path, size, mtime_ns, ctime_ns, inode)` tuple — NO
359
371
  # content read; in strict mode a row is `(path, content-sha256)`, restoring the pre-ADR-87 authority.
360
- # In `:names` mode a row is the path alone, stat-free, so only the matching SET moves the signature.
372
+ # In `:names` mode a row is the path alone, stat-free, so only the matching SET moves the signature; in
373
+ # `:kinds` mode it is the path and its {.lstat_kind}.
361
374
  # Per-file stat failures (a file vanishing between the glob and the stat) drop the row — same race
362
375
  # posture as {Descriptor#file_entry_fresh?}. `Dir.glob` returns sorted entries by default so the row
363
376
  # order — and therefore the signature — is stable.
364
377
  def self.signature_for(root:, pattern:, mode: :stat)
365
- paths = Dir.glob(File.join(root, pattern))
378
+ paths = RootedGlob.glob(root, pattern)
366
379
  return Digest::SHA256.hexdigest(paths.map { |path| "#{path}#{ROW_SEPARATOR}" }.join) if mode == :names
380
+ return kinds_signature(paths) if mode == :kinds
367
381
 
368
382
  strict = FileDigest.strict_validation?
369
383
  rows = paths.filter_map do |path|
@@ -382,10 +396,29 @@ module Rigor
382
396
  Digest::SHA256.hexdigest(rows.join)
383
397
  end
384
398
 
399
+ # #1959 — what `path` itself is, a symlink not followed: `:file`, `:directory`, `:symlink` or `:other`, and
400
+ # `:absent` for a path that vanished between the glob and the `lstat`.
401
+ def self.lstat_kind(path)
402
+ case File.lstat(path).ftype
403
+ when "file" then :file
404
+ when "directory" then :directory
405
+ when "link" then :symlink
406
+ else :other
407
+ end
408
+ rescue SystemCallError
409
+ :absent
410
+ end
411
+
412
+ def self.kinds_signature(paths)
413
+ rows = paths.map { |path| "#{path}#{FIELD_SEPARATOR}#{lstat_kind(path)}#{ROW_SEPARATOR}" }
414
+ Digest::SHA256.hexdigest(rows.join)
415
+ end
416
+ private_class_method :kinds_signature
417
+
385
418
  # ADR-87 WD2 — fresh iff re-globbing + re-stat-ing reproduces the recorded signature. Zero file-content
386
- # bytes are read in the default mode, and no stat at all in `:names`. Any failure reads as stale
387
- # (recompute), never a crash. A stored signature recorded under the opposite validation mode simply
388
- # mismatches and recomputes.
419
+ # bytes are read in the default mode, no stat at all in `:names`, and one `lstat` per match in `:kinds`.
420
+ # Any failure reads as stale (recompute), never a crash. A stored signature recorded under the opposite
421
+ # validation mode simply mismatches and recomputes.
389
422
  def self.fresh?(entry)
390
423
  signature_for(root: entry.root, pattern: entry.pattern, mode: entry.mode) == entry.value
391
424
  rescue StandardError
@@ -4,6 +4,7 @@ require "digest"
4
4
 
5
5
  require_relative "../version"
6
6
  require_relative "descriptor"
7
+ require_relative "../rooted_glob"
7
8
 
8
9
  module Rigor
9
10
  module Cache
@@ -195,8 +196,8 @@ module Rigor
195
196
  # Sorted, de-duplicated absolute paths of every engine `.rb` file and {DATA_GLOBS} data file, across
196
197
  # the directories that exist. `plugins/*/sig/**/*.rbs` overlaps nothing else but is de-duplicated anyway.
197
198
  def source_files(root)
198
- sources = SOURCE_DIRECTORIES.flat_map { |relative| Dir.glob(File.join(root, relative, "**", "*.rb")) }
199
- data = DATA_GLOBS.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }
199
+ sources = SOURCE_DIRECTORIES.flat_map { |relative| RootedGlob.glob(root, File.join(relative, "**", "*.rb")) }
200
+ data = DATA_GLOBS.flat_map { |pattern| RootedGlob.glob(root, pattern) }
200
201
  (sources + data).uniq.sort
201
202
  end
202
203
  end
@@ -10,6 +10,7 @@ require_relative "../analysis/shadow_harness"
10
10
  require_relative "../analysis/run_cache_key"
11
11
  require_relative "../environment"
12
12
  require_relative "../analysis/path_expansion"
13
+ require_relative "../rooted_glob"
13
14
 
14
15
  module Rigor
15
16
  module Cache
@@ -172,7 +173,29 @@ module Rigor
172
173
  # 38: issue #1898 — the payload gains `effect_plugin_digest`, the plugin effect-fact digest the collections were
173
174
  # stamped under, which a recheck now checks before its served collections reach the envelope pass. A pre-38
174
175
  # blob carries none, so the gate rejects it and the next run is cold.
175
- SCHEMA = 38
176
+ # 39: issue #1984 — the visibility walk records the singleton slots (`[:singleton, name]`) and contests the ones
177
+ # two files disagree on, which a pre-39 bundle carries none of, so a warm run would answer a singleton call a
178
+ # cold run reports.
179
+ # 40: issue #1553 — the payload gains `plugin_reads`, the plugin reads each analysed file's last analysis made,
180
+ # which a recheck validates to put a reader of a changed input into the closure. A pre-40 blob carries none,
181
+ # so its readers would be served stale; the gate rejects it and the next run is cold.
182
+ # 41: issue #1583 — the payload gains `discovery_digests`, the packed digest of each file a run over path
183
+ # arguments discovered without analysing, which a recheck compares to re-baseline when one moved. A pre-41
184
+ # blob carries none; the gate rejects it and the next run is cold.
185
+ # 42: issue #2184 — a `deferred_ranges` row for a `def` inside a block now carries the method's name, and a
186
+ # literal-named `define_method` / `define_singleton_method` call adds a row. A pre-42 bundle carries neither,
187
+ # so a warm run would report a top-level block call that a cold run declines.
188
+ # 43: issue #2020 — the payload gains `plugin_disclosures`, the run-scoped plugin rows each analysed file's
189
+ # last analysis registered, which a recheck replays for the files it serves. A pre-43 blob carries none, so
190
+ # its rechecks would drop every such row; the gate rejects it and the next run is cold.
191
+ # 44: PR #2286 review M1 — the payload gains `synthetic_surface_digest`, the ADR-16 synthetic-method index's
192
+ # {Rigor::Inference::SyntheticMethodIndex#surface_digest} at the run the cache was built under, which a recheck
193
+ # compares to re-baseline when a class-body DSL edit moved it. A pre-44 blob carries none; the gate rejects it
194
+ # and the next run is cold.
195
+ # 45: issue #1154 — each seed bundle's `global_write_census` also records the ivar writers the declared-parameter
196
+ # credit reads (`[:ivar_set, name]`, `[:ivar_set_class, class]`, `[:own_write, class, name, path]`), which a
197
+ # pre-45 bundle carries none of, so a warm run would credit an ivar write a cold run declines.
198
+ SCHEMA = 45
176
199
 
177
200
  # The persisted per-file state.
178
201
  # `cache` maps an analyzed file to its diagnostics.
@@ -227,19 +250,31 @@ module Rigor
227
250
  # Issues #796 / #794 — the run-level rows a narrowed run replays instead of re-deriving; see
228
251
  # docs/internal-spec/cache.md § "Run-level rows the snapshot carries" for why exactly these two are
229
252
  # fresh under the fingerprint and why no other `.rigor.yml`-level row may join them.
253
+ # Issue #1583 — `discovery_digests` maps each file a run over path arguments discovered but did not analyse
254
+ # (#684's widening to the configured `paths:`) to its ADR-87 packed stat-digest entry; `{}` on a run over
255
+ # the configured paths. A recheck that finds one moved, or the set changed, re-baselines.
230
256
  # Issue #1898 — `effect_plugin_digest` is {Rigor::Effects::PluginFacts#digest} at the run that collected
231
257
  # `effect_collections` (nil when collection was off). A plugin stamps its attributions and edges into a file's
232
258
  # collection, and `effects_identity` is plugin-blind (it is compared before any plugin loads), so the session
233
259
  # compares this one after the recheck has loaded them.
260
+ # Issue #2020 — `plugin_disclosures` is {Analysis::RunDisclosureLedger#to_h}: `files`, each analysed file's
261
+ # run-scoped plugin rows ({Plugin::Base#disclose_once} / {Plugin::Base#emit_once}) from its last analysis, which a
262
+ # recheck replays for the files it serves, and `reads`, the boundary rows per `[plugin id, key]` that make one
263
+ # stale.
234
264
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
235
265
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
236
266
  :missing, :class_decls, :constant_decls, :seed_bundles, :plugin_fact_digest,
237
267
  :return_summaries, :param_table,
238
268
  :effect_collections, :effects_identity, :run_level_rows,
239
- :effect_plugin_digest) do
240
- # Defaults to nil so a payload built without it (an older caller, a spec fixture) reads as "unknown",
241
- # which the session treats as moved.
242
- def initialize(effect_plugin_digest: nil, **rest)
269
+ :effect_plugin_digest, :plugin_reads, :discovery_digests, :plugin_disclosures,
270
+ :synthetic_surface_digest) do
271
+ # `effect_plugin_digest` defaults to nil so a payload built without it (an older caller, a spec fixture)
272
+ # reads as "unknown", which the session treats as moved. `plugin_reads` defaults to no reads, and
273
+ # `discovery_digests` to no discovery-only files, and `plugin_disclosures` (#2020) to no replayed rows.
274
+ # `synthetic_surface_digest` defaults to nil, the empty index's digest: a run that builds a non-empty one
275
+ # reads it as moved.
276
+ def initialize(effect_plugin_digest: nil, plugin_reads: {}, discovery_digests: {}, plugin_disclosures: {},
277
+ synthetic_surface_digest: nil, **rest)
243
278
  super
244
279
  end
245
280
  end
@@ -367,7 +402,7 @@ module Rigor
367
402
  # checkouts.
368
403
  def self.digest_signature_paths(signature_paths)
369
404
  globbed = Array(signature_paths).flat_map do |entry|
370
- File.directory?(entry) ? Dir.glob(File.join(entry, "**", "*.rbs")) : [entry]
405
+ File.directory?(entry) ? RootedGlob.glob(entry, "**/*.rbs") : [entry]
371
406
  end
372
407
  files = globbed.select { |path| File.file?(path) }.sort
373
408
  digest = Digest::SHA256.new
@@ -449,7 +484,11 @@ module Rigor
449
484
  effect_collections: data[:effect_collections] || {},
450
485
  effects_identity: data[:effects_identity],
451
486
  run_level_rows: data[:run_level_rows],
452
- effect_plugin_digest: data[:effect_plugin_digest]
487
+ effect_plugin_digest: data[:effect_plugin_digest],
488
+ plugin_reads: data[:plugin_reads] || {},
489
+ plugin_disclosures: data.fetch(:plugin_disclosures, {}),
490
+ discovery_digests: data.fetch(:discovery_digests, {}),
491
+ synthetic_surface_digest: data[:synthetic_surface_digest]
453
492
  )
454
493
  end
455
494
  private :payload_from
@@ -475,7 +514,9 @@ module Rigor
475
514
  effect_collections: payload.effect_collections,
476
515
  effects_identity: payload.effects_identity,
477
516
  run_level_rows: payload.run_level_rows,
478
- effect_plugin_digest: payload.effect_plugin_digest
517
+ effect_plugin_digest: payload.effect_plugin_digest,
518
+ plugin_reads: payload.plugin_reads, plugin_disclosures: payload.plugin_disclosures,
519
+ discovery_digests: payload.discovery_digests, synthetic_surface_digest: payload.synthetic_surface_digest
479
520
  )
480
521
  # #1507 — every `--incremental` run that analysed anything rewrites this whole blob, so its deflate is
481
522
  # on the edit path; see {Store::DEFLATE_LEVEL}.
@@ -5,6 +5,7 @@ require "digest"
5
5
  require_relative "descriptor"
6
6
  require_relative "engine_source"
7
7
  require_relative "file_digest"
8
+ require_relative "../rooted_glob"
8
9
 
9
10
  module Rigor
10
11
  module Cache
@@ -110,7 +111,7 @@ module Rigor
110
111
  roots.flat_map do |root|
111
112
  next [] unless root.directory?
112
113
 
113
- Dir.glob(root.join("**", "*.rbs")).map do |path|
114
+ RootedGlob.glob(root, "**/*.rbs").map do |path|
114
115
  digest = FileDigest.hexdigest(path)
115
116
  if comparator == :stat
116
117
  Descriptor::FileEntry.stat(path: path, digest: digest)
@@ -9,6 +9,7 @@ require "zlib"
9
9
 
10
10
  require_relative "../version"
11
11
  require_relative "descriptor"
12
+ require_relative "../rooted_glob"
12
13
 
13
14
  module Rigor
14
15
  module Cache
@@ -176,7 +177,7 @@ module Rigor
176
177
  subdir = File.join(root, child)
177
178
  next nil unless File.directory?(subdir)
178
179
 
179
- entries = Dir.glob(File.join(subdir, "**", "*.entry"))
180
+ entries = RootedGlob.glob(subdir, "**/*.entry")
180
181
  next nil if entries.empty?
181
182
 
182
183
  { id: child, entries: entries.size, bytes: entries.sum { |e| File.size(e) } }
@@ -709,7 +710,7 @@ module Rigor
709
710
 
710
711
  def cleanup_stale_temp_files
711
712
  cutoff = Time.now - STALE_TEMP_FILE_AGE_SECONDS
712
- Dir.glob(File.join(@root, "**", "*.tmp.*")).each do |path|
713
+ RootedGlob.glob(@root, "**/*.tmp.*").each do |path|
713
714
  next unless File.file?(path)
714
715
  next if File.mtime(path) > cutoff
715
716
 
@@ -771,7 +772,7 @@ module Rigor
771
772
  # Returns an array of `{ path:, producer:, mtime:, bytes: }` hashes for every `.entry` file under the
772
773
  # cache root, skipping unreadable entries.
773
774
  def collect_entry_stats
774
- Dir.glob(File.join(@root, "**", "*.entry")).filter_map do |path|
775
+ RootedGlob.glob(@root, "**/*.entry").filter_map do |path|
775
776
  stat = File.stat(path)
776
777
  producer = producer_id_for_entry(path)
777
778
  next nil if producer.nil?
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require "optionparser"
6
+ require "tempfile"
6
7
 
7
8
  require_relative "../configuration"
8
9
  require_relative "../config_audit"
@@ -11,6 +12,7 @@ require_relative "../analysis/rule_catalog"
11
12
  # The baseline filter runs on EVERY exit path — including the ADR-87 WD4 cache-HIT fast path, which skips
12
13
  # `load_check_dependencies` — so it must be a load-time require. It pulls only YAML, never the engine.
13
14
  require_relative "../analysis/baseline"
15
+ require_relative "../analysis/edit_replay"
14
16
  require_relative "command"
15
17
  require_relative "options"
16
18
  require_relative "diagnostic_formats"
@@ -272,8 +274,83 @@ module Rigor
272
274
  incremental = session.reanalyze_subset(subset).map(&:to_h)
273
275
  full = verify_full_diagnostics(configuration, paths, collect_stats: options.fetch(:stats)).map(&:to_h)
274
276
 
275
- report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
276
- format: options.fetch(:format))
277
+ status = report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
278
+ format: options.fetch(:format))
279
+ return status unless status.zero? && options.fetch(:verify_incremental_edits).positive?
280
+
281
+ verify_edit_replays(session, configuration, paths, options)
282
+ end
283
+
284
+ # Issue #1967 — the partition check above re-analyses an unchanged tree, so every served row is still right and a
285
+ # missing invalidation edge goes unseen. Here each of `--verify-incremental-edits` sampled files is edited through
286
+ # an editor buffer (never on disk) by each of {Analysis::EditReplay.edits} — emptied, then reduced to its
287
+ # `class` / `module` skeleton — and the warm recheck of the baseline ({IncrementalSession#replay_edit}) must equal
288
+ # a full analysis of the edited tree: a dependent the recheck failed to re-analyse is served from the baseline and
289
+ # differs. A run in which every replay declined compared nothing, so it fails rather than report OK.
290
+ def verify_edit_replays(session, configuration, paths, options)
291
+ sample = edit_replay_sample(session.analyzed_files, options.fetch(:verify_incremental_edits))
292
+ replayed = 0
293
+ declined = 0
294
+ sample.each do |path|
295
+ Analysis::EditReplay.edits(File.read(path)).each do |edit|
296
+ incremental, full = replay_edit(session, configuration, paths, path, edit)
297
+ next declined += 1 if incremental.nil?
298
+
299
+ replayed += 1
300
+ next if incremental == full
301
+
302
+ return report_edit_replay_mismatch(path, edit, incremental, full)
303
+ end
304
+ end
305
+ report_edit_replays(replayed, declined, options.fetch(:format))
306
+ end
307
+
308
+ def report_edit_replay_mismatch(path, edit, incremental, full)
309
+ @err.puts("rigor: --verify-incremental FAILED — the recheck after #{edit.label} #{path} " \
310
+ "differs from a full run.")
311
+ return report_verify_order_mismatch(incremental, full) if incremental.tally == full.tally
312
+
313
+ report_verify_set_mismatch(incremental, full, header: false)
314
+ end
315
+
316
+ # `count` files spread evenly over the analysed list, so a small count still reaches both ends of it.
317
+ def edit_replay_sample(analyzed, count)
318
+ return analyzed if count >= analyzed.size
319
+
320
+ Array.new(count) { |index| analyzed[index * analyzed.size / count] }
321
+ end
322
+
323
+ # The warm recheck's rows and a full run's, both with `path` replaced by `edit`; `[nil, nil]` when the recheck
324
+ # declined.
325
+ def replay_edit(session, configuration, paths, path, edit)
326
+ Tempfile.create(["rigor-verify-edit-", ".rb"]) do |file|
327
+ file.write(edit.source)
328
+ file.close
329
+ buffer = Analysis::BufferBinding.new(logical_path: path, physical_path: file.path)
330
+ recheck = session.replay_edit(buffer)
331
+ return [nil, nil] if recheck.nil?
332
+
333
+ # `analyze_only:` with the buffer selects editor mode option B, a whole-project run reading the buffer at
334
+ # its logical path; the buffer alone would analyse that one file.
335
+ runner = Analysis::Runner.new(configuration: configuration, cache_store: nil, collect_stats: false,
336
+ buffer: buffer, analyze_only: session.analyzed_files.to_set)
337
+ full = paths ? runner.run(paths) : runner.run
338
+ [recheck.diagnostics.map(&:to_h), full.diagnostics.map(&:to_h)]
339
+ end
340
+ end
341
+
342
+ def report_edit_replays(replayed, declined, format)
343
+ if replayed.zero? && declined.positive?
344
+ @err.puts("rigor: --verify-incremental FAILED — every edit replay (#{declined}) declined, " \
345
+ "since the recheck would have been a full analysis, so no edit was compared.")
346
+ return 1
347
+ end
348
+
349
+ line = "rigor: --verify-incremental OK — #{replayed} edits replayed (files emptied and reduced to their " \
350
+ "class/module skeleton), every recheck matches full"
351
+ line = "#{line}; #{declined} declined (the recheck would have been a full analysis)" if declined.positive?
352
+ side_output(format).puts(line)
353
+ 0
277
354
  end
278
355
 
279
356
  # ADR-46 — cross-process incremental analysis (`--incremental`). Derives the global fingerprint cheaply (no RBS
@@ -490,10 +567,10 @@ module Rigor
490
567
  end
491
568
  end
492
569
 
493
- def report_verify_set_mismatch(incremental, full)
570
+ def report_verify_set_mismatch(incremental, full, header: true)
494
571
  only_incremental = multiset_difference(incremental, full)
495
572
  only_full = multiset_difference(full, incremental)
496
- @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
573
+ @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.") if header
497
574
  @err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
498
575
  (only_incremental + only_full).first(10).each do |hash|
499
576
  @err.puts(" #{diagnostic_location(hash)}")
@@ -643,6 +720,9 @@ module Rigor
643
720
  # the rest from the per-file cache, and asserts the merged diagnostics are byte-identical to a full
644
721
  # `--no-cache` run. Exits non-zero on any mismatch. Off by default.
645
722
  verify_incremental: false,
723
+ # Issue #1967 — the number of files `--verify-incremental` replays an edit on (each emptied through an editor
724
+ # buffer) after the partition check. 0 replays none.
725
+ verify_incremental_edits: 0,
646
726
  # ADR-46 — cross-process incremental analysis. With a disk snapshot of the prior run's per-file diagnostics +
647
727
  # dependency graph, re-analyzes only the changed closure and serves the rest from the snapshot. Off by
648
728
  # default.
@@ -720,6 +800,15 @@ module Rigor
720
800
  "assert incremental analysis matches a full run, then exit") do
721
801
  options[:verify_incremental] = true
722
802
  end
803
+ opts.on("--verify-incremental-edits=N", Integer,
804
+ "with --verify-incremental, also replay 2 edits (empty, class/module skeleton) on N files") do |value|
805
+ if value.negative?
806
+ raise OptionParser::InvalidArgument, "--verify-incremental-edits=#{value} (must be 0 or more)"
807
+ end
808
+
809
+ options[:verify_incremental] = true
810
+ options[:verify_incremental_edits] = value
811
+ end
723
812
  opts.on("--incremental",
724
813
  "re-analyze only files changed since the last run (cross-process cache)") do
725
814
  options[:incremental] = true
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rooted_glob"
4
+
3
5
  module Rigor
4
6
  class CLI
5
7
  # Base class for the `rigor <subcommand>` command objects.
@@ -26,7 +28,7 @@ module Rigor
26
28
  paths = []
27
29
  args.each do |arg|
28
30
  if File.directory?(arg)
29
- paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
31
+ paths.concat(RootedGlob.glob(arg, "**/*.rb"))
30
32
  elsif File.file?(arg)
31
33
  paths << arg
32
34
  else
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "options"
4
5
 
5
6
  require "json"
6
7
  require "optionparser"
@@ -63,7 +64,7 @@ module Rigor
63
64
  options[:current_path] = path
64
65
  end
65
66
  opt.on("--config=PATH", "Path to .rigor.yml. Forwarded to the implicit `rigor check` run.") do |path|
66
- options[:config] = path
67
+ options[:config] = Options.config_path(path)
67
68
  end
68
69
  end.parse!(@argv)
69
70
  options
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "command"
4
4
  require_relative "doc_links"
5
+ require_relative "../rooted_glob"
5
6
 
6
7
  module Rigor
7
8
  class CLI
@@ -186,8 +187,8 @@ module Rigor
186
187
  # `install.md` sits at the docs root (category `guide`); the rest are manual / handbook chapters.
187
188
  def discover_docs
188
189
  paths = [File.join(DOCS_ROOT, "install.md")]
189
- paths += Dir.glob(File.join(MANUAL_ROOT, "**", "*.md")) # Dir.glob is already sorted
190
- paths += Dir.glob(File.join(HANDBOOK_ROOT, "**", "*.md"))
190
+ paths += RootedGlob.glob(MANUAL_ROOT, "**/*.md") # Dir.glob is already sorted
191
+ paths += RootedGlob.glob(HANDBOOK_ROOT, "**/*.md")
191
192
  paths.select { |path| File.file?(path) }.map { |path| doc_entry(path) }
192
193
  end
193
194
 
@@ -364,7 +364,7 @@ module Rigor
364
364
  status: :warn,
365
365
  message: "#{gap.locked_gems.join(', ')} #{gap.locked_gems.one? ? 'is a dependency' : 'are dependencies'} " \
366
366
  "of this project and #{gap.plugin_gem} models it, but it is not enabled",
367
- hint: "Add `#{gap.plugin_gem}` to `.rigor.yml` `plugins:`, or leave it out deliberately."
367
+ hint: "Add `#{gap.plugin_gem}` to `.rigor.yml` `plugins:`, or decline it with an `enabled: false` entry."
368
368
  }
369
369
  end
370
370
 
@@ -10,7 +10,7 @@ require_relative "command"
10
10
  module Rigor
11
11
  class CLI
12
12
  # Executes `rigor explain <rule>`. Prints the catalog entry for one canonical rule id, a legacy alias, or a family
13
- # wildcard (`call`, `flow`, `assert`, `dump`, `def`).
13
+ # wildcard (`call`, `flow`, `def`, `assert`, `dump`, `global`, `suppression`, `static`, `effect`).
14
14
  #
15
15
  # Without arguments lists every rule's id and one-line summary.
16
16
  #
@@ -74,7 +74,8 @@ module Rigor
74
74
  end
75
75
  @out.puts("")
76
76
  @out.puts("Run `rigor explain <rule>` for the full description.")
77
- @out.puts("Family wildcards (`call`, `flow`, `assert`, `dump`, `def`) print every rule under that prefix.")
77
+ @out.puts("Family wildcards (#{Analysis::CheckRules::RULE_FAMILIES.map { |f| "`#{f}`" }.join(', ')}) " \
78
+ "print every rule under that prefix.")
78
79
  @out.puts("")
79
80
  @out.puts("`rigor sig-gen` skip reasons:")
80
81
  @out.puts("")
@@ -22,11 +22,6 @@ module Rigor
22
22
  return CLI::EXIT_USAGE if options.nil?
23
23
 
24
24
  config = options.fetch(:config)
25
- if config && !File.file?(config)
26
- usage_error("no configuration file at #{config}")
27
- return CLI::EXIT_USAGE
28
- end
29
-
30
25
  configuration = Configuration.load(config)
31
26
  config_path = options.fetch(:config) || Configuration.discover
32
27
  # The working directory: Rigor auto-detects sig/ and the lockfiles there (lens.md § Serialization).
@@ -56,6 +51,11 @@ module Rigor
56
51
  options
57
52
  rescue OptionParser::ParseError => e
58
53
  usage_error(e.message)
54
+ rescue Rigor::ConfigurationError => e
55
+ # The shared --config check (CLI::Options.config_path). Mapped here the way Rigor::CLI#run maps it, so a
56
+ # direct LensCommand run exits with the usage code too.
57
+ @err.puts("rigor: #{e.message}")
58
+ nil
59
59
  end
60
60
 
61
61
  def usage_error(message)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "options"
4
5
  require_relative "../runtime/jit"
5
6
 
6
7
  require "optionparser"
@@ -54,7 +55,7 @@ module Rigor
54
55
  end
55
56
  opts.on("--config=PATH",
56
57
  "Session-level default config path (individual tool calls may override)") do |value|
57
- options[:config] = value
58
+ options[:config] = Options.config_path(value)
58
59
  end
59
60
  end
60
61
  parser.parse!(@argv)