rigortype 0.3.5 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
  4. data/docs/handbook/02-everyday-types.md +1 -1
  5. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  6. data/docs/handbook/08-understanding-errors.md +1 -1
  7. data/docs/handbook/09-plugins.md +2 -2
  8. data/docs/handbook/10-sorbet.md +1 -1
  9. data/docs/handbook/README.md +2 -2
  10. data/docs/handbook/appendix-go.md +1 -1
  11. data/docs/handbook/appendix-java-csharp.md +2 -2
  12. data/docs/handbook/appendix-mypy.md +1 -1
  13. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  14. data/docs/handbook/appendix-rust.md +1 -1
  15. data/docs/handbook/appendix-type-theory.md +2 -2
  16. data/docs/handbook/appendix-typescript.md +4 -4
  17. data/docs/manual/02-cli-reference.md +13 -9
  18. data/docs/manual/03-configuration.md +1 -1
  19. data/docs/manual/04-diagnostics.md +12 -0
  20. data/docs/manual/08-skills.md +1 -1
  21. data/docs/manual/11-ci.md +1 -1
  22. data/docs/manual/12-caching.md +1 -1
  23. data/docs/manual/15-type-protection-coverage.md +1 -1
  24. data/docs/manual/18-removing-dead-code.md +13 -4
  25. data/docs/manual/19-effect-labels.md +19 -12
  26. data/docs/manual/README.md +2 -2
  27. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  28. data/docs/manual/plugins/rigor-activejob.md +3 -3
  29. data/docs/manual/plugins/rigor-activerecord.md +4 -4
  30. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  31. data/docs/manual/plugins/rigor-rspec.md +6 -2
  32. data/lib/rigor/analysis/diagnostic.rb +17 -6
  33. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  34. data/lib/rigor/analysis/reachability/graph.rb +30 -11
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  36. data/lib/rigor/analysis/reachability/scan.rb +20 -4
  37. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +12 -0
  39. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  40. data/lib/rigor/analysis/runner.rb +151 -28
  41. data/lib/rigor/cache/file_digest.rb +20 -2
  42. data/lib/rigor/cli/check_command.rb +67 -48
  43. data/lib/rigor/cli/coverage_command.rb +5 -6
  44. data/lib/rigor/cli/doc_links.rb +100 -0
  45. data/lib/rigor/cli/docs_command.rb +32 -2
  46. data/lib/rigor/cli/effects_command.rb +27 -2
  47. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  48. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  49. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  50. data/lib/rigor/cli/unused_command.rb +65 -16
  51. data/lib/rigor/cli.rb +21 -8
  52. data/lib/rigor/effects/definition_lines.rb +100 -0
  53. data/lib/rigor/effects/envelope_check.rb +18 -1
  54. data/lib/rigor/effects/liskov_check.rb +17 -8
  55. data/lib/rigor/effects/signature_sources.rb +13 -2
  56. data/lib/rigor/effects/snapshot.rb +53 -21
  57. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  59. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  60. data/lib/rigor/version.rb +1 -1
  61. data/skills/rigor-ci-setup/SKILL.md +2 -2
  62. data/skills/rigor-editor-setup/SKILL.md +2 -2
  63. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  64. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  65. data/skills/rigor-plugin-review/SKILL.md +3 -3
  66. metadata +5 -1
@@ -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
@@ -101,13 +101,26 @@ module Rigor
101
101
 
102
102
  def dispatch(command)
103
103
  handler = HANDLERS[command]
104
- return send(handler) if handler
104
+ return arm_jit_deadline { send(handler) } if handler
105
105
 
106
106
  @err.puts("Unknown command: #{command}")
107
107
  @err.puts(help)
108
108
  EXIT_USAGE
109
109
  end
110
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
+
111
124
  def run_check
112
125
  require_relative "cli/check_command"
113
126
 
@@ -367,13 +380,13 @@ module Rigor
367
380
  trace Replay how the engine typed FILE as a terminal animation
368
381
  type-scan Report Scope#type_of coverage across PATHs
369
382
  effects Report each method's effect labels, and the committed effect snapshot
370
- (ADR-103, opt-in; effects update/check/diff/explain)
383
+ (opt-in; effects update/check/diff/explain)
371
384
  explain Print the description of one or all CheckRules
372
385
  diff Compare current diagnostics to a saved baseline JSON
373
- sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
386
+ sig-gen Emit RBS skeletons inferred from .rb sources
374
387
  lsp Run the Rigor Language Server (LSP) over stdio
375
- mcp Run the Rigor MCP server over stdio (ADR-33)
376
- triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
388
+ mcp Run the Rigor MCP server over stdio
389
+ triage Summarise diagnostics: distribution, hotspots, hints
377
390
  coverage Report type-precision coverage (precise vs Dynamic ratio)
378
391
  plugins Report activation status of every configured plugin
379
392
  plugin Browse bundled plugin source as worked examples (list/path/print/root)
@@ -381,9 +394,9 @@ module Rigor
381
394
  describe Recommend the next skill for this project (alias for `skill describe`)
382
395
  skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
383
396
  docs Print the bundled docs offline (docs <name>, docs --list)
384
- show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
385
- doctor Classify setup problems vs clean run with routed next actions (ADR-77)
386
- 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)
387
400
  version Print the Rigor version
388
401
  help Print this help
389
402
  HELP
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "method_key"
6
+
7
+ module Rigor
8
+ module Effects
9
+ # Where a method key's `def` is written, resolved from the file the key was already traced to
10
+ # (#435).
11
+ #
12
+ # A drift row names the file out of `Runner#effect_sources`, which rides the cached summary entry
13
+ # and is therefore free. The **line** is not in any value the effects surfaces hold: the discovery
14
+ # tables {EnvelopeCheck::Positions} reads are built by one Prism parse of every project file, which
15
+ # is exactly what [ADR-104](../../../docs/adr/104-effects-boot-slim-probe.md) removed from this
16
+ # command — a warm `rigor effects check` is fast *because* it never parses the project.
17
+ #
18
+ # So this parses the drift's own files and nothing else: an index is built the first time a row asks
19
+ # about a path, and a report with no rows builds none. The cost is proportional to the drift, not to
20
+ # the project — the {EnvelopeCheck::DeferredPositions} shape (#479) applied one layer up, and the
21
+ # reason it can be one layer up is that the caller already knows the file.
22
+ #
23
+ # It is deliberately not a second discovery pass. It answers `def`s and only `def`s: a key whose
24
+ # method has no Ruby `def` at all — a synthesized accessor — keeps the file and loses the line,
25
+ # which is the same degradation `Positions` makes when it falls back to the class's own source.
26
+ class DefinitionLines
27
+ TOPLEVEL = "<toplevel>"
28
+ private_constant :TOPLEVEL
29
+
30
+ NO_LINES = {}.freeze
31
+ private_constant :NO_LINES
32
+
33
+ # @param key [String] an effect unit key — `Tracer::Loud#emit`, `Net::HTTP.get`.
34
+ # @param path [String] the file the key was traced to.
35
+ # @return [Integer, nil] the `def`'s line, or nil when this file does not spell that key with a
36
+ # `def` — an unreadable file, a syntax error, and a synthesized method all land here.
37
+ def for(key:, path:)
38
+ index_for(path)[key]
39
+ end
40
+
41
+ private
42
+
43
+ def index_for(path)
44
+ @indexes ||= {}
45
+ @indexes[path] ||= build_index(path)
46
+ end
47
+
48
+ # First `def` wins: a file that reopens the same method twice has two lines and only one of them is
49
+ # where a reader starts.
50
+ def build_index(path)
51
+ result = Prism.parse_file(path.to_s)
52
+ return NO_LINES unless result.success?
53
+
54
+ {}.tap { |index| walk(result.value, [], singleton: false, index: index) }
55
+ rescue StandardError
56
+ NO_LINES
57
+ end
58
+
59
+ # The nesting is tracked, never resolved: `class Tracer::Loud` inside `module Tracer` spells a key
60
+ # this cannot see, and answering it would need the constant resolution an engine-free path does not
61
+ # have. A key it cannot spell keeps its file, which is what the row printed before this class existed.
62
+ def walk(node, nesting, singleton:, index:)
63
+ case node
64
+ when Prism::ModuleNode, Prism::ClassNode
65
+ name = constant_name(node.constant_path)
66
+ return if name.nil?
67
+
68
+ walk_children(node.body, nesting + [name], singleton: false, index: index)
69
+ when Prism::SingletonClassNode
70
+ walk_children(node.body, nesting, singleton: true, index: index)
71
+ when Prism::DefNode
72
+ record(node, nesting, singleton: singleton, index: index)
73
+ walk_children(node.body, nesting, singleton: singleton, index: index)
74
+ else
75
+ walk_children(node, nesting, singleton: singleton, index: index)
76
+ end
77
+ end
78
+
79
+ def walk_children(node, nesting, singleton:, index:)
80
+ node&.compact_child_nodes&.each { |child| walk(child, nesting, singleton: singleton, index: index) }
81
+ end
82
+
83
+ def record(node, nesting, singleton:, index:)
84
+ separator = singleton || node.receiver.is_a?(Prism::SelfNode) ? "." : "#"
85
+ owner = nesting.empty? ? TOPLEVEL : nesting.join("::")
86
+ key = "#{owner}#{separator}#{node.name}"
87
+ index[key] ||= node.location.start_line
88
+ end
89
+
90
+ def constant_name(node)
91
+ case node
92
+ when Prism::ConstantReadNode then node.name.to_s
93
+ when Prism::ConstantPathNode then node.full_name
94
+ end
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -83,6 +83,21 @@ module Rigor
83
83
  end
84
84
  end
85
85
 
86
+ # {Positions} behind a thunk: the discovery tables — one Prism parse of every project file —
87
+ # are built on the first `.for`, which both judgments reach only once a finding is being
88
+ # constructed. A clean judgment, the common CI case, therefore forces no discovery and parses
89
+ # nothing; that is the whole point of this class existing rather than the pass forcing the
90
+ # tables up front.
91
+ class DeferredPositions
92
+ def initialize(&build)
93
+ @build = build
94
+ end
95
+
96
+ def for(key)
97
+ (@positions ||= @build.call).for(key)
98
+ end
99
+ end
100
+
86
101
  NO_FINDINGS = [].freeze
87
102
  private_constant :NO_FINDINGS
88
103
 
@@ -93,7 +108,9 @@ module Rigor
93
108
  # @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
94
109
  # @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
95
110
  # to the classes they select ({ConfigEnvelopes.for_classes}), to distribute at the lowest precedence.
96
- # @param positions [Positions] the discovery tables a finding's `def` position is read from.
111
+ # @param positions [Positions, DeferredPositions] the discovery tables a finding's `def`
112
+ # position is read from — consulted only when a finding is built, so a deferred value's
113
+ # discovery force is reached exactly as often as a finding exists.
97
114
  # @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
98
115
  # the `--no-tolerated-effects` audit switch.
99
116
  # @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
@@ -112,11 +112,13 @@ module Rigor
112
112
 
113
113
  inherited = envelopes.fetch(ancestor_key)
114
114
  own = envelopes[key]
115
- position = positions.for(key)
115
+ # The position is read inside the two collectors, after they know a finding exists: `.for` is
116
+ # what forces a deferred position table, and an inherited envelope nothing widens must not
117
+ # cost a whole-project discovery parse.
116
118
  if own && !own.top?
117
- collect_declared(findings, key, ancestor_key, inherited, own, position)
119
+ collect_declared(findings, key, ancestor_key, inherited, own, positions)
118
120
  else
119
- collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
121
+ collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
120
122
  end
121
123
  end
122
124
 
@@ -135,16 +137,19 @@ module Rigor
135
137
  nil
136
138
  end
137
139
 
138
- def collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
140
+ def collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
139
141
  entry = table[key]
140
142
  return if entry.nil?
141
143
 
142
144
  exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
145
+ return if exceeding.empty?
146
+
147
+ path, line = positions.for(key)
143
148
  exceeding.each do |label|
144
149
  trail = PathFinder.shortest(table, symbol: key, label: label)
145
150
  findings << Finding.new(
146
151
  key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
147
- own_envelope: nil, path: position.first, line: position.last,
152
+ own_envelope: nil, path: path, line: line,
148
153
  chain: trail&.chain || [key].freeze, origin: trail&.origin
149
154
  )
150
155
  end
@@ -152,11 +157,15 @@ module Rigor
152
157
 
153
158
  # Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
154
159
  # everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
155
- def collect_declared(findings, key, ancestor_key, inherited, own, position)
156
- own.bound.to_a.reject { |label| inherited.tolerates?(label) }.each do |label|
160
+ def collect_declared(findings, key, ancestor_key, inherited, own, positions)
161
+ widened = own.bound.to_a.reject { |label| inherited.tolerates?(label) }
162
+ return if widened.empty?
163
+
164
+ path, line = positions.for(key)
165
+ widened.each do |label|
157
166
  findings << Finding.new(
158
167
  key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
159
- own_envelope: own, path: position.first, line: position.last, chain: nil, origin: nil
168
+ own_envelope: own, path: path, line: line, chain: nil, origin: nil
160
169
  )
161
170
  end
162
171
  end
@@ -21,10 +21,21 @@ module Rigor
21
21
  DEFAULT_ROOTS = ["sig"].freeze
22
22
 
23
23
  # A cheap text pre-filter for "does this source carry an effect annotation at all". It matches
24
- # the two spellings the envelope reader honours and nothing else, so a signature tree with no
24
+ # the two payloads the envelope reader honours and nothing else, so a signature tree with no
25
25
  # effect annotation is answered by one regex per file and never parsed. It is a ROUTING test,
26
26
  # not the grammar — `RbsExtended.parse_effect_annotation` is still what decides meaning.
27
- ANNOTATION_HINT = /%a\{\s*(?:pure\s*\}|rigor:v1:effect\b)/
27
+ #
28
+ # RBS accepts five bracket pairs for an annotation, and the reader sees only the text inside
29
+ # them, so the hint must accept all five too: routing on `%a{` alone made a `%a(pure)` project
30
+ # invisible to the run-cache probe, which then served the fast path while a bound existed —
31
+ # the silent-lane shape the #428 family is about. Over-matching (a mismatched closer) is safe:
32
+ # the cost is one declined fast path or one parsed file, never a missed bound.
33
+ ANNOTATION_BRACKETS = { "{" => "}", "(" => ")", "[" => "]", "|" => "|", "<" => ">" }.freeze
34
+ ANNOTATION_HINT = Regexp.union(
35
+ ANNOTATION_BRACKETS.map do |opener, closer|
36
+ /%a#{Regexp.escape(opener)}\s*(?:pure\s*#{Regexp.escape(closer)}|rigor:v1:effect\b)/
37
+ end
38
+ )
28
39
 
29
40
  # A `virtual:<plugin-id>:<source path>` buffer is rbs-inline's (or a plugin's) synthesized RBS for
30
41
  # a Ruby file the author actually wrote in. Naming that file is what a reader can act on, so the
@@ -38,7 +38,10 @@ module Rigor
38
38
  class Snapshot
39
39
  # Bumped when the file's shape changes in a way an older reader would misread. A bump makes every
40
40
  # existing file a regeneration event rather than a silent reinterpretation.
41
- SCHEMA = 1
41
+ # 2 — `unresolved:` became a count (#434). A schema-1 file still loads, and its only reported
42
+ # difference is this field, so an existing project sees one regeneration line rather than a
43
+ # reinterpretation.
44
+ SCHEMA = 2
42
45
 
43
46
  HEADER = "# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."
44
47
 
@@ -47,8 +50,16 @@ module Rigor
47
50
 
48
51
  # One row of either table.
49
52
  #
50
- # `effects` and `declared` are sorted label arrays; `unresolved` is the sorted list of taint-cause
51
- # renderings behind a false `exhaustive`, and is empty when the row is exhaustive.
53
+ # `effects` and `declared` are sorted label arrays; `unresolved` is HOW MANY taint causes sit behind
54
+ # a false `exhaustive`, and is zero when the row is exhaustive.
55
+ #
56
+ # A count rather than the causes themselves (#434). On redmine the cause renderings were 144,771 of
57
+ # the file's 293,276 bytes, on lines up to 821 characters — simultaneously the half a reviewer
58
+ # cannot read and the half that churns when an unrelated call moves. This file already makes that
59
+ # argument about origins ("a record keyed by them would churn on every refactor"); the causes are
60
+ # the same class of thing and were the one place it was not applied. What a reviewer needs from the
61
+ # record is the stable fact that the row is not exhaustive and by how much, which is what a count
62
+ # is; `rigor effects explain` names the causes on demand, against the live table.
52
63
  class Entry < Data.define(:key, :effects, :declared, :exhaustive, :unresolved)
53
64
  def exhaustive?
54
65
  exhaustive
@@ -61,7 +72,7 @@ module Rigor
61
72
  row = { "effects" => effects }
62
73
  row["declared"] = declared unless declared.empty?
63
74
  row["exhaustive"] = false unless exhaustive
64
- row["unresolved"] = unresolved unless unresolved.empty?
75
+ row["unresolved"] = unresolved unless unresolved.zero?
65
76
  row
66
77
  end
67
78
 
@@ -73,10 +84,23 @@ module Rigor
73
84
  effects: string_list(row["effects"], key, "effects"),
74
85
  declared: string_list(row["declared"], key, "declared"),
75
86
  exhaustive: row.fetch("exhaustive", true) != false,
76
- unresolved: string_list(row["unresolved"], key, "unresolved")
87
+ unresolved: unresolved_count(row["unresolved"], key)
77
88
  )
78
89
  end
79
90
 
91
+ # Tolerant of the schema-1 spelling, which was the list itself: the field's whole point now is a
92
+ # stable number, and a schema-1 file must LOAD so the header's own mismatch can be reported as
93
+ # the one regeneration event it is. Refusing to parse it would turn a migration into an error.
94
+ def self.unresolved_count(value, key)
95
+ case value
96
+ when nil then 0
97
+ when Integer then value
98
+ when Array then value.length
99
+ else raise ParseError, "#{key.inspect} unresolved: expected a count, got #{value.class}"
100
+ end
101
+ end
102
+ private_class_method :unresolved_count
103
+
80
104
  def self.string_list(value, key, field)
81
105
  return [].freeze if value.nil?
82
106
  raise ParseError, "#{key.inspect} #{field}: expected a list, got #{value.class}" unless value.is_a?(Array)
@@ -185,6 +209,29 @@ module Rigor
185
209
  parse(File.read(path, encoding: "UTF-8"))
186
210
  end
187
211
 
212
+ # A path as a reader sees it: project-relative when it is under the root, absolute when it is not.
213
+ #
214
+ # Public for the same reason {.render_causes} is — `reach:`'s entry-point globs and a drift row's
215
+ # source suffix (#435) must agree about what a project-relative path is, and a report that leaked
216
+ # `/private/tmp/nix-shell.../loud.rb` into a reviewer's terminal would be answering with this
217
+ # machine's layout rather than with the project's.
218
+ def relativize(path, project_root)
219
+ absolute = File.absolute_path(path.to_s)
220
+ root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
221
+ absolute.start_with?(root) ? absolute[root.length..] : absolute
222
+ end
223
+
224
+ # The taint causes as a reviewer reads them, from the closed enum of
225
+ # `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`.
226
+ #
227
+ # Public because two surfaces render the same causes and must not disagree about what one IS:
228
+ # a row's `unresolved:` count is the length of this list, and `rigor effects explain` prints the
229
+ # list itself for an `exhaustive → not` drift row (#434 / #435). One implementation, as WD3 asks.
230
+ def render_causes(causes)
231
+ causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
232
+ .uniq.sort.freeze
233
+ end
234
+
188
235
  private
189
236
 
190
237
  def parse_header(data)
@@ -241,12 +288,6 @@ module Rigor
241
288
  end
242
289
  end
243
290
 
244
- def relativize(path, project_root)
245
- absolute = File.absolute_path(path.to_s)
246
- root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
247
- absolute.start_with?(root) ? absolute[root.length..] : absolute
248
- end
249
-
250
291
  # Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
251
292
  # `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
252
293
  # its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
@@ -261,19 +302,10 @@ module Rigor
261
302
  effects: proven.to_a,
262
303
  declared: declared.excluding_subsumed_by(proven).to_a,
263
304
  exhaustive: exhaustive,
264
- unresolved: exhaustive ? [].freeze : render_causes(causes)
305
+ unresolved: exhaustive ? 0 : render_causes(causes).length
265
306
  )
266
307
  end
267
308
 
268
- # `unresolved:` says why the row is not exhaustive, from the closed taint-cause enum of
269
- # `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`. The
270
- # design note sketched this field as "call names"; the collector keeps causes rather than names,
271
- # and for the causes that have one the detail already IS the call name.
272
- def render_causes(causes)
273
- causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
274
- .uniq.sort.freeze
275
- end
276
-
277
309
  # A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
278
310
  # accessors that join it when discovery synthesises them — carries no information a reviewer acts
279
311
  # on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
@@ -107,6 +107,7 @@ module Rigor
107
107
  @undischarged = undischarged
108
108
  @added_symbols = 0
109
109
  @removed_symbols = 0
110
+ @suppressed = 0
110
111
  @events = build_events.freeze
111
112
  freeze
112
113
  end
@@ -123,9 +124,16 @@ module Rigor
123
124
  end
124
125
 
125
126
  # Renames are a removal plus an addition and are never reported as a lost effect; the footer is
126
- # where the reviewer sees that the two counts balance.
127
+ # where the reviewer sees that the two counts balance. `suppressed` is the per-symbol events a
128
+ # regeneration event withheld — zero on an ordinary comparison.
127
129
  def footer
128
- { added_symbols: @added_symbols, removed_symbols: @removed_symbols }
130
+ { added_symbols: @added_symbols, removed_symbols: @removed_symbols, suppressed: @suppressed }
131
+ end
132
+
133
+ # Whether the two sides were computed under different rules (#434). The per-symbol comparison is
134
+ # then meaningless rather than merely noisy, which is why {#build_events} withholds it.
135
+ def regeneration?
136
+ @events.any? { |event| event.category == REGENERATION }
129
137
  end
130
138
 
131
139
  def events_for(table)
@@ -145,10 +153,25 @@ module Rigor
145
153
  ADDITIVE_CATEGORIES.include?(event.category)
146
154
  end
147
155
 
156
+ # A regeneration event withholds the per-symbol comparison rather than printing it (#434).
157
+ #
158
+ # The header says the record was written under different rules, so the two sides are not
159
+ # comparable — a claim this class already makes in its own documentation and then contradicted by
160
+ # emitting every row anyway. On redmine a moved `config_digest:` produced one regeneration line
161
+ # followed by 482 `-symbol` lines, none of which was a review signal: they say the recorded set was
162
+ # computed differently, which the header already said once.
163
+ #
164
+ # The table comparison still RUNS, because its per-symbol counters are what tell the reader the
165
+ # scale of what is withheld. Only the events are dropped.
148
166
  def build_events
149
167
  return [missing_snapshot_event] if @recorded.nil?
150
168
 
151
- header_events + TABLES.flat_map { |table| table_events(table) }
169
+ header = header_events
170
+ table = TABLES.flat_map { |name| table_events(name) }
171
+ return table if header.empty?
172
+
173
+ @suppressed = table.length
174
+ header
152
175
  end
153
176
 
154
177
  def missing_snapshot_event
@@ -65,6 +65,13 @@ module Rigor
65
65
  registries = collect_trait_registries(plugin_registry)
66
66
  nested_templates = collect_nested_class_templates(plugin_registry)
67
67
  return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
68
+ # Tier B alone cannot emit without an environment: every trait entry — the direct route and the
69
+ # concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
70
+ # answers `[]` for a nil environment. The production pre-pass passes `environment: nil` (#476),
71
+ # so a project whose only contributing plugin registers trait registries (rigor-devise on a
72
+ # Rails app) would otherwise pay a whole-project parse to build a provably empty index. This
73
+ # gate mirrors that nil guard and MUST be removed in the change that threads a real environment.
74
+ return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
68
75
 
69
76
  asts = parse_paths(paths, buffer: buffer)
70
77
  hierarchy = build_hierarchy(asts)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rbs"
4
4
 
5
+ require_relative "../effects/signature_sources"
5
6
  require_relative "../rbs_extended"
6
7
  require_relative "reporter"
7
8
 
@@ -50,11 +51,19 @@ module Rigor
50
51
  # `.rbs` files, plus the virtual entries rbs-inline and plugin `source_rbs` synthesis contribute.
51
52
  # @param registry [Rigor::Effects::Registry] the vocabulary an unknown label is judged against.
52
53
  # @return [Result]
54
+ #
55
+ # The `ANNOTATION_HINT` routing test runs here, before any parse: a source with no honoured
56
+ # payload can contribute neither an envelope nor an unresolved report, so a signature tree with
57
+ # no effect annotation is answered by one regex per file — which is what that hint's own
58
+ # documentation promises, and what every warm envelope-lane run was paying a full
59
+ # `RBS::Parser.parse_signature` per file for.
53
60
  def scan(sources:, registry:)
54
61
  reporter = Reporter.new
55
62
  methods = {}
56
63
  classes = {}
57
64
  sources.each do |name, content|
65
+ next unless Effects::SignatureSources::ANNOTATION_HINT.match?(content)
66
+
58
67
  declarations(name, content).each do |decl|
59
68
  walk(decl, [], methods, classes, registry, reporter)
60
69
  end