rigortype 0.3.4 → 0.3.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
  6. data/docs/handbook/02-everyday-types.md +1 -1
  7. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  8. data/docs/handbook/08-understanding-errors.md +1 -1
  9. data/docs/handbook/09-plugins.md +2 -2
  10. data/docs/handbook/10-sorbet.md +1 -1
  11. data/docs/handbook/README.md +2 -2
  12. data/docs/handbook/appendix-go.md +1 -1
  13. data/docs/handbook/appendix-java-csharp.md +2 -2
  14. data/docs/handbook/appendix-mypy.md +1 -1
  15. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  16. data/docs/handbook/appendix-rust.md +1 -1
  17. data/docs/handbook/appendix-type-theory.md +2 -2
  18. data/docs/handbook/appendix-typescript.md +4 -4
  19. data/docs/manual/02-cli-reference.md +73 -22
  20. data/docs/manual/03-configuration.md +19 -2
  21. data/docs/manual/04-diagnostics.md +15 -2
  22. data/docs/manual/08-skills.md +1 -1
  23. data/docs/manual/11-ci.md +38 -1
  24. data/docs/manual/12-caching.md +40 -1
  25. data/docs/manual/15-type-protection-coverage.md +1 -1
  26. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  27. data/docs/manual/18-removing-dead-code.md +13 -4
  28. data/docs/manual/19-effect-labels.md +678 -0
  29. data/docs/manual/README.md +7 -2
  30. data/docs/manual/ci-templates/README.md +9 -0
  31. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  32. data/docs/manual/plugins/rigor-activejob.md +3 -3
  33. data/docs/manual/plugins/rigor-activerecord.md +4 -4
  34. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  35. data/docs/manual/plugins/rigor-rspec.md +6 -2
  36. data/lib/rigor/analysis/diagnostic.rb +17 -6
  37. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  38. data/lib/rigor/analysis/reachability/graph.rb +30 -11
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  40. data/lib/rigor/analysis/reachability/scan.rb +20 -4
  41. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  42. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  43. data/lib/rigor/analysis/run_cache_key.rb +12 -0
  44. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  45. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
  48. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  49. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  50. data/lib/rigor/analysis/runner.rb +163 -31
  51. data/lib/rigor/analysis/worker_session.rb +3 -1
  52. data/lib/rigor/cache/file_digest.rb +20 -2
  53. data/lib/rigor/cli/check_command.rb +67 -48
  54. data/lib/rigor/cli/coverage_command.rb +5 -6
  55. data/lib/rigor/cli/doc_links.rb +100 -0
  56. data/lib/rigor/cli/docs_command.rb +32 -2
  57. data/lib/rigor/cli/effects_command.rb +149 -10
  58. data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
  59. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  60. data/lib/rigor/cli/effects_renderer.rb +41 -3
  61. data/lib/rigor/cli/effects_report.rb +116 -6
  62. data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
  63. data/lib/rigor/cli/unused_command.rb +65 -16
  64. data/lib/rigor/cli.rb +33 -9
  65. data/lib/rigor/configuration.rb +37 -25
  66. data/lib/rigor/configuration_error.rb +20 -0
  67. data/lib/rigor/effects/collector.rb +38 -1
  68. data/lib/rigor/effects/definition_lines.rb +100 -0
  69. data/lib/rigor/effects/entry_points.rb +47 -1
  70. data/lib/rigor/effects/envelope_check.rb +18 -1
  71. data/lib/rigor/effects/file_collection.rb +18 -4
  72. data/lib/rigor/effects/framework_units.rb +68 -13
  73. data/lib/rigor/effects/inline_anchor.rb +134 -0
  74. data/lib/rigor/effects/liskov_check.rb +17 -8
  75. data/lib/rigor/effects/plugin_facts.rb +62 -13
  76. data/lib/rigor/effects/propagator.rb +79 -19
  77. data/lib/rigor/effects/registry.rb +10 -3
  78. data/lib/rigor/effects/scanner.rb +21 -9
  79. data/lib/rigor/effects/signature_sources.rb +29 -2
  80. data/lib/rigor/effects/snapshot.rb +74 -26
  81. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  82. data/lib/rigor/effects/taint_cause.rb +1 -0
  83. data/lib/rigor/effects/unit_scan.rb +87 -12
  84. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  85. data/lib/rigor/plugin/base.rb +4 -0
  86. data/lib/rigor/plugin/box.rb +18 -2
  87. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  88. data/lib/rigor/plugin/manifest.rb +34 -10
  89. data/lib/rigor/plugin/registry.rb +10 -3
  90. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  91. data/lib/rigor/rbs_extended.rb +22 -2
  92. data/lib/rigor/version.rb +1 -1
  93. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  94. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  95. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  96. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  97. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  98. data/skills/rigor-ci-setup/SKILL.md +2 -2
  99. data/skills/rigor-editor-setup/SKILL.md +2 -2
  100. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  101. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  102. data/skills/rigor-plugin-review/SKILL.md +3 -3
  103. metadata +10 -1
@@ -7,7 +7,12 @@ module Rigor
7
7
  #
8
8
  # Rows are sorted by key and every collection inside one is sorted, so two runs over the same tree
9
9
  # print byte-identical output whether analysis ran sequentially or across the fork pool.
10
- class EffectsReport < Data.define(:rows, :full)
10
+ class EffectsReport < Data.define(:rows, :full, :totals)
11
+ # `totals:` defaults so a caller that builds a report by hand keeps working.
12
+ def initialize(totals: nil, **rest)
13
+ super
14
+ end
15
+
11
16
  # One method's line in the report.
12
17
  #
13
18
  # `effects` is the transitive proven lane — this method's labels joined with every project method it
@@ -20,18 +25,116 @@ module Rigor
20
25
  # is printed apart from `effects` and never folded into it, because the two answer different
21
26
  # questions: one is proven, the other is asserted. A declared label the proven lane already admits
22
27
  # is dropped here, where output is rendered; the table keeps both lanes raw.
23
- class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct)
28
+ class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct, :attribution)
29
+ # `attribution:` defaults so a caller that builds a row by hand keeps working.
30
+ def initialize(attribution: {}, **rest)
31
+ super
32
+ end
33
+
24
34
  def exhaustive?
25
35
  exhaustive
26
36
  end
37
+
38
+ # The reading `--pure` selects and the default report omits: nothing proven beyond what every
39
+ # envelope tolerates, nothing claimed, and no "possibly more".
40
+ def pure?
41
+ exhaustive && declared.empty? && (effects - TRIVIAL).empty?
42
+ end
43
+
44
+ # `mutate.local` is mutation of objects the frame allocated and never let out, which every
45
+ # envelope tolerates — so a method proving only it is what `%a{pure}` means here.
46
+ TRIVIAL = ["mutate.local"].freeze
47
+ private_constant :TRIVIAL
48
+
49
+ def carries_label?(labels)
50
+ labels.any? { |label| (effects + declared).any? { |own| own == label || own.start_with?("#{label}.") } }
51
+ end
27
52
  end
28
53
 
54
+ # The counts the footer prints and the JSON payload carries (#434). The two lanes are counted
55
+ # **separately** and deliberately: a declared label can never fail a build ([ADR-103](../adr/103-effect-labels.md)
56
+ # § WD17), and on a Rails application it is most of the mass — 709 `io.db.read` plus 644
57
+ # `io.db.write` on Redmine's 4,234 rows against zero of either proven. A single total tells a reader
58
+ # how big the report is; the split tells them which half is a policy surface and which half is a
59
+ # record whose diff they should be reviewing instead.
60
+ Totals = Data.define(:units, :printed, :omitted, :unselected, :proven, :declared, :exhaustive,
61
+ :truncated)
62
+
29
63
  # Builds a report from an effect table. `full:` keeps the rows the report otherwise omits — an
30
64
  # exhaustive method proving nothing beyond `mutate.local`, which is the reading of `%a{pure}`.
31
- def self.build(table, full: false)
32
- rows = table.filter_map { |entry| row_for(entry) unless !full && entry.trivial? }
33
- new(rows: rows.freeze, full: full)
65
+ #
66
+ # `scope:` selects which units are **printed** and never which are analysed (#439). A path argument
67
+ # used to narrow the analysis, and a summary is transitive over whatever was analysed, so the
68
+ # narrowed run answered `[] …?` for a method the whole-project run answered four labels for — with
69
+ # nothing distinguishing that from a method which genuinely does nothing. `sources:` is
70
+ # `Runner#effect_sources`, `{ "Class#m" => [path, …] }`, which is how a key is traced back to the
71
+ # file it was written in.
72
+ def self.build(table, full: false, sources: nil, scope: [], label: [], pure: false, limit: nil)
73
+ roots = normalize_scope(scope)
74
+ in_scope = table.filter_map do |entry|
75
+ row_for(entry) if roots.empty? || in_scope?(entry.key, sources, roots)
76
+ end
77
+ selected = in_scope.select { |row| keep?(row, full: full, label: label, pure: pure) }
78
+ new(rows: (limit ? selected.first(limit) : selected).freeze, full: full,
79
+ totals: totals_for(table, in_scope, selected, limit, query: pure || !label.empty?))
80
+ end
81
+
82
+ # What the default report drops, and why each is a separate question:
83
+ #
84
+ # - **`full:`** keeps a row the omission rule would drop. Two shapes qualify: a method proving
85
+ # nothing beyond `mutate.local` and claiming nothing (the reading of `%a{pure}`), and a row with
86
+ # no label in **either** lane, which is 35–38 % of a real application's rows and says literally
87
+ # nothing — it exists only to record that something was unresolved, which the footer counts.
88
+ # - **`pure:`** is the complement of the first: exactly the rows the default omits for being
89
+ # provably harmless, which is the set worth annotating and which nothing could ask for (#457).
90
+ # - **`label:`** is the question chapter 19 opens with. It matches **either** lane, because "which
91
+ # controllers reach the network" is a question about the code and not about which lane knows it;
92
+ # the row's own rendering keeps the two apart.
93
+ def self.keep?(row, full:, label:, pure:)
94
+ return row.pure? if pure
95
+ return row.carries_label?(label) unless label.empty?
96
+ return true if full
97
+
98
+ !row.pure? && !(row.effects.empty? && row.declared.empty?)
99
+ end
100
+ private_class_method :keep?
101
+
102
+ # Two ways a row can be missing, and only one of them `--full` answers: the **omission rule** drops
103
+ # a row that says nothing, and a **filter** — a path, `--label`, `--pure` — drops one that did not
104
+ # match. Telling a `--label io.net` reader that 4,678 more are behind `--full` would be false, so
105
+ # the two are counted apart.
106
+ def self.totals_for(table, in_scope, selected, limit, query:)
107
+ omitted = query ? 0 : in_scope.length - selected.length
108
+ Totals.new(
109
+ units: table.size, printed: limit ? [selected.length, limit].min : selected.length,
110
+ omitted: omitted, unselected: table.size - selected.length - omitted,
111
+ proven: selected.count { |row| !row.effects.empty? },
112
+ declared: selected.count { |row| !row.declared.empty? },
113
+ exhaustive: selected.count(&:exhaustive?),
114
+ truncated: limit ? [selected.length - limit, 0].max : 0
115
+ )
116
+ end
117
+ private_class_method :totals_for
118
+
119
+ # A path argument may name a file or a directory, and either may be written relative or absolute —
120
+ # so both sides are expanded and a directory matches by prefix. Deliberately not the `reach:` glob
121
+ # syntax: this is the argument a shell just tab-completed, and `rigor effects app/models` meaning
122
+ # "that directory" is the only reading a reader would guess.
123
+ def self.normalize_scope(scope)
124
+ Array(scope).map { |path| File.expand_path(path.to_s.chomp("/")) }.freeze
125
+ end
126
+ private_class_method :normalize_scope
127
+
128
+ def self.in_scope?(key, sources, roots)
129
+ paths = sources && sources[key]
130
+ return false if paths.nil? || paths.empty?
131
+
132
+ paths.any? do |path|
133
+ absolute = File.expand_path(path)
134
+ roots.any? { |root| absolute == root || absolute.start_with?("#{root}/") }
135
+ end
34
136
  end
137
+ private_class_method :in_scope?
35
138
 
36
139
  def self.row_for(entry)
37
140
  Row.new(
@@ -40,7 +143,14 @@ module Rigor
40
143
  declared: entry.rendered_declared.to_a,
41
144
  exhaustive: entry.exhaustive?,
42
145
  causes: entry.causes,
43
- direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze
146
+ direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze,
147
+ # The declared lane's provenance (#434). A *discharging* plugin row leaves no cause line by
148
+ # design — WD6: a row the engine bundles is trusted, so it does not taint — which is why
149
+ # `plugin-attribution` appeared zero times in a whole Redmine run while 1,320 rows carried a
150
+ # `≤` clause. The origins are where the answer actually lives, and this is the direct half; a
151
+ # label that arrived transitively is `rigor effects explain`'s question.
152
+ attribution: entry.direct.declared_bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }
153
+ .freeze
44
154
  )
45
155
  end
46
156
  private_class_method :row_for
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
3
4
  require "optionparser"
4
5
 
5
- require_relative "../analysis/runner"
6
+ # ADR-104 — `analysis/runner` is required lazily (in `#analyze`): a warm snapshot verb served by the
7
+ # probe must reach its answer without `rigor/inference` in `$LOADED_FEATURES`.
8
+ require_relative "../analysis/effects_cache_probe"
6
9
  require_relative "../cache/store"
7
10
  require_relative "../configuration"
8
11
  require_relative "../effects/discharge"
12
+ require_relative "../effects/entry_points"
9
13
  require_relative "../effects/path_finder"
10
14
  require_relative "../effects/snapshot"
11
15
  require_relative "../effects/snapshot_diff"
@@ -79,8 +83,14 @@ module Rigor
79
83
  File.write(path, snapshot.to_yaml)
80
84
  @err.puts("rigor: wrote #{path} (#{snapshot.methods.size} method(s), #{snapshot.reach.size} reach entr" \
81
85
  "#{snapshot.reach.size == 1 ? 'y' : 'ies'})")
82
- @err.puts("rigor: note`effects.snapshot.reach:` is empty, so the snapshot records `methods:` only.") if
83
- configuration.effects_snapshot_reach.empty?
86
+ # #436the note names what to write, not only what is missing: the same per-project preset
87
+ # enumeration the unregistered-preset error uses, so the two can never disagree about which
88
+ # names this project's plugin set makes available.
89
+ if configuration.effects_snapshot_reach.empty?
90
+ @err.puts("rigor: note — `effects.snapshot.reach:` is empty, so the snapshot records `methods:` " \
91
+ "only — the direct half. Name your entry points to record what they cause: " \
92
+ "#{Effects::EntryPoints.availability}. The written file carries the same hint.")
93
+ end
84
94
  0
85
95
  end
86
96
 
@@ -103,7 +113,8 @@ module Rigor
103
113
  )
104
114
  return explain(diff, options) if @verb == "explain"
105
115
 
106
- EffectsDiffRenderer.new(out: @out, path: path).render(diff, format: options.fetch(:format))
116
+ EffectsDiffRenderer.new(out: @out, path: path, sources: @sources)
117
+ .render(diff, format: options.fetch(:format))
107
118
  @verb == "check" && diff.drift? ? 1 : 0
108
119
  end
109
120
 
@@ -126,24 +137,38 @@ module Rigor
126
137
 
127
138
  def explain(diff, options)
128
139
  rows = options.fetch(:symbol) ? rows_for_symbol(options.fetch(:symbol)) : rows_for_changes(diff)
140
+ return CLI::EXIT_USAGE if rows == :unknown
141
+
129
142
  EffectsExplainRenderer.new(out: @out).render(rows, format: options.fetch(:format))
130
143
  0
131
144
  end
132
145
 
146
+ # A misspelled `--symbol` used to print `Nothing to explain.` and exit 0, which is exactly what a
147
+ # method with no effects prints — so a typo and a real answer were indistinguishable, and a script
148
+ # could not tell them apart at all (#435). It is a usage error now, with the nearest key offered:
149
+ # the key set is right there, and a method key is long enough to get wrong.
133
150
  def rows_for_symbol(symbol)
134
151
  entry = @table[symbol]
135
152
  if entry.nil?
136
- @err.puts("rigor: no effect unit named #{symbol}")
137
- return []
153
+ @err.puts("rigor: no effect unit named #{symbol}#{suggestion(symbol)}")
154
+ return :unknown
138
155
  end
139
156
 
140
157
  reach_rows(symbol, entry.proven.to_a) + method_rows(symbol, entry.direct.proven.to_a)
141
158
  end
142
159
 
160
+ def suggestion(symbol)
161
+ nearest = ::DidYouMean::SpellChecker.new(dictionary: @table.keys).correct(symbol).first
162
+ nearest ? " — did you mean #{nearest}?" : ""
163
+ end
164
+
143
165
  # Every label a change introduced, explained once: a `reach:` change gets its shortest edge path, a
144
166
  # `methods:` change gets the origin in the method's own body.
145
167
  def rows_for_changes(diff)
146
168
  diff.events.flat_map do |event|
169
+ next exhaustiveness_rows(event) if
170
+ event.category == Effects::SnapshotDiff::EXHAUSTIVE_LOST
171
+
147
172
  labels = labels_of(event)
148
173
  next [] if labels.empty?
149
174
 
@@ -159,13 +184,29 @@ module Rigor
159
184
  entry ? entry.proven.to_a : []
160
185
  end
161
186
 
187
+ # `exhaustive → not` is the drift row a reader is least equipped to interpret, and it was the one
188
+ # row `explain` could not expand (#435): it carries no label, so {#labels_of} answered with nothing
189
+ # and the event produced no explanation at all. What explains it is the taint causes — the calls
190
+ # the analyzer could not follow — which is the detail the snapshot stopped recording per row
191
+ # (#434). The two halves meet here: the record keeps the stable count, and this names them.
192
+ def exhaustiveness_rows(event)
193
+ entry = @table[event.symbol]
194
+ return [] if entry.nil?
195
+
196
+ causes = Effects::Snapshot.render_causes(entry.causes)
197
+ return [] if causes.empty?
198
+
199
+ [EffectsExplainRenderer::Row.new(table: event.table, symbol: event.symbol, label: nil,
200
+ path: [].freeze, origin: nil, causes: causes)]
201
+ end
202
+
162
203
  def reach_rows(symbol, labels)
163
204
  labels.filter_map do |label|
164
205
  path = Effects::PathFinder.shortest(@table, symbol: symbol, label: label)
165
206
  next if path.nil?
166
207
 
167
208
  EffectsExplainRenderer::Row.new(table: "reach", symbol: symbol, label: label,
168
- path: path.to_a, origin: path.origin)
209
+ path: path.to_a, origin: path.origin, causes: [].freeze)
169
210
  end
170
211
  end
171
212
 
@@ -178,7 +219,7 @@ module Rigor
178
219
  next if origin.nil?
179
220
 
180
221
  EffectsExplainRenderer::Row.new(table: "methods", symbol: symbol, label: label,
181
- path: [].freeze, origin: origin.to_s)
222
+ path: [].freeze, origin: origin.to_s, causes: [].freeze)
182
223
  end
183
224
  end
184
225
 
@@ -188,7 +229,20 @@ module Rigor
188
229
  # the ADR-45 whole-run result cache like `rigor check` — the diagnostics entry plus the #382 effects
189
230
  # sidecar keyed beside it, so `rigor effects check` after `rigor check` under a configured `effects:`
190
231
  # block is a warm hit plus the fixpoint.
232
+ # ADR-104 — a snapshot is built from the table, the sources and the vocabulary, all three of which
233
+ # the #482 summary entry (plus the plugins' own declarations) carries, so a warm run takes the
234
+ # probe and never loads the engine. A decline is the runner path below, unchanged.
191
235
  def build_snapshot(configuration, full:)
236
+ served = Analysis::EffectsCacheProbe.new(
237
+ configuration: configuration, cache_root: configuration.cache_path
238
+ ).serve(configuration.paths)
239
+ return snapshot_from(configuration, full, served) if served
240
+
241
+ snapshot_from(configuration, full, analyze(configuration))
242
+ end
243
+
244
+ def analyze(configuration)
245
+ require_relative "../analysis/runner"
192
246
  runner = Analysis::Runner.new(
193
247
  configuration: configuration,
194
248
  cache_store: Cache::Store.new(root: configuration.cache_path),
@@ -196,14 +250,24 @@ module Rigor
196
250
  workers: 0
197
251
  )
198
252
  runner.run(configuration.paths)
199
- @table = runner.effect_table
200
- Effects::Snapshot.build(
201
- table: @table, configuration: configuration, sources: runner.effect_sources, full: full,
253
+ Analysis::EffectsCacheProbe::Served.new(
254
+ table: runner.effect_table, sources: runner.effect_sources, plugin_facts: nil,
202
255
  registry: Effects::Registry.for_configuration(configuration,
203
256
  plugin_facts: runner.effect_plugin_facts)
204
257
  )
205
258
  end
206
259
 
260
+ # WD3 — one build, whichever lane produced the inputs, so a served snapshot and an analysed one
261
+ # cannot drift in how they are assembled.
262
+ def snapshot_from(configuration, full, served)
263
+ @table = served.table
264
+ @sources = served.sources
265
+ Effects::Snapshot.build(
266
+ table: @table, configuration: configuration, sources: served.sources, full: full,
267
+ registry: served.registry
268
+ )
269
+ end
270
+
207
271
  # ---- options ------------------------------------------------
208
272
 
209
273
  def parse_options
@@ -6,10 +6,12 @@ require "json"
6
6
  require_relative "../configuration"
7
7
  require_relative "../analysis/path_expansion"
8
8
  require_relative "../analysis/reachability/scan"
9
+ require_relative "../analysis/reachability/scan_cache"
9
10
  require_relative "../analysis/reachability/graph"
10
11
  require_relative "../analysis/reachability/plugin_roots"
11
12
  require_relative "../analysis/reachability/signature_scan"
12
13
  require_relative "../analysis/reachability/project_files"
14
+ require_relative "../cache/store"
13
15
  require_relative "options"
14
16
  require_relative "command"
15
17
  require_relative "probe_environment"
@@ -22,7 +24,11 @@ module Rigor
22
24
  # measured precision of this signal is 7.0% on an adjudicated corpus target
23
25
  # (`docs/notes/20260813-unused-constant-fp-baseline.md`), so its output is a review queue, not a defect
24
26
  # list, and it never enters `rigor check`'s stream at any severity (WD1). Exits 0 whatever it finds.
25
- class UnusedCommand < Command
27
+ #
28
+ # Like {CheckCommand}, it aggregates one command's concerns — the two scans, the graph, and both
29
+ # render formats — that read clearer together than split across micro-classes, so it carries the
30
+ # same ClassLength exemption.
31
+ class UnusedCommand < Command # rubocop:disable Metrics/ClassLength
26
32
  USAGE = "Usage: rigor unused [options] [paths]"
27
33
 
28
34
  # WD7 — the REFERENCE corpus is wider than the ANALYSIS corpus. `.rake` files sit inside `paths:` and
@@ -43,23 +49,32 @@ module Rigor
43
49
 
44
50
  configuration = Configuration.load(options.fetch(:config))
45
51
  paths = @argv.empty? ? configuration.paths : @argv
46
- declarations, references, dynamic_uses = scan(paths, configuration)
52
+ scan_cache = Analysis::Reachability::ScanCache.open(configuration.cache_path,
53
+ target_ruby: configuration.target_ruby)
54
+ declarations, references, dynamic_uses = scan(paths, configuration, scan_cache)
47
55
  references.concat(signature_references(configuration))
48
- dynamic_uses.concat(template_mentions(declarations))
56
+ dynamic_uses.concat(template_mentions(declarations, scan_cache))
49
57
 
50
- contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration)
58
+ contribution = Analysis::Reachability::PluginRoots.collect(configuration: configuration,
59
+ cache_store: cache_store(configuration))
51
60
  references.concat(plugin_references(contribution.references))
52
- graph = Analysis::Reachability::Graph.new(
53
- declarations: declarations, references: references, dynamic_uses: dynamic_uses,
54
- root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
55
- foreign: foreign_predicate(configuration)
56
- )
61
+ graph = build_graph(configuration, options, contribution,
62
+ declarations: declarations, references: references, dynamic_uses: dynamic_uses)
57
63
  emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
64
+ scan_cache.save
58
65
  0
59
66
  end
60
67
 
61
68
  private
62
69
 
70
+ def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:)
71
+ Analysis::Reachability::Graph.new(
72
+ declarations: declarations, references: references, dynamic_uses: dynamic_uses,
73
+ root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
74
+ foreign: foreign_predicate(configuration)
75
+ )
76
+ end
77
+
63
78
  def parse_options
64
79
  options = { config: nil, format: "text", entry_points: [], limit: nil }
65
80
  parser = OptionParser.new do |opts|
@@ -91,13 +106,15 @@ module Rigor
91
106
  end
92
107
 
93
108
  # Declarations come from the analysed paths; references additionally from the wider corpus (WD7).
94
- def scan(paths, configuration)
109
+ # Every file is consulted every run — an unchanged one contributes its cached scan, so the
110
+ # whole-project completeness the `--incremental` refusal protects is unaffected.
111
+ def scan(paths, configuration, cache)
95
112
  declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
96
113
  declarations = []
97
114
  references = []
98
115
  dynamic_uses = []
99
116
  (declaration_files + reference_files(paths, configuration)).sort.each do |file|
100
- result = read_and_scan(file, configuration)
117
+ result = cache.serve(:scan, file) { read_and_scan(file, configuration) }
101
118
  next if result.nil?
102
119
 
103
120
  declarations.concat(result.declarations) if declaration_files.include?(file)
@@ -155,24 +172,38 @@ module Rigor
155
172
  end
156
173
  end
157
174
 
158
- def template_mentions(declarations)
175
+ def template_mentions(declarations, cache)
159
176
  names = declarations.map(&:fqn)
160
177
  return [] if names.empty?
161
178
 
162
179
  Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
163
- text = File.read(File.expand_path(rel)).scrub
180
+ absolute = File.expand_path(rel)
181
+ haystack = cache.serve(:runs, absolute) { constant_bearing_text(File.read(absolute).scrub) }
164
182
  names.filter_map do |fqn|
165
- next unless text.include?(fqn)
183
+ next unless haystack.include?(fqn)
166
184
 
167
185
  Analysis::Reachability::Scan::DynamicUse.new(name: nil, prefix: fqn, site: nil,
168
186
  reason: "named as a string in #{rel}",
169
- path: rel, line: 1)
187
+ path: rel, line: 1, scope: :exact)
170
188
  end
171
189
  rescue SystemCallError, ArgumentError
172
190
  []
173
191
  end
174
192
  end
175
193
 
194
+ # A maximal run of constant-path characters carrying at least one capital — the only substrings a
195
+ # declaration FQN can occur inside, since an FQN starts with a capital and uses this charset alone.
196
+ # An occurrence cannot cross a non-charset character, so `include?` over the de-duplicated runs
197
+ # (joined by a character outside the charset) answers exactly what `include?` over the whole text
198
+ # did, on a fraction of the bytes: a locale YAML or fixture JSON is almost entirely lowercase prose,
199
+ # and scanning 21 MB of it per declaration name was 81% of this command's wall time on mastodon.
200
+ CONSTANT_BEARING_RUN = /[A-Za-z0-9_:]*[A-Z][A-Za-z0-9_:]*/
201
+ private_constant :CONSTANT_BEARING_RUN
202
+
203
+ def constant_bearing_text(text)
204
+ text.scan(CONSTANT_BEARING_RUN).uniq.join("\n")
205
+ end
206
+
176
207
  def root_fqns(declarations, globs)
177
208
  return [] if globs.empty?
178
209
 
@@ -188,13 +219,31 @@ module Rigor
188
219
  # declaration, which produced three of redmine's artifacts from a single initializer. A name the bundled
189
220
  # (non-project) environment already knows is not ours to call unused. The project's own `sig/` is
190
221
  # deliberately excluded from this environment, so a project class that ships a signature stays owned.
222
+ #
223
+ # The cache store makes the environment a Marshal restore instead of a cold RBS build on every
224
+ # invocation — the same ADR-54 slot `rigor check` reads, keyed apart by this environment's own
225
+ # (sig-less) descriptor. The report only ever asks "is this name known", which the cached
226
+ # environment answers identically: the one thing the cache degrades is `RBS::Location`, and no
227
+ # location is read here.
191
228
  def foreign_predicate(configuration)
192
- env = Environment.for_project(libraries: configuration.libraries, signature_paths: [])
229
+ env = Environment.for_project(libraries: configuration.libraries, signature_paths: [],
230
+ cache_store: cache_store(configuration))
193
231
  ->(fqn) { !env.singleton_for_name(fqn).nil? }
194
232
  rescue StandardError
195
233
  ->(_fqn) { false }
196
234
  end
197
235
 
236
+ # One store per run, shared by the environment build and the plugin-roots collection. Nil when the
237
+ # store cannot be opened — both consumers already treat a nil store as "recompute", which was this
238
+ # command's only mode before it had a cache at all.
239
+ def cache_store(configuration)
240
+ return @cache_store if defined?(@cache_store)
241
+
242
+ @cache_store = Cache::Store.new(root: configuration.cache_path)
243
+ rescue StandardError
244
+ @cache_store = nil
245
+ end
246
+
198
247
  # ADR-102 § Consequences — "a root source that OVER-supplies silently hides real dead code, which is
199
248
  # worse than one that under-supplies, so each plugin's contribution needs its own corpus check". A
200
249
  # supplied root naming a constant the project does not declare is inert in the graph, but it is the
data/lib/rigor/cli.rb CHANGED
@@ -84,19 +84,43 @@ module Rigor
84
84
  rescue OptionParser::ParseError => e
85
85
  @err.puts(e.message)
86
86
  EXIT_USAGE
87
+ rescue ConfigurationError => e
88
+ # #433 — a mistake in `.rigor.yml` is the same kind of event as a bad flag, and belongs in the same
89
+ # shape: one `rigor:` line naming the key, and the conventional usage exit code. It used to escape
90
+ # as an uncaught exception with a ~30-frame backtrace naming a file inside `lib/rigor/`, which reads
91
+ # as a crash even though the message it carried said exactly which key to fix.
92
+ #
93
+ # Caught here rather than per command because every command loads a configuration, and the ones
94
+ # that resolve a key later (`effects update` expanding `snapshot.reach:` once the plugins that
95
+ # register presets have loaded) would each need their own rescue at their own point.
96
+ @err.puts("rigor: #{e.message}")
97
+ EXIT_USAGE
87
98
  end
88
99
 
89
100
  private
90
101
 
91
102
  def dispatch(command)
92
103
  handler = HANDLERS[command]
93
- return send(handler) if handler
104
+ return arm_jit_deadline { send(handler) } if handler
94
105
 
95
106
  @err.puts("Unknown command: #{command}")
96
107
  @err.puts(help)
97
108
  EXIT_USAGE
98
109
  end
99
110
 
111
+ # Deferred YJIT for EVERY dispatched command, not just `check` / `coverage` where PR #75 first
112
+ # calibrated it. The deadline makes the decision command-independent: a run that finishes inside
113
+ # the window never pays JIT compile, and a run that outlasts it JITs its dominant tail — measured
114
+ # on `rigor effects` cold over Mastodon, which ran its whole 21 s interpreted while the same
115
+ # analysis under `check` took 14.8 s (forcing YJIT on the effects run: 15.9 s; disabling it on
116
+ # check: 21.1 s). `lsp` / `mcp` still call `Runtime::Jit.enable_now` at boot, which makes the
117
+ # deadline thread armed here a no-op when it later fires.
118
+ def arm_jit_deadline
119
+ require_relative "runtime/jit"
120
+ Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
121
+ yield
122
+ end
123
+
100
124
  def run_check
101
125
  require_relative "cli/check_command"
102
126
 
@@ -160,7 +184,7 @@ module Rigor
160
184
  # - paths: directories scanned by `rigor check` and
161
185
  # `rigor type-scan` when no path is given.
162
186
  # - plugins: opt-in list of plugin gem names to load.
163
- # See https://github.com/rigortype/rigor/tree/main/plugins
187
+ # See https://github.com/rigortype/rigor/tree/master/plugins
164
188
  # for production plugins (rigor-activerecord, rigor-sorbet, …).
165
189
  # - disable: list of `rigor check` rule identifiers to
166
190
  # silence project-wide. The shipped rules are
@@ -356,13 +380,13 @@ module Rigor
356
380
  trace Replay how the engine typed FILE as a terminal animation
357
381
  type-scan Report Scope#type_of coverage across PATHs
358
382
  effects Report each method's effect labels, and the committed effect snapshot
359
- (ADR-103, opt-in; effects update/check/diff/explain)
383
+ (opt-in; effects update/check/diff/explain)
360
384
  explain Print the description of one or all CheckRules
361
385
  diff Compare current diagnostics to a saved baseline JSON
362
- sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
386
+ sig-gen Emit RBS skeletons inferred from .rb sources
363
387
  lsp Run the Rigor Language Server (LSP) over stdio
364
- mcp Run the Rigor MCP server over stdio (ADR-33)
365
- triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
388
+ mcp Run the Rigor MCP server over stdio
389
+ triage Summarise diagnostics: distribution, hotspots, hints
366
390
  coverage Report type-precision coverage (precise vs Dynamic ratio)
367
391
  plugins Report activation status of every configured plugin
368
392
  plugin Browse bundled plugin source as worked examples (list/path/print/root)
@@ -370,9 +394,9 @@ module Rigor
370
394
  describe Recommend the next skill for this project (alias for `skill describe`)
371
395
  skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
372
396
  docs Print the bundled docs offline (docs <name>, docs --list)
373
- show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
374
- doctor Classify setup problems vs clean run with routed next actions (ADR-77)
375
- upgrade Migration command skeleton (ADR-50 WD7, queued)
397
+ show-bleedingedge Show the bleeding-edge overlay + what your config adopts
398
+ doctor Classify setup problems vs clean run with routed next actions
399
+ upgrade Migration command skeleton (queued)
376
400
  version Print the Rigor version
377
401
  help Print this help
378
402
  HELP