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
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
require_relative "../version"
|
|
8
|
+
require_relative "effect_table"
|
|
9
|
+
require_relative "entry_points"
|
|
10
|
+
require_relative "identity"
|
|
11
|
+
require_relative "registry"
|
|
12
|
+
|
|
13
|
+
module Rigor
|
|
14
|
+
module Effects
|
|
15
|
+
# The committed effect snapshot — `.rigor-effects.yml`, the primary validation mode (ADR-103 WD7 /
|
|
16
|
+
# WD14, design note § 9.4).
|
|
17
|
+
#
|
|
18
|
+
# It is `db/schema.rb` for effects: a generated artefact a project commits, whose diff in a pull
|
|
19
|
+
# request *is* the review signal, whose freshness CI checks, and where intent is expressed by
|
|
20
|
+
# committing the regenerated file rather than by annotating code. It emits no diagnostic and never
|
|
21
|
+
# enters `rigor check`'s stream ([ADR-102](../adr/102-unused-code-reachability-report.md)).
|
|
22
|
+
#
|
|
23
|
+
# Two tables, chosen so a diff is **attributable**:
|
|
24
|
+
#
|
|
25
|
+
# - `methods:` holds each unit's **direct** summary — origins in the method's own body (block literals
|
|
26
|
+
# included) plus catalogued and attributed callees, but never a project callee, which is an edge. An
|
|
27
|
+
# entry therefore moves only when its own lines, the catalogue or an attribution moved.
|
|
28
|
+
# - `reach:` holds the **transitive** footprint at the entry points `effects.snapshot.reach:` names.
|
|
29
|
+
# A leaf change fans out here, and the fan-out is the information (blast radius).
|
|
30
|
+
#
|
|
31
|
+
# The record is **undischarged**: `tolerated:` is consulted at judgment time by {SnapshotDiff}, never
|
|
32
|
+
# while writing, so `update --no-tolerated-effects` and `update` write byte-identical files and a
|
|
33
|
+
# policy change diffs the config rather than the record.
|
|
34
|
+
#
|
|
35
|
+
# The serialisation is a hand-rolled **JSON-compatible YAML subset**: string keys, JSON scalars, flow
|
|
36
|
+
# sequences, sorted keys and sorted labels, no anchors, no tags, no timestamps. `YAML.safe_load` of the
|
|
37
|
+
# file round-trips through `JSON` unchanged, which is the property a sibling implementation reads it by.
|
|
38
|
+
class Snapshot
|
|
39
|
+
# Bumped when the file's shape changes in a way an older reader would misread. A bump makes every
|
|
40
|
+
# existing file a regeneration event rather than a silent reinterpretation.
|
|
41
|
+
SCHEMA = 1
|
|
42
|
+
|
|
43
|
+
HEADER = "# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."
|
|
44
|
+
|
|
45
|
+
class ParseError < StandardError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# One row of either table.
|
|
49
|
+
#
|
|
50
|
+
# `effects` and `declared` are sorted label arrays; `unresolved` is the sorted list of taint-cause
|
|
51
|
+
# renderings behind a false `exhaustive`, and is empty when the row is exhaustive.
|
|
52
|
+
class Entry < Data.define(:key, :effects, :declared, :exhaustive, :unresolved)
|
|
53
|
+
def exhaustive?
|
|
54
|
+
exhaustive
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The wire form, with every field the reader can default omitted: `declared:` when empty,
|
|
58
|
+
# `exhaustive:` when true, `unresolved:` when there is nothing to say. What stays is what a
|
|
59
|
+
# reviewer reads.
|
|
60
|
+
def to_h
|
|
61
|
+
row = { "effects" => effects }
|
|
62
|
+
row["declared"] = declared unless declared.empty?
|
|
63
|
+
row["exhaustive"] = false unless exhaustive
|
|
64
|
+
row["unresolved"] = unresolved unless unresolved.empty?
|
|
65
|
+
row
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.from_h(key, row)
|
|
69
|
+
raise ParseError, "entry #{key.inspect} is not a mapping" unless row.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
new(
|
|
72
|
+
key: key,
|
|
73
|
+
effects: string_list(row["effects"], key, "effects"),
|
|
74
|
+
declared: string_list(row["declared"], key, "declared"),
|
|
75
|
+
exhaustive: row.fetch("exhaustive", true) != false,
|
|
76
|
+
unresolved: string_list(row["unresolved"], key, "unresolved")
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.string_list(value, key, field)
|
|
81
|
+
return [].freeze if value.nil?
|
|
82
|
+
raise ParseError, "#{key.inspect} #{field}: expected a list, got #{value.class}" unless value.is_a?(Array)
|
|
83
|
+
|
|
84
|
+
value.map do |member|
|
|
85
|
+
raise ParseError, "#{key.inspect} #{field}: expected strings" unless member.is_a?(String)
|
|
86
|
+
|
|
87
|
+
member
|
|
88
|
+
end.freeze
|
|
89
|
+
end
|
|
90
|
+
private_class_method :string_list
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class << self
|
|
94
|
+
# Builds the snapshot a run's effect table describes.
|
|
95
|
+
#
|
|
96
|
+
# @param table [EffectTable] the propagated graph
|
|
97
|
+
# @param configuration [Rigor::Configuration] supplies the reach globs and the digested block
|
|
98
|
+
# @param sources [Hash{String=>Array<String>}] `Runner#effect_sources` — where each unit is defined
|
|
99
|
+
# @param full [Boolean] keep the rows {#omit?} drops
|
|
100
|
+
# @param registry [Registry] the vocabulary whose version the header carries
|
|
101
|
+
# @param project_root [String] what `sources` paths are relativised against for glob matching
|
|
102
|
+
def build(table:, configuration:, sources: {}, full: false, registry: Registry.default,
|
|
103
|
+
project_root: Dir.pwd)
|
|
104
|
+
globs = expand_reach(configuration.effects_snapshot_reach)
|
|
105
|
+
new(
|
|
106
|
+
header: {
|
|
107
|
+
"schema" => SCHEMA,
|
|
108
|
+
"rigor" => Rigor::VERSION,
|
|
109
|
+
"vocabulary" => registry.vocabulary_version,
|
|
110
|
+
"config_digest" => config_digest(configuration)
|
|
111
|
+
},
|
|
112
|
+
methods: build_methods(table, full: full),
|
|
113
|
+
reach: build_reach(table, globs, sources, project_root, full: full)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The per-origin discharge judgment's input, for the side of a comparison that HAS origins
|
|
118
|
+
# (#385). `{table => {symbol => [label]}}`: for each row this snapshot carries, the labels that
|
|
119
|
+
# survive `effects.tolerated:` applied bundle by bundle — direct bundles under `methods:`, the
|
|
120
|
+
# transitive lane under `reach:`, matching what each table records.
|
|
121
|
+
#
|
|
122
|
+
# It is built beside the snapshot and never inside it: the file stays flat and undischarged, and
|
|
123
|
+
# this index exists only for the duration of one comparison ({SnapshotDiff}).
|
|
124
|
+
#
|
|
125
|
+
# @param snapshot [Snapshot] the current side, already built
|
|
126
|
+
# @param table [EffectTable] the run that produced it
|
|
127
|
+
# @param discharge [Discharge] the policy
|
|
128
|
+
# @return [Hash, nil] nil when the policy discharges nothing, which is the common case
|
|
129
|
+
def undischarged_index(snapshot:, table:, discharge:)
|
|
130
|
+
return nil if discharge.inert?
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
"methods" => snapshot.methods.keys.filter_map do |key|
|
|
134
|
+
entry = table[key]
|
|
135
|
+
[key, discharge.undischarged(entry.direct.bundles).to_a] if entry
|
|
136
|
+
end.to_h,
|
|
137
|
+
"reach" => snapshot.reach.keys.filter_map do |key|
|
|
138
|
+
entry = table[key]
|
|
139
|
+
[key, entry.undischarged.to_a] if entry
|
|
140
|
+
end.to_h
|
|
141
|
+
}.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The `effects:` block of `.rigor.yml`, canonicalised (keys sorted at every depth, rendered as
|
|
145
|
+
# JSON) and hashed. A Rigor upgrade, a vocabulary bump or a `tolerated:` edit is therefore a
|
|
146
|
+
# **visible** regeneration event rather than a silent reinterpretation of the record.
|
|
147
|
+
#
|
|
148
|
+
# The implementation lives in {Effects::Identity} because the effects CACHE identity (#382) digests
|
|
149
|
+
# the same block: a header that agreed with the cache key only by convention would eventually stop
|
|
150
|
+
# agreeing, and the failure would be a silently reused stale sidecar.
|
|
151
|
+
def config_digest(configuration)
|
|
152
|
+
Identity.config_digest(configuration)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Resolves `effects.snapshot.reach:` entries to file globs: a preset name becomes the globs it
|
|
156
|
+
# stands for, a glob stays itself.
|
|
157
|
+
#
|
|
158
|
+
# This is where an unknown preset name is caught (#387). `Configuration` cannot: presets are named
|
|
159
|
+
# by plugins, and plugins load from the configuration being validated. By the time a snapshot is
|
|
160
|
+
# built the registered set is complete, so a name nothing registered is a real error — most often
|
|
161
|
+
# `reach: [rails]` in a project that never listed the Rails plugins — and saying so beats a `reach:`
|
|
162
|
+
# table that comes back mysteriously empty.
|
|
163
|
+
def expand_reach(entries)
|
|
164
|
+
entries.flat_map do |entry|
|
|
165
|
+
next [entry] if EntryPoints.glob?(entry)
|
|
166
|
+
|
|
167
|
+
EntryPoints.globs_for(entry) ||
|
|
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)")
|
|
172
|
+
end.uniq.sort.freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse(text)
|
|
176
|
+
data = YAML.safe_load(text)
|
|
177
|
+
raise ParseError, "not a mapping" unless data.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
new(
|
|
180
|
+
header: parse_header(data),
|
|
181
|
+
methods: parse_table(data["methods"], "methods"),
|
|
182
|
+
reach: parse_table(data["reach"], "reach")
|
|
183
|
+
)
|
|
184
|
+
rescue Psych::Exception => e
|
|
185
|
+
raise ParseError, e.message
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def load(path)
|
|
189
|
+
parse(File.read(path, encoding: "UTF-8"))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def parse_header(data)
|
|
195
|
+
{
|
|
196
|
+
"schema" => data["schema"],
|
|
197
|
+
"rigor" => data["rigor"].to_s,
|
|
198
|
+
"vocabulary" => data["vocabulary"],
|
|
199
|
+
"config_digest" => data["config_digest"].to_s
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def parse_table(value, name)
|
|
204
|
+
return {} if value.nil?
|
|
205
|
+
raise ParseError, "#{name}: expected a mapping, got #{value.class}" unless value.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
value.to_h { |key, row| [key.to_s, Entry.from_h(key.to_s, row)] }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# `methods:` records the DIRECT summary. Two omissions keep the file to what is worth reading:
|
|
211
|
+
# a summary that is exhaustive and proves nothing beyond `mutate.local` (which every envelope
|
|
212
|
+
# tolerates), and a synthesised default accessor summary. `--full` keeps both.
|
|
213
|
+
def build_methods(table, full:)
|
|
214
|
+
table.each_with_object({}) do |entry, out|
|
|
215
|
+
next if !full && omit?(trivial: entry.direct.trivial?, direct: entry.direct,
|
|
216
|
+
proven: entry.direct.proven, declared: entry.direct.declared)
|
|
217
|
+
|
|
218
|
+
out[entry.key] = entry_for(entry.key, entry.direct.proven, entry.direct,
|
|
219
|
+
declared: entry.direct.declared)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# `reach:` records the TRANSITIVE footprint, and only at the entry points the configuration names.
|
|
224
|
+
# A key qualifies when any file that defines it matches a glob — an entry-point glob selects a
|
|
225
|
+
# *file*, so every unit written in it is one, and the same omission rule then keeps the table to
|
|
226
|
+
# the entry points worth reading. One that later gains an effect arrives as an added symbol.
|
|
227
|
+
def build_reach(table, globs, sources, project_root, full:)
|
|
228
|
+
return {} if globs.empty?
|
|
229
|
+
|
|
230
|
+
table.each_with_object({}) do |entry, out|
|
|
231
|
+
next unless entry_point?(entry.key, globs, sources, project_root)
|
|
232
|
+
next if !full && omit?(trivial: entry.trivial?, direct: entry.direct,
|
|
233
|
+
proven: entry.proven, declared: entry.declared)
|
|
234
|
+
|
|
235
|
+
out[entry.key] = entry_for(entry.key, entry.proven, entry.direct, declared: entry.declared,
|
|
236
|
+
exhaustive: entry.exhaustive?,
|
|
237
|
+
causes: entry.causes)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def entry_point?(key, globs, sources, project_root)
|
|
242
|
+
Array(sources[key]).any? do |path|
|
|
243
|
+
relative = relativize(path, project_root)
|
|
244
|
+
globs.any? { |glob| File.fnmatch?(glob, relative, File::FNM_PATHNAME) }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
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
|
+
# Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
|
|
255
|
+
# `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
|
|
256
|
+
# its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
|
|
257
|
+
# services carries the claim, exactly as it carries the proven labels.
|
|
258
|
+
#
|
|
259
|
+
# The rendering rule applies here and only here: a declared label the row's own proven set already
|
|
260
|
+
# admits says nothing more, so it is dropped rather than printed as a second fact. The table keeps
|
|
261
|
+
# both lanes raw.
|
|
262
|
+
def entry_for(key, proven, direct, declared:, exhaustive: direct.exhaustive?, causes: direct.causes)
|
|
263
|
+
Entry.new(
|
|
264
|
+
key: key,
|
|
265
|
+
effects: proven.to_a,
|
|
266
|
+
declared: declared.excluding_subsumed_by(proven).to_a,
|
|
267
|
+
exhaustive: exhaustive,
|
|
268
|
+
unresolved: exhaustive ? [].freeze : render_causes(causes)
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
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
|
+
# A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
|
|
282
|
+
# accessors that join it when discovery synthesises them — carries no information a reviewer acts
|
|
283
|
+
# on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
|
|
284
|
+
# constructs, so a hand-written `def name=` keeps its row.
|
|
285
|
+
SYNTHESISED_ORIGINS = %w[construct:attr-writer].to_set.freeze
|
|
286
|
+
private_constant :SYNTHESISED_ORIGINS
|
|
287
|
+
|
|
288
|
+
# A **taint-only** row carries no label in either lane: it says "not exhaustive, and here is why",
|
|
289
|
+
# and nothing a reviewer ratchets against. The snapshot lists what is attributable (issue #411,
|
|
290
|
+
# option (b)), so the row is omitted and the exhaustiveness transition stays visible for every row
|
|
291
|
+
# that does carry a label or a declared bound. `--full` keeps it, and a method that later proves a
|
|
292
|
+
# label arrives as an added symbol.
|
|
293
|
+
def omit?(trivial:, direct:, proven:, declared:)
|
|
294
|
+
return true if trivial
|
|
295
|
+
return true if proven.empty? && declared.empty?
|
|
296
|
+
return false unless direct.exhaustive?
|
|
297
|
+
return false if direct.bundles.empty?
|
|
298
|
+
|
|
299
|
+
direct.bundles.each_key.all? { |origin| SYNTHESISED_ORIGINS.include?(origin.to_s) }
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
attr_reader :header, :methods, :reach
|
|
304
|
+
|
|
305
|
+
def initialize(header:, methods: {}, reach: {})
|
|
306
|
+
@header = header.freeze
|
|
307
|
+
@methods = sorted(methods)
|
|
308
|
+
@reach = sorted(reach)
|
|
309
|
+
freeze
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def ==(other)
|
|
313
|
+
other.is_a?(Snapshot) && other.header == @header && other.methods == @methods && other.reach == @reach
|
|
314
|
+
end
|
|
315
|
+
alias eql? ==
|
|
316
|
+
|
|
317
|
+
def hash
|
|
318
|
+
[self.class, @header, @methods, @reach].hash
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The table a diff category names, so `SnapshotDiff` walks the two by name.
|
|
322
|
+
def table(name)
|
|
323
|
+
name == "reach" ? @reach : @methods
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# The wire form: plain Hashes, Strings, Integers, Booleans and Arrays — nothing a JSON parser would
|
|
327
|
+
# choke on and nothing a YAML reader has to resolve a tag for.
|
|
328
|
+
def to_h
|
|
329
|
+
@header.merge(
|
|
330
|
+
"methods" => @methods.transform_values(&:to_h),
|
|
331
|
+
"reach" => @reach.transform_values(&:to_h)
|
|
332
|
+
)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Renders the file. Hand-rolled rather than `YAML.dump`ed so the leading comment, the key order and
|
|
336
|
+
# the flow sequences are ours: two runs over one tree must be byte-identical, and a snapshot written
|
|
337
|
+
# by a pooled run must equal the sequential one bit for bit.
|
|
338
|
+
def to_yaml
|
|
339
|
+
lines = [HEADER]
|
|
340
|
+
lines << "schema: #{@header.fetch('schema')}"
|
|
341
|
+
lines << "rigor: #{scalar(@header.fetch('rigor'))}"
|
|
342
|
+
lines << "vocabulary: #{@header.fetch('vocabulary')}"
|
|
343
|
+
lines << "config_digest: #{scalar(@header.fetch('config_digest'))}"
|
|
344
|
+
render_table(lines, "methods", @methods)
|
|
345
|
+
render_table(lines, "reach", @reach)
|
|
346
|
+
"#{lines.join("\n")}\n"
|
|
347
|
+
end
|
|
348
|
+
alias to_s to_yaml
|
|
349
|
+
|
|
350
|
+
private
|
|
351
|
+
|
|
352
|
+
def render_table(lines, name, table)
|
|
353
|
+
if table.empty?
|
|
354
|
+
lines << "#{name}: {}"
|
|
355
|
+
return
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
lines << "#{name}:"
|
|
359
|
+
table.each do |key, entry|
|
|
360
|
+
lines << " #{scalar(key)}:"
|
|
361
|
+
entry.to_h.each { |field, value| lines << " #{field}: #{wire(value)}" }
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def wire(value)
|
|
366
|
+
value.is_a?(Array) ? "[#{value.map { |member| scalar(member) }.join(', ')}]" : value.to_s
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# JSON string escaping is a valid YAML double-quoted scalar, so one rule covers both formats and a
|
|
370
|
+
# key carrying `#`, `:` or a leading indicator cannot change the document's shape.
|
|
371
|
+
def scalar(value)
|
|
372
|
+
value.to_s.to_json
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def sorted(table)
|
|
376
|
+
table.sort_by { |key, _| key }.to_h.freeze
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label_set"
|
|
4
|
+
require_relative "snapshot"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Effects
|
|
8
|
+
# Compares a committed {Snapshot} with the one this run computes, and judges the difference
|
|
9
|
+
# (ADR-103 WD7; the event vocabulary is design note § 9.4).
|
|
10
|
+
#
|
|
11
|
+
# The comparison itself is exact — drift between two observations by the same tool is 100 % precise.
|
|
12
|
+
# What is *judged* is whether the drift matters, and that has two knobs, applied here and never while
|
|
13
|
+
# writing the record:
|
|
14
|
+
#
|
|
15
|
+
# - the **gate**. `symmetric` (the default) fails on any drift, the `db/schema.rb` model: a removal is
|
|
16
|
+
# news too, because a job that stopped enqueueing is a bug rather than an improvement. `additions`
|
|
17
|
+
# is the ratchet: only growth fails.
|
|
18
|
+
# - the **tolerated set**. An event whose labels are all admitted by `effects.tolerated:` is reported
|
|
19
|
+
# under its own heading and does not fail the gate unless `strict_tolerated:` is set.
|
|
20
|
+
#
|
|
21
|
+
# **Additions are judged per origin; removals by label.** The file is flat — origins are `explain`'s
|
|
22
|
+
# job and a record keyed by them would churn on every refactor — but the *current* side of a
|
|
23
|
+
# comparison is a live effect table, so the caller can hand over `undischarged:`, the labels that
|
|
24
|
+
# survive per-origin discharge for each current symbol (#385). An added label is then tolerated
|
|
25
|
+
# exactly when every origin that introduces it is discharged, which is the same answer the envelope
|
|
26
|
+
# check gives, rather than the coarser "the label is on the list". A **removal** has no current-side
|
|
27
|
+
# origins to consult — the thing that produced it is gone — so it stays judged by label. Without the
|
|
28
|
+
# index (a caller that has no table, and every parse-only spec) both fall back to the label reading.
|
|
29
|
+
#
|
|
30
|
+
# A header mismatch is neither: it is a **regeneration event** — the record was written by a different
|
|
31
|
+
# Rigor, a different vocabulary or a different `effects:` block, so the two sides are not comparable
|
|
32
|
+
# in the first place. It fails under both gates, because there is nothing to ratchet against.
|
|
33
|
+
class SnapshotDiff
|
|
34
|
+
# Categories, in the order a text rendering emits them. The strings are the JSON contract.
|
|
35
|
+
MISSING_SNAPSHOT = "missing-snapshot"
|
|
36
|
+
REGENERATION = "regeneration"
|
|
37
|
+
SYMBOL_ADDED = "symbol-added"
|
|
38
|
+
SYMBOL_REMOVED = "symbol-removed"
|
|
39
|
+
LABEL_ADDED = "label-added"
|
|
40
|
+
LABEL_REMOVED = "label-removed"
|
|
41
|
+
DECLARED_ADDED = "declared-added"
|
|
42
|
+
DECLARED_REMOVED = "declared-removed"
|
|
43
|
+
MATERIALISED = "materialised"
|
|
44
|
+
EXHAUSTIVE_LOST = "exhaustive-lost"
|
|
45
|
+
EXHAUSTIVE_GAINED = "exhaustive-gained"
|
|
46
|
+
|
|
47
|
+
# What the `additions` ratchet still fails on: growth in either lane, a new symbol, and a method
|
|
48
|
+
# that stopped being exhaustive (someone introduced a call the analyzer cannot follow — the one
|
|
49
|
+
# "removal-shaped" change that is unambiguously a loss of knowledge).
|
|
50
|
+
ADDITIVE_CATEGORIES = [SYMBOL_ADDED, LABEL_ADDED, DECLARED_ADDED, EXHAUSTIVE_LOST,
|
|
51
|
+
MISSING_SNAPSHOT, REGENERATION].freeze
|
|
52
|
+
|
|
53
|
+
TABLES = %w[methods reach].freeze
|
|
54
|
+
|
|
55
|
+
# One difference.
|
|
56
|
+
#
|
|
57
|
+
# `symbol` is the method key (nil for a header event), `label` the label it is about (nil for the
|
|
58
|
+
# symbol- and exhaustiveness-shaped ones), `detail` any extra the renderer prints verbatim, and
|
|
59
|
+
# `tolerated` whether policy discharged it.
|
|
60
|
+
class Event < Data.define(:category, :symbol, :label, :table, :tolerated, :detail, :hedged)
|
|
61
|
+
def tolerated?
|
|
62
|
+
tolerated
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether the current side could not prove an absence. A removal read off a non-exhaustive summary
|
|
66
|
+
# is rendered hedged — "possibly more" cannot prove that something stopped happening.
|
|
67
|
+
def hedged?
|
|
68
|
+
hedged
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
row = { "category" => category, "symbol" => symbol, "table" => table, "tolerated" => tolerated }
|
|
73
|
+
row["label"] = label if label
|
|
74
|
+
row["detail"] = detail if detail
|
|
75
|
+
row["hedged"] = true if hedged
|
|
76
|
+
row
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The categories the per-origin judgment is exact for: both are additions in the PROVEN lane, so the
|
|
81
|
+
# current side's undischarged labels answer them directly. The declared lane's additions are not
|
|
82
|
+
# here — nothing declared has a proven origin to discharge.
|
|
83
|
+
ORIGIN_JUDGED_CATEGORIES = [SYMBOL_ADDED, LABEL_ADDED].freeze
|
|
84
|
+
private_constant :ORIGIN_JUDGED_CATEGORIES
|
|
85
|
+
|
|
86
|
+
# Compares `current` against `recorded`. `recorded` may be nil — no snapshot on disk at all, which
|
|
87
|
+
# is drift with a routed message rather than an error.
|
|
88
|
+
#
|
|
89
|
+
# @param undischarged [Hash] `{table name => {symbol => [label]}}` — the current side's labels that
|
|
90
|
+
# survive per-origin discharge ({Snapshot.undischarged_index}). Optional; omitting it judges every
|
|
91
|
+
# event by label.
|
|
92
|
+
def self.compare(recorded:, current:, tolerated: [], gate: :symmetric, strict_tolerated: false,
|
|
93
|
+
undischarged: nil)
|
|
94
|
+
new(recorded: recorded, current: current, tolerated: tolerated, gate: gate,
|
|
95
|
+
strict_tolerated: strict_tolerated, undischarged: undischarged)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
attr_reader :events, :recorded, :current, :gate
|
|
99
|
+
|
|
100
|
+
def initialize(recorded:, current:, tolerated: [], gate: :symmetric, strict_tolerated: false,
|
|
101
|
+
undischarged: nil)
|
|
102
|
+
@recorded = recorded
|
|
103
|
+
@current = current
|
|
104
|
+
@gate = gate
|
|
105
|
+
@strict_tolerated = strict_tolerated
|
|
106
|
+
@tolerated = LabelSet.new(tolerated)
|
|
107
|
+
@undischarged = undischarged
|
|
108
|
+
@added_symbols = 0
|
|
109
|
+
@removed_symbols = 0
|
|
110
|
+
@events = build_events.freeze
|
|
111
|
+
freeze
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Whether the record matches what this run computed. A tolerated-only difference is NOT fresh — the
|
|
115
|
+
# file still needs regenerating — it merely does not fail the gate.
|
|
116
|
+
def fresh?
|
|
117
|
+
@events.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Whether `rigor effects check` exits non-zero.
|
|
121
|
+
def drift?
|
|
122
|
+
@events.any? { |event| gating?(event) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 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
|
+
def footer
|
|
128
|
+
{ added_symbols: @added_symbols, removed_symbols: @removed_symbols }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def events_for(table)
|
|
132
|
+
@events.select { |event| event.table == table && !event.tolerated? }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def tolerated_events
|
|
136
|
+
@events.select(&:tolerated?)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def gating?(event)
|
|
142
|
+
return false if event.tolerated? && !@strict_tolerated
|
|
143
|
+
return true if @gate == :symmetric
|
|
144
|
+
|
|
145
|
+
ADDITIVE_CATEGORIES.include?(event.category)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_events
|
|
149
|
+
return [missing_snapshot_event] if @recorded.nil?
|
|
150
|
+
|
|
151
|
+
header_events + TABLES.flat_map { |table| table_events(table) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def missing_snapshot_event
|
|
155
|
+
event(MISSING_SNAPSHOT, symbol: nil, table: "snapshot",
|
|
156
|
+
detail: "no snapshot; run `rigor effects update`")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The header says which record this is. A mismatch on any of its four fields means the recorded
|
|
160
|
+
# sets were computed under different rules, so every downstream comparison would be noise.
|
|
161
|
+
def header_events
|
|
162
|
+
%w[schema rigor vocabulary config_digest].filter_map do |field|
|
|
163
|
+
was = @recorded.header[field]
|
|
164
|
+
now = @current.header[field]
|
|
165
|
+
next if was == now
|
|
166
|
+
|
|
167
|
+
event(REGENERATION, symbol: nil, table: "header", detail: "#{field}: #{was.inspect} → #{now.inspect}")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def table_events(table)
|
|
172
|
+
was = @recorded.table(table)
|
|
173
|
+
now = @current.table(table)
|
|
174
|
+
(was.keys | now.keys).sort.flat_map do |symbol|
|
|
175
|
+
before = was[symbol]
|
|
176
|
+
after = now[symbol]
|
|
177
|
+
if before.nil? then added_symbol(symbol, after, table)
|
|
178
|
+
elsif after.nil? then removed_symbol(symbol, before, table)
|
|
179
|
+
else changed_symbol(symbol, before, after, table)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def added_symbol(symbol, entry, table)
|
|
185
|
+
@added_symbols += 1
|
|
186
|
+
return [] if quiet?(entry)
|
|
187
|
+
|
|
188
|
+
[event(SYMBOL_ADDED, symbol: symbol, table: table, labels: entry.effects,
|
|
189
|
+
detail: render_labels(entry))]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def removed_symbol(symbol, entry, table)
|
|
193
|
+
@removed_symbols += 1
|
|
194
|
+
return [] if quiet?(entry)
|
|
195
|
+
|
|
196
|
+
[event(SYMBOL_REMOVED, symbol: symbol, table: table, labels: entry.effects,
|
|
197
|
+
detail: render_labels(entry))]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A row carrying nothing — no labels, nothing declared, exhaustive — appears only under `--full`.
|
|
201
|
+
# Its coming and going is not news; the footer still counts it, so a rename still balances.
|
|
202
|
+
def quiet?(entry)
|
|
203
|
+
entry.effects.empty? && entry.declared.empty? && entry.exhaustive?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def changed_symbol(symbol, before, after, table)
|
|
207
|
+
materialised = before.declared & (after.effects - before.effects)
|
|
208
|
+
lane_events(symbol, before, after, table, materialised) +
|
|
209
|
+
exhaustiveness_events(symbol, before, after, table)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The proven and declared lanes, with materialisation lifted out of both: a label that was an
|
|
213
|
+
# authored upper bound and is now proven is ONE event, never a removal plus an addition.
|
|
214
|
+
def lane_events(symbol, before, after, table, materialised)
|
|
215
|
+
[[MATERIALISED, materialised],
|
|
216
|
+
[LABEL_ADDED, after.effects - before.effects - materialised],
|
|
217
|
+
[LABEL_REMOVED, before.effects - after.effects],
|
|
218
|
+
[DECLARED_ADDED, after.declared - before.declared],
|
|
219
|
+
[DECLARED_REMOVED, before.declared - after.declared - materialised]].flat_map do |category, labels|
|
|
220
|
+
hedged = category == LABEL_REMOVED && !after.exhaustive?
|
|
221
|
+
labels.map { |label| event(category, symbol: symbol, table: table, label: label, hedged: hedged) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def exhaustiveness_events(symbol, before, after, table)
|
|
226
|
+
return [] if before.exhaustive? == after.exhaustive?
|
|
227
|
+
|
|
228
|
+
category = after.exhaustive? ? EXHAUSTIVE_GAINED : EXHAUSTIVE_LOST
|
|
229
|
+
[event(category, symbol: symbol, table: table,
|
|
230
|
+
detail: after.exhaustive? ? "not → exhaustive" : "exhaustive → not")]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def render_labels(entry)
|
|
234
|
+
"[#{entry.effects.join(', ')}]#{' …?' unless entry.exhaustive?}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Policy discharge, at judgment time. A single-label event is tolerated when the policy discharges
|
|
238
|
+
# that label; a symbol event when it discharges every label the symbol carries — and never when it
|
|
239
|
+
# carries none, because an empty set is discharged by nothing.
|
|
240
|
+
def event(category, symbol:, table:, label: nil, labels: nil, detail: nil, hedged: false)
|
|
241
|
+
Event.new(category: category, symbol: symbol, label: label, table: table, detail: detail,
|
|
242
|
+
hedged: hedged, tolerated: tolerated?(category, symbol, table, label, labels))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def tolerated?(category, symbol, table, label, labels)
|
|
246
|
+
members = labels || (label ? [label] : nil)
|
|
247
|
+
return false if members.nil? || members.empty? || @tolerated.empty?
|
|
248
|
+
|
|
249
|
+
surviving = surviving_labels(category, symbol, table)
|
|
250
|
+
return members.none? { |member| surviving.include?(member) } if surviving
|
|
251
|
+
|
|
252
|
+
members.all? { |member| @tolerated.admits?(member) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The current side's undischarged labels for one symbol, or nil when this event is not one the
|
|
256
|
+
# per-origin judgment answers — no index, a removal, or a lane with no proven origins.
|
|
257
|
+
def surviving_labels(category, symbol, table)
|
|
258
|
+
return nil if @undischarged.nil? || symbol.nil?
|
|
259
|
+
return nil unless ORIGIN_JUDGED_CATEGORIES.include?(category)
|
|
260
|
+
|
|
261
|
+
@undischarged.dig(table, symbol)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|