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
data/docs/manual/README.md
CHANGED
|
@@ -65,6 +65,11 @@ flag, key, or command that *acts* on it.
|
|
|
65
65
|
`rigor unused` as a campaign on an old codebase: why the list
|
|
66
66
|
starts long, which lever actually shortens it, what to confirm
|
|
67
67
|
before deleting, and how to make the case to a reviewer.
|
|
68
|
+
19. [Effect labels](19-effect-labels.md) — what your code *does*
|
|
69
|
+
beside what it returns: the label vocabulary, the `rigor
|
|
70
|
+
effects` report, the committed `.rigor-effects.yml` snapshot
|
|
71
|
+
and its review loop, the CI gate, and the envelopes and
|
|
72
|
+
`%a{pure}` annotations that bound a layer.
|
|
68
73
|
|
|
69
74
|
### Integration and operations
|
|
70
75
|
|
|
@@ -75,7 +80,7 @@ flag, key, or command that *acts* on it.
|
|
|
75
80
|
Cline, …) via `rigor mcp`.
|
|
76
81
|
11. [Running Rigor in CI](11-ci.md) — a clean CI job, inline
|
|
77
82
|
PR/MR diagnostics (SARIF / GitHub Actions / GitLab Code
|
|
78
|
-
Quality), copy-paste [templates](ci-templates
|
|
83
|
+
Quality), copy-paste [templates](ci-templates), and
|
|
79
84
|
version pinning.
|
|
80
85
|
12. [Caching](12-caching.md) — where the cache lives, what
|
|
81
86
|
invalidates it, and how to clear it.
|
|
@@ -89,4 +94,4 @@ flag, key, or command that *acts* on it.
|
|
|
89
94
|
- [`docs/types.md`](../types.md) — one-page type-system guide.
|
|
90
95
|
- [`docs/type-specification/`](../type-specification/README.md)
|
|
91
96
|
— the normative spec corpus.
|
|
92
|
-
- [`docs/adr/`](../adr
|
|
97
|
+
- [`docs/adr/`](../adr) — architecture decision records.
|
|
@@ -26,6 +26,15 @@ comments (it works the same way against GitLab, Gerrit, Bitbucket, and Gitea
|
|
|
26
26
|
— see the [`rigor-ci-setup`](../../../skills/rigor-ci-setup/SKILL.md) skill).
|
|
27
27
|
All run Rigor the same way — only the output format and publish step differ.
|
|
28
28
|
|
|
29
|
+
Every template ends with a **commented-out `rigor effects check` step**.
|
|
30
|
+
Uncomment it once the project has an `effects:` block and a committed
|
|
31
|
+
`.rigor-effects.yml`, and CI will fail when a branch changes what the code
|
|
32
|
+
*does* — a job that starts talking to the network, a presenter that starts
|
|
33
|
+
querying. It ships commented because `effects check` exits `1` when the
|
|
34
|
+
snapshot file is absent, which is exactly what you want on a project that
|
|
35
|
+
has one and pure noise on a project that does not. The workflow around it
|
|
36
|
+
is [chapter 19, "Effect labels"](../19-effect-labels.md).
|
|
37
|
+
|
|
29
38
|
## Other runners (generic recipe)
|
|
30
39
|
|
|
31
40
|
On any CI system, the four steps are: provision Ruby 4.0, install
|
|
@@ -17,10 +17,10 @@ plugins:
|
|
|
17
17
|
## What it checks
|
|
18
18
|
|
|
19
19
|
```text
|
|
20
|
-
demo.rb:7:1:
|
|
21
|
-
errors_demo.rb:
|
|
22
|
-
errors_demo.rb:
|
|
23
|
-
app/mailers/user_mailer.rb:
|
|
20
|
+
demo.rb:7:1: info: `UserMailer.welcome` matches mailer action (arity 1..2) [plugin.actionmailer.mailer-call]
|
|
21
|
+
errors_demo.rb:10:1: error: `UserMailer.welcome` expects 1..2 argument(s), got 0 [plugin.actionmailer.wrong-arity]
|
|
22
|
+
errors_demo.rb:18:1: error: `UserMailer.does_not_exist` is not a defined mailer action (known actions: digest, reset_password, welcome) [plugin.actionmailer.unknown-action]
|
|
23
|
+
app/mailers/user_mailer.rb:26:7: warning: `UserMailer#digest` has no view template under `app/views/user_mailer/` [plugin.actionmailer.missing-view]
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
1. **Action existence** — `Mailer.unknown_action(...)` →
|
|
@@ -18,9 +18,9 @@ Given a job whose `#perform` takes one required and one optional
|
|
|
18
18
|
argument (arity `1..2`):
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
|
-
demo.rb:
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
demo.rb:8:1: info: `WelcomeEmailJob.perform_later` matches `#perform` (arity 1..2) [plugin.activejob.job-call]
|
|
22
|
+
errors_demo.rb:10:1: error: `WelcomeEmailJob.perform_later` expects 1..2 argument(s), got 0 [plugin.activejob.wrong-arity]
|
|
23
|
+
errors_demo.rb:14:1: error: `WelcomeEmailJob.perform_later` expects 1..2 argument(s), got 3 [plugin.activejob.wrong-arity]
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
A `*rest` parameter yields an unbounded upper bound (`arity 0+`).
|
|
@@ -18,11 +18,11 @@ plugins:
|
|
|
18
18
|
## What it checks
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
|
-
demo.rb:
|
|
22
|
-
demo.rb:
|
|
21
|
+
demo.rb:18:1: info: `User.find` returns User (table: `users`) [plugin.activerecord.model-call]
|
|
22
|
+
demo.rb:21:1: info: `User.where` (:admin) on table `users` [plugin.activerecord.model-call]
|
|
23
23
|
|
|
24
|
-
errors_demo.rb:
|
|
25
|
-
errors_demo.rb:
|
|
24
|
+
errors_demo.rb:12:1: error: `User.where(emial: ...)` references unknown column `emial` on table `users` (did you mean `:email`?) [plugin.activerecord.unknown-column]
|
|
25
|
+
errors_demo.rb:24:1: error: `User.find` expects at least 1 argument, got 0 [plugin.activerecord.wrong-arity]
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
| Diagnostic | Severity | Rule |
|
|
@@ -19,11 +19,11 @@ Against a locale catalogue, every statically-resolvable call site
|
|
|
19
19
|
is validated:
|
|
20
20
|
|
|
21
21
|
```text
|
|
22
|
-
demo.rb:
|
|
23
|
-
errors_demo.rb:
|
|
24
|
-
errors_demo.rb:
|
|
25
|
-
errors_demo.rb:
|
|
26
|
-
errors_demo.rb:
|
|
22
|
+
demo.rb:12:1: info: `t('users.welcome')` resolves in en, ja [plugin.rails-i18n.translation-call]
|
|
23
|
+
errors_demo.rb:10:1: error: missing translation key `users.welcom` in any locale (did you mean `users.welcome`?) [plugin.rails-i18n.unknown-key]
|
|
24
|
+
errors_demo.rb:14:1: error: `t('users.welcome')` expects interpolation `name`, got (none) [plugin.rails-i18n.wrong-interpolation]
|
|
25
|
+
errors_demo.rb:18:1: warning: `t('users.welcome')` does not use interpolation `extra` (known placeholders: `name`) [plugin.rails-i18n.extra-interpolation]
|
|
26
|
+
errors_demo.rb:23:1: warning: `t('errors.messages.blank')` is missing from locale(s) ja [plugin.rails-i18n.missing-locale]
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
1. **Key existence** — a key absent from every locale is flagged,
|
|
@@ -29,9 +29,13 @@ RSpec.describe "User" do
|
|
|
29
29
|
end
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
The snippet above is condensed for reading; the output below is what
|
|
33
|
+
Rigor actually prints for the plugin's own demo
|
|
34
|
+
(`plugins/rigor-rspec/demo/`), so the line numbers are that file's:
|
|
35
|
+
|
|
32
36
|
```text
|
|
33
|
-
spec/
|
|
34
|
-
spec/
|
|
37
|
+
spec/errors_spec.rb:23:3: warning: duplicate `let(:user)` in this scope (first declared at line 22); the last declaration wins at runtime [plugin.rspec.duplicate-let]
|
|
38
|
+
spec/errors_spec.rb:27:3: error: `let(:tags)` references its own name `tags` — this will infinite-loop at runtime [plugin.rspec.self-reference]
|
|
35
39
|
```
|
|
36
40
|
|
|
37
41
|
1. **Duplicate `let` / `subject` declarations** within the same
|
|
@@ -134,14 +134,25 @@ module Rigor
|
|
|
134
134
|
end
|
|
135
135
|
|
|
136
136
|
# Text rendering for `rigor check`. The qualified rule identifier (per ADR-2 § "Plugin Diagnostic
|
|
137
|
-
# Provenance" — `plugin.<id>.<rule>`, `rbs_extended.<rule>`,
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
137
|
+
# Provenance" — `call.undefined-method`, `plugin.<id>.<rule>`, `rbs_extended.<rule>`,
|
|
138
|
+
# `generated.<provider>.<rule>`) is appended in brackets.
|
|
139
|
+
#
|
|
140
|
+
# Slice 5 (v0.1.0) introduced the bracket for non-builtin families only, "without changing the layout
|
|
141
|
+
# for built-in rules" — a layout-conservatism call made when provenance was the point, not a judgment
|
|
142
|
+
# that the identifier is noise. The consequence outlived the reason (#431): the exception covers the
|
|
143
|
+
# rules `docs/manual/04-diagnostics.md` tells the reader to suppress with `# rigor:disable <id>` and
|
|
144
|
+
# to key `severity_profile:` on, so the default output was the one place the identifier could not be
|
|
145
|
+
# read. A run could not be grepped for a rule either — the effect-system walkthrough grepped
|
|
146
|
+
# `effect\.` over a whole Redmine run, got nothing, and concluded the feature was broken.
|
|
147
|
+
#
|
|
148
|
+
# The cost is bounded and was measured before the change: on Redmine every diagnostic carries an
|
|
149
|
+
# identifier, only 13 distinct ones appear across the project, and the suffix adds 14 characters to a
|
|
150
|
+
# 165-character median line.
|
|
151
|
+
#
|
|
152
|
+
# `rule` is nil for diagnostics no rule produced — parse errors, path errors, internal analyzer
|
|
153
|
+
# errors — and those stay unsuffixed, because there is nothing to suppress or configure.
|
|
141
154
|
def to_s
|
|
142
155
|
base = "#{path}:#{line}:#{column}: #{severity}: #{message}"
|
|
143
|
-
return base if source_family == DEFAULT_SOURCE_FAMILY
|
|
144
|
-
|
|
145
156
|
qualified = qualified_rule
|
|
146
157
|
return base if qualified.nil?
|
|
147
158
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_expansion"
|
|
4
|
+
require_relative "run_cache_key"
|
|
5
|
+
require_relative "../cache/store"
|
|
6
|
+
require_relative "../cache/file_digest"
|
|
7
|
+
require_relative "../effects/identity"
|
|
8
|
+
require_relative "../effects/plugin_facts"
|
|
9
|
+
require_relative "../effects/registry"
|
|
10
|
+
require_relative "../plugin"
|
|
11
|
+
require_relative "../plugin/loader"
|
|
12
|
+
require_relative "../plugin/services"
|
|
13
|
+
require_relative "../reflection"
|
|
14
|
+
require_relative "../type/combinator"
|
|
15
|
+
|
|
16
|
+
module Rigor
|
|
17
|
+
module Analysis
|
|
18
|
+
# ADR-104 — the boot-slimming probe for the effects surfaces, the shape ADR-87 WD4's
|
|
19
|
+
# {RunCacheProbe} gave `rigor check`.
|
|
20
|
+
#
|
|
21
|
+
# A warm `rigor effects` had almost no work left after #475 and #482 and still cost 2–2.5× a warm
|
|
22
|
+
# `rigor check`, because it loaded the inference engine to compute two cache keys and adopt two
|
|
23
|
+
# cached values. This serves the report from the #482 summary entry — the propagated table and the
|
|
24
|
+
# unit sources — loading configuration, the cache, the plugin loader and the `effects/*` value
|
|
25
|
+
# layer, and never `rigor/inference`.
|
|
26
|
+
#
|
|
27
|
+
# ## What makes an engine-free key possible
|
|
28
|
+
#
|
|
29
|
+
# The effects identity ({Effects::Identity.descriptor}) is the run's diagnostics key descriptor plus
|
|
30
|
+
# the vocabulary version, the catalogue identity, the `effects:` digest and {Effects::PluginFacts}'
|
|
31
|
+
# own digest. The first is what {RunCacheProbe} already computes without a loader; the rest are pure
|
|
32
|
+
# functions of shipped data, configuration, and the plugins' class-level declarations — a plugin's
|
|
33
|
+
# contributions are fixed by `init`, which the loader runs, so `#prepare` (the expensive half) is
|
|
34
|
+
# not needed. `PluginFacts#digest` is computed before the project's superclass table is even
|
|
35
|
+
# assigned to it, so the discovery tables a probe cannot build do not participate.
|
|
36
|
+
#
|
|
37
|
+
# ## Why a decline is always safe
|
|
38
|
+
#
|
|
39
|
+
# Every reason to decline — a key this path cannot reproduce, a miss, a stale dependency — ends in
|
|
40
|
+
# the caller running the full `Analysis::Runner`, which answers exactly as it does today. The one
|
|
41
|
+
# reproducibility gap is deliberate and inherited: a project whose plugins synthesise virtual RBS
|
|
42
|
+
# gets a key without the `rbs.virtual_rbs` slot the runner writes, so it misses here rather than
|
|
43
|
+
# matching something it should not.
|
|
44
|
+
#
|
|
45
|
+
# The rule the surfaces are held to (ADR-104's criterion): a probe may serve a surface only when
|
|
46
|
+
# every answer that surface can give is reproducible from stored values and declarations alone.
|
|
47
|
+
# `rigor effects` and the snapshot verbs qualify because their output is a pure function of the
|
|
48
|
+
# table, the sources, the configuration and the vocabulary — the envelope diagnostics the full path
|
|
49
|
+
# also computes are discarded by every one of them.
|
|
50
|
+
class EffectsCacheProbe
|
|
51
|
+
# What a served run hands back: the two cached tables, plus the vocabulary the caller would
|
|
52
|
+
# otherwise rebuild (the probe loaded the plugins to key the entry, so it already knows it).
|
|
53
|
+
Served = Data.define(:table, :sources, :registry, :plugin_facts)
|
|
54
|
+
|
|
55
|
+
# @param configuration [Rigor::Configuration] with effects already enabled by the caller.
|
|
56
|
+
# @param cache_root [String, nil]
|
|
57
|
+
def initialize(configuration:, cache_root:)
|
|
58
|
+
@configuration = configuration
|
|
59
|
+
@cache_root = cache_root
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param paths [Array<String>] the analysed set — `configuration.paths` unioned with any path
|
|
63
|
+
# arguments, exactly what {CLI::EffectsCommand#analyze} hands the runner, because the analysed
|
|
64
|
+
# set is part of the diagnostics key.
|
|
65
|
+
# @return [Served, nil] nil to decline, on any failure whatsoever.
|
|
66
|
+
def serve(paths)
|
|
67
|
+
return nil if @cache_root.nil? || !@configuration.effects_check?
|
|
68
|
+
|
|
69
|
+
descriptor = effects_descriptor(paths)
|
|
70
|
+
return nil if descriptor.nil?
|
|
71
|
+
|
|
72
|
+
summary = validated_summary(descriptor)
|
|
73
|
+
return nil if summary.nil?
|
|
74
|
+
|
|
75
|
+
table, sources, = summary
|
|
76
|
+
Served.new(table: table, sources: sources, registry: registry, plugin_facts: plugin_facts)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def effects_descriptor(paths)
|
|
84
|
+
files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns)
|
|
85
|
+
base = RunCacheKey.descriptor(
|
|
86
|
+
configuration: @configuration, files: files, explain: false,
|
|
87
|
+
rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
|
|
88
|
+
)
|
|
89
|
+
return nil if base.nil?
|
|
90
|
+
|
|
91
|
+
Effects::Identity.descriptor(base: base, configuration: @configuration, registry: registry,
|
|
92
|
+
plugin_facts: plugin_facts)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The same shape guard the runner's own read half applies, for the same reason: a miss, a stale
|
|
96
|
+
# dependency, a corrupt entry and a value of the wrong shape all mean "run it properly".
|
|
97
|
+
def validated_summary(descriptor)
|
|
98
|
+
store = Cache::Store.new(root: @cache_root, max_bytes: @configuration.cache_max_bytes)
|
|
99
|
+
cached = Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
|
|
100
|
+
store.peek_validated(
|
|
101
|
+
producer_id: RunCacheKey::RUN_EFFECTS_TABLE_PRODUCER_ID, key_descriptor: descriptor
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
return nil unless cached.is_a?(Array) && cached.length == 3
|
|
105
|
+
return nil unless cached[0].is_a?(Effects::EffectTable) && cached[1].is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
cached
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Loaded once, and shared by the key and the answer. `#prepare` is deliberately not run: a
|
|
111
|
+
# plugin's effect contributions are fixed by `init`, and `#prepare` is the half that parses
|
|
112
|
+
# routes and walks worker trees — the cost this probe exists to skip.
|
|
113
|
+
def plugin_facts
|
|
114
|
+
return @plugin_facts if defined?(@plugin_facts)
|
|
115
|
+
|
|
116
|
+
@plugin_facts = Effects::PluginFacts.build(plugin_registry)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def plugin_registry
|
|
120
|
+
services = Plugin::Services.new(reflection: Reflection, type: Type::Combinator,
|
|
121
|
+
configuration: @configuration, cache_store: nil)
|
|
122
|
+
Plugin::Loader.load(configuration: @configuration, services: services)
|
|
123
|
+
rescue StandardError, ScriptError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def registry
|
|
128
|
+
@registry ||= Effects::Registry.for_configuration(@configuration, plugin_facts: plugin_facts)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -54,16 +54,25 @@ module Rigor
|
|
|
54
54
|
reachable = walk(edges, seeds: production_seeds | test_seeds, roles: %i[production task config test])
|
|
55
55
|
unreached = @owned - reachable
|
|
56
56
|
namespaces = namespace_only(unreached, reachable)
|
|
57
|
-
|
|
57
|
+
# The data-file demotion applies to BOTH buckets it can speak to, which is what the tier
|
|
58
|
+
# contract says and what the implementation had narrowed (#370). See {#tainted}.
|
|
59
|
+
test_only = reachable - production
|
|
60
|
+
undecidable = tainted(unreached - namespaces).merge(tainted(test_only))
|
|
61
|
+
build_report(edges: edges, reachable: reachable, unreached: unreached, namespaces: namespaces,
|
|
62
|
+
test_only: test_only, undecidable: undecidable)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
|
|
68
|
+
demoted = undecidable.keys.to_set
|
|
58
69
|
Report.new(declared: @owned.size, reachable: reachable.size,
|
|
59
|
-
candidates: rows(unreached - namespaces -
|
|
70
|
+
candidates: rows(unreached - namespaces - demoted),
|
|
60
71
|
undecidable: undecidable.map { |fqn, reason| undecidable_row(fqn, reason) }.freeze,
|
|
61
|
-
test_only: rows(
|
|
72
|
+
test_only: rows(test_only - demoted),
|
|
62
73
|
namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
|
|
63
74
|
end
|
|
64
75
|
|
|
65
|
-
private
|
|
66
|
-
|
|
67
76
|
# A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
|
|
68
77
|
# unknown. Keeping this distinct from the taint below is what stops the tier being a blanket namespace
|
|
69
78
|
# poison — Rigor knows the argument's shape, and a type-free indexer does not.
|
|
@@ -76,17 +85,27 @@ module Rigor
|
|
|
76
85
|
end
|
|
77
86
|
end
|
|
78
87
|
|
|
79
|
-
# `{fqn => reason}` for every
|
|
88
|
+
# `{fqn => reason}` for every declaration in `fqns` a dynamic site could still be naming. A site with a
|
|
80
89
|
# literal prefix taints that namespace and everything under it; a site with no prefix at all cannot be
|
|
81
90
|
# bounded, so it taints nothing rather than everything — poisoning the whole project would empty the
|
|
82
91
|
# report and teach the reader that the tier means nothing.
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
#
|
|
93
|
+
# Asked of the unreached AND of the test-only set (#370). The tier contract says a name appearing in a
|
|
94
|
+
# data file "MUST demote to this tier"; the implementation had asked only about the unreached, so a
|
|
95
|
+
# declaration a spec references AND `config/recurring.yml` names kept its data-file evidence discarded
|
|
96
|
+
# and landed under "live test, dead production path". That heading is an assertion about production,
|
|
97
|
+
# and a scheduler entry is evidence against it — the reported case runs every three minutes.
|
|
98
|
+
#
|
|
99
|
+
# Both buckets ask a different question of the same ambiguity ("is this dead?" against "is the
|
|
100
|
+
# production path dead?"), and the answer for both is that this reading cannot settle it. The reason
|
|
101
|
+
# string is what tells the two apart for a reader, so it names the evidence rather than the bucket.
|
|
102
|
+
def tainted(fqns)
|
|
103
|
+
sites = @dynamic_uses.select { |use| use.name.nil? && use.prefix }
|
|
85
104
|
|
|
86
|
-
return {} if
|
|
105
|
+
return {} if sites.empty?
|
|
87
106
|
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
fqns.each_with_object({}) do |fqn, out|
|
|
108
|
+
use = sites.find { |site| site.taints?(fqn) }
|
|
90
109
|
out[fqn] = use.site.nil? ? use.reason : "#{use.reason} (#{use.site})" if use
|
|
91
110
|
end
|
|
92
111
|
end
|
|
@@ -86,12 +86,16 @@ module Rigor
|
|
|
86
86
|
# @param configuration [Rigor::Configuration] the loaded project configuration.
|
|
87
87
|
# @param plugin_requirer [#call] how a plugin gem is brought into the process. The same seam
|
|
88
88
|
# `Analysis::Runner` exposes, so a spec can register a plugin class without publishing a gem.
|
|
89
|
+
# @param cache_store [Rigor::Cache::Store, nil] when given, each plugin's `#prepare` producers read
|
|
90
|
+
# and write the same ADR-60 record-and-validate slots they use under `rigor check`, instead of
|
|
91
|
+
# recomputing from scratch — a routes parse or a factory discovery is a validated cache read on
|
|
92
|
+
# every invocation after the first. Nil keeps the historical recompute-always behaviour.
|
|
89
93
|
# @return [Contribution] sorted and de-duplicated. Empty whenever the project declares no plugins, no
|
|
90
94
|
# plugin contributes anything, or anything at all goes wrong.
|
|
91
|
-
def collect(configuration:, plugin_requirer: ->(name) { require name })
|
|
95
|
+
def collect(configuration:, plugin_requirer: ->(name) { require name }, cache_store: nil)
|
|
92
96
|
return Contribution.empty if configuration.plugins.empty?
|
|
93
97
|
|
|
94
|
-
services = build_services(configuration)
|
|
98
|
+
services = build_services(configuration, cache_store)
|
|
95
99
|
registry = Plugin::Loader.load(configuration: configuration, services: services,
|
|
96
100
|
requirer: plugin_requirer)
|
|
97
101
|
return Contribution.empty if registry.nil? || registry.empty?
|
|
@@ -102,16 +106,15 @@ module Rigor
|
|
|
102
106
|
Contribution.empty
|
|
103
107
|
end
|
|
104
108
|
|
|
105
|
-
# Mirrors `
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
def build_services(configuration)
|
|
109
|
+
# Mirrors `Analysis::WorkerSession`'s services: the shared fact store plus whatever cache store the
|
|
110
|
+
# caller holds. Holding the `Services` is what gives access to the fact store the loaded plugins
|
|
111
|
+
# share — the loader hands the same instance to every plugin.
|
|
112
|
+
def build_services(configuration, cache_store)
|
|
110
113
|
Plugin::Services.new(
|
|
111
114
|
reflection: Reflection,
|
|
112
115
|
type: Type::Combinator,
|
|
113
116
|
configuration: configuration,
|
|
114
|
-
cache_store:
|
|
117
|
+
cache_store: cache_store
|
|
115
118
|
)
|
|
116
119
|
end
|
|
117
120
|
|
|
@@ -33,10 +33,26 @@ module Rigor
|
|
|
33
33
|
|
|
34
34
|
# ADR-102 WD4 — a site where a constant is reached by a mechanism the static reading cannot follow.
|
|
35
35
|
# `name` is the exact constant when the argument is a literal (`"Foo".constantize`), in which case this
|
|
36
|
-
# is as good as a reference. `prefix` is
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
|
|
36
|
+
# is as good as a reference. `prefix` is what a dynamic construction can reach, and `scope` says how
|
|
37
|
+
# far — the two kinds of weak evidence are not the same shape:
|
|
38
|
+
#
|
|
39
|
+
# - `:namespace` — `"Foo::#{k}".constantize` can construct ANY name under `Foo`, so it taints `Foo`
|
|
40
|
+
# and every declaration beneath it. This is the default and the original meaning of `prefix`.
|
|
41
|
+
# - `:exact` — a data-file or template match names ONE declaration and says nothing about its
|
|
42
|
+
# children (#370). `config/recurring.yml` mentioning `Admin` is not evidence about
|
|
43
|
+
# `Admin::CollectionPolicy`; treating it as such demoted 18 unrelated Mastodon rows off one
|
|
44
|
+
# Afrikaans word ("Administrasie" contains "Admin").
|
|
45
|
+
DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope) do
|
|
46
|
+
def initialize(scope: :namespace, **) = super
|
|
47
|
+
|
|
48
|
+
# Whether this site's evidence reaches `fqn`.
|
|
49
|
+
def taints?(fqn)
|
|
50
|
+
return false if prefix.nil?
|
|
51
|
+
return fqn == prefix if scope == :exact
|
|
52
|
+
|
|
53
|
+
fqn == prefix || fqn.start_with?("#{prefix}::")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
40
56
|
|
|
41
57
|
Result = Data.define(:declarations, :references, :dynamic_uses)
|
|
42
58
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
require_relative "../../version"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Analysis
|
|
11
|
+
module Reachability
|
|
12
|
+
# ADR-102 — the warm cache for `rigor unused`'s two per-file passes: the Prism reachability
|
|
13
|
+
# scan over `.rb` / `.rake` files, and the capital-run extraction over template files. Both
|
|
14
|
+
# passes are pure functions of one file's bytes, and both were measured re-running in full on
|
|
15
|
+
# every invocation (2.0 s of a 2.6 s warm run on Mastodon; the Marshal restore is 25× cheaper
|
|
16
|
+
# than the rescan, `docs/notes/20260825-feature-warm-cold-corpus-perf.md`).
|
|
17
|
+
#
|
|
18
|
+
# One self-validating zlib-Marshal blob under the cache root, the ADR-46 IncrementalSnapshot
|
|
19
|
+
# shape rather than an ADR-54 `Cache::Store` producer: the sound unit of reuse is the FILE
|
|
20
|
+
# (any subset of files may change between runs), so the payload carries a stat signature per
|
|
21
|
+
# entry and validates each on read, where a store entry validates all-or-nothing and a miss
|
|
22
|
+
# would throw away every unchanged file's work. The whole-project answer stays complete —
|
|
23
|
+
# every file is still consulted every run; an unchanged one contributes its cached product.
|
|
24
|
+
#
|
|
25
|
+
# Fail-soft everywhere: a missing, torn, or stale-schema blob is an empty cache, and a file
|
|
26
|
+
# whose signature cannot be taken is computed and never recorded. Nothing here can change
|
|
27
|
+
# what the report says — only whether a per-file product was recomputed to say it.
|
|
28
|
+
class ScanCache
|
|
29
|
+
SCHEMA = 1
|
|
30
|
+
FILE_NAME = "reachability-scan.bundle"
|
|
31
|
+
|
|
32
|
+
# The sources whose behaviour the cached products embody. Digested into the header so a
|
|
33
|
+
# checkout that edits the scan invalidates its survey targets' bundles without anyone
|
|
34
|
+
# remembering to bump {SCHEMA} — the `Cache::EngineSource` idea, scoped to this feature.
|
|
35
|
+
SOURCE_FILES = [
|
|
36
|
+
File.expand_path("scan.rb", __dir__),
|
|
37
|
+
File.expand_path(__FILE__)
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# A file modified within this window of the recording instant is computed but never
|
|
41
|
+
# recorded — the FileDigest racy-write guard, one tier simpler because a refused signature
|
|
42
|
+
# costs one rescan on the next run, never a wrong answer.
|
|
43
|
+
RACY_WINDOW_NS = 2_000_000_000
|
|
44
|
+
private_constant :RACY_WINDOW_NS
|
|
45
|
+
|
|
46
|
+
# @param cache_path [String, nil] the configuration's cache root; nil or empty is an inert
|
|
47
|
+
# cache (every fetch computes, nothing persists).
|
|
48
|
+
# @param target_ruby [String, nil] parse-affecting configuration, part of the identity.
|
|
49
|
+
def self.open(cache_path, target_ruby: nil)
|
|
50
|
+
return new(path: nil, header: nil) if cache_path.nil? || cache_path.to_s.empty?
|
|
51
|
+
|
|
52
|
+
new(path: File.join(cache_path.to_s, FILE_NAME),
|
|
53
|
+
header: "#{SCHEMA}:#{Rigor::VERSION}:#{target_ruby}:#{source_digest}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.source_digest
|
|
57
|
+
Digest::SHA256.hexdigest(SOURCE_FILES.map { |path| File.read(path) }.join)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
"unreadable"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(path:, header:)
|
|
63
|
+
@path = path
|
|
64
|
+
@header = header
|
|
65
|
+
@entries = load_entries
|
|
66
|
+
@live = {}
|
|
67
|
+
@dirty = false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Serves the cached product for `(kind, path)` while the file's stat signature holds;
|
|
71
|
+
# otherwise computes via the block and records the fresh product.
|
|
72
|
+
def serve(kind, path)
|
|
73
|
+
key = [kind, path]
|
|
74
|
+
sig = signature(path)
|
|
75
|
+
cached = @entries[key]
|
|
76
|
+
if cached && sig && cached[0] == sig
|
|
77
|
+
@live[key] = cached
|
|
78
|
+
return cached[1]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
value = yield
|
|
82
|
+
@live[key] = [sig, value] if sig
|
|
83
|
+
@dirty = true
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Best-effort persist, only when something changed. Carries forward entries this run never
|
|
88
|
+
# consulted (a path-argument run must not shrink the full-project bundle) but drops the ones
|
|
89
|
+
# whose file no longer exists; tmp-then-rename so a torn write is never read back.
|
|
90
|
+
def save
|
|
91
|
+
return if @path.nil? || !@dirty
|
|
92
|
+
|
|
93
|
+
keep = @entries.merge(@live).select { |(_, path), _| File.exist?(path) }
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
95
|
+
tmp = "#{@path}.#{Process.pid}.tmp"
|
|
96
|
+
File.binwrite(tmp, Zlib::Deflate.deflate(Marshal.dump([@header, keep])))
|
|
97
|
+
File.rename(tmp, @path)
|
|
98
|
+
nil
|
|
99
|
+
rescue StandardError
|
|
100
|
+
FileUtils.rm_f(tmp) if tmp
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def load_entries
|
|
107
|
+
return {} if @path.nil?
|
|
108
|
+
|
|
109
|
+
# Same trust model as the ADR-45 store and the ADR-46 snapshot: the blob lives in the
|
|
110
|
+
# project's own cache directory and a corrupt one degrades to an empty cache.
|
|
111
|
+
header, entries = Marshal.load(Zlib::Inflate.inflate(File.binread(@path))) # rubocop:disable Security/MarshalLoad
|
|
112
|
+
header == @header && entries.is_a?(Hash) ? entries : {}
|
|
113
|
+
rescue StandardError
|
|
114
|
+
{}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def signature(path)
|
|
118
|
+
st = File.stat(path)
|
|
119
|
+
mtime_ns = (st.mtime.to_i * 1_000_000_000) + st.mtime.nsec
|
|
120
|
+
now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
121
|
+
return nil if mtime_ns >= now_ns - RACY_WINDOW_NS
|
|
122
|
+
|
|
123
|
+
[st.size, mtime_ns, (st.ctime.to_i * 1_000_000_000) + st.ctime.nsec, st.ino]
|
|
124
|
+
rescue SystemCallError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -36,9 +36,16 @@ module Rigor
|
|
|
36
36
|
module RuleCatalog # rubocop:disable Metrics/ModuleLength
|
|
37
37
|
# Stable documentation home for a built-in rule. `documentation_url` appends a per-rule fragment that
|
|
38
38
|
# resolves to the rule's anchor in the published diagnostics catalogue; the page itself points at
|
|
39
|
-
# `rigor explain <rule>` as the authoritative per-rule reference.
|
|
40
|
-
#
|
|
41
|
-
|
|
39
|
+
# `rigor explain <rule>` as the authoritative per-rule reference.
|
|
40
|
+
#
|
|
41
|
+
# The canonical docs host, deliberately NOT a `github.com/…/blob/<ref>/…` path (ADR-65 amendment,
|
|
42
|
+
# #438): a git ref inside a frozen public contract is a mutable component, and the one that was
|
|
43
|
+
# baked in here — `main`, a branch this repository has never had — made every emitted URL 404 from
|
|
44
|
+
# the day the field shipped. A branch name rots on a rename and a tag only resolves once that tag
|
|
45
|
+
# is pushed (so every unreleased build would emit 404s); the published site carries no ref at all.
|
|
46
|
+
# The site renders `docs/manual/04-diagnostics.md` verbatim, `<a id="rule-…">` anchors included, so
|
|
47
|
+
# the fragment half of the contract is unchanged.
|
|
48
|
+
DOCUMENTATION_BASE = "https://rigor.typedduck.fail/manual/04-diagnostics/"
|
|
42
49
|
|
|
43
50
|
class Entry < Data.define(:id, :summary, :fires_when, :does_not_fire_when,
|
|
44
51
|
:suppression, :severity_authored, :severity_by_profile,
|
|
@@ -48,6 +48,18 @@ module Rigor
|
|
|
48
48
|
# collecting run wrote elsewhere.
|
|
49
49
|
RUN_EFFECTS_PRODUCER_ID = "analysis.run-effects"
|
|
50
50
|
|
|
51
|
+
# #482 — the **serving** half of the sidecar, under the same effects identity: the propagated table,
|
|
52
|
+
# the unit sources and the merged as-written superclass table. Everything a warm run reads, and
|
|
53
|
+
# nothing else.
|
|
54
|
+
#
|
|
55
|
+
# It is a separate entry rather than a section of {RUN_EFFECTS_PRODUCER_ID} because the collections
|
|
56
|
+
# blob is large in exactly the projects where warm latency matters — 6.9 MB and 0.71 s of `Marshal`
|
|
57
|
+
# on gitlab `app lib`, against ~40 KB here — and a warm run consumes none of it. The collections
|
|
58
|
+
# entry stays, read lazily by the paths that genuinely need per-file form (an ADR-46 recheck, the
|
|
59
|
+
# fail-soft re-propagation), so a consumer this split did not anticipate loads the blob rather than
|
|
60
|
+
# seeing an empty table.
|
|
61
|
+
RUN_EFFECTS_TABLE_PRODUCER_ID = "analysis.run-effects-table"
|
|
62
|
+
|
|
51
63
|
# The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
|
|
52
64
|
# but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
|
|
53
65
|
# one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
|