rigortype 0.3.2 → 0.3.4

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 (179) 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 +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  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 +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "label_set"
6
+
7
+ module Rigor
8
+ module Effects
9
+ # Argument-dependent narrowing for catalogue rows (ADR-103 WD3; the row's `narrow:` names one of
10
+ # these handlers, and `data/effects/core.yml` says why each row wants one).
11
+ #
12
+ # A handler reads **the call's own argument literals and nothing else**. There is no dataflow here
13
+ # on purpose: the effect scan is observational (it never asks the typer for anything it did not
14
+ # already decide), and a narrowing that depended on inference would make the catalogue's answer a
15
+ # function of analysis quality rather than of the source in front of it.
16
+ #
17
+ # Every handler is total and answers an **upper bound**: when the literal does not settle the
18
+ # question it returns the row's parent label, never a guess. `open(path)` with a computed argument
19
+ # is `io` — file, pipe or URI — and that is the honest reading of the classic pipe-injection shape.
20
+ #
21
+ # Handlers are module functions dispatched by a `case`, deliberately not a Hash of lambdas: the
22
+ # catalogue is loaded once per process and inherited across the fork pool, and a table of procs is
23
+ # neither Marshal-clean nor shareable.
24
+ module Narrowing
25
+ IO_ANY = LabelSet.new(["io"]).freeze
26
+ FS = LabelSet.new(["io.fs"]).freeze
27
+ FS_READ = LabelSet.new(["io.fs.read"]).freeze
28
+ FS_WRITE = LabelSet.new(["io.fs.write"]).freeze
29
+ FS_READ_WRITE = LabelSet.new(["io.fs.read", "io.fs.write"]).freeze
30
+ PROCESS = LabelSet.new(["io.process"]).freeze
31
+ HTTP = LabelSet.new(["io.net.http"]).freeze
32
+ TIME = LabelSet.new(["nondet.time"]).freeze
33
+ DB = LabelSet.new(["io.db"]).freeze
34
+ DB_READ = LabelSet.new(["io.db.read"]).freeze
35
+ DB_WRITE = LabelSet.new(["io.db.write"]).freeze
36
+ DB_TRANSACTION = LabelSet.new(["io.db.transaction"]).freeze
37
+ RANDOM = LabelSet.new(["nondet.random"]).freeze
38
+ NONE = LabelSet::EMPTY
39
+
40
+ # Ruby's file modes, minus the encoding suffix (`"r:UTF-8"`) and the `b` / `t` flags. `"r"` is the
41
+ # only pure read; every `+` form reads and writes.
42
+ WRITE_MODES = %w[w a w+ a+ r+].freeze
43
+ private_constant :WRITE_MODES
44
+
45
+ # SQL verbs, by what they do to the database. `WITH` heads a CTE whose tail may be either, so it
46
+ # deliberately does not narrow. Everything not listed answers `io.db`.
47
+ SQL_READ_VERBS = %w[SELECT SHOW EXPLAIN DESCRIBE DESC PRAGMA].freeze
48
+ SQL_WRITE_VERBS = %w[
49
+ INSERT UPDATE DELETE REPLACE UPSERT MERGE TRUNCATE
50
+ CREATE ALTER DROP RENAME COMMENT GRANT REVOKE REINDEX VACUUM ANALYZE COPY
51
+ ].freeze
52
+ SQL_TRANSACTION_VERBS = %w[BEGIN START COMMIT ROLLBACK SAVEPOINT RELEASE SET LOCK].freeze
53
+ private_constant :SQL_READ_VERBS, :SQL_WRITE_VERBS, :SQL_TRANSACTION_VERBS
54
+
55
+ HANDLERS = %w[kernel_open file_open pathname_open time_new random_new uri_open sql_verb].freeze
56
+
57
+ module_function
58
+
59
+ def known?(name)
60
+ HANDLERS.include?(name)
61
+ end
62
+
63
+ # The labels `name`'s handler reads off `node`. An unknown handler name answers nil, which the
64
+ # catalogue loader rejects at load time rather than at call time.
65
+ def apply(name, node)
66
+ case name
67
+ when "kernel_open" then kernel_open(node)
68
+ when "file_open" then file_open(node)
69
+ when "pathname_open" then pathname_open(node)
70
+ when "time_new" then time_new(node)
71
+ when "random_new" then random_new(node)
72
+ when "uri_open" then uri_open(node)
73
+ when "sql_verb" then sql_verb(node)
74
+ end
75
+ end
76
+
77
+ # `connection.execute(sql)` / `exec_query` / `select_all` — the SQL string's leading verb settles the
78
+ # direction, which is the argument-dependent narrowing of the design note § 4 applied to SQL. It is
79
+ # the one place a raw-SQL escape hatch can still be read precisely, and the shape that matters most —
80
+ # `execute("UPDATE …")` inside a method whose envelope says `io.db.read` — is exactly the one a
81
+ # literal answers.
82
+ #
83
+ # Interpolation is fine as long as the VERB is written out: `execute("SELECT * FROM #{table}")`
84
+ # narrows, because {literal_prefix} keeps the leading literal run. A fully computed string does not,
85
+ # and answers the parent `io.db`.
86
+ def sql_verb(node)
87
+ head = literal_prefix(argument(node, 0))
88
+ return DB if head.nil?
89
+
90
+ verb = head[/[A-Za-z]+/]&.upcase
91
+ return DB if verb.nil?
92
+ return DB_READ if SQL_READ_VERBS.include?(verb)
93
+ return DB_WRITE if SQL_WRITE_VERBS.include?(verb)
94
+ return DB_TRANSACTION if SQL_TRANSACTION_VERBS.include?(verb)
95
+
96
+ DB
97
+ end
98
+
99
+ # `Kernel#open` — a path, a `|command` pipe, or (with open-uri loaded) a URI. A literal leading
100
+ # `|` is the pipe form; anything else literal is a path, whose direction the mode literal decides.
101
+ def kernel_open(node)
102
+ target = literal_prefix(argument(node, 0))
103
+ return IO_ANY if target.nil?
104
+ return PROCESS if target.start_with?("|")
105
+
106
+ mode_labels(node, 1)
107
+ end
108
+
109
+ # `File.open(path, mode)` — the mode literal decides the direction. An ABSENT mode is not an
110
+ # unknown one: Ruby's default is `"r"`, so it reads. A mode the call computes — or an integer flag
111
+ # such as `File::RDWR`, which the scan deliberately does not resolve — is genuinely unknown and
112
+ # answers the subsystem parent.
113
+ def file_open(node)
114
+ mode_labels(node, 1)
115
+ end
116
+
117
+ # `Pathname#open(mode)` — the same reading one argument to the left, because the receiver is the
118
+ # path.
119
+ def pathname_open(node)
120
+ mode_labels(node, 0)
121
+ end
122
+
123
+ # `Time.new` — with no positional arguments it is `Time.now`; with any it constructs from them and
124
+ # consults no clock. Keyword arguments (`in:`) do not count: `Time.new(in: "+09:00")` is still now.
125
+ def time_new(node)
126
+ positional_count(node).zero? ? TIME : NONE
127
+ end
128
+
129
+ # `Random.new` — a seed argument makes the generator reproducible from the source; without one it
130
+ # draws the seed from platform entropy.
131
+ def random_new(node)
132
+ positional_count(node).zero? ? RANDOM : NONE
133
+ end
134
+
135
+ # `URI.open` / `OpenURI.open_uri` — the scheme literal decides the subsystem. A bare path (no
136
+ # `scheme://`) is open-uri's filesystem fallback; a scheme nobody rowed answers the parent.
137
+ def uri_open(node)
138
+ target = literal_prefix(argument(node, 0))
139
+ return IO_ANY if target.nil?
140
+ return HTTP if target.start_with?("http://", "https://")
141
+ return FS_READ if target.start_with?("file://")
142
+ return IO_ANY if target.include?("://")
143
+
144
+ FS_READ
145
+ end
146
+
147
+ # The direction a mode argument proves. Three states, and the middle one is the reason this is not
148
+ # a two-way test: **absent** is Ruby's `"r"` default, **present but unreadable** is genuinely
149
+ # unknown and answers the subsystem parent, and **a literal** narrows.
150
+ def mode_labels(node, index)
151
+ mode = argument(node, index) || keyword_argument(node, "mode")
152
+ return FS_READ if mode.nil?
153
+
154
+ canonical = string_literal(mode)&.[](/\A[rwa]\+?/)
155
+ return FS if canonical.nil?
156
+ return FS_READ unless WRITE_MODES.include?(canonical)
157
+
158
+ canonical.end_with?("+") ? FS_READ_WRITE : FS_WRITE
159
+ end
160
+
161
+ def argument(node, index)
162
+ positional(node)[index]
163
+ end
164
+
165
+ def positional_count(node)
166
+ positional(node).length
167
+ end
168
+
169
+ def positional(node)
170
+ node.arguments&.arguments&.grep_v(Prism::KeywordHashNode) || []
171
+ end
172
+
173
+ def keyword_argument(node, name)
174
+ hash = node.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
175
+ pair = hash&.elements&.find do |element|
176
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
177
+ element.key.unescaped == name
178
+ end
179
+ pair&.value
180
+ end
181
+
182
+ def string_literal(node)
183
+ node.unescaped if node.is_a?(Prism::StringNode)
184
+ end
185
+
186
+ # The literal head of a string argument: the whole thing for a plain literal, and the leading
187
+ # literal run for an interpolated one. `open("|#{cmd}")` and `URI.open("https://#{host}/x")` are
188
+ # the shapes that matter — the part that decides the subsystem is written out even when the rest
189
+ # is computed.
190
+ def literal_prefix(node)
191
+ return node.unescaped if node.is_a?(Prism::StringNode)
192
+ return nil unless node.is_a?(Prism::InterpolatedStringNode)
193
+
194
+ head = node.parts.first
195
+ head.is_a?(Prism::StringNode) ? head.unescaped : nil
196
+ end
197
+
198
+ private_class_method :sql_verb, :mode_labels, :argument, :positional_count, :positional,
199
+ :keyword_argument, :string_literal, :literal_prefix
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Effects
5
+ # Where one bundle of labels in a summary came from (ADR-103 WD14).
6
+ #
7
+ # An origin is the pair of **what** coloured the labels and **which source** did the colouring, and it
8
+ # is deliberately **line-free**: two `puts` calls in one method share one origin, so a summary is stable
9
+ # under a line move and a snapshot diff shows a change of behaviour rather than a change of formatting.
10
+ # Call sites are kept separately, per run, for the report only.
11
+ #
12
+ # `name` is the callee key (`Kernel#puts`, `Time.now`) for a catalogued row, and the construct's name
13
+ # (`xstring`, `gvar-write`) for a language construct. `source` says which of the two it is, because the
14
+ # two namespaces overlap in principle and a later slice discharges policy per origin.
15
+ class Origin < Data.define(:name, :source)
16
+ # A Ruby language construct — backticks, a `$gvar` write, an `@ivar` write (§ 5.1 of the design note).
17
+ CONSTRUCT = :construct
18
+ # A row of the built-in effect catalogue ({Catalog}; `data/effects/core.yml` from #380).
19
+ CATALOGUE = :catalogue
20
+ # A row of the project's own `effects.attribution:` table — a claim about code Rigor did not
21
+ # analyse. Its labels land in the summary's DECLARED lane and never in the proven one, and the site
22
+ # keeps its `plugin-attribution` taint (ADR-103 WD6: "declared this, and possibly more").
23
+ ATTRIBUTION = :attribution
24
+ # An {Envelope} the callee's own declaration carries — a project annotation, an
25
+ # `effects.envelopes:` convention, or an accepted signature's `%a{…}` (#386). Its labels land in
26
+ # the DECLARED lane, like an attribution's, but unlike an attribution the bound is *checked* (or
27
+ # trusted at the type tier), so the site is exhaustive rather than tainted: ADR-103 WD6's
28
+ # discharging strata. `name` is the callee key the bound was resolved for.
29
+ ENVELOPE = :envelope
30
+ # A row of a loaded plugin's `effect_attributions:` — what the plugin that models a framework says a
31
+ # call into it does (#387; ADR-103 WD6 / WD10). Its labels land in the DECLARED lane, like an
32
+ # attribution's and an envelope's, and which of those two it behaves like is decided by the plugin's
33
+ # standing: a **first-party bundled** plugin's row discharges, so the site is exhaustive, exactly as
34
+ # an accepted signature's `%a{…}` is; a third-party plugin's does not, and the site keeps its
35
+ # `plugin-attribution` taint. One origin source for both, because the label came from the same place
36
+ # either way and `explain` should say so.
37
+ PLUGIN = :plugin
38
+
39
+ def self.construct(name)
40
+ new(name: name.to_s, source: CONSTRUCT)
41
+ end
42
+
43
+ def self.catalogue(name)
44
+ new(name: name.to_s, source: CATALOGUE)
45
+ end
46
+
47
+ def self.attribution(name)
48
+ new(name: name.to_s, source: ATTRIBUTION)
49
+ end
50
+
51
+ def self.envelope(name)
52
+ new(name: name.to_s, source: ENVELOPE)
53
+ end
54
+
55
+ def self.plugin(name)
56
+ new(name: name.to_s, source: PLUGIN)
57
+ end
58
+
59
+ # The rendering the report and the JSON payload key on. Sorted lexicographically, so `catalogue:`
60
+ # rows group before `construct:` ones and the output is deterministic.
61
+ def to_s
62
+ "#{source}:#{name}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effect_table"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # "Why does this entry point reach `io.net.http`?" — the shortest edge path from a method to the
8
+ # origin that introduced a label (ADR-103 WD7; design note § 9.4, "the review feature that pays for
9
+ # the fixpoint").
10
+ #
11
+ # OrdersController#create → OrderService#place → PaymentGateway#charge → Net::HTTP.get [io.net.http]
12
+ #
13
+ # A breadth-first walk over the {EffectTable}'s resolved edges, so the answer is a *shortest* path
14
+ # rather than whichever one a depth-first walk stumbled into first — a reviewer wants the tightest
15
+ # explanation available. Ties break on sorted key order, so two runs explain a change identically.
16
+ #
17
+ # The path ends at the **origin**, not at the last method: the catalogue row or language construct
18
+ # that actually coloured the label is what the reviewer is looking for, and the method that owns it
19
+ # is the hop before.
20
+ module PathFinder
21
+ # One explanation.
22
+ #
23
+ # `chain` are the project method keys walked, entry point first; `origin` is the callee key or
24
+ # construct name that introduced the label, or nil when no method on the path proves it directly
25
+ # (which happens only when the table is inconsistent, and is rendered as the method chain alone).
26
+ class Path < Data.define(:symbol, :label, :chain, :origin, :origin_source)
27
+ # The rendering the text form prints and the JSON payload carries as `path`.
28
+ def to_a
29
+ origin.nil? ? chain : chain + [origin]
30
+ end
31
+
32
+ def to_s
33
+ "#{to_a.join(' → ')} [#{label}]"
34
+ end
35
+ end
36
+
37
+ module_function
38
+
39
+ # The shortest path from `symbol` to a direct origin of `label`, or nil when the table proves the
40
+ # label nowhere reachable from it.
41
+ def shortest(table, symbol:, label:)
42
+ entry = table[symbol]
43
+ return nil if entry.nil?
44
+
45
+ walk(table, symbol, label)
46
+ end
47
+
48
+ # Every label of `symbol`'s transitive summary, explained. Sorted by label, so the output is stable.
49
+ def explain_all(table, symbol:)
50
+ entry = table[symbol]
51
+ return [] if entry.nil?
52
+
53
+ entry.proven.to_a.filter_map { |label| shortest(table, symbol: symbol, label: label) }
54
+ end
55
+
56
+ def walk(table, start, label)
57
+ queue = [[start]]
58
+ seen = { start => true }
59
+ until queue.empty?
60
+ trail = queue.shift
61
+ entry = table[trail.last]
62
+ next if entry.nil?
63
+
64
+ origin = direct_origin(entry, label)
65
+ if origin
66
+ return Path.new(symbol: start, label: label, chain: trail.freeze,
67
+ origin: origin.name, origin_source: origin.source)
68
+ end
69
+
70
+ entry.edges.each do |callee|
71
+ next unless seen[callee].nil?
72
+
73
+ seen[callee] = true
74
+ queue << (trail + [callee])
75
+ end
76
+ end
77
+ nil
78
+ end
79
+
80
+ # Which origin of this method's own summary carries the label. Sorted by rendering, so a method
81
+ # coloured by two rows explains through the same one every time.
82
+ def direct_origin(entry, label)
83
+ entry.direct.bundles.find { |_origin, labels| labels.include?(label) }&.first
84
+ end
85
+
86
+ private_class_method :walk, :direct_origin
87
+ end
88
+ end
89
+ end