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