rigortype 0.3.3 → 0.3.5

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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "renderable"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # Prints what `rigor effects explain` found: for a `reach:` change, the shortest edge path from the
10
+ # entry point to the origin that introduced the label; for a `methods:` change, the origin itself
11
+ # (ADR-103 WD7).
12
+ #
13
+ # reach:
14
+ # OrdersController#create → OrderService#place → PaymentGateway#charge → Net::HTTP.get [io.net.http]
15
+ # methods:
16
+ # PaymentGateway#charge [io.net.http] ← catalogue:Net::HTTP.get
17
+ #
18
+ # A `methods:` change has no path to walk — the label came from this method's own body — so what
19
+ # explains it is the origin: the catalogue row or language construct, and which of the two it was.
20
+ # Origins are line-free by design (a summary must be stable under a line move), so no position is
21
+ # printed; call sites are per-run data the report carries.
22
+ class EffectsExplainRenderer
23
+ include Renderable
24
+
25
+ # One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin.
26
+ Row = Data.define(:table, :symbol, :label, :path, :origin)
27
+
28
+ def initialize(out:)
29
+ @out = out
30
+ end
31
+
32
+ private
33
+
34
+ def render_text(rows)
35
+ if rows.empty?
36
+ @out.puts("Nothing to explain.")
37
+ return
38
+ end
39
+
40
+ rows.group_by(&:table).each do |table, group|
41
+ @out.puts("#{table}:")
42
+ group.each { |row| @out.puts(" #{render_row(row)}") }
43
+ end
44
+ end
45
+
46
+ def render_row(row)
47
+ return "#{row.path.join(' → ')} [#{row.label}]" unless row.path.empty?
48
+
49
+ origin = row.origin ? " ← #{row.origin}" : ""
50
+ "#{row.symbol} [#{row.label}]#{origin}"
51
+ end
52
+
53
+ def render_json(rows)
54
+ @out.puts(JSON.pretty_generate(
55
+ "paths" => rows.map do |row|
56
+ {
57
+ "table" => row.table,
58
+ "symbol" => row.symbol,
59
+ "label" => row.label,
60
+ "path" => row.path,
61
+ "origin" => row.origin
62
+ }
63
+ end
64
+ ))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "renderable"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # Prints an {EffectsReport}. Text is the review surface; JSON is what a bot reads.
10
+ #
11
+ # The text form says three things per method and nothing else: what it is proven to do, what a source
12
+ # Rigor trusts merely *claims* it does (`≤`, the declared lane), and whether the list is complete. The
13
+ # `…?` suffix is the exhaustiveness bit — "these effects, and possibly more" — and the indented lines
14
+ # under it are the closed-enum causes behind it. Deliberately not a diagnostic format: no severities,
15
+ # no positions, no exit-code weight.
16
+ class EffectsRenderer
17
+ include Renderable
18
+
19
+ def initialize(out:, why: false)
20
+ @out = out
21
+ @why = why
22
+ end
23
+
24
+ private
25
+
26
+ # The reason block is **collapsed to a count** by default (#434). It was 86.5 % of the bytes of a
27
+ # 31,191-line Redmine run, and it answers a question a reader asks about one row after reading many
28
+ # — so `--why` expands it, and the count is what stays on the line that made them curious.
29
+ def render_text(report)
30
+ report.rows.each do |row|
31
+ @out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{hedge(row)}")
32
+ next unless @why
33
+
34
+ row.causes.each { |cause, detail| @out.puts(" #{cause}#{" (#{detail})" if detail}") }
35
+ row.attribution.each { |origin, labels| @out.puts(" #{origin} → [#{labels.join(', ')}]") }
36
+ end
37
+ render_footer(report.totals)
38
+ end
39
+
40
+ def hedge(row)
41
+ return "" if row.exhaustive?
42
+ return " …?" if @why || row.causes.empty?
43
+
44
+ " …? (#{row.causes.length} #{row.causes.length == 1 ? 'reason' : 'reasons'}, --why)"
45
+ end
46
+
47
+ # The footer a 31,191-line report never had, and the reason it counts the two lanes apart: a
48
+ # declared label can never fail a build (ADR-103 § WD17), so a reader who sees one total cannot tell
49
+ # which half of the report is a policy surface and which half is a record to review the diff of.
50
+ def render_footer(totals)
51
+ return if totals.nil?
52
+
53
+ @out.puts("──")
54
+ @out.puts("#{totals.printed} of #{totals.units} units printed#{omitted(totals)}")
55
+ @out.puts("#{totals.proven} carry a proven label · #{totals.declared} carry a declared (≤) one " \
56
+ "· #{totals.exhaustive} are exhaustive")
57
+ end
58
+
59
+ # Two ways a row can be missing, counted apart because `--full` answers only one of them.
60
+ def omitted(totals)
61
+ parts = []
62
+ parts << "#{totals.omitted} omitted (--full)" if totals.omitted.positive?
63
+ parts << "#{totals.unselected} not selected" if totals.unselected.positive?
64
+ parts << "#{totals.truncated} cut by --limit" if totals.truncated.positive?
65
+ parts.empty? ? "" : "; #{parts.join(', ')}"
66
+ end
67
+
68
+ # `≤` is the lane's spelling everywhere in the model — an upper bound, not an observation — so the
69
+ # report writes it rather than inventing a second word for it.
70
+ def declared(row)
71
+ row.declared.empty? ? "" : " ≤ [#{row.declared.join(', ')}]"
72
+ end
73
+
74
+ def render_json(report)
75
+ payload = {
76
+ "methods" => report.rows.to_h do |row|
77
+ [row.key, {
78
+ "effects" => row.effects,
79
+ "declared" => row.declared,
80
+ "exhaustive" => row.exhaustive?,
81
+ "causes" => row.causes.map { |cause, detail| [cause, detail] },
82
+ "direct" => row.direct,
83
+ "attribution" => row.attribution
84
+ }]
85
+ end
86
+ }
87
+ payload["totals"] = report.totals.to_h.transform_keys(&:to_s) if report.totals
88
+ @out.puts(JSON.pretty_generate(payload))
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # The value `EffectsCommand` assembles and `EffectsRenderer` prints — one row per effect unit, already
6
+ # projected out of {Rigor::Effects::EffectTable} so the renderer never touches an engine value.
7
+ #
8
+ # Rows are sorted by key and every collection inside one is sorted, so two runs over the same tree
9
+ # print byte-identical output whether analysis ran sequentially or across the fork pool.
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
+
16
+ # One method's line in the report.
17
+ #
18
+ # `effects` is the transitive proven lane — this method's labels joined with every project method it
19
+ # reaches. `exhaustive` false reads "these effects, and possibly more", and `causes` says why.
20
+ # `direct` is what this method's own body contributed, per origin, which is the attributable half a
21
+ # snapshot records (#381).
22
+ #
23
+ # `declared` is the `≤` lane, transitive like `effects`: what a source Rigor trusts but did not
24
+ # verify *claims* this method reaches — today the project's `effects.attribution:` table (#385). It
25
+ # is printed apart from `effects` and never folded into it, because the two answer different
26
+ # questions: one is proven, the other is asserted. A declared label the proven lane already admits
27
+ # is dropped here, where output is rendered; the table keeps both lanes raw.
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
+
34
+ def exhaustive?
35
+ exhaustive
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
52
+ end
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
+
63
+ # Builds a report from an effect table. `full:` keeps the rows the report otherwise omits — an
64
+ # exhaustive method proving nothing beyond `mutate.local`, which is the reading of `%a{pure}`.
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
136
+ end
137
+ private_class_method :in_scope?
138
+
139
+ def self.row_for(entry)
140
+ Row.new(
141
+ key: entry.key,
142
+ effects: entry.proven.to_a,
143
+ declared: entry.rendered_declared.to_a,
144
+ exhaustive: entry.exhaustive?,
145
+ causes: entry.causes,
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
154
+ )
155
+ end
156
+ private_class_method :row_for
157
+
158
+ def empty?
159
+ rows.empty?
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+ require "optionparser"
5
+
6
+ require_relative "../analysis/runner"
7
+ require_relative "../cache/store"
8
+ require_relative "../configuration"
9
+ require_relative "../effects/discharge"
10
+ require_relative "../effects/entry_points"
11
+ require_relative "../effects/path_finder"
12
+ require_relative "../effects/snapshot"
13
+ require_relative "../effects/snapshot_diff"
14
+ require_relative "command"
15
+ require_relative "effects_diff_renderer"
16
+ require_relative "effects_explain_renderer"
17
+ require_relative "options"
18
+
19
+ module Rigor
20
+ class CLI
21
+ # ADR-103 WD7 — the snapshot verbs of `rigor effects`: `update`, `check`, `diff` and `explain`.
22
+ #
23
+ # rigor effects update # write .rigor-effects.yml; commit it
24
+ # rigor effects check # 0 fresh, 1 drift — the CI gate
25
+ # rigor effects diff --baseline PATH # the same comparison, never gating
26
+ # rigor effects explain --symbol KEY # the shortest edge path behind a reach change
27
+ #
28
+ # None of them emits a diagnostic or enters `rigor check`'s stream: drift between two observations by
29
+ # the same tool is 100 % precise, and whether it *matters* is the reviewer's judgment — which is
30
+ # exactly what makes it a review artefact rather than a finding
31
+ # ([ADR-102](../adr/102-unused-code-reachability-report.md)).
32
+ #
33
+ # **The verbs take no paths.** The report does (`rigor effects lib/foo.rb`), because a report is a
34
+ # view; a snapshot is a record of the whole project, and one written over a subset would read as a
35
+ # project where every other method vanished. The analysed set is always the configured `paths:`.
36
+ class EffectsSnapshotCommand < Command
37
+ VERBS = %w[update check diff explain].freeze
38
+
39
+ FORMATS = %w[text json].freeze
40
+ private_constant :FORMATS
41
+
42
+ # The verbs that read a committed record, and so accept `--baseline PATH` —
43
+ # `--baseline <(git show origin/main:.rigor-effects.yml)` is the bot form.
44
+ COMPARING_VERBS = %w[check diff explain].freeze
45
+ private_constant :COMPARING_VERBS
46
+
47
+ def initialize(argv:, verb:, out: $stdout, err: $stderr)
48
+ super(argv: argv, out: out, err: err)
49
+ @verb = verb
50
+ end
51
+
52
+ # @return [Integer] 0 fresh / written, 1 on drift under `check`, 64 on a usage error.
53
+ def run
54
+ options = parse_options
55
+ return usage_error("unsupported format: #{options.fetch(:format)}") unless
56
+ FORMATS.include?(options[:format])
57
+ return usage_error("rigor effects #{@verb} takes no paths; it records the configured paths:") unless
58
+ @argv.empty?
59
+
60
+ execute(options)
61
+ rescue OptionParser::ParseError => e
62
+ usage_error(e.message)
63
+ end
64
+
65
+ private
66
+
67
+ def execute(options)
68
+ configuration = Configuration.load(options.fetch(:config)).with_effects_enabled
69
+ current = build_snapshot(configuration, full: options.fetch(:full))
70
+ return write(configuration, current) if @verb == "update"
71
+
72
+ compare(configuration, current, options)
73
+ end
74
+
75
+ # ---- update -------------------------------------------------
76
+
77
+ # Always writes — there is no generate / regenerate split, because a snapshot has no "already
78
+ # exists" hazard: it records observations, so overwriting it is the whole point of running it.
79
+ def write(configuration, snapshot)
80
+ path = configuration.effects_snapshot_path
81
+ File.write(path, snapshot.to_yaml)
82
+ @err.puts("rigor: wrote #{path} (#{snapshot.methods.size} method(s), #{snapshot.reach.size} reach entr" \
83
+ "#{snapshot.reach.size == 1 ? 'y' : 'ies'})")
84
+ # #436 — the note names what to write, not only what is missing: the same per-project preset
85
+ # enumeration the unregistered-preset error uses, so the two can never disagree about which
86
+ # names this project's plugin set makes available.
87
+ if configuration.effects_snapshot_reach.empty?
88
+ @err.puts("rigor: note — `effects.snapshot.reach:` is empty, so the snapshot records `methods:` " \
89
+ "only — the direct half. Name your entry points to record what they cause: " \
90
+ "#{Effects::EntryPoints.availability}. The written file carries the same hint.")
91
+ end
92
+ 0
93
+ end
94
+
95
+ # ---- check / diff / explain ---------------------------------
96
+
97
+ def compare(configuration, current, options)
98
+ path = options.fetch(:baseline) || configuration.effects_snapshot_path
99
+ recorded = load_recorded(path)
100
+ return CLI::EXIT_USAGE if recorded == :error
101
+
102
+ discharge = options.fetch(:no_tolerated) ? Effects::Discharge.none : effect_discharge(configuration)
103
+ diff = Effects::SnapshotDiff.compare(
104
+ recorded: recorded, current: current,
105
+ tolerated: options.fetch(:no_tolerated) ? [] : configuration.effects_tolerated,
106
+ gate: configuration.effects_snapshot_gate,
107
+ strict_tolerated: options.fetch(:strict_tolerated),
108
+ undischarged: Effects::Snapshot.undischarged_index(
109
+ snapshot: current, table: @table, discharge: discharge
110
+ )
111
+ )
112
+ return explain(diff, options) if @verb == "explain"
113
+
114
+ EffectsDiffRenderer.new(out: @out, path: path).render(diff, format: options.fetch(:format))
115
+ @verb == "check" && diff.drift? ? 1 : 0
116
+ end
117
+
118
+ def effect_discharge(configuration)
119
+ Effects::Discharge.new(configuration.effects_tolerated)
120
+ end
121
+
122
+ # A missing snapshot is drift with a routed message, not an error: it is the state every project is
123
+ # in before its first `update`, and the fix is one command away.
124
+ def load_recorded(path)
125
+ return nil unless File.exist?(path)
126
+
127
+ Effects::Snapshot.load(path)
128
+ rescue Effects::Snapshot::ParseError => e
129
+ @err.puts("rigor: effect snapshot load failed: #{path}: #{e.message}")
130
+ :error
131
+ end
132
+
133
+ # ---- explain ------------------------------------------------
134
+
135
+ def explain(diff, options)
136
+ rows = options.fetch(:symbol) ? rows_for_symbol(options.fetch(:symbol)) : rows_for_changes(diff)
137
+ return CLI::EXIT_USAGE if rows == :unknown
138
+
139
+ EffectsExplainRenderer.new(out: @out).render(rows, format: options.fetch(:format))
140
+ 0
141
+ end
142
+
143
+ # A misspelled `--symbol` used to print `Nothing to explain.` and exit 0, which is exactly what a
144
+ # method with no effects prints — so a typo and a real answer were indistinguishable, and a script
145
+ # could not tell them apart at all (#435). It is a usage error now, with the nearest key offered:
146
+ # the key set is right there, and a method key is long enough to get wrong.
147
+ def rows_for_symbol(symbol)
148
+ entry = @table[symbol]
149
+ if entry.nil?
150
+ @err.puts("rigor: no effect unit named #{symbol}#{suggestion(symbol)}")
151
+ return :unknown
152
+ end
153
+
154
+ reach_rows(symbol, entry.proven.to_a) + method_rows(symbol, entry.direct.proven.to_a)
155
+ end
156
+
157
+ def suggestion(symbol)
158
+ nearest = ::DidYouMean::SpellChecker.new(dictionary: @table.keys).correct(symbol).first
159
+ nearest ? " — did you mean #{nearest}?" : ""
160
+ end
161
+
162
+ # Every label a change introduced, explained once: a `reach:` change gets its shortest edge path, a
163
+ # `methods:` change gets the origin in the method's own body.
164
+ def rows_for_changes(diff)
165
+ diff.events.flat_map do |event|
166
+ labels = labels_of(event)
167
+ next [] if labels.empty?
168
+
169
+ event.table == "reach" ? reach_rows(event.symbol, labels) : method_rows(event.symbol, labels)
170
+ end.uniq
171
+ end
172
+
173
+ def labels_of(event)
174
+ return [event.label] if event.label
175
+ return [] unless event.category == Effects::SnapshotDiff::SYMBOL_ADDED
176
+
177
+ entry = @table[event.symbol]
178
+ entry ? entry.proven.to_a : []
179
+ end
180
+
181
+ def reach_rows(symbol, labels)
182
+ labels.filter_map do |label|
183
+ path = Effects::PathFinder.shortest(@table, symbol: symbol, label: label)
184
+ next if path.nil?
185
+
186
+ EffectsExplainRenderer::Row.new(table: "reach", symbol: symbol, label: label,
187
+ path: path.to_a, origin: path.origin)
188
+ end
189
+ end
190
+
191
+ def method_rows(symbol, labels)
192
+ entry = @table[symbol]
193
+ return [] if entry.nil?
194
+
195
+ labels.filter_map do |label|
196
+ origin = entry.direct.bundles.find { |_origin, set| set.include?(label) }&.first
197
+ next if origin.nil?
198
+
199
+ EffectsExplainRenderer::Row.new(table: "methods", symbol: symbol, label: label,
200
+ path: [].freeze, origin: origin.to_s)
201
+ end
202
+ end
203
+
204
+ # ---- the run ------------------------------------------------
205
+
206
+ # Same run the report takes: collection forced on for this invocation, sequential, and going through
207
+ # the ADR-45 whole-run result cache like `rigor check` — the diagnostics entry plus the #382 effects
208
+ # sidecar keyed beside it, so `rigor effects check` after `rigor check` under a configured `effects:`
209
+ # block is a warm hit plus the fixpoint.
210
+ def build_snapshot(configuration, full:)
211
+ runner = Analysis::Runner.new(
212
+ configuration: configuration,
213
+ cache_store: Cache::Store.new(root: configuration.cache_path),
214
+ collect_stats: false,
215
+ workers: 0
216
+ )
217
+ runner.run(configuration.paths)
218
+ @table = runner.effect_table
219
+ Effects::Snapshot.build(
220
+ table: @table, configuration: configuration, sources: runner.effect_sources, full: full,
221
+ registry: Effects::Registry.for_configuration(configuration,
222
+ plugin_facts: runner.effect_plugin_facts)
223
+ )
224
+ end
225
+
226
+ # ---- options ------------------------------------------------
227
+
228
+ def parse_options
229
+ options = { config: nil, format: "text", full: false, baseline: nil, symbol: nil,
230
+ strict_tolerated: false, no_tolerated: false }
231
+ OptionParser.new do |opts|
232
+ opts.banner = "Usage: rigor effects #{@verb} [options]"
233
+ Options.add_config(opts, options)
234
+ add_common_options(opts, options)
235
+ add_comparison_options(opts, options) if COMPARING_VERBS.include?(@verb)
236
+ if @verb == "explain"
237
+ opts.on("--symbol=KEY", "Explain this unit instead of the changed ones") do |value|
238
+ options[:symbol] = value
239
+ end
240
+ end
241
+ end.parse!(@argv)
242
+ options
243
+ end
244
+
245
+ # The policy switches are accepted by every verb, `update` included, and there they are deliberately
246
+ # inert: the record is **undischarged**, so `update --no-tolerated-effects` and `update` write
247
+ # byte-identical files. A flag that changed the record would make the file a function of policy and
248
+ # a policy change indistinguishable from a code change.
249
+ def add_common_options(opts, options)
250
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
251
+ opts.on("--full", "Record every method, including the omitted trivial and synthesised ones") do
252
+ options[:full] = true
253
+ end
254
+ opts.on("--strict-tolerated", "Fail the gate on tolerated changes too (check / diff)") do
255
+ options[:strict_tolerated] = true
256
+ end
257
+ opts.on("--no-tolerated-effects", "Judge as if effects.tolerated: were empty (check / diff)") do
258
+ options[:no_tolerated] = true
259
+ end
260
+ end
261
+
262
+ def add_comparison_options(opts, options)
263
+ opts.on("--baseline=PATH", "Compare against PATH instead of effects.snapshot.path") do |value|
264
+ options[:baseline] = value
265
+ end
266
+ end
267
+
268
+ def usage_error(message)
269
+ @err.puts(message)
270
+ @err.puts("Usage: rigor effects #{@verb} [options]")
271
+ CLI::EXIT_USAGE
272
+ end
273
+ end
274
+ end
275
+ end