rigortype 0.3.5 → 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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
  4. data/docs/handbook/02-everyday-types.md +1 -1
  5. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  6. data/docs/handbook/08-understanding-errors.md +1 -1
  7. data/docs/handbook/09-plugins.md +2 -2
  8. data/docs/handbook/10-sorbet.md +1 -1
  9. data/docs/handbook/README.md +2 -2
  10. data/docs/handbook/appendix-go.md +1 -1
  11. data/docs/handbook/appendix-java-csharp.md +2 -2
  12. data/docs/handbook/appendix-mypy.md +1 -1
  13. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  14. data/docs/handbook/appendix-rust.md +1 -1
  15. data/docs/handbook/appendix-type-theory.md +2 -2
  16. data/docs/handbook/appendix-typescript.md +4 -4
  17. data/docs/manual/02-cli-reference.md +13 -9
  18. data/docs/manual/03-configuration.md +1 -1
  19. data/docs/manual/04-diagnostics.md +12 -0
  20. data/docs/manual/08-skills.md +1 -1
  21. data/docs/manual/11-ci.md +1 -1
  22. data/docs/manual/12-caching.md +1 -1
  23. data/docs/manual/15-type-protection-coverage.md +1 -1
  24. data/docs/manual/18-removing-dead-code.md +13 -4
  25. data/docs/manual/19-effect-labels.md +19 -12
  26. data/docs/manual/README.md +2 -2
  27. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  28. data/docs/manual/plugins/rigor-activejob.md +3 -3
  29. data/docs/manual/plugins/rigor-activerecord.md +4 -4
  30. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  31. data/docs/manual/plugins/rigor-rspec.md +6 -2
  32. data/lib/rigor/analysis/diagnostic.rb +17 -6
  33. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  34. data/lib/rigor/analysis/reachability/graph.rb +30 -11
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  36. data/lib/rigor/analysis/reachability/scan.rb +20 -4
  37. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +12 -0
  39. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  40. data/lib/rigor/analysis/runner.rb +151 -28
  41. data/lib/rigor/cache/file_digest.rb +20 -2
  42. data/lib/rigor/cli/check_command.rb +67 -48
  43. data/lib/rigor/cli/coverage_command.rb +5 -6
  44. data/lib/rigor/cli/doc_links.rb +100 -0
  45. data/lib/rigor/cli/docs_command.rb +32 -2
  46. data/lib/rigor/cli/effects_command.rb +27 -2
  47. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  48. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  49. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  50. data/lib/rigor/cli/unused_command.rb +65 -16
  51. data/lib/rigor/cli.rb +21 -8
  52. data/lib/rigor/effects/definition_lines.rb +100 -0
  53. data/lib/rigor/effects/envelope_check.rb +18 -1
  54. data/lib/rigor/effects/liskov_check.rb +17 -8
  55. data/lib/rigor/effects/signature_sources.rb +13 -2
  56. data/lib/rigor/effects/snapshot.rb +53 -21
  57. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  59. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  60. data/lib/rigor/version.rb +1 -1
  61. data/skills/rigor-ci-setup/SKILL.md +2 -2
  62. data/skills/rigor-editor-setup/SKILL.md +2 -2
  63. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  64. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  65. data/skills/rigor-plugin-review/SKILL.md +3 -3
  66. metadata +5 -1
@@ -18,11 +18,11 @@ plugins:
18
18
  ## What it checks
19
19
 
20
20
  ```text
21
- demo.rb:20:1: info: `User.find` returns User (table: `users`) [plugin.activerecord.model-call]
22
- demo.rb:23:1: info: `User.where` (:admin) on table `users` [plugin.activerecord.model-call]
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:13:1: error: `User.where(emial: ...)` references unknown column `emial` on table `users` (did you mean `:email`?) [plugin.activerecord.unknown-column]
25
- errors_demo.rb:25:1: error: `User.find` expects at least 1 argument, got 0 [plugin.activerecord.wrong-arity]
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:14:1: info: `t('users.welcome')` resolves in en, ja
23
- errors_demo.rb:12:1: error: missing translation key `users.welcom` in any locale (did you mean `users.welcome`?)
24
- errors_demo.rb:16:1: error: `t('users.welcome')` expects interpolation `name`, got (none)
25
- errors_demo.rb:20:1: warning: `t('users.welcome')` does not use interpolation `extra` (known placeholders: `name`)
26
- errors_demo.rb:25:1: warning: `t('errors.messages.blank')` is missing from locale(s) ja
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/user_spec.rb:5:3: warning: duplicate `let(:user)` in this scope (first declared at line 4); the last declaration wins at runtime
34
- spec/user_spec.rb:7:3: error: `let(:tags)` references its own name `tags` — this will infinite-loop at runtime
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>`, `generated.<provider>.<rule>`) is appended
138
- # in brackets whenever the diagnostic carries a non-default `source_family`, so plugin / RBS::Extended
139
- # / generated provenance is visible in the standard text output without changing the layout for
140
- # built-in rules. Slice 5 (v0.1.0) wires this surface.
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
- undecidable = tainted(unreached - namespaces)
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 - undecidable.keys.to_set),
70
+ candidates: rows(unreached - namespaces - demoted),
60
71
  undecidable: undecidable.map { |fqn, reason| undecidable_row(fqn, reason) }.freeze,
61
- test_only: rows(reachable - production),
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 unreached declaration a dynamic site could still be naming. A site with a
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
- def tainted(unreached)
84
- prefixes = @dynamic_uses.filter_map { |use| [use.prefix, use] if use.name.nil? && use.prefix }
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 prefixes.empty?
105
+ return {} if sites.empty?
87
106
 
88
- unreached.each_with_object({}) do |fqn, out|
89
- _, use = prefixes.find { |prefix, _| fqn == prefix || fqn.start_with?("#{prefix}::") }
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 `CLI::ProbeEnvironment.load_plugin_registry`: a `Plugin::Services` with no cache store
106
- # (this is a one-shot report, so a producer recomputes rather than reading and writing cache slots)
107
- # driving `Plugin::Loader.load`. Holding the `Services` is what gives access to the fact store the
108
- # loaded plugins share — the loader hands the same instance to every plugin.
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: nil
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 the namespace a dynamic construction can reach into
37
- # (`"Foo::#{k}".constantize` `"Foo"`, and `nil` when even that is unknown), which taints every
38
- # declaration at or below it rather than proving any single one used.
39
- DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line)
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
@@ -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
@@ -30,11 +30,13 @@ module Rigor
30
30
  #
31
31
  # 1. **Read the envelopes** off the project's own RBS ({RbsExtended::EnvelopeScanner}). A project
32
32
  # with `effects:` but no envelope pays this walk and stops here.
33
- # 2. **Force cross-file discovery**, but only when step 1 found something — the diagnostic is
34
- # positioned at the Ruby `def`, and the discovery tables are what map a method key to one.
35
- # 3. **Judge** ({Effects::EnvelopeCheck}) and render, then run the findings through the ordinary
36
- # suppression filter so `# rigor:disable effect.envelope-exceeded` on the `def` line and
37
- # `disable:` in `.rigor.yml` work exactly as they do for a per-file rule.
33
+ # 2. **Judge** ({Effects::EnvelopeCheck}). Cross-file discovery — the diagnostic is positioned
34
+ # at the Ruby `def`, and the discovery tables are what map a method key to one — is forced
35
+ # from inside the judgment, on the first finding built, so an envelope nothing exceeds costs
36
+ # no discovery parse at all.
37
+ # 3. **Render**, then run the findings through the ordinary suppression filter so
38
+ # `# rigor:disable effect.envelope-exceeded` on the `def` line and `disable:` in `.rigor.yml`
39
+ # work exactly as they do for a per-file rule.
38
40
  #
39
41
  # **Why this is not part of the cached run assembly.** ADR-103 WD12 says envelope diagnostics are
40
42
  # recomputed every run from the (possibly cached) summaries and never stored. The `effects:` block
@@ -80,7 +82,8 @@ module Rigor
80
82
  # @param rbs_loader [Rigor::Environment::RbsLoader, nil] the run's loader; nil disables the pass.
81
83
  # @param effect_table [Rigor::Effects::EffectTable] the propagated graph.
82
84
  # @param discovery [#call] forces and returns the cross-file discovery tables as
83
- # `[def_sources, singleton_def_sources, class_sources]`. Called only when an envelope exists.
85
+ # `[def_sources, singleton_def_sources, class_sources]`. Called only when a finding needs a
86
+ # position — a judged-clean envelope never forces it.
84
87
  # @param sources [Hash{String => String}] in-memory sources, for the buffer-backed run path.
85
88
  # @param unit_sources [Hash{String => Array<String>}] `Runner#effect_sources` — where each effect
86
89
  # unit is defined, which is what an `effects.envelopes[].match:` path glob selects on. Its paths
@@ -272,9 +275,12 @@ module Rigor
272
275
  )
273
276
  end
274
277
 
275
- # The discovery tables both judgments position their findings from, forced once.
278
+ # The discovery tables both judgments position their findings from behind a deferred value,
279
+ # forced at most once, on the first finding either judgment builds. A clean run (an envelope
280
+ # nothing exceeds — the common CI case) therefore never calls `@discovery` at all, which on a
281
+ # warm run is the difference between a no-op and a Prism parse of every project file.
276
282
  def positions
277
- @positions ||= begin
283
+ @positions ||= Effects::EnvelopeCheck::DeferredPositions.new do
278
284
  def_sources, singleton_def_sources, class_sources = @discovery.call
279
285
  Effects::EnvelopeCheck::Positions.build(
280
286
  def_sources: def_sources, singleton_def_sources: singleton_def_sources,