rigortype 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +73 -22
- data/docs/manual/03-configuration.md +19 -2
- data/docs/manual/04-diagnostics.md +15 -2
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +38 -1
- data/docs/manual/12-caching.md +40 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +678 -0
- data/docs/manual/README.md +7 -2
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +4 -4
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/reachability/graph.rb +30 -11
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +20 -4
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +163 -31
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +5 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +149 -10
- data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +33 -9
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +29 -2
- data/lib/rigor/effects/snapshot.rb +74 -26
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +10 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class CLI
|
|
5
|
+
# Turns the documentation's own relative links into keys `rigor docs` can answer (#430).
|
|
6
|
+
#
|
|
7
|
+
# The manual and handbook link each other and the design corpus the way any prose does —
|
|
8
|
+
# `[ADR-103](../adr/103-effect-labels.md)`. Those links are correct in the repository and correct on
|
|
9
|
+
# GitHub, and they are the coupling between documents: they say which document explains what, and
|
|
10
|
+
# deleting them to leave a bare `ADR-103` would cost that and give nothing back.
|
|
11
|
+
#
|
|
12
|
+
# They are wrong in exactly one place: a reader looking at the file inside an installed gem, where
|
|
13
|
+
# `docs/adr/` was never packaged. Two earlier attempts fixed the file for that reader and made it
|
|
14
|
+
# worse for the others — rewriting every link to a `blob/master` URL hardcoded a host, an
|
|
15
|
+
# organisation and a branch in 297 places and sent a v0.3.5 reader to a document that had moved on;
|
|
16
|
+
# deleting the markup severed the coupling.
|
|
17
|
+
#
|
|
18
|
+
# So the source keeps its links and `rigor docs` rewrites them on the way out:
|
|
19
|
+
#
|
|
20
|
+
# [ADR-103](../adr/103-effect-labels.md) → [ADR-103][adr/103-effect-labels]
|
|
21
|
+
#
|
|
22
|
+
# The key is the repository path minus `docs/` and `.md`, which for a packaged page is exactly the
|
|
23
|
+
# name `rigor docs` already takes, so the reader can run what they are shown. For an unpackaged one
|
|
24
|
+
# the key still resolves — to a routed message naming the path and the repository, from the single
|
|
25
|
+
# constant below rather than from 297 rewritten links.
|
|
26
|
+
module DocLinks
|
|
27
|
+
REPOSITORY = "https://github.com/rigortype/rigor"
|
|
28
|
+
|
|
29
|
+
# `[label](target)` and `[label](target#anchor)`, skipping absolute URLs and bare anchors.
|
|
30
|
+
MARKDOWN_LINK = /\[([^\]]*)\]\((?!https?:|mailto:|#)([^)#\s]+)(#[^)\s]*)?\)/
|
|
31
|
+
private_constant :MARKDOWN_LINK
|
|
32
|
+
|
|
33
|
+
# Where the gem's `docs/` tree sits, so a rendered path can be made repository-relative.
|
|
34
|
+
GEM_ROOT = File.expand_path("../../..", __dir__)
|
|
35
|
+
private_constant :GEM_ROOT
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# @param body [String] the page as it is stored.
|
|
40
|
+
# @param from [String] its absolute path, so relative targets resolve.
|
|
41
|
+
# @return [String] the page with every relative link turned into a `rigor docs` key.
|
|
42
|
+
def rewrite(body, from:)
|
|
43
|
+
dir = File.dirname(from)
|
|
44
|
+
body.gsub(MARKDOWN_LINK) do
|
|
45
|
+
label = Regexp.last_match(1)
|
|
46
|
+
target = Regexp.last_match(2)
|
|
47
|
+
anchor = Regexp.last_match(3)
|
|
48
|
+
key = key_for(target, dir)
|
|
49
|
+
key.nil? ? "[#{label}](#{target}#{anchor})" : "[#{label}][#{key}#{anchor}]"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Splits a key into its page and its section, for a reader who pasted one back.
|
|
54
|
+
def split_anchor(key)
|
|
55
|
+
page, anchor = key.to_s.split("#", 2)
|
|
56
|
+
[page, anchor]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The key naming `target` as written in `dir`, or nil when it points outside the repository.
|
|
60
|
+
#
|
|
61
|
+
# A `.md` under `docs/` loses both the prefix and the extension, which is exactly the name
|
|
62
|
+
# `rigor docs` already answers — that is what lets a rendered page hand the reader something they
|
|
63
|
+
# can run. Everything else keeps its repository-relative path verbatim: a directory
|
|
64
|
+
# (`examples/rigor-deprecations/`), a source file (`spec/rigor/environment_spec.rb`), a root
|
|
65
|
+
# document (`CHANGELOG.md`). Those are equally routable and equally worth naming; only the
|
|
66
|
+
# round-trip differs, and {.repository_path} reverses both.
|
|
67
|
+
#
|
|
68
|
+
# `target` is taken as written rather than expanded, because `File.expand_path` drops the trailing
|
|
69
|
+
# slash that tells a directory link apart from a page link — prose that writes `[the ADRs](../adr/)`
|
|
70
|
+
# is pointing at a tree, and a key without the slash would claim there is a page there.
|
|
71
|
+
def key_for(target, dir)
|
|
72
|
+
relative = File.expand_path(target, dir).sub("#{GEM_ROOT}/", "")
|
|
73
|
+
return nil if relative.start_with?("/")
|
|
74
|
+
|
|
75
|
+
relative += "/" if target.end_with?("/")
|
|
76
|
+
return relative unless relative.start_with?("docs/") && relative.end_with?(".md")
|
|
77
|
+
|
|
78
|
+
relative.delete_prefix("docs/").delete_suffix(".md")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Repository directories a key may name verbatim. A key that starts with one of these already IS a
|
|
82
|
+
# path — `docs/adr`, `plugins/rigor-sorbet` — because {.key_for} only rewrites `.md` files under
|
|
83
|
+
# `docs/`. Anything else is a documentation key and gets its prefix and extension back.
|
|
84
|
+
#
|
|
85
|
+
# Keying on the ROOT rather than on "has an extension" is what makes a directory work: prose links
|
|
86
|
+
# a tree as often without a trailing slash (`../adr`) as with one, and no string test tells an
|
|
87
|
+
# extensionless file from a directory.
|
|
88
|
+
REPOSITORY_ROOTS = %w[docs examples plugins spec skills data tool bench apps].freeze
|
|
89
|
+
|
|
90
|
+
# The repository path a key names, reversing {.key_for}.
|
|
91
|
+
def repository_path(key)
|
|
92
|
+
return nil if key.nil? || key.empty? || key.include?("..")
|
|
93
|
+
return key if REPOSITORY_ROOTS.include?(key.split("/").first)
|
|
94
|
+
return key if File.extname(key) != ""
|
|
95
|
+
|
|
96
|
+
"docs/#{key}.md"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "command"
|
|
4
|
+
require_relative "doc_links"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
class CLI
|
|
@@ -118,7 +119,7 @@ module Rigor
|
|
|
118
119
|
# a UTF-8 header would set the output buffer to UTF-8 and clash with the body.
|
|
119
120
|
@out.puts("<!-- rigor docs #{doc.fetch(:name)} (rigortype #{Rigor::VERSION}, offline) -->")
|
|
120
121
|
@out.puts
|
|
121
|
-
@out.write(File.read(doc.fetch(:path)))
|
|
122
|
+
@out.write(DocLinks.rewrite(File.read(doc.fetch(:path)), from: doc.fetch(:path)))
|
|
122
123
|
0
|
|
123
124
|
end
|
|
124
125
|
|
|
@@ -138,6 +139,10 @@ module Rigor
|
|
|
138
139
|
# @return [Hash, Integer] the doc entry, or an error exit status
|
|
139
140
|
# after the error has been written to `@err`.
|
|
140
141
|
def resolve_doc(query)
|
|
142
|
+
# A key printed by a rendered page may carry the section it pointed at; resolve the page and say
|
|
143
|
+
# where to look inside it, rather than refusing a key this command handed out.
|
|
144
|
+
query, anchor = DocLinks.split_anchor(query)
|
|
145
|
+
@err.puts("rigor: (section `#{anchor}`)") if anchor && !anchor.empty?
|
|
141
146
|
docs = discover_docs
|
|
142
147
|
|
|
143
148
|
exact = docs.find { |doc| doc.fetch(:exact_aliases).include?(query) }
|
|
@@ -146,11 +151,36 @@ module Rigor
|
|
|
146
151
|
short = docs.select { |doc| doc.fetch(:short_name) == query }
|
|
147
152
|
case short.size
|
|
148
153
|
when 1 then short.first
|
|
149
|
-
when 0 then name_error(query)
|
|
154
|
+
when 0 then unpackaged_doc(query) || name_error(query)
|
|
150
155
|
else ambiguous_error(query, short)
|
|
151
156
|
end
|
|
152
157
|
end
|
|
153
158
|
|
|
159
|
+
# A key the rendered pages hand out for a document the gem does not carry (ADR-74 ships the manual and
|
|
160
|
+
# handbook; the ADR / specification / notes corpus and the repository trees stay out). The key is
|
|
161
|
+
# still answerable — it names a real path — so this routes rather than failing: a reader who followed
|
|
162
|
+
# `[ADR-103][adr/103-effect-labels]` out of `rigor docs` gets told where that document is, which is the
|
|
163
|
+
# whole point of handing them the key instead of a dead relative path or a `master` URL.
|
|
164
|
+
# Categories this command serves itself. A name that fails to resolve inside one of them is a typo,
|
|
165
|
+
# not an unpackaged document, and must reach the did-you-mean listing rather than be routed to a
|
|
166
|
+
# repository path that does not exist — `rigor docs list` and `rigor docs pathh` are the cases
|
|
167
|
+
# that found this.
|
|
168
|
+
PACKAGED_CATEGORIES = %w[manual handbook].freeze
|
|
169
|
+
private_constant :PACKAGED_CATEGORIES
|
|
170
|
+
|
|
171
|
+
def unpackaged_doc(query)
|
|
172
|
+
return nil unless query.include?("/")
|
|
173
|
+
return nil if PACKAGED_CATEGORIES.include?(query.split("/").first)
|
|
174
|
+
|
|
175
|
+
path = DocLinks.repository_path(query)
|
|
176
|
+
return nil if path.nil?
|
|
177
|
+
|
|
178
|
+
@err.puts("rigor: `#{query}` is not packaged with this gem — the manual and handbook are, the " \
|
|
179
|
+
"design records are not.")
|
|
180
|
+
@err.puts("rigor: it is `#{path}` in the Rigor repository (#{DocLinks::REPOSITORY}).")
|
|
181
|
+
1
|
|
182
|
+
end
|
|
183
|
+
|
|
154
184
|
# Every bundled doc, each carrying the aliases `rigor docs <name>` accepts and the category used by `--list`.
|
|
155
185
|
# `install.md` sits at the docs root (category `guide`); the rest are manual / handbook chapters.
|
|
156
186
|
def discover_docs
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
require "optionparser"
|
|
4
4
|
|
|
5
5
|
require_relative "../configuration"
|
|
6
|
-
|
|
6
|
+
# ADR-104 — `analysis/runner` is required lazily (in `#analyze`), not here: a warm report served by
|
|
7
|
+
# {Analysis::EffectsCacheProbe} must reach its answer without `rigor/inference` in `$LOADED_FEATURES`,
|
|
8
|
+
# which is the whole of the boot saving. The probe pulls only the cache and the effects value layer.
|
|
9
|
+
require_relative "../analysis/effects_cache_probe"
|
|
10
|
+
require_relative "../effects/plugin_facts"
|
|
11
|
+
require_relative "../effects/registry"
|
|
12
|
+
require_relative "../plugin/loader"
|
|
7
13
|
require_relative "../cache/store"
|
|
8
14
|
require_relative "command"
|
|
9
15
|
require_relative "effects_renderer"
|
|
@@ -61,7 +67,17 @@ module Rigor
|
|
|
61
67
|
#{USAGE}
|
|
62
68
|
|
|
63
69
|
With no subcommand, prints one line per method: its proven effect labels and whether that
|
|
64
|
-
list is exhaustive.
|
|
70
|
+
list is exhaustive. A PATH selects which methods are printed, never which are analysed.
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
--config=PATH Path to the Rigor configuration file
|
|
74
|
+
--format=FORMAT Output format: text (default) or json
|
|
75
|
+
--full List every method, including the ones with nothing to say
|
|
76
|
+
--label=LABEL Only methods carrying LABEL (or a label under it), in either lane
|
|
77
|
+
--pure Only methods proven to do nothing beyond mutate.local
|
|
78
|
+
--limit=N Print at most N methods
|
|
79
|
+
--why Expand each method's unresolved reasons and declared-lane sources
|
|
80
|
+
--list-labels Print the effect vocabulary this project can name, and exit
|
|
65
81
|
|
|
66
82
|
Subcommands (the committed effect snapshot, ADR-103 WD7):
|
|
67
83
|
update Write the snapshot to effects.snapshot.path. Commit it; review its diff.
|
|
@@ -78,24 +94,69 @@ module Rigor
|
|
|
78
94
|
return usage_error("unsupported format: #{options.fetch(:format)}") unless FORMATS.include?(options[:format])
|
|
79
95
|
|
|
80
96
|
configuration = Configuration.load(options.fetch(:config)).with_effects_enabled
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
return list_labels(configuration) if options.fetch(:list_labels)
|
|
98
|
+
|
|
99
|
+
scope = @argv.dup
|
|
100
|
+
table, sources = resolve_table(configuration, scope)
|
|
101
|
+
report = EffectsReport.build(
|
|
102
|
+
table, full: options.fetch(:full), sources: sources, scope: scope,
|
|
103
|
+
label: options.fetch(:label), pure: options.fetch(:pure), limit: options.fetch(:limit)
|
|
104
|
+
)
|
|
105
|
+
note_scope(scope, report, table)
|
|
106
|
+
EffectsRenderer.new(out: @out, why: options.fetch(:why)).render(report, format: options.fetch(:format))
|
|
84
107
|
0
|
|
85
108
|
end
|
|
86
109
|
|
|
110
|
+
# A path argument is a **view**, and the note says so (#439).
|
|
111
|
+
#
|
|
112
|
+
# It used to narrow the analysed set, and effect labels are transitive over whatever was analysed —
|
|
113
|
+
# so `rigor effects app/controllers/issues_controller.rb` reported `IssuesController#create: [] …?`
|
|
114
|
+
# where the whole-project run reported four labels and a declared lane. The weakened answer was
|
|
115
|
+
# indistinguishable from a genuinely effect-free method, and a path argument is the only tractability
|
|
116
|
+
# lever the report has, so it was the thing an adopter reached for first.
|
|
117
|
+
#
|
|
118
|
+
# The note goes to stderr rather than into the report: it is about the invocation, not about the
|
|
119
|
+
# code, and `--format json` and `rigor effects … > report.txt` both stay exactly what they were.
|
|
120
|
+
def note_scope(scope, report, table)
|
|
121
|
+
return if scope.empty?
|
|
122
|
+
|
|
123
|
+
if report.empty?
|
|
124
|
+
@err.puts("rigor: no effect unit is defined in #{scope.join(', ')} " \
|
|
125
|
+
"(a path selects what is printed, not what is analysed)")
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@err.puts("rigor: showing #{report.rows.length} of #{table.size} units, selected by " \
|
|
130
|
+
"#{scope.join(', ')}; a path narrows the printing and not the analysis, so every " \
|
|
131
|
+
"label is the one the whole-project run reports")
|
|
132
|
+
end
|
|
133
|
+
|
|
87
134
|
FORMATS = %w[text json].freeze
|
|
88
135
|
private_constant :FORMATS
|
|
89
136
|
|
|
90
137
|
def parse_options
|
|
91
|
-
options = { config: nil, format: "text", full: false, no_tolerated: false
|
|
138
|
+
options = { config: nil, format: "text", full: false, no_tolerated: false, label: [], pure: false,
|
|
139
|
+
limit: nil, why: false, list_labels: false }
|
|
92
140
|
OptionParser.new do |opts|
|
|
93
141
|
opts.banner = USAGE
|
|
94
142
|
Options.add_config(opts, options)
|
|
95
143
|
opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
|
|
96
|
-
opts.on("--full", "List every method, including
|
|
144
|
+
opts.on("--full", "List every method, including the ones with nothing to say") do
|
|
97
145
|
options[:full] = true
|
|
98
146
|
end
|
|
147
|
+
opts.on("--label=LABEL", "Only methods carrying LABEL (or a label under it), in either lane") do |value|
|
|
148
|
+
options[:label].concat(value.split(",").map(&:strip).reject(&:empty?))
|
|
149
|
+
end
|
|
150
|
+
opts.on("--pure", "Only methods proven to do nothing beyond mutate.local — the %a{pure} set") do
|
|
151
|
+
options[:pure] = true
|
|
152
|
+
end
|
|
153
|
+
opts.on("--limit=N", Integer, "Print at most N methods") { |value| options[:limit] = value }
|
|
154
|
+
opts.on("--why", "Expand each method's unresolved reasons and declared-lane sources") do
|
|
155
|
+
options[:why] = true
|
|
156
|
+
end
|
|
157
|
+
opts.on("--list-labels", "Print the effect vocabulary this project can name, and exit") do
|
|
158
|
+
options[:list_labels] = true
|
|
159
|
+
end
|
|
99
160
|
# Accepted here and deliberately inert, exactly as it is on `update`: the report is an
|
|
100
161
|
# observation, and observations are undischarged. Only a JUDGMENT reads `effects.tolerated:` —
|
|
101
162
|
# `rigor effects check` / `diff`, and `rigor check`'s envelope contract.
|
|
@@ -116,15 +177,93 @@ module Rigor
|
|
|
116
177
|
# the diagnostics entry serves the run and the #382 effects sidecar serves the collections, leaving
|
|
117
178
|
# only the fixpoint. Sequential is not a cache decision — the run-result cache declines pool mode —
|
|
118
179
|
# but a collecting run is pinned to the fork backend anyway, so `workers: 0` costs nothing here.
|
|
119
|
-
|
|
180
|
+
# #429 — the vocabulary, from the product rather than from a document the gem does not ship.
|
|
181
|
+
#
|
|
182
|
+
# Four configuration keys and two annotation forms all require typing an effect label, and until
|
|
183
|
+
# this there was no way to find out what the labels are: the manual's pointers resolve into
|
|
184
|
+
# `docs/type-specification/`, which `rigortype.gemspec` does not package. The registry is shipped
|
|
185
|
+
# data and already knows the answer; nothing surfaced it.
|
|
186
|
+
#
|
|
187
|
+
# It loads plugins but never analyses, because a plugin is where `rails.flash.write` comes from and
|
|
188
|
+
# a reader deciding what to write in `effects.envelopes:` needs to see it. A plugin that fails to
|
|
189
|
+
# load costs its labels and nothing else — `rigor plugins` is where that failure is reported.
|
|
190
|
+
def list_labels(configuration)
|
|
191
|
+
registry = project_registry(configuration)
|
|
192
|
+
@out.puts("Effect vocabulary — #{registry.labels.length} labels (vocabulary #{registry.vocabulary_version})")
|
|
193
|
+
registry.roots.each do |root|
|
|
194
|
+
@out.puts
|
|
195
|
+
@out.puts(root_heading(root, registry))
|
|
196
|
+
in_root = registry.labels.select { |label| Effects::Label.root(label) == root }
|
|
197
|
+
in_root.each_slice(4) { |slice| @out.puts(" #{slice.join(', ')}") }
|
|
198
|
+
end
|
|
199
|
+
@out.puts
|
|
200
|
+
@out.puts("A bound naming a label admits everything under it and nothing above it: `io` covers " \
|
|
201
|
+
"`io.db.read`, `io.db.read` does not cover `io`.")
|
|
202
|
+
0
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A shipped root carries the registry's own one-liner; a root a plugin or this project opened
|
|
206
|
+
# carries where it came from instead, which is the more useful thing to say about it.
|
|
207
|
+
def root_heading(root, registry)
|
|
208
|
+
description = registry.descriptions[root]
|
|
209
|
+
return "#{root} — #{description}" if description
|
|
210
|
+
|
|
211
|
+
"#{root} — opened by a plugin or by this project's `effects.labels:`"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def project_registry(configuration)
|
|
215
|
+
services = Plugin::Services.new(reflection: Reflection, type: Type::Combinator,
|
|
216
|
+
configuration: configuration, cache_store: nil)
|
|
217
|
+
plugins = Plugin::Loader.load(configuration: configuration, services: services)
|
|
218
|
+
Effects::Registry.for_configuration(configuration, plugin_facts: Effects::PluginFacts.build(plugins))
|
|
219
|
+
rescue StandardError
|
|
220
|
+
Effects::Registry.for_configuration(configuration)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The analysed set is the configured `paths:` **plus** whatever the arguments name — never the
|
|
224
|
+
# arguments alone (#439). An effect summary is transitive over whatever was analysed, so analysing
|
|
225
|
+
# less does not filter the report, it lowers every answer in it: `rigor effects
|
|
226
|
+
# app/controllers/issues_controller.rb` used to report `IssuesController#create: [] …?` where the
|
|
227
|
+
# whole-project run reported four labels and a declared lane, with nothing marking the difference.
|
|
228
|
+
#
|
|
229
|
+
# The union rather than the configured paths alone, so that pointing the command at a tree the
|
|
230
|
+
# configuration does not cover — which is what every `rigor effects PATH` invocation from outside a
|
|
231
|
+
# project does — still analyses it. Inside a project the argument is already under `paths:` and the
|
|
232
|
+
# union is the configured set unchanged.
|
|
233
|
+
#
|
|
234
|
+
# @return [Array(Rigor::Effects::EffectTable, Hash{String=>Array<String>})] the table, and which
|
|
235
|
+
# file each unit was defined in — the map {EffectsReport} needs to answer a path argument.
|
|
236
|
+
def analyze(configuration, scope)
|
|
237
|
+
require_relative "../analysis/runner"
|
|
120
238
|
runner = Analysis::Runner.new(
|
|
121
239
|
configuration: configuration,
|
|
122
240
|
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
123
241
|
collect_stats: false,
|
|
124
242
|
workers: 0
|
|
125
243
|
)
|
|
126
|
-
runner.run(
|
|
127
|
-
runner.effect_table
|
|
244
|
+
runner.run((configuration.paths + scope).uniq)
|
|
245
|
+
[runner.effect_table, runner.effect_sources]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# ADR-104 — the report before the engine. A warm run's whole answer is the #482 summary entry, so
|
|
249
|
+
# {Analysis::EffectsCacheProbe} tries to serve it without loading `rigor/inference`; any decline
|
|
250
|
+
# falls through to the full runner, which behaves exactly as it did.
|
|
251
|
+
#
|
|
252
|
+
# The report is a pure function of the table, the sources and the configuration: the envelope
|
|
253
|
+
# diagnostics the analysing path also computes are discarded here (this method reads the table
|
|
254
|
+
# and the sources and nothing else), which is what makes the served answer identical rather than
|
|
255
|
+
# merely similar.
|
|
256
|
+
def resolve_table(configuration, scope)
|
|
257
|
+
served = probe(configuration, scope)
|
|
258
|
+
return [served.table, served.sources] if served
|
|
259
|
+
|
|
260
|
+
analyze(configuration, scope)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def probe(configuration, scope)
|
|
264
|
+
Analysis::EffectsCacheProbe.new(
|
|
265
|
+
configuration: configuration, cache_root: configuration.cache_path
|
|
266
|
+
).serve((configuration.paths + scope).uniq)
|
|
128
267
|
end
|
|
129
268
|
end
|
|
130
269
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
+
require_relative "../effects/definition_lines"
|
|
6
|
+
require_relative "../effects/snapshot"
|
|
5
7
|
require_relative "../effects/snapshot_diff"
|
|
6
8
|
require_relative "renderable"
|
|
7
9
|
|
|
@@ -16,8 +18,9 @@ module Rigor
|
|
|
16
18
|
# the one line the whole workflow turns on: intent is expressed by regenerating and committing the
|
|
17
19
|
# file, not by annotating the code.
|
|
18
20
|
#
|
|
19
|
-
# It is deliberately not a diagnostic format: no severities
|
|
20
|
-
#
|
|
21
|
+
# It is deliberately not a diagnostic format: no severities and no exit-code weight of its own — the
|
|
22
|
+
# exit code comes from the gate. A row carries a position for a reader to jump to, never one a
|
|
23
|
+
# `# rigor:disable` or a baseline could act on.
|
|
21
24
|
class EffectsDiffRenderer
|
|
22
25
|
include Renderable
|
|
23
26
|
|
|
@@ -35,11 +38,29 @@ module Rigor
|
|
|
35
38
|
|
|
36
39
|
HEDGED_REMOVAL = "-? %<label>s (current summary is not exhaustive)"
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
# Both commands, because they answer the two questions a drift report raises and a reader almost
|
|
42
|
+
# always asks them in this order (#435). `explain` is the one the manual's own narrative reaches for
|
|
43
|
+
# first, and the footer used to name only the one that makes the report go away.
|
|
44
|
+
CLOSING_LINE = "Run `rigor effects explain` to see what caused this, and `rigor effects update` to " \
|
|
45
|
+
"accept it."
|
|
46
|
+
|
|
47
|
+
# A regeneration event routes to `update` alone: `explain` answers "what caused this label to
|
|
48
|
+
# appear", and the answer here is "the record was written under different rules", which explain
|
|
49
|
+
# cannot expand and the line above already said.
|
|
50
|
+
REGENERATION_CLOSING_LINE = "Run `rigor effects update` to regenerate the record under the current " \
|
|
51
|
+
"rules."
|
|
52
|
+
|
|
53
|
+
# @param sources [Hash{String=>Array<String>}] `Runner#effect_sources` — where each unit is defined.
|
|
54
|
+
# A drift row names `file:line` so a reviewer does not have to search for the method (#435). The
|
|
55
|
+
# file rides the cached summary entry and is free; the line is resolved by parsing the row's own
|
|
56
|
+
# file ({Effects::DefinitionLines}), which is why a fresh report — no rows — still parses nothing
|
|
57
|
+
# and the whole-project parse ADR-104 removed from this command stays removed.
|
|
58
|
+
# @param lines [Effects::DefinitionLines] seam for the specs; the default parses on demand.
|
|
59
|
+
def initialize(out:, path:, sources: nil, lines: Effects::DefinitionLines.new)
|
|
41
60
|
@out = out
|
|
42
61
|
@path = path
|
|
62
|
+
@sources = sources || {}
|
|
63
|
+
@lines = lines
|
|
43
64
|
end
|
|
44
65
|
|
|
45
66
|
private
|
|
@@ -63,10 +84,7 @@ module Rigor
|
|
|
63
84
|
|
|
64
85
|
@out.puts("")
|
|
65
86
|
@out.puts("#{section_name(table)}:")
|
|
66
|
-
events.each
|
|
67
|
-
suffix = qualified ? " (#{event.table})" : ""
|
|
68
|
-
@out.puts(" #{render_event(event)}#{suffix}")
|
|
69
|
-
end
|
|
87
|
+
events.each { |event| @out.puts(" #{render_event(event, qualified: qualified)}") }
|
|
70
88
|
end
|
|
71
89
|
|
|
72
90
|
def section_name(table)
|
|
@@ -77,9 +95,38 @@ module Rigor
|
|
|
77
95
|
end
|
|
78
96
|
end
|
|
79
97
|
|
|
80
|
-
def render_event(event)
|
|
98
|
+
def render_event(event, qualified: false)
|
|
81
99
|
marker = marker_for(event)
|
|
82
|
-
|
|
100
|
+
return marker if event.symbol.nil?
|
|
101
|
+
|
|
102
|
+
"#{event.symbol} #{marker}#{annotation(event, qualified)}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# One parenthetical, never two: where the unit is defined, and — under `tolerated:`, which pools
|
|
106
|
+
# both tables — which table the event came from. A row with no known source under `tolerated:`
|
|
107
|
+
# renders exactly as it did before this suffix existed.
|
|
108
|
+
#
|
|
109
|
+
# A unit defined by a reopening spans several files and the row names them all rather than picking
|
|
110
|
+
# one; which of them a reviewer wants is exactly what the row cannot know.
|
|
111
|
+
def annotation(event, qualified)
|
|
112
|
+
parts = sources_of(event.symbol).map { |path, line| line ? "#{path}:#{line}" : path }
|
|
113
|
+
parts << event.table if qualified
|
|
114
|
+
parts.empty? ? "" : " (#{parts.join(', ')})"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Where a symbol is defined, as the two renderings share it — one implementation, so the text form
|
|
118
|
+
# and the JSON one cannot disagree about a position (WD3's rule).
|
|
119
|
+
#
|
|
120
|
+
# **A removed symbol has no entry here at all**, and cannot: `sources` is the CURRENT run's, and a
|
|
121
|
+
# method that no longer exists was not defined by it. Such a row renders exactly as it did before
|
|
122
|
+
# this suffix existed.
|
|
123
|
+
#
|
|
124
|
+
# @return [Array<Array(String, Integer)>] `[relative path, line]` per file, the line nil when that
|
|
125
|
+
# file spells the key with no `def` — an accessor, or a definition the nesting cannot name.
|
|
126
|
+
def sources_of(symbol)
|
|
127
|
+
Array(@sources[symbol]).map do |path|
|
|
128
|
+
[Effects::Snapshot.relativize(path, Dir.pwd), @lines.for(key: symbol, path: path)]
|
|
129
|
+
end
|
|
83
130
|
end
|
|
84
131
|
|
|
85
132
|
def marker_for(event)
|
|
@@ -95,21 +142,48 @@ module Rigor
|
|
|
95
142
|
def render_footer(diff)
|
|
96
143
|
footer = diff.footer
|
|
97
144
|
@out.puts("")
|
|
145
|
+
@out.puts(withheld_line(footer)) if diff.regeneration?
|
|
98
146
|
unless footer[:added_symbols].zero? && footer[:removed_symbols].zero?
|
|
99
147
|
@out.puts("symbols: +#{footer[:added_symbols]} / -#{footer[:removed_symbols]} " \
|
|
100
148
|
"(a rename is one of each)")
|
|
101
149
|
end
|
|
102
|
-
@out.puts(CLOSING_LINE)
|
|
150
|
+
@out.puts(diff.regeneration? ? REGENERATION_CLOSING_LINE : CLOSING_LINE)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# What a regeneration withheld, said as a count. The counts below it are still printed, so the
|
|
154
|
+
# reader sees the scale of the difference without reading it row by row.
|
|
155
|
+
def withheld_line(footer)
|
|
156
|
+
suppressed = footer[:suppressed]
|
|
157
|
+
return "The two records are not comparable, so no per-method difference is shown." if
|
|
158
|
+
suppressed.zero?
|
|
159
|
+
|
|
160
|
+
noun = suppressed == 1 ? "1 per-method difference is" : "#{suppressed} per-method differences are"
|
|
161
|
+
"The two records were computed under different rules and are not comparable, so #{noun} not shown."
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The machine form carries the same position the text one prints (#435): a bot that annotates a pull
|
|
165
|
+
# request is the consumer with the most use for a line and the least ability to find one itself.
|
|
166
|
+
# `sources` is omitted rather than emitted empty when the row names nothing — a removed symbol.
|
|
167
|
+
def event_json(event)
|
|
168
|
+
row = event.to_h
|
|
169
|
+
sources = sources_of(event.symbol)
|
|
170
|
+
return row if sources.empty?
|
|
171
|
+
|
|
172
|
+
row.merge("sources" => sources.map do |path, line|
|
|
173
|
+
line ? { "path" => path, "line" => line } : { "path" => path }
|
|
174
|
+
end)
|
|
103
175
|
end
|
|
104
176
|
|
|
105
177
|
def render_json(diff)
|
|
106
178
|
@out.puts(JSON.pretty_generate(
|
|
107
179
|
"fresh" => diff.fresh?,
|
|
108
|
-
"events" => diff.events.map(
|
|
180
|
+
"events" => diff.events.map { |event| event_json(event) },
|
|
109
181
|
"footer" => {
|
|
110
182
|
"added_symbols" => diff.footer[:added_symbols],
|
|
111
|
-
"removed_symbols" => diff.footer[:removed_symbols]
|
|
183
|
+
"removed_symbols" => diff.footer[:removed_symbols],
|
|
184
|
+
"suppressed" => diff.footer[:suppressed]
|
|
112
185
|
},
|
|
186
|
+
"regeneration" => diff.regeneration?,
|
|
113
187
|
"header" => {
|
|
114
188
|
"snapshot" => @path,
|
|
115
189
|
"gate" => diff.gate.to_s,
|
|
@@ -22,8 +22,9 @@ module Rigor
|
|
|
22
22
|
class EffectsExplainRenderer
|
|
23
23
|
include Renderable
|
|
24
24
|
|
|
25
|
-
# One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin
|
|
26
|
-
|
|
25
|
+
# One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin;
|
|
26
|
+
# `causes` is non-empty only for an exhaustiveness row, which has neither (#435).
|
|
27
|
+
Row = Data.define(:table, :symbol, :label, :path, :origin, :causes)
|
|
27
28
|
|
|
28
29
|
def initialize(out:)
|
|
29
30
|
@out = out
|
|
@@ -44,6 +45,7 @@ module Rigor
|
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def render_row(row)
|
|
48
|
+
return "#{row.symbol} stopped being exhaustive ← #{row.causes.join(', ')}" unless row.causes.empty?
|
|
47
49
|
return "#{row.path.join(' → ')} [#{row.label}]" unless row.path.empty?
|
|
48
50
|
|
|
49
51
|
origin = row.origin ? " ← #{row.origin}" : ""
|
|
@@ -58,7 +60,8 @@ module Rigor
|
|
|
58
60
|
"symbol" => row.symbol,
|
|
59
61
|
"label" => row.label,
|
|
60
62
|
"path" => row.path,
|
|
61
|
-
"origin" => row.origin
|
|
63
|
+
"origin" => row.origin,
|
|
64
|
+
"causes" => row.causes
|
|
62
65
|
}
|
|
63
66
|
end
|
|
64
67
|
))
|
|
@@ -16,17 +16,53 @@ module Rigor
|
|
|
16
16
|
class EffectsRenderer
|
|
17
17
|
include Renderable
|
|
18
18
|
|
|
19
|
-
def initialize(out:)
|
|
19
|
+
def initialize(out:, why: false)
|
|
20
20
|
@out = out
|
|
21
|
+
@why = why
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
private
|
|
24
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.
|
|
25
29
|
def render_text(report)
|
|
26
30
|
report.rows.each do |row|
|
|
27
|
-
@out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{
|
|
31
|
+
@out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{hedge(row)}")
|
|
32
|
+
next unless @why
|
|
33
|
+
|
|
28
34
|
row.causes.each { |cause, detail| @out.puts(" #{cause}#{" (#{detail})" if detail}") }
|
|
35
|
+
row.attribution.each { |origin, labels| @out.puts(" #{origin} → [#{labels.join(', ')}]") }
|
|
29
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(', ')}"
|
|
30
66
|
end
|
|
31
67
|
|
|
32
68
|
# `≤` is the lane's spelling everywhere in the model — an upper bound, not an observation — so the
|
|
@@ -43,10 +79,12 @@ module Rigor
|
|
|
43
79
|
"declared" => row.declared,
|
|
44
80
|
"exhaustive" => row.exhaustive?,
|
|
45
81
|
"causes" => row.causes.map { |cause, detail| [cause, detail] },
|
|
46
|
-
"direct" => row.direct
|
|
82
|
+
"direct" => row.direct,
|
|
83
|
+
"attribution" => row.attribution
|
|
47
84
|
}]
|
|
48
85
|
end
|
|
49
86
|
}
|
|
87
|
+
payload["totals"] = report.totals.to_h.transform_keys(&:to_s) if report.totals
|
|
50
88
|
@out.puts(JSON.pretty_generate(payload))
|
|
51
89
|
end
|
|
52
90
|
end
|