rigortype 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +73 -22
- data/docs/manual/03-configuration.md +19 -2
- data/docs/manual/04-diagnostics.md +15 -2
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +38 -1
- data/docs/manual/12-caching.md +40 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +678 -0
- data/docs/manual/README.md +7 -2
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +4 -4
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/reachability/graph.rb +30 -11
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +20 -4
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +163 -31
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +5 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +149 -10
- data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +33 -9
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +29 -2
- data/lib/rigor/effects/snapshot.rb +74 -26
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +10 -1
|
@@ -116,12 +116,20 @@ module Rigor
|
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def enter_def(node, prefix, singleton)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
own_singleton = singleton || !node.receiver.nil?
|
|
120
|
+
scan = add_unit(class_name_for(prefix), node.name.to_s, own_singleton, node.body, node.parameters)
|
|
121
|
+
harvest_def(prefix, node.name.to_s, own_singleton, scan) if @harvest && !prefix.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What the framework strategies need to know about a `def` the class body spelled out itself: that it
|
|
125
|
+
# exists, and whether it reaches `super`. `:defs` stays instance-only, because a mailer action is an
|
|
126
|
+
# instance method; `:units` is keyed by the suffix a synthetic unit key carries, so both sides of the
|
|
127
|
+
# `#` / `.` split are answerable (#440). A body the collector could not finish reads as delegating,
|
|
128
|
+
# which keeps the framework's claim — the fail-soft direction for an upper bound.
|
|
129
|
+
def harvest_def(prefix, name, singleton, scan)
|
|
130
|
+
entry = harvest_for(prefix)
|
|
131
|
+
entry[:defs] << name unless singleton
|
|
132
|
+
entry[:units]["#{singleton ? '.' : '#'}#{name}"] = scan.nil? || scan.delegates_upward?
|
|
125
133
|
end
|
|
126
134
|
|
|
127
135
|
# ADR-103 WD10 — a receiver-less call in a class body, recorded as `macro => [literal symbol
|
|
@@ -156,7 +164,7 @@ module Rigor
|
|
|
156
164
|
end
|
|
157
165
|
|
|
158
166
|
def harvest_for(prefix)
|
|
159
|
-
@harvest[class_name_for(prefix)] ||= { defs: [], macros: {}, uniqueness: false }
|
|
167
|
+
@harvest[class_name_for(prefix)] ||= { defs: [], units: {}, macros: {}, uniqueness: false }
|
|
160
168
|
end
|
|
161
169
|
|
|
162
170
|
# Files the units the framework contributes for each class this file declares. Runs after the walk,
|
|
@@ -168,7 +176,7 @@ module Rigor
|
|
|
168
176
|
@harvest.each do |class_name, entry|
|
|
169
177
|
FrameworkUnits.synthesize(
|
|
170
178
|
class_name: class_name, instance_methods: entry[:defs], macros: entry[:macros],
|
|
171
|
-
uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts
|
|
179
|
+
uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts, own_units: entry[:units]
|
|
172
180
|
).each { |key, summary, edges| merge_unit(key, summary, edges) }
|
|
173
181
|
end
|
|
174
182
|
end
|
|
@@ -176,6 +184,8 @@ module Rigor
|
|
|
176
184
|
# Scans one unit and files its summary, then recurses into the units its body declared. Fail-soft
|
|
177
185
|
# per unit (ADR-103 WD13): a unit the scanner cannot finish is recorded as non-exhaustive with
|
|
178
186
|
# `collector-error` and its siblings are unaffected.
|
|
187
|
+
#
|
|
188
|
+
# @return [UnitScan, nil] the finished scan, or nil when the unit failed soft
|
|
179
189
|
def add_unit(class_name, method_name, singleton, body, parameters)
|
|
180
190
|
key = "#{class_name}#{singleton ? '.' : '#'}#{method_name}"
|
|
181
191
|
names = parameter_names(parameters)
|
|
@@ -184,15 +194,17 @@ module Rigor
|
|
|
184
194
|
block_parameter: block_parameter_name(parameters),
|
|
185
195
|
owned_locals: LocalOwnership.owned(body, names), calls: @calls,
|
|
186
196
|
attribution: @attribution, envelopes: @envelopes, plugin_facts: @plugin_facts,
|
|
187
|
-
owner_class: class_name
|
|
197
|
+
owner_class: class_name, method_name: method_name
|
|
188
198
|
)
|
|
189
199
|
summary, edges = scan.run(body)
|
|
190
200
|
merge_unit(key, summary, edges)
|
|
191
201
|
scan.nested.each do |name, nested_singleton, nested_body, nested_parameters|
|
|
192
202
|
add_unit(class_name, name, singleton || nested_singleton, nested_body, nested_parameters)
|
|
193
203
|
end
|
|
204
|
+
scan
|
|
194
205
|
rescue StandardError
|
|
195
206
|
merge_unit(key, Summary.tainted("collector-error", method_name), [])
|
|
207
|
+
nil
|
|
196
208
|
end
|
|
197
209
|
|
|
198
210
|
# A receiver-less call in a class / module body that declares units or ancestry. Class bodies are
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
@@ -58,6 +69,22 @@ module Rigor
|
|
|
58
69
|
signature_paths.nil? || signature_paths.empty? ? DEFAULT_ROOTS : signature_paths
|
|
59
70
|
end
|
|
60
71
|
|
|
72
|
+
# The FIRST annotated virtual entry, as a `collect`-shaped one-element array (or an empty one).
|
|
73
|
+
#
|
|
74
|
+
# #441 — the analysis paths use this to hand the annotations-unchecked residual the inline stratum
|
|
75
|
+
# of a run that resolved an environment but never stored it. Reduced to one entry on purpose: the
|
|
76
|
+
# residual reports one annotation per run, so the carrier a run holds past the environment's own
|
|
77
|
+
# lifetime is a single synthesized buffer rather than the project's whole virtual tree.
|
|
78
|
+
#
|
|
79
|
+
# @param virtual_rbs [Array<Array(String, String)>, nil]
|
|
80
|
+
# @return [Array<Array(String, String)>] zero or one pair.
|
|
81
|
+
def annotated_carrier(virtual_rbs)
|
|
82
|
+
Array(virtual_rbs).each do |name, content|
|
|
83
|
+
return [[name.to_s, content.to_s]] if ANNOTATION_HINT.match?(content)
|
|
84
|
+
end
|
|
85
|
+
[].freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
61
88
|
# The first `[name, content, line]` carrying an effect annotation, or nil. Line numbers are
|
|
62
89
|
# 1-based and count into `content`, which for a `virtual:` buffer is the SYNTHESIZED text — the
|
|
63
90
|
# caller re-anchors it against the Ruby file when that matters.
|
|
@@ -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
|
-
|
|
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
|
|
51
|
-
#
|
|
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.
|
|
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:
|
|
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)
|
|
@@ -164,11 +188,7 @@ module Rigor
|
|
|
164
188
|
entries.flat_map do |entry|
|
|
165
189
|
next [entry] if EntryPoints.glob?(entry)
|
|
166
190
|
|
|
167
|
-
EntryPoints.
|
|
168
|
-
raise(EntryPoints::Error,
|
|
169
|
-
"effects.snapshot.reach names no registered entry-point preset: #{entry.inspect} " \
|
|
170
|
-
"(registered: #{EntryPoints.names.inspect}; a preset is named by the plugin that " \
|
|
171
|
-
"models the framework, so listing that plugin is what registers it)")
|
|
191
|
+
EntryPoints.resolve!(entry)
|
|
172
192
|
end.uniq.sort.freeze
|
|
173
193
|
end
|
|
174
194
|
|
|
@@ -189,6 +209,29 @@ module Rigor
|
|
|
189
209
|
parse(File.read(path, encoding: "UTF-8"))
|
|
190
210
|
end
|
|
191
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
|
+
|
|
192
235
|
private
|
|
193
236
|
|
|
194
237
|
def parse_header(data)
|
|
@@ -245,12 +288,6 @@ module Rigor
|
|
|
245
288
|
end
|
|
246
289
|
end
|
|
247
290
|
|
|
248
|
-
def relativize(path, project_root)
|
|
249
|
-
absolute = File.absolute_path(path.to_s)
|
|
250
|
-
root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
|
|
251
|
-
absolute.start_with?(root) ? absolute[root.length..] : absolute
|
|
252
|
-
end
|
|
253
|
-
|
|
254
291
|
# Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
|
|
255
292
|
# `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
|
|
256
293
|
# its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
|
|
@@ -265,19 +302,10 @@ module Rigor
|
|
|
265
302
|
effects: proven.to_a,
|
|
266
303
|
declared: declared.excluding_subsumed_by(proven).to_a,
|
|
267
304
|
exhaustive: exhaustive,
|
|
268
|
-
unresolved: exhaustive ?
|
|
305
|
+
unresolved: exhaustive ? 0 : render_causes(causes).length
|
|
269
306
|
)
|
|
270
307
|
end
|
|
271
308
|
|
|
272
|
-
# `unresolved:` says why the row is not exhaustive, from the closed taint-cause enum of
|
|
273
|
-
# `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`. The
|
|
274
|
-
# design note sketched this field as "call names"; the collector keeps causes rather than names,
|
|
275
|
-
# and for the causes that have one the detail already IS the call name.
|
|
276
|
-
def render_causes(causes)
|
|
277
|
-
causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
|
|
278
|
-
.uniq.sort.freeze
|
|
279
|
-
end
|
|
280
|
-
|
|
281
309
|
# A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
|
|
282
310
|
# accessors that join it when discovery synthesises them — carries no information a reviewer acts
|
|
283
311
|
# on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
|
|
@@ -335,6 +363,25 @@ module Rigor
|
|
|
335
363
|
# Renders the file. Hand-rolled rather than `YAML.dump`ed so the leading comment, the key order and
|
|
336
364
|
# the flow sequences are ours: two runs over one tree must be byte-identical, and a snapshot written
|
|
337
365
|
# by a pooled run must equal the sequential one bit for bit.
|
|
366
|
+
# The hint goes in the artefact, not only in the run's output (#436): `reach:` is the half of the
|
|
367
|
+
# snapshot that answers what an entry point *causes*, and a project that never configured one
|
|
368
|
+
# commits the less interesting half with no way to know it. The run also names the presets its own
|
|
369
|
+
# plugin set registered, which this file cannot — a committed artefact must not carry a list that
|
|
370
|
+
# goes stale when the plugin list changes.
|
|
371
|
+
EMPTY_REACH_NOTE = [
|
|
372
|
+
"",
|
|
373
|
+
"# `reach:` is empty. It records the TRANSITIVE footprint at your entry points — what a",
|
|
374
|
+
"# controller action or a job causes, rather than what its own body does — and it is the half of",
|
|
375
|
+
"# this file most worth reviewing. Fill it in by naming entry points in `.rigor.yml`:",
|
|
376
|
+
"#",
|
|
377
|
+
"# effects:",
|
|
378
|
+
"# snapshot:",
|
|
379
|
+
"# reach: [rails] # a preset a plugin registered, or a project-relative file glob",
|
|
380
|
+
"#",
|
|
381
|
+
"# `rigor effects update` prints the presets this project's plugins actually register."
|
|
382
|
+
].freeze
|
|
383
|
+
private_constant :EMPTY_REACH_NOTE
|
|
384
|
+
|
|
338
385
|
def to_yaml
|
|
339
386
|
lines = [HEADER]
|
|
340
387
|
lines << "schema: #{@header.fetch('schema')}"
|
|
@@ -342,6 +389,7 @@ module Rigor
|
|
|
342
389
|
lines << "vocabulary: #{@header.fetch('vocabulary')}"
|
|
343
390
|
lines << "config_digest: #{scalar(@header.fetch('config_digest'))}"
|
|
344
391
|
render_table(lines, "methods", @methods)
|
|
392
|
+
lines.concat(EMPTY_REACH_NOTE) if @reach.empty?
|
|
345
393
|
render_table(lines, "reach", @reach)
|
|
346
394
|
"#{lines.join("\n")}\n"
|
|
347
395
|
end
|
|
@@ -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
|
-
|
|
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
|
|
@@ -110,9 +110,12 @@ module Rigor
|
|
|
110
110
|
# @param plugin_facts [PluginFacts] the loaded plugins' `effect_attributions:` (#387)
|
|
111
111
|
# @param owner_class [String, nil] the class this unit is defined on — the carrier an
|
|
112
112
|
# implicit-self call's envelope is looked up under, since the syntax spells `Kernel#name`
|
|
113
|
+
# @param method_name [String, nil] this unit's own selector — what a `super` in its body names as
|
|
114
|
+
# the target the propagator resolves above `owner_class` (#446). With no name to state, a `super`
|
|
115
|
+
# taints instead.
|
|
113
116
|
def initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
|
|
114
117
|
attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
115
|
-
plugin_facts: PluginFacts.empty, owner_class: nil)
|
|
118
|
+
plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil)
|
|
116
119
|
@singleton = singleton
|
|
117
120
|
@block_parameter = block_parameter
|
|
118
121
|
@calls = calls
|
|
@@ -120,6 +123,7 @@ module Rigor
|
|
|
120
123
|
@envelopes = envelopes
|
|
121
124
|
@plugin_facts = plugin_facts
|
|
122
125
|
@owner_class = owner_class
|
|
126
|
+
@method_name = method_name
|
|
123
127
|
@mutation = MutationClassifier.new(
|
|
124
128
|
singleton: singleton, parameters: parameters, owned_locals: owned_locals
|
|
125
129
|
)
|
|
@@ -128,12 +132,24 @@ module Rigor
|
|
|
128
132
|
@causes = []
|
|
129
133
|
@edges = []
|
|
130
134
|
@nested = []
|
|
135
|
+
@delegates_upward = false
|
|
131
136
|
end
|
|
132
137
|
|
|
133
138
|
# Units discovered inside this one — a nested `def`, or a `define_method` with a literal name whose
|
|
134
139
|
# block becomes that method's body. Each is `[name, singleton, body_node, parameters_node]`.
|
|
135
140
|
attr_reader :nested
|
|
136
141
|
|
|
142
|
+
# Whether this body reaches `super` — an override that delegates upward still runs whatever the
|
|
143
|
+
# superclass does. Nested `def`s are unit boundaries, so a `super` counted here is this unit's.
|
|
144
|
+
#
|
|
145
|
+
# A separate reading of the same node from the edge {#visit_super} records: what this bit answers is
|
|
146
|
+
# whether a framework's claim about the selector survives the class having written a body for it
|
|
147
|
+
# ({FrameworkUnits.replaced?}, #440), which is a question about the *class*, not about what the
|
|
148
|
+
# parent implementation does.
|
|
149
|
+
def delegates_upward?
|
|
150
|
+
@delegates_upward
|
|
151
|
+
end
|
|
152
|
+
|
|
137
153
|
# Walks `body` and returns `[Summary, edges]`.
|
|
138
154
|
def run(body)
|
|
139
155
|
walk(body)
|
|
@@ -214,9 +230,31 @@ module Rigor
|
|
|
214
230
|
when Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
|
|
215
231
|
Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
|
|
216
232
|
classify_mutation(node.receiver)
|
|
233
|
+
when Prism::SuperNode, Prism::ForwardingSuperNode
|
|
234
|
+
visit_super
|
|
217
235
|
end
|
|
218
236
|
end
|
|
219
237
|
|
|
238
|
+
# `super` in every shape — bare, `super()`, `super(args)`, and each of those inside a block or a
|
|
239
|
+
# rescue, since the walk descends into both (#446).
|
|
240
|
+
#
|
|
241
|
+
# It is a dispatch and must contribute like one. What the scan can settle from the syntax is the
|
|
242
|
+
# *identity* of the target — this unit's own class and selector — and nothing more: which definition
|
|
243
|
+
# sits above the class is a question about the whole project, so it goes out as an edge and the
|
|
244
|
+
# propagator resolves it against the merged ancestry, tainting when nothing there answers.
|
|
245
|
+
#
|
|
246
|
+
# A unit with no class or no name has no target to state. That is not a shape the scanner produces,
|
|
247
|
+
# and the taint is what a "cannot say" must read as rather than a silently dropped call.
|
|
248
|
+
def visit_super
|
|
249
|
+
@delegates_upward = true
|
|
250
|
+
return taint("unresolved-super", @method_name) if @owner_class.nil? || @method_name.nil?
|
|
251
|
+
|
|
252
|
+
@edges << FileCollection::Edge.new(
|
|
253
|
+
receiver_class: @owner_class, kind: @singleton ? :singleton : :instance,
|
|
254
|
+
selector: @method_name, self_call: true, super_call: true
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
220
258
|
def visit_call(node)
|
|
221
259
|
record = @calls[node]
|
|
222
260
|
attribute(node, record)
|
|
@@ -285,14 +323,30 @@ module Rigor
|
|
|
285
323
|
|
|
286
324
|
# The class whose call produced this receiver, for an `on_result:` row: the constant in
|
|
287
325
|
# `UserMailer.welcome(u).deliver_now`, the receiver class the typer had otherwise. Nil unless the
|
|
288
|
-
# receiver is itself a call
|
|
326
|
+
# receiver is itself a call — a bare local variable says nothing about what made it.
|
|
327
|
+
#
|
|
328
|
+
# Two shapes beyond the constant, because between them they are how mail is actually sent (#456):
|
|
329
|
+
#
|
|
330
|
+
# - **an implicit-self producing call** — `issue_add(user, issue).deliver_later`, which is what a
|
|
331
|
+
# mailer's own `def self.deliver_issue_add` wrapper looks like in Redmine, 97 call sites of it.
|
|
332
|
+
# The producer is the unit's own class, which is exactly what an unqualified name resolves
|
|
333
|
+
# against;
|
|
334
|
+
# - **a builder one link further out** — `AdminMailer.with(recipient: a).new_trends(…).deliver_later!`,
|
|
335
|
+
# ActionMailer's parameterized form and the spelling Mastodon uses. `with` returns a lazy
|
|
336
|
+
# `Parameterized::Mailer` no project declares a type for, so the constant is two calls away rather
|
|
337
|
+
# than one, and stopping at the first link found nothing.
|
|
338
|
+
#
|
|
339
|
+
# The walk is the same heuristic the `on_result:` row already is — "the class that produced this" —
|
|
340
|
+
# applied transitively rather than once. A row still has to match the producer's ancestry AND the
|
|
341
|
+
# selector, so a chain that ends somewhere unrelated contributes nothing rather than a wrong label.
|
|
289
342
|
def producer_class(receiver)
|
|
290
343
|
return nil unless receiver.is_a?(Prism::CallNode)
|
|
291
344
|
|
|
292
345
|
inner = receiver.receiver
|
|
293
|
-
return
|
|
346
|
+
return @owner_class if inner.nil?
|
|
294
347
|
|
|
295
|
-
Source::ConstantPath.qualified_name(inner) ||
|
|
348
|
+
Source::ConstantPath.qualified_name(inner) || producer_class(inner) ||
|
|
349
|
+
@calls[receiver]&.receiver_class
|
|
296
350
|
end
|
|
297
351
|
|
|
298
352
|
# The receiver expression as the syntax spells it — `"Rails.cache"`, `"self.flash.now"` — or nil when
|
|
@@ -379,12 +433,12 @@ module Rigor
|
|
|
379
433
|
# receiver mutation; `Time#localtime` is both). A **posture** states nothing about this selector,
|
|
380
434
|
# so the uncatalogued path's own mutation rule applies to it unchanged.
|
|
381
435
|
def claimed_by_catalogue?(node, record)
|
|
382
|
-
owner, singleton, implicit = catalog_target(node, record)
|
|
436
|
+
owner, singleton, implicit, named = catalog_target(node, record)
|
|
383
437
|
return false if owner.nil?
|
|
384
438
|
|
|
385
439
|
entry = Catalog.default.lookup(
|
|
386
440
|
owner, node.name.to_s, singleton: singleton, call_node: node,
|
|
387
|
-
posture: posture_allowed?(node, record, implicit)
|
|
441
|
+
posture: posture_allowed?(node, record, implicit, named)
|
|
388
442
|
)
|
|
389
443
|
return false if entry.nil?
|
|
390
444
|
|
|
@@ -426,8 +480,26 @@ module Rigor
|
|
|
426
480
|
# it would be a proven label with nothing proving it (the `dynamic-receiver` taint is the right
|
|
427
481
|
# answer, and the uncatalogued path records it);
|
|
428
482
|
# - `send` / `call`, whose own taints are the more specific reading ({DEFERRED_SELECTORS}).
|
|
429
|
-
|
|
430
|
-
|
|
483
|
+
#
|
|
484
|
+
# **Unless the syntax named the class** (#463). The `Dynamic` exclusion exists because a projected
|
|
485
|
+
# receiver class is a guess; a written constant path is not one, and {#catalog_target} already
|
|
486
|
+
# trusts it for the row lookup. Refusing the posture there made the catalogue's reach a function of
|
|
487
|
+
# whether the bundled rbs happens to ship a signature for the constant: `Net::HTTP.get` proves
|
|
488
|
+
# `io.net.http` and `Net::IMAP.new` proved nothing, because rbs ships `net-http` and not
|
|
489
|
+
# `net-imap`. That is why the `Net::FTP` row has never once fired, and why Redmine's IMAP and POP3
|
|
490
|
+
# pollers proved no network at all.
|
|
491
|
+
#
|
|
492
|
+
# The site is then **claimed**, and so discharges, like every other catalogue answer. That is not a
|
|
493
|
+
# softening of the `dynamic-receiver` rule but its premise failing: the taint says the class the
|
|
494
|
+
# typer projected is a guess, and on a constant path there is no projection — the receiver of
|
|
495
|
+
# `Net::IMAP.new` is that constant, exactly. What the posture asserts on top is the hand-audited
|
|
496
|
+
# claim the catalogue is made of, the same one `Socket#connect` rests on when the typer did name
|
|
497
|
+
# the receiver. `implicit` and {DEFERRED_SELECTORS} keep their exclusions, which are about what the
|
|
498
|
+
# syntax means rather than about what the typer knew.
|
|
499
|
+
def posture_allowed?(node, record, implicit, named)
|
|
500
|
+
return false if implicit || DEFERRED_SELECTORS.include?(node.name)
|
|
501
|
+
|
|
502
|
+
named || !record&.dynamic
|
|
431
503
|
end
|
|
432
504
|
|
|
433
505
|
def visit_uncatalogued(node, record, bound = nil)
|
|
@@ -550,18 +622,21 @@ module Rigor
|
|
|
550
622
|
# The class the catalogue would look this call up under, as `[owner, singleton, implicit_self]`.
|
|
551
623
|
# Spelled from the syntax where the syntax settles it and from the typer's receiver otherwise;
|
|
552
624
|
# `[nil, …]` when neither does.
|
|
625
|
+
# `[owner, singleton, implicit, named]`. `named` says the owner came from a **constant path the
|
|
626
|
+
# author wrote**, which is what licenses the posture on a receiver the typer could not resolve
|
|
627
|
+
# ({#posture_allowed?}).
|
|
553
628
|
def catalog_target(node, record)
|
|
554
629
|
receiver = node.receiver
|
|
555
|
-
return ["Kernel", false, true] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
630
|
+
return ["Kernel", false, true, false] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
556
631
|
|
|
557
632
|
constant = Source::ConstantPath.qualified_name(receiver)
|
|
558
|
-
return [constant, !Catalog.default.object_constant?(constant), false] if constant
|
|
633
|
+
return [constant, !Catalog.default.object_constant?(constant), false, true] if constant
|
|
559
634
|
return NO_TARGET if record.nil? || record.receiver_class.nil?
|
|
560
635
|
|
|
561
|
-
[record.receiver_class, record.kind == :singleton, false]
|
|
636
|
+
[record.receiver_class, record.kind == :singleton, false, false]
|
|
562
637
|
end
|
|
563
638
|
|
|
564
|
-
NO_TARGET = [nil, false, false].freeze
|
|
639
|
+
NO_TARGET = [nil, false, false, false].freeze
|
|
565
640
|
private_constant :NO_TARGET
|
|
566
641
|
|
|
567
642
|
def positional_arity(node)
|
|
@@ -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)
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -734,6 +734,10 @@ module Rigor
|
|
|
734
734
|
manifest.effect_entry_points
|
|
735
735
|
end
|
|
736
736
|
|
|
737
|
+
def effect_ancestry
|
|
738
|
+
manifest.effect_ancestry
|
|
739
|
+
end
|
|
740
|
+
|
|
737
741
|
# ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}. Memoised so the boundary's accumulated `FileEntry`
|
|
738
742
|
# rows persist across producer invocations within the same plugin instance and feed cache invalidation
|
|
739
743
|
# via `cache_for`.
|
data/lib/rigor/plugin/box.rb
CHANGED
|
@@ -32,14 +32,30 @@ module Rigor
|
|
|
32
32
|
# Requires `feature` into the shared box exactly once. Returns true when the feature is available in the
|
|
33
33
|
# box, false when it could not be loaded (the caller then declines — never falls back to loading into the
|
|
34
34
|
# main space, which would defeat the boundary).
|
|
35
|
+
#
|
|
36
|
+
# The rescue is `::`-qualified and covers `::ScriptError`, matching {Isolation::Process.run_worker_loop}:
|
|
37
|
+
# a failed box require raises `::LoadError` — a ScriptError, not a StandardError — and a plain
|
|
38
|
+
# `rescue StandardError` lets it escape and abort the whole run instead of the clean decline.
|
|
35
39
|
def require_feature(feature)
|
|
36
40
|
@required ||= {}
|
|
37
41
|
return @required[feature] if @required.key?(feature)
|
|
38
42
|
|
|
39
43
|
shared.require(feature)
|
|
40
44
|
@required[feature] = true
|
|
41
|
-
rescue StandardError
|
|
42
|
-
@required[feature] =
|
|
45
|
+
rescue ::StandardError, ::ScriptError
|
|
46
|
+
@required[feature] = eval_require(feature)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The gem-resolving fallback: `Ruby::Box#require` resolves against the raw `$LOAD_PATH` only (it
|
|
50
|
+
# calls the C-level require directly), so a target library installed as a gem is reachable only
|
|
51
|
+
# through the box's own RubyGems — the `Kernel#require` *inside* the box. `feature` is a fixed,
|
|
52
|
+
# plugin-declared name rendered via `String#inspect` (a safe Ruby literal), so the eval carries no
|
|
53
|
+
# free input. Returns false when the box cannot load the feature either way (the caller declines).
|
|
54
|
+
def eval_require(feature)
|
|
55
|
+
shared.eval("require #{feature.inspect}") # require "active_support/inflector"
|
|
56
|
+
true
|
|
57
|
+
rescue ::StandardError, ::ScriptError
|
|
58
|
+
false
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
# Evaluates `code` inside the shared box and returns the result across the box boundary. The caller MUST
|