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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +843 -0
- data/data/effects/registry.yml +67 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +311 -1
- data/docs/manual/03-configuration.md +84 -1
- data/docs/manual/04-diagnostics.md +14 -2
- data/docs/manual/16-rbs-extended-annotations.md +77 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +163 -1
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
- data/lib/rigor/analysis/runner.rb +420 -10
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +131 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +54 -0
- data/lib/rigor/cli/effects_report.rb +53 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +16 -0
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +296 -14
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +182 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +93 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +160 -0
- data/lib/rigor/effects/framework_units.rb +167 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +335 -0
- data/lib/rigor/effects/propagator.rb +275 -0
- data/lib/rigor/effects/registry.rb +193 -0
- data/lib/rigor/effects/scanner.rb +290 -0
- data/lib/rigor/effects/signature_sources.rb +74 -0
- data/lib/rigor/effects/snapshot.rb +380 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +38 -0
- data/lib/rigor/effects/unit_scan.rb +572 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +74 -17
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +134 -31
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/base.rb +24 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +103 -3
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +168 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor.rbs +21 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +83 -1
|
@@ -10,6 +10,9 @@ require_relative "../rbs_extended/reporter"
|
|
|
10
10
|
require_relative "../reflection"
|
|
11
11
|
require_relative "../type/combinator"
|
|
12
12
|
require_relative "../inference/coverage_scanner"
|
|
13
|
+
require_relative "../effects/attribution"
|
|
14
|
+
require_relative "../effects/collector"
|
|
15
|
+
require_relative "../effects/envelope_index"
|
|
13
16
|
require_relative "../inference/scope_indexer"
|
|
14
17
|
require_relative "../inference/method_dispatcher/file_folding"
|
|
15
18
|
require_relative "check_rules"
|
|
@@ -93,6 +96,14 @@ module Rigor
|
|
|
93
96
|
# normal `--workers N` run leaves it false and pays nothing (the recorder's disabled fast path).
|
|
94
97
|
@record_dependencies = record_dependencies
|
|
95
98
|
@file_dependencies = {}
|
|
99
|
+
# ADR-103 WD13 — effect collection, derived from the configuration exactly as `Runner` derives it,
|
|
100
|
+
# so a worker and the parent agree without a flag to keep in sync. Records marshal back through
|
|
101
|
+
# {#drain_effects}.
|
|
102
|
+
@record_effects = configuration.effects_enabled?
|
|
103
|
+
# ADR-103 WD6 / #385 — same table, derived the same way, for the same reason as `@record_effects`
|
|
104
|
+
# above: a worker and the parent agree without a flag to keep in sync.
|
|
105
|
+
@effect_attribution = Effects::Attribution.build(configuration.effects_attribution)
|
|
106
|
+
@file_effects = {}
|
|
96
107
|
# ADR-32 WD4 — full project file list (frozen Array<String>) for env-build-time invocation of any
|
|
97
108
|
# loaded plugin's `source_rbs_synthesizer` callable.
|
|
98
109
|
@source_files = source_files
|
|
@@ -144,14 +155,50 @@ module Rigor
|
|
|
144
155
|
# worker captures the file's cross-file reads for {#drain_dependencies}; the recorder's own disabled
|
|
145
156
|
# fast path makes the unwrapped case free.
|
|
146
157
|
def analyze(path)
|
|
147
|
-
return
|
|
158
|
+
return analyze_with_effects(path) unless @record_dependencies
|
|
148
159
|
|
|
149
160
|
diagnostics = nil
|
|
150
|
-
record = DependencyRecorder.record_for(path) { diagnostics =
|
|
161
|
+
record = DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
|
|
151
162
|
@file_dependencies[path] = record
|
|
152
163
|
diagnostics
|
|
153
164
|
end
|
|
154
165
|
|
|
166
|
+
# ADR-103 WD13 — the worker-side mirror of `Runner#analyze_with_effects`.
|
|
167
|
+
def analyze_with_effects(path)
|
|
168
|
+
return analyze_body(path) unless @record_effects
|
|
169
|
+
|
|
170
|
+
diagnostics = nil
|
|
171
|
+
collection = Effects::Collector.collect_for(
|
|
172
|
+
path, attribution: @effect_attribution, envelopes: effect_envelope_index,
|
|
173
|
+
plugin_facts: effect_plugin_facts
|
|
174
|
+
) do
|
|
175
|
+
diagnostics = analyze_body(path)
|
|
176
|
+
end
|
|
177
|
+
@file_effects[path] = collection
|
|
178
|
+
diagnostics
|
|
179
|
+
end
|
|
180
|
+
private :analyze_with_effects
|
|
181
|
+
|
|
182
|
+
# ADR-103 WD6 / #386 — the worker-side mirror of `Runner#effect_envelope_index`. Derived from the
|
|
183
|
+
# same configuration and the session's own environment, so a worker's `≤` lane is the one the
|
|
184
|
+
# parent would have computed for the same file.
|
|
185
|
+
# ADR-103 WD10 / #387 — the worker-side mirror of `Runner#effect_plugin_facts`. The superclass table
|
|
186
|
+
# arrives with the coordinator's scope seed, so a worker resolves an `ActiveRecord::Base` row through
|
|
187
|
+
# the same project ancestry the parent would have walked.
|
|
188
|
+
def effect_plugin_facts
|
|
189
|
+
@effect_plugin_facts ||= Effects::PluginFacts.build(
|
|
190
|
+
@plugin_registry, superclasses: @project_scope_seed[:discovered_superclasses] || {}
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
private :effect_plugin_facts
|
|
194
|
+
|
|
195
|
+
def effect_envelope_index
|
|
196
|
+
@effect_envelope_index ||= Effects::EnvelopeIndex.build(
|
|
197
|
+
configuration: @configuration, environment: @environment, plugin_facts: effect_plugin_facts
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
private :effect_envelope_index
|
|
201
|
+
|
|
155
202
|
def analyze_body(path)
|
|
156
203
|
parse_result = parse_source(path)
|
|
157
204
|
unless parse_result.errors.empty?
|
|
@@ -160,6 +207,7 @@ module Rigor
|
|
|
160
207
|
return parse_diagnostics(path, parse_result)
|
|
161
208
|
end
|
|
162
209
|
|
|
210
|
+
Effects::Collector.record_root(parse_result.value)
|
|
163
211
|
scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
|
|
164
212
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
165
213
|
# ADR-53 B4 — built-in collectors + plugin node rules share one walk.
|
|
@@ -204,6 +252,14 @@ module Rigor
|
|
|
204
252
|
@file_dependencies
|
|
205
253
|
end
|
|
206
254
|
|
|
255
|
+
# ADR-103 WD12 — the per-file {Effects::FileCollection}s this session collected (empty unless the
|
|
256
|
+
# configuration carries an `effects:` block). Marshal-clean by construction — frozen Hashes of
|
|
257
|
+
# Strings, `LabelSet`s of frozen Arrays, and `Data` edges — so the fork pool ships them back to the
|
|
258
|
+
# coordinator, which folds them into the runner's collection exactly as the sequential path would.
|
|
259
|
+
def drain_effects
|
|
260
|
+
@file_effects
|
|
261
|
+
end
|
|
262
|
+
|
|
207
263
|
private
|
|
208
264
|
|
|
209
265
|
# Mirrors {Runner#seed_project_scope}: applies the cross-file pre-pass discovery tables the
|
data/lib/rigor/bleeding_edge.rb
CHANGED
|
@@ -159,6 +159,28 @@ module Rigor
|
|
|
159
159
|
"the two corpora measured so far (Rigor's own `lib`, redmine `app/models`) it added no " \
|
|
160
160
|
"kills — every surviving breakage there is one the analyzer reports nowhere at all, not one " \
|
|
161
161
|
"it reports in a caller."
|
|
162
|
+
),
|
|
163
|
+
# ADR-103 WD15 — the owner-ruled preview of the v0.4.0 default. `:behaviour` because it moves no rule's
|
|
164
|
+
# severity; safe under the header comment's cache-identity rule for a reason specific to this feature
|
|
165
|
+
# rather than the generic WD13 argument, so it is spelled out here: `effects:` collection is
|
|
166
|
+
# OBSERVATIONAL (ADR-103 WD13) and the `effects:` block itself is deliberately absent from the
|
|
167
|
+
# diagnostics cache-key identity, so whether this feature is adopted never changes what a `rigor check`
|
|
168
|
+
# diagnostics entry holds — only whether the (separately identity-keyed) effects sidecar slot
|
|
169
|
+
# (`Effects::Identity.descriptor`, `lib/rigor/analysis/runner.rb`) gets populated. Envelope findings
|
|
170
|
+
# (`effect.envelope-exceeded`) are recomputed every run from that sidecar and are never written into
|
|
171
|
+
# the diagnostics entry (ADR-103 WD12), so a warm diagnostics HIT is correct whichever way this feature
|
|
172
|
+
# is set, exactly like every other `:behaviour` feature. Verified against `Runner#compute_run_diagnostics`
|
|
173
|
+
# / `#serve_effect_collections` on 2026-08-17.
|
|
174
|
+
Feature.new(
|
|
175
|
+
id: "effects-on-by-default",
|
|
176
|
+
kind: :behaviour,
|
|
177
|
+
summary: "With it, a project whose `.rigor.yml` carries no `effects:` key at all behaves as though " \
|
|
178
|
+
"it had written `effects: {}` — effect collection, the `rigor effects` verbs' cache sharing, " \
|
|
179
|
+
"and `effects.check` all turn on with every sub-key at its default. Writing `effects: false` " \
|
|
180
|
+
"explicitly still opts out, and a project that writes its own `effects:` block keeps that " \
|
|
181
|
+
"block's shape untouched either way. Graduates to default-on at v0.4.0 (owner ruling, " \
|
|
182
|
+
"2026-08-17): ADR-50 § WD7 reads \"at a major\" as \"at v0.4.0\" for this feature on the 0.x " \
|
|
183
|
+
"evaluation line. See ADR-103 § WD15 for the preconditions that gate the flip."
|
|
162
184
|
)
|
|
163
185
|
].freeze
|
|
164
186
|
|
|
@@ -51,8 +51,11 @@ module Rigor
|
|
|
51
51
|
# nil (a clean cold rebuild — no migration). 11: ADR-67 WD6c lift adds `param_table` (the inferred-param
|
|
52
52
|
# seed table the run's diagnostics were computed under, diffed on the next recheck to invalidate a
|
|
53
53
|
# callee whose seeds moved because a caller changed); a pre-11 blob mismatches the SCHEMA gate and
|
|
54
|
-
# loads as nil (a clean cold rebuild — no migration).
|
|
55
|
-
|
|
54
|
+
# loads as nil (a clean cold rebuild — no migration). 12: ADR-103 WD13 / issue #382 adds the effects
|
|
55
|
+
# sidecar — `effect_collections` (the per-file {Rigor::Effects::FileCollection}s a collecting run
|
|
56
|
+
# produced) and the `effects_identity` they were produced under; a pre-12 blob mismatches the SCHEMA
|
|
57
|
+
# gate and loads as nil (a clean cold rebuild — no migration).
|
|
58
|
+
SCHEMA = 12
|
|
56
59
|
|
|
57
60
|
# The persisted per-file state.
|
|
58
61
|
# `cache` maps an analyzed file to its diagnostics.
|
|
@@ -87,10 +90,22 @@ module Rigor
|
|
|
87
90
|
# recomputes the table fresh (the pre-pass is whole-project by design) and diffs it against this copy;
|
|
88
91
|
# a changed entry invalidates the callee's file and its symbol dependents. The types are Marshal-clean
|
|
89
92
|
# by the session's per-entry filter; a dropped entry re-checks its callee, the conservative direction.
|
|
93
|
+
# ADR-103 WD13 / issue #382 — the effects sidecar, ADR-46's half of "one cache, two identities, one
|
|
94
|
+
# extra slot":
|
|
95
|
+
# `effect_collections` maps an analyzed path to the {Rigor::Effects::FileCollection} that file
|
|
96
|
+
# contributed (`{}` when collection is off), so a recheck re-collects only the changed closure and
|
|
97
|
+
# serves the rest from here. The propagated table is NEVER stored — it is recomputed from the merged
|
|
98
|
+
# whole every run, because a leaf's summary reaches every caller and a stored table would have to be
|
|
99
|
+
# invalidated by all of them.
|
|
100
|
+
# `effects_identity` is {Rigor::Effects::Identity.digest} at the run that wrote them (nil when
|
|
101
|
+
# collection was off). It is a SEPARATE gate from the global `fingerprint`: the `effects:` block is
|
|
102
|
+
# deliberately absent from `Configuration#to_h`, so turning collection on invalidates no diagnostics,
|
|
103
|
+
# and a vocabulary / catalogue / `effects:` change must invalidate the summaries alone.
|
|
90
104
|
Payload = Data.define(:cache, :sources, :digests, :analyzed,
|
|
91
105
|
:symbol_sources, :ancestry_sources, :symbol_fingerprints,
|
|
92
106
|
:missing, :class_decls, :seed_bundles, :plugin_fact_digest,
|
|
93
|
-
:return_summaries, :param_table
|
|
107
|
+
:return_summaries, :param_table,
|
|
108
|
+
:effect_collections, :effects_identity)
|
|
94
109
|
|
|
95
110
|
# The global fingerprint that gates a snapshot load: a digest of the inputs whose change requires a full
|
|
96
111
|
# rebuild — the engine version + schema, the engine's own SOURCE when the version does not pin it, the
|
|
@@ -206,7 +221,9 @@ module Rigor
|
|
|
206
221
|
seed_bundles: data[:seed_bundles] || {},
|
|
207
222
|
plugin_fact_digest: data[:plugin_fact_digest],
|
|
208
223
|
return_summaries: data[:return_summaries] || {},
|
|
209
|
-
param_table: data[:param_table] || {}
|
|
224
|
+
param_table: data[:param_table] || {},
|
|
225
|
+
effect_collections: data[:effect_collections] || {},
|
|
226
|
+
effects_identity: data[:effects_identity]
|
|
210
227
|
)
|
|
211
228
|
end
|
|
212
229
|
private :payload_from
|
|
@@ -227,7 +244,9 @@ module Rigor
|
|
|
227
244
|
seed_bundles: payload.seed_bundles,
|
|
228
245
|
plugin_fact_digest: payload.plugin_fact_digest,
|
|
229
246
|
return_summaries: payload.return_summaries,
|
|
230
|
-
param_table: payload.param_table
|
|
247
|
+
param_table: payload.param_table,
|
|
248
|
+
effect_collections: payload.effect_collections,
|
|
249
|
+
effects_identity: payload.effects_identity
|
|
231
250
|
)
|
|
232
251
|
blob = Zlib::Deflate.deflate(raw)
|
|
233
252
|
tmp = "#{@path}.#{Process.pid}.tmp"
|
|
@@ -459,6 +459,11 @@ module Rigor
|
|
|
459
459
|
# key. `:unset` means "no flag — use the configured selection"; `true` adopts the whole overlay, `false`
|
|
460
460
|
# adopts none, and an Array of ids adopts only those (see `apply_bleeding_edge_override`).
|
|
461
461
|
bleeding_edge: :unset,
|
|
462
|
+
# ADR-103 WD1 / #385 — the effect-policy audit switch. Judges `effect.envelope-exceeded` as if
|
|
463
|
+
# `effects.tolerated:` were empty, so a project can see what its discharge policy is hiding
|
|
464
|
+
# without editing the policy. Judgment-time only: the run, its collection and its cache identity
|
|
465
|
+
# are identical either way.
|
|
466
|
+
no_tolerated_effects: false,
|
|
462
467
|
# Type-precision coverage block. Off by default — it is a second precision pass over the analyzed files (the
|
|
463
468
|
# same scan `rigor coverage` runs), so it is opt-in to keep the default check path's cost unchanged. When set,
|
|
464
469
|
# `--format json` gains a `coverage` object (scan_files + precision tiers) and the text output prints a
|
|
@@ -528,6 +533,10 @@ module Rigor
|
|
|
528
533
|
"ADR-50: ignore any configured bleeding_edge: selection for this run") do
|
|
529
534
|
options[:bleeding_edge] = false
|
|
530
535
|
end
|
|
536
|
+
opts.on("--no-tolerated-effects",
|
|
537
|
+
"ADR-103: check effect envelopes as if effects.tolerated: were empty") do
|
|
538
|
+
options[:no_tolerated_effects] = true
|
|
539
|
+
end
|
|
531
540
|
end
|
|
532
541
|
parser.parse!(@argv)
|
|
533
542
|
options
|
|
@@ -558,9 +567,12 @@ module Rigor
|
|
|
558
567
|
|
|
559
568
|
path = options.fetch(:config) || Configuration.discover
|
|
560
569
|
data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
|
|
570
|
+
# ADR-103 WD15 — captured before `DEFAULTS.merge` below folds "no `effects:` key" and "`effects:
|
|
571
|
+
# false`" into the same value; see `Configuration.load`'s identical capture.
|
|
572
|
+
effects_key_present = data.key?("effects")
|
|
561
573
|
data = data.dup
|
|
562
574
|
data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
|
|
563
|
-
Configuration.new(Configuration::DEFAULTS.merge(data))
|
|
575
|
+
Configuration.new(Configuration::DEFAULTS.merge(data), effects_key_present)
|
|
564
576
|
end
|
|
565
577
|
|
|
566
578
|
# ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge` CLI selection over the configured
|
|
@@ -35,7 +35,10 @@ module Rigor
|
|
|
35
35
|
cache_store: cache_store,
|
|
36
36
|
collect_stats: options.fetch(:stats),
|
|
37
37
|
workers: resolve_workers(options, configuration),
|
|
38
|
-
buffer: buffer
|
|
38
|
+
buffer: buffer,
|
|
39
|
+
# ADR-103 #385 — absent from `doctor`'s option hash and every other reuser's, so it defaults off
|
|
40
|
+
# for them without each having to declare a key about a feature they do not surface.
|
|
41
|
+
no_tolerated_effects: options.fetch(:no_tolerated_effects, false)
|
|
39
42
|
)
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "command"
|
|
9
|
+
require_relative "effects_renderer"
|
|
10
|
+
require_relative "effects_report"
|
|
11
|
+
require_relative "effects_snapshot_command"
|
|
12
|
+
require_relative "options"
|
|
13
|
+
|
|
14
|
+
module Rigor
|
|
15
|
+
class CLI
|
|
16
|
+
# ADR-103 — executes `rigor effects`.
|
|
17
|
+
#
|
|
18
|
+
# Two surfaces behind one command, the shape `rigor baseline` already has:
|
|
19
|
+
#
|
|
20
|
+
# - `rigor effects [PATH…]` — the **report**. Runs the same analysis `rigor check` runs, with effect
|
|
21
|
+
# collection on, and prints the resulting summaries instead of the diagnostic stream.
|
|
22
|
+
# - `rigor effects {update,check,diff,explain}` — the committed **effect snapshot** and its drift
|
|
23
|
+
# gate, in {EffectsSnapshotCommand}.
|
|
24
|
+
#
|
|
25
|
+
# Neither emits a diagnostic nor enters `rigor check`'s stream; the report always exits 0, and only
|
|
26
|
+
# `check` ever exits non-zero (1 on drift, 64 on a usage error).
|
|
27
|
+
#
|
|
28
|
+
# The command turns collection on for its own run by loading the project's configuration and enabling
|
|
29
|
+
# an implicit `effects: {}` when the file carries no `effects:` block — the ad-hoc mode ADR-103 WD14
|
|
30
|
+
# describes. A project that *does* configure `effects:` gets its own settings instead, and then this
|
|
31
|
+
# run shares the whole cache with `rigor check`: the diagnostics come from the ADR-45 run entry and the
|
|
32
|
+
# summaries from the effects sidecar keyed beside it (#382), so `rigor effects` after `rigor check` in
|
|
33
|
+
# the same job is a warm hit plus the fixpoint. The ad-hoc mode digests a different `effects:` block
|
|
34
|
+
# than the project's, so it shares the diagnostics entry and keys its own effects slot.
|
|
35
|
+
class EffectsCommand < Command
|
|
36
|
+
USAGE = "Usage: rigor effects [options] [paths]"
|
|
37
|
+
|
|
38
|
+
# @return [Integer] CLI exit status: 0 on success, 1 on `check` drift, 64 on a usage error.
|
|
39
|
+
def run
|
|
40
|
+
verb = @argv.first
|
|
41
|
+
return run_verb(verb) if EffectsSnapshotCommand::VERBS.include?(verb)
|
|
42
|
+
return print_help if %w[help --help -h].include?(verb)
|
|
43
|
+
|
|
44
|
+
run_report
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def run_verb(verb)
|
|
50
|
+
@argv.shift
|
|
51
|
+
EffectsSnapshotCommand.new(argv: @argv, verb: verb, out: @out, err: @err).run
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def print_help
|
|
55
|
+
@out.puts(help)
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def help
|
|
60
|
+
<<~HELP
|
|
61
|
+
#{USAGE}
|
|
62
|
+
|
|
63
|
+
With no subcommand, prints one line per method: its proven effect labels and whether that
|
|
64
|
+
list is exhaustive.
|
|
65
|
+
|
|
66
|
+
Subcommands (the committed effect snapshot, ADR-103 WD7):
|
|
67
|
+
update Write the snapshot to effects.snapshot.path. Commit it; review its diff.
|
|
68
|
+
check Recompute and compare; exits 1 on drift, 0 when fresh.
|
|
69
|
+
diff The same comparison, never gating.
|
|
70
|
+
explain The shortest edge path behind a reach change (--symbol KEY for one unit).
|
|
71
|
+
|
|
72
|
+
Run `rigor effects <subcommand> --help` for subcommand options.
|
|
73
|
+
HELP
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run_report
|
|
77
|
+
options = parse_options
|
|
78
|
+
return usage_error("unsupported format: #{options.fetch(:format)}") unless FORMATS.include?(options[:format])
|
|
79
|
+
|
|
80
|
+
configuration = Configuration.load(options.fetch(:config)).with_effects_enabled
|
|
81
|
+
table = analyze(configuration)
|
|
82
|
+
report = EffectsReport.build(table, full: options.fetch(:full))
|
|
83
|
+
EffectsRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
84
|
+
0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
FORMATS = %w[text json].freeze
|
|
88
|
+
private_constant :FORMATS
|
|
89
|
+
|
|
90
|
+
def parse_options
|
|
91
|
+
options = { config: nil, format: "text", full: false, no_tolerated: false }
|
|
92
|
+
OptionParser.new do |opts|
|
|
93
|
+
opts.banner = USAGE
|
|
94
|
+
Options.add_config(opts, options)
|
|
95
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
|
|
96
|
+
opts.on("--full", "List every method, including exhaustive ones with no effects beyond mutate.local") do
|
|
97
|
+
options[:full] = true
|
|
98
|
+
end
|
|
99
|
+
# Accepted here and deliberately inert, exactly as it is on `update`: the report is an
|
|
100
|
+
# observation, and observations are undischarged. Only a JUDGMENT reads `effects.tolerated:` —
|
|
101
|
+
# `rigor effects check` / `diff`, and `rigor check`'s envelope contract.
|
|
102
|
+
opts.on("--no-tolerated-effects", "Judge as if effects.tolerated: were empty (inert on the report)") do
|
|
103
|
+
options[:no_tolerated] = true
|
|
104
|
+
end
|
|
105
|
+
end.parse!(@argv)
|
|
106
|
+
options
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def usage_error(message)
|
|
110
|
+
@err.puts(message)
|
|
111
|
+
@err.puts(USAGE)
|
|
112
|
+
CLI::EXIT_USAGE
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Sequential and cache-backed, through the same ADR-45 whole-run result cache `rigor check` uses:
|
|
116
|
+
# the diagnostics entry serves the run and the #382 effects sidecar serves the collections, leaving
|
|
117
|
+
# only the fixpoint. Sequential is not a cache decision — the run-result cache declines pool mode —
|
|
118
|
+
# but a collecting run is pinned to the fork backend anyway, so `workers: 0` costs nothing here.
|
|
119
|
+
def analyze(configuration)
|
|
120
|
+
runner = Analysis::Runner.new(
|
|
121
|
+
configuration: configuration,
|
|
122
|
+
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
123
|
+
collect_stats: false,
|
|
124
|
+
workers: 0
|
|
125
|
+
)
|
|
126
|
+
runner.run(@argv.empty? ? configuration.paths : @argv)
|
|
127
|
+
runner.effect_table
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../effects/snapshot_diff"
|
|
6
|
+
require_relative "renderable"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
class CLI
|
|
10
|
+
# Prints a {Rigor::Effects::SnapshotDiff} — what `rigor effects check` and `rigor effects diff` show a
|
|
11
|
+
# reviewer (ADR-103 WD7).
|
|
12
|
+
#
|
|
13
|
+
# The text form is grouped by table and then by policy: real drift under `methods:` / `reach:`,
|
|
14
|
+
# policy-discharged drift under `tolerated:`, and a regeneration event — a record written by a
|
|
15
|
+
# different Rigor, vocabulary or `effects:` block — under `regeneration:` above both. It closes with
|
|
16
|
+
# the one line the whole workflow turns on: intent is expressed by regenerating and committing the
|
|
17
|
+
# file, not by annotating the code.
|
|
18
|
+
#
|
|
19
|
+
# It is deliberately not a diagnostic format: no severities, no positions, no exit-code weight of its
|
|
20
|
+
# own. The exit code comes from the gate.
|
|
21
|
+
class EffectsDiffRenderer
|
|
22
|
+
include Renderable
|
|
23
|
+
|
|
24
|
+
# How each category renders as the marker after the symbol. Steins' event vocabulary, made
|
|
25
|
+
# symmetric: `≤` is the declared lane, and a hedged removal says why it is hedged.
|
|
26
|
+
MARKERS = {
|
|
27
|
+
Effects::SnapshotDiff::LABEL_ADDED => "+ %<label>s",
|
|
28
|
+
Effects::SnapshotDiff::LABEL_REMOVED => "- %<label>s",
|
|
29
|
+
Effects::SnapshotDiff::DECLARED_ADDED => "≤+ %<label>s",
|
|
30
|
+
Effects::SnapshotDiff::DECLARED_REMOVED => "≤- %<label>s",
|
|
31
|
+
Effects::SnapshotDiff::MATERIALISED => "materialised %<label>s (declared → proven)",
|
|
32
|
+
Effects::SnapshotDiff::SYMBOL_ADDED => "+symbol %<detail>s",
|
|
33
|
+
Effects::SnapshotDiff::SYMBOL_REMOVED => "-symbol %<detail>s"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
HEDGED_REMOVAL = "-? %<label>s (current summary is not exhaustive)"
|
|
37
|
+
|
|
38
|
+
CLOSING_LINE = "Run `rigor effects update` and commit the result if this change is intended."
|
|
39
|
+
|
|
40
|
+
def initialize(out:, path:)
|
|
41
|
+
@out = out
|
|
42
|
+
@path = path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def render_text(diff)
|
|
48
|
+
if diff.fresh?
|
|
49
|
+
@out.puts("No effect drift against #{@path}.")
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@out.puts("Effect drift against #{@path}:")
|
|
54
|
+
%w[snapshot header methods reach].each { |table| render_section(table, diff.events_for(table)) }
|
|
55
|
+
render_section("tolerated", diff.tolerated_events, qualified: true)
|
|
56
|
+
render_footer(diff)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `qualified:` names each event's table inline, which the `tolerated:` heading needs: it pools events
|
|
60
|
+
# from both tables, and one symbol commonly appears in each.
|
|
61
|
+
def render_section(table, events, qualified: false)
|
|
62
|
+
return if events.empty?
|
|
63
|
+
|
|
64
|
+
@out.puts("")
|
|
65
|
+
@out.puts("#{section_name(table)}:")
|
|
66
|
+
events.each do |event|
|
|
67
|
+
suffix = qualified ? " (#{event.table})" : ""
|
|
68
|
+
@out.puts(" #{render_event(event)}#{suffix}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def section_name(table)
|
|
73
|
+
case table
|
|
74
|
+
when "header" then "regeneration"
|
|
75
|
+
when "snapshot" then "snapshot"
|
|
76
|
+
else table
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_event(event)
|
|
81
|
+
marker = marker_for(event)
|
|
82
|
+
event.symbol.nil? ? marker : "#{event.symbol} #{marker}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def marker_for(event)
|
|
86
|
+
return format(HEDGED_REMOVAL, label: event.label) if
|
|
87
|
+
event.category == Effects::SnapshotDiff::LABEL_REMOVED && event.hedged?
|
|
88
|
+
|
|
89
|
+
template = MARKERS[event.category]
|
|
90
|
+
return event.detail.to_s unless template
|
|
91
|
+
|
|
92
|
+
format(template, label: event.label.to_s, detail: event.detail.to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_footer(diff)
|
|
96
|
+
footer = diff.footer
|
|
97
|
+
@out.puts("")
|
|
98
|
+
unless footer[:added_symbols].zero? && footer[:removed_symbols].zero?
|
|
99
|
+
@out.puts("symbols: +#{footer[:added_symbols]} / -#{footer[:removed_symbols]} " \
|
|
100
|
+
"(a rename is one of each)")
|
|
101
|
+
end
|
|
102
|
+
@out.puts(CLOSING_LINE)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render_json(diff)
|
|
106
|
+
@out.puts(JSON.pretty_generate(
|
|
107
|
+
"fresh" => diff.fresh?,
|
|
108
|
+
"events" => diff.events.map(&:to_h),
|
|
109
|
+
"footer" => {
|
|
110
|
+
"added_symbols" => diff.footer[:added_symbols],
|
|
111
|
+
"removed_symbols" => diff.footer[:removed_symbols]
|
|
112
|
+
},
|
|
113
|
+
"header" => {
|
|
114
|
+
"snapshot" => @path,
|
|
115
|
+
"gate" => diff.gate.to_s,
|
|
116
|
+
"recorded" => diff.recorded&.header,
|
|
117
|
+
"current" => diff.current.header
|
|
118
|
+
}
|
|
119
|
+
))
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -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,54 @@
|
|
|
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:)
|
|
20
|
+
@out = out
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render_text(report)
|
|
26
|
+
report.rows.each do |row|
|
|
27
|
+
@out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{' …?' unless row.exhaustive?}")
|
|
28
|
+
row.causes.each { |cause, detail| @out.puts(" #{cause}#{" (#{detail})" if detail}") }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `≤` is the lane's spelling everywhere in the model — an upper bound, not an observation — so the
|
|
33
|
+
# report writes it rather than inventing a second word for it.
|
|
34
|
+
def declared(row)
|
|
35
|
+
row.declared.empty? ? "" : " ≤ [#{row.declared.join(', ')}]"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_json(report)
|
|
39
|
+
payload = {
|
|
40
|
+
"methods" => report.rows.to_h do |row|
|
|
41
|
+
[row.key, {
|
|
42
|
+
"effects" => row.effects,
|
|
43
|
+
"declared" => row.declared,
|
|
44
|
+
"exhaustive" => row.exhaustive?,
|
|
45
|
+
"causes" => row.causes.map { |cause, detail| [cause, detail] },
|
|
46
|
+
"direct" => row.direct
|
|
47
|
+
}]
|
|
48
|
+
end
|
|
49
|
+
}
|
|
50
|
+
@out.puts(JSON.pretty_generate(payload))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
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)
|
|
11
|
+
# One method's line in the report.
|
|
12
|
+
#
|
|
13
|
+
# `effects` is the transitive proven lane — this method's labels joined with every project method it
|
|
14
|
+
# reaches. `exhaustive` false reads "these effects, and possibly more", and `causes` says why.
|
|
15
|
+
# `direct` is what this method's own body contributed, per origin, which is the attributable half a
|
|
16
|
+
# snapshot records (#381).
|
|
17
|
+
#
|
|
18
|
+
# `declared` is the `≤` lane, transitive like `effects`: what a source Rigor trusts but did not
|
|
19
|
+
# verify *claims* this method reaches — today the project's `effects.attribution:` table (#385). It
|
|
20
|
+
# is printed apart from `effects` and never folded into it, because the two answer different
|
|
21
|
+
# questions: one is proven, the other is asserted. A declared label the proven lane already admits
|
|
22
|
+
# is dropped here, where output is rendered; the table keeps both lanes raw.
|
|
23
|
+
class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct)
|
|
24
|
+
def exhaustive?
|
|
25
|
+
exhaustive
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Builds a report from an effect table. `full:` keeps the rows the report otherwise omits — an
|
|
30
|
+
# exhaustive method proving nothing beyond `mutate.local`, which is the reading of `%a{pure}`.
|
|
31
|
+
def self.build(table, full: false)
|
|
32
|
+
rows = table.filter_map { |entry| row_for(entry) unless !full && entry.trivial? }
|
|
33
|
+
new(rows: rows.freeze, full: full)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.row_for(entry)
|
|
37
|
+
Row.new(
|
|
38
|
+
key: entry.key,
|
|
39
|
+
effects: entry.proven.to_a,
|
|
40
|
+
declared: entry.rendered_declared.to_a,
|
|
41
|
+
exhaustive: entry.exhaustive?,
|
|
42
|
+
causes: entry.causes,
|
|
43
|
+
direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
private_class_method :row_for
|
|
47
|
+
|
|
48
|
+
def empty?
|
|
49
|
+
rows.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|