rigortype 0.1.18 → 0.1.19

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -155,6 +155,7 @@ module Rigor
155
155
  @cached_plugin_prepare_diagnostics = [].freeze
156
156
  @project_discovered_classes = {}.freeze
157
157
  @project_discovered_def_nodes = {}.freeze
158
+ @project_discovered_singleton_def_nodes = {}.freeze
158
159
  @project_discovered_def_sources = {}.freeze
159
160
  @project_discovered_superclasses = {}.freeze
160
161
  @project_discovered_includes = {}.freeze
@@ -457,7 +458,7 @@ module Rigor
457
458
  # discovery tables at their frozen-empty constructor defaults
458
459
  # (the bundle carries `nil` for them, matching the original
459
460
  # adopt path that never touched them).
460
- def apply_pre_passes_result(result)
461
+ def apply_pre_passes_result(result) # rubocop:disable Metrics/AbcSize
461
462
  @plugin_registry = result.plugin_registry
462
463
  @dependency_source_index = result.dependency_source_index
463
464
  @cached_plugin_prepare_diagnostics = result.cached_plugin_prepare_diagnostics
@@ -466,6 +467,9 @@ module Rigor
466
467
  @pre_eval_diagnostics_from_scanner = result.pre_eval_diagnostics_from_scanner
467
468
  @project_discovered_classes = result.discovered_classes if result.discovered_classes
468
469
  @project_discovered_def_nodes = result.discovered_def_nodes if result.discovered_def_nodes
470
+ if result.discovered_singleton_def_nodes
471
+ @project_discovered_singleton_def_nodes = result.discovered_singleton_def_nodes
472
+ end
469
473
  @project_discovered_def_sources = result.discovered_def_sources if result.discovered_def_sources
470
474
  @project_discovered_superclasses = result.discovered_superclasses if result.discovered_superclasses
471
475
  @project_discovered_includes = result.discovered_includes if result.discovered_includes
@@ -636,30 +640,44 @@ module Rigor
636
640
  # `#diagnostics_for_file` or declared a `node_rule` are visited
637
641
  # (`contribution_index.for_file_diagnostics`); a skipped plugin's
638
642
  # two hooks could only have returned `[]`.
639
- def plugin_emitted_diagnostics(path, root, scope)
643
+ def plugin_emitted_diagnostics(path, root, scope, node_results)
640
644
  return [] if @plugin_registry.empty?
641
645
 
642
- # ADR-52 WD4 — one engine-owned AST walk per file dispatches each
643
- # node to every matching (plugin, rule); the per-plugin results
644
- # are bucketed in registry order so emission stays plugin-major
645
- # (byte-identical with the old per-plugin walk).
646
- node_results = node_rule_results_by_plugin(path, root, scope)
647
-
648
646
  @plugin_registry.contribution_index.for_file_diagnostics.flat_map do |plugin|
649
647
  collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
650
648
  end
651
649
  end
652
650
 
653
- def node_rule_results_by_plugin(path, root, scope)
651
+ # ADR-52 WD4 + ADR-53 B4 — one engine-owned AST walk per file
652
+ # dispatches each node to every matching (plugin, rule) AND drives
653
+ # the built-in node collectors (`node_collectors`), so the file is
654
+ # walked once for both. The per-plugin results are bucketed in
655
+ # registry order so plugin emission stays plugin-major
656
+ # (byte-identical with the old per-plugin walk); the collectors are
657
+ # populated in place for `diagnose` to consume.
658
+ #
659
+ # When no plugin declares a node rule, the walk still runs to drive
660
+ # the collectors (the converged path replaces the standalone
661
+ # `RuleWalk.run`); `node_collectors` nil means a caller that does
662
+ # not need built-in collection from this walk.
663
+ def node_rule_results_by_plugin(path, root, scope, node_collectors, scope_index)
654
664
  walk = @plugin_registry.node_rule_walk
655
- return {}.compare_by_identity if walk.empty?
665
+ driver = node_collectors && CheckRules.node_collector_driver(node_collectors)
666
+ return {}.compare_by_identity if walk.empty? && driver.nil?
656
667
 
657
- results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
668
+ results = walk.diagnostics_for_file(
669
+ path: path, scope: scope, root: root, collector_driver: driver
670
+ )
671
+ CheckRules.shadow_verify_converged_collectors(path, root, scope_index, node_collectors) if shadow_rule_walk?
658
672
  results.each_with_object({}.compare_by_identity) do |result, by_plugin|
659
673
  by_plugin[result.plugin] = result
660
674
  end
661
675
  end
662
676
 
677
+ def shadow_rule_walk?
678
+ ENV.fetch("RIGOR_SHADOW_RULE_WALK", nil)
679
+ end
680
+
663
681
  def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
664
682
  raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
665
683
  # A node-rule context/rule raise isolates the whole plugin's
@@ -807,6 +825,9 @@ module Rigor
807
825
  tables = {}
808
826
  tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
809
827
  tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
828
+ unless @project_discovered_singleton_def_nodes.empty?
829
+ tables[:discovered_singleton_def_nodes] = @project_discovered_singleton_def_nodes
830
+ end
810
831
  tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
811
832
  unless @project_discovered_superclasses.empty?
812
833
  tables[:discovered_superclasses] = @project_discovered_superclasses
@@ -861,15 +882,8 @@ module Rigor
861
882
  self_call_record = with_self_call_recording(path) do
862
883
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
863
884
  end
864
- diagnostics = CheckRules.diagnose(
865
- path: path,
866
- root: parse_result.value,
867
- scope_index: index,
868
- self_call_misses: self_call_record ? self_call_record.calls : [],
869
- comments: parse_result.comments,
870
- disabled_rules: @configuration.disabled_rules
871
- )
872
- diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
885
+ self_call_misses = self_call_record ? self_call_record.calls : []
886
+ diagnostics = rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
873
887
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
874
888
  rescue Errno::ENOENT => e
875
889
  [
@@ -893,6 +907,28 @@ module Rigor
893
907
  ]
894
908
  end
895
909
 
910
+ # ADR-53 B4 — the built-in node collectors and the plugin node rules
911
+ # share ONE traversal of the file. The collectors are built here (they
912
+ # need the completed `index`) and populated by the converged plugin
913
+ # walk; `node_results` carries the per-plugin node-rule output. Both
914
+ # the built-in `diagnose` output and the plugin diagnostics are then
915
+ # built from that single walk's results.
916
+ def rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
917
+ root = parse_result.value
918
+ node_collectors = CheckRules.build_node_collectors(path, index)
919
+ node_results = node_rule_results_by_plugin(path, root, scope, node_collectors, index)
920
+ diagnostics = CheckRules.diagnose(
921
+ path: path,
922
+ root: root,
923
+ scope_index: index,
924
+ self_call_misses: self_call_misses,
925
+ comments: parse_result.comments,
926
+ disabled_rules: @configuration.disabled_rules,
927
+ node_collectors: node_collectors
928
+ )
929
+ diagnostics + plugin_emitted_diagnostics(path, root, scope, node_results)
930
+ end
931
+
896
932
  # ADR-24 slice 4a — runs `block` (the typing pass) with the self-call
897
933
  # recorder active when either the test-only `record_self_calls:` flag is
898
934
  # set or the `call.self-undefined-method` rule resolves to a firing
@@ -928,7 +964,8 @@ module Rigor
928
964
  else
929
965
  Configuration::SeverityProfile.resolve(
930
966
  rule: rule, authored_severity: :warning,
931
- profile: @configuration.severity_profile, overrides: @configuration.severity_overrides
967
+ profile: @configuration.severity_profile, overrides: @configuration.severity_overrides,
968
+ bleeding_edge_overrides: @configuration.bleeding_edge_severity_overrides
932
969
  ) != :off
933
970
  end
934
971
  end
@@ -175,14 +175,18 @@ module Rigor
175
175
 
176
176
  scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
177
177
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
178
+ # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
179
+ node_collectors = CheckRules.build_node_collectors(path, index)
180
+ node_results = node_rule_results_by_plugin(path, parse_result.value, scope, node_collectors, index)
178
181
  diagnostics = CheckRules.diagnose(
179
182
  path: path,
180
183
  root: parse_result.value,
181
184
  scope_index: index,
182
185
  comments: parse_result.comments,
183
- disabled_rules: @configuration.disabled_rules
186
+ disabled_rules: @configuration.disabled_rules,
187
+ node_collectors: node_collectors
184
188
  )
185
- diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
189
+ diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope, node_results)
186
190
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
187
191
  rescue Errno::ENOENT => e
188
192
  [analyzer_error(path, e.message)]
@@ -294,24 +298,30 @@ module Rigor
294
298
  )
295
299
  end
296
300
 
297
- def plugin_emitted_diagnostics(path, root, scope)
301
+ def plugin_emitted_diagnostics(path, root, scope, node_results)
298
302
  return [] if @plugin_registry.empty?
299
303
 
300
- # ADR-52 WD4 — single engine-owned node-rule walk per file; the
301
- # results are bucketed per plugin (registry order) so emission
302
- # stays plugin-major and byte-identical with the per-plugin walk.
303
- node_results = node_rule_results_by_plugin(path, root, scope)
304
-
305
304
  @plugin_registry.plugins.flat_map do |plugin|
306
305
  collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
307
306
  end
308
307
  end
309
308
 
310
- def node_rule_results_by_plugin(path, root, scope)
309
+ # ADR-52 WD4 + ADR-53 B4 — single engine-owned walk per file drives
310
+ # both the plugin node rules (bucketed per plugin in registry order,
311
+ # plugin-major emission) and the built-in node collectors
312
+ # (`node_collectors`, populated in place). Runs even with no node-rule
313
+ # plugins so the collectors still get driven (converged path).
314
+ def node_rule_results_by_plugin(path, root, scope, node_collectors, scope_index)
311
315
  walk = @plugin_registry.node_rule_walk
312
- return {}.compare_by_identity if walk.empty?
316
+ driver = node_collectors && CheckRules.node_collector_driver(node_collectors)
317
+ return {}.compare_by_identity if walk.empty? && driver.nil?
313
318
 
314
- results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
319
+ results = walk.diagnostics_for_file(
320
+ path: path, scope: scope, root: root, collector_driver: driver
321
+ )
322
+ if ENV["RIGOR_SHADOW_RULE_WALK"]
323
+ CheckRules.shadow_verify_converged_collectors(path, root, scope_index, node_collectors)
324
+ end
315
325
  results.each_with_object({}.compare_by_identity) do |result, by_plugin|
316
326
  by_plugin[result.plugin] = result
317
327
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # ADR-50 § WD2 — the bleeding-edge overlay.
5
+ #
6
+ # A Rigor-maintained set of the *next major's* queued changes —
7
+ # severity-map promotions and new-discipline rule enablements — that a
8
+ # user can adopt early, before they become default-on at a major
9
+ # (ADR-50 § WD7). It is orthogonal to `severity_profile:` (how loud
10
+ # *today's* rules are) and is versioned with the gem, NOT a
11
+ # user-supplied file: the inspectable counterpart to PHPStan's
12
+ # `bleedingEdge` include.
13
+ #
14
+ # The overlay is **empty today** — no discipline has yet been queued
15
+ # for the next major. This module is the WD2 *foundation* (the v0.1.19
16
+ # slice): the surface (`bleeding_edge:` config, the
17
+ # `rigor show-bleedingedge` command, the severity-composition hook in
18
+ # {Configuration::SeverityProfile.resolve}) exists and is wired
19
+ # end-to-end, so the first real feature lands as a single {FEATURES}
20
+ # entry with no engine plumbing.
21
+ #
22
+ # Each feature carries a **stable feature id** — part of the ADR-50
23
+ # WD1 contract vocabulary: the config, the `show` command, and the
24
+ # eventual CHANGELOG migration note all name the same id, and a
25
+ # feature graduates to default-on at a major by being removed from
26
+ # {FEATURES}.
27
+ module BleedingEdge
28
+ # One queued change.
29
+ #
30
+ # @!attribute id
31
+ # @return [String] the stable feature id (contract vocabulary).
32
+ # @!attribute summary
33
+ # @return [String] a one-line description of what it changes.
34
+ # @!attribute severity_overrides
35
+ # @return [Hash{String => Symbol}] canonical rule id → the
36
+ # severity this feature imposes. Composed *below* the user's own
37
+ # `severity_overrides:` and *above* the active `severity_profile`
38
+ # (see {Configuration::SeverityProfile.resolve}).
39
+ Feature = Data.define(:id, :summary, :severity_overrides) do
40
+ def to_h
41
+ {
42
+ "id" => id,
43
+ "summary" => summary,
44
+ "severity_overrides" => severity_overrides.transform_values(&:to_s)
45
+ }
46
+ end
47
+ end
48
+
49
+ # The overlay. Empty until the first next-major discipline is
50
+ # queued; add a {Feature} here (with a stable id) when one is.
51
+ FEATURES = [].freeze
52
+
53
+ module_function
54
+
55
+ # @return [Array<Feature>] the whole overlay.
56
+ def features
57
+ FEATURES
58
+ end
59
+
60
+ # @return [Array<String>] every feature id in the overlay.
61
+ def feature_ids
62
+ FEATURES.map(&:id)
63
+ end
64
+
65
+ # @param id [String]
66
+ # @return [Feature, nil]
67
+ def feature(id)
68
+ FEATURES.find { |f| f.id == id }
69
+ end
70
+
71
+ # Resolves a normalized `bleeding_edge:` selector (see
72
+ # {Configuration#bleeding_edge}) to the active {Feature} list.
73
+ # Unknown ids in a `list` / `except` selector are simply absent from
74
+ # the overlay and contribute nothing — symmetric with how
75
+ # `severity_overrides:` keeps an unknown rule id inert until it
76
+ # lands (robust across gem versions).
77
+ #
78
+ # @param selector [Hash] `{ "mode" => "none" }`,
79
+ # `{ "mode" => "all" }`, `{ "mode" => "all", "except" => [ids] }`,
80
+ # or `{ "mode" => "list", "ids" => [ids] }`.
81
+ # @return [Array<Feature>]
82
+ def active_features(selector)
83
+ case selector["mode"]
84
+ when "all"
85
+ except = selector["except"] || []
86
+ FEATURES.reject { |f| except.include?(f.id) }
87
+ when "list"
88
+ ids = selector["ids"] || []
89
+ FEATURES.select { |f| ids.include?(f.id) }
90
+ else
91
+ []
92
+ end
93
+ end
94
+
95
+ # The merged severity-override map the active features impose for a
96
+ # selector. Frozen so the result is `Ractor.shareable?`.
97
+ #
98
+ # @param selector [Hash] see {#active_features}.
99
+ # @return [Hash{String => Symbol}]
100
+ def severity_overrides_for(selector)
101
+ active_features(selector).each_with_object({}) do |feature, acc|
102
+ acc.merge!(feature.severity_overrides)
103
+ end.freeze
104
+ end
105
+
106
+ # Feature ids named by a selector that are NOT in the overlay
107
+ # (typo / graduated / from a newer gem). Surfaced by
108
+ # `rigor show-bleedingedge` as a hint; never an error.
109
+ #
110
+ # @param selector [Hash] see {#active_features}.
111
+ # @return [Array<String>]
112
+ def unknown_selected_ids(selector)
113
+ named =
114
+ case selector["mode"]
115
+ when "list" then selector["ids"] || []
116
+ when "all" then selector["except"] || []
117
+ else []
118
+ end
119
+ known = feature_ids
120
+ named.reject { |id| known.include?(id) }
121
+ end
122
+ end
123
+ end
@@ -13,8 +13,8 @@ module Rigor
13
13
  # ([`Rigor::Cache::Store`](store.rb), v0.0.8 slice 2) consumes
14
14
  # descriptors but does not extend them.
15
15
  #
16
- # The descriptor has four slots (`files`, `gems`, `plugins`,
17
- # `configs`); every slot is an array of typed entries; an empty
16
+ # The descriptor has six slots (`files`, `gems`, `plugins`,
17
+ # `configs`, `dependencies`, `globs`); every slot is an array of typed entries; an empty
18
18
  # array means "no dependency in this slot". Composition unions
19
19
  # by key per slot; conflicts on the comparison fields raise
20
20
  # {Conflict}.
@@ -32,7 +32,12 @@ module Rigor
32
32
  # references but never declares, so the marshalled RBS env
33
33
  # cached by an older Rigor (which would leave those signatures
34
34
  # inert) MUST be rebuilt for the synthesis to take effect.
35
- SCHEMA_VERSION = 3
35
+ # v4: ADR-60 WD3 added the `globs` slot ({GlobEntry}) for the
36
+ # record-and-validate plugin-producer cache; the new slot
37
+ # changes `#to_canonical_hash` (and is Marshal-dumped inside
38
+ # `fetch_or_validate` entry pairs), so entries written by an
39
+ # older Rigor must read as misses.
40
+ SCHEMA_VERSION = 4
36
41
 
37
42
  # Per-slot entry value objects. Constructors validate enums /
38
43
  # required fields and freeze the resulting struct so no caller
@@ -160,6 +165,62 @@ module Rigor
160
165
  end
161
166
  end
162
167
 
168
+ # ADR-60 WD3 — one glob's-worth of watched files, digested as a
169
+ # single value so the entry covers content change, addition,
170
+ # AND removal in one row: the digest is the SHA-256 over the
171
+ # sorted `"<path>\0<sha256-of-content>\n"` rows of every file
172
+ # matching `File.join(root, pattern)`. A new file adds a row, a
173
+ # deleted file drops one, an edit changes one — all three move
174
+ # the digest. {Descriptor#fresh?} re-runs the same computation
175
+ # and compares.
176
+ class GlobEntry
177
+ include Rigor::ValueSemantics
178
+
179
+ attr_reader :root, :pattern, :value
180
+
181
+ value_fields :root, :pattern, :value
182
+
183
+ def initialize(root:, pattern:, value:)
184
+ @root = root.to_s.dup.freeze
185
+ @pattern = pattern.to_s.dup.freeze
186
+ @value = value.to_s.dup.freeze
187
+ freeze
188
+ end
189
+
190
+ # Builds the entry for the glob's CURRENT filesystem state.
191
+ def self.compute(root:, pattern:)
192
+ new(root: root, pattern: pattern, value: digest_for(root: root, pattern: pattern))
193
+ end
194
+
195
+ # The digest the entry's `value` carries. Per-file read
196
+ # failures (a file vanishing between the glob and the
197
+ # digest) are treated as the file being absent — same
198
+ # race posture as {Descriptor#file_entry_fresh?}.
199
+ def self.digest_for(root:, pattern:)
200
+ # Dir.glob returns sorted entries by default (sort: true),
201
+ # so the row order — and therefore the digest — is stable.
202
+ rows = Dir.glob(File.join(root, pattern)).filter_map do |path|
203
+ next nil unless File.file?(path)
204
+
205
+ "#{path}\0#{Digest::SHA256.file(path).hexdigest}\n"
206
+ rescue StandardError
207
+ nil
208
+ end
209
+ Digest::SHA256.hexdigest(rows.join)
210
+ end
211
+
212
+ # Composition key — {.compose} unions per (root, pattern)
213
+ # slot; two contributions for the same slot must agree on
214
+ # the digest or {Conflict} is raised.
215
+ def slot_key
216
+ "#{root}\0#{pattern}"
217
+ end
218
+
219
+ def to_h
220
+ { "root" => root, "pattern" => pattern, "value" => value }
221
+ end
222
+ end
223
+
163
224
  # Raised when {.compose} encounters incompatible entries
164
225
  # under the same key (file digest mismatch, gem-locked
165
226
  # disagreement, …). Callers handle the exception by
@@ -167,14 +228,15 @@ module Rigor
167
228
  # contribution silently.
168
229
  class Conflict < StandardError; end
169
230
 
170
- attr_reader :files, :gems, :plugins, :configs, :dependencies
231
+ attr_reader :files, :gems, :plugins, :configs, :dependencies, :globs
171
232
 
172
- def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [])
233
+ def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [], globs: [])
173
234
  @files = files.dup.freeze
174
235
  @gems = gems.dup.freeze
175
236
  @plugins = plugins.dup.freeze
176
237
  @configs = configs.dup.freeze
177
238
  @dependencies = dependencies.dup.freeze
239
+ @globs = globs.dup.freeze
178
240
  freeze
179
241
  end
180
242
 
@@ -185,11 +247,15 @@ module Rigor
185
247
  # `files` are checked — non-file inputs (config / gems / version)
186
248
  # belong in the cache *key*, not the validated dependency set — so
187
249
  # a descriptor carrying any non-file slot is never considered fresh
188
- # (it was built wrong for this use).
250
+ # (it was built wrong for this use). ADR-60 WD3 adds `globs`
251
+ # alongside `files` as a re-validatable slot: a {GlobEntry} is
252
+ # fresh when re-globbing + re-digesting reproduces its recorded
253
+ # value.
189
254
  def fresh?
190
255
  return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
191
256
 
192
- files.all? { |entry| file_entry_fresh?(entry) }
257
+ files.all? { |entry| file_entry_fresh?(entry) } &&
258
+ globs.all? { |entry| glob_entry_fresh?(entry) }
193
259
  end
194
260
 
195
261
  # File-comparator strictness ordering. `:digest` is strictest
@@ -212,7 +278,9 @@ module Rigor
212
278
  plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
213
279
  configs = compose_by_key(descriptors.flat_map(&:configs), :key)
214
280
  dependencies = compose_by_key(descriptors.flat_map(&:dependencies), :gem_name)
215
- new(files: files, gems: gems, plugins: plugins, configs: configs, dependencies: dependencies)
281
+ globs = compose_by_key(descriptors.flat_map(&:globs), :slot_key)
282
+ new(files: files, gems: gems, plugins: plugins, configs: configs,
283
+ dependencies: dependencies, globs: globs)
216
284
  end
217
285
 
218
286
  # @param producer_id [String]
@@ -241,6 +309,7 @@ module Rigor
241
309
  "dependencies" => sort_entries(dependencies, "gem_name").map(&:to_h),
242
310
  "files" => sort_entries(files, "path").map(&:to_h),
243
311
  "gems" => sort_entries(gems, "name").map(&:to_h),
312
+ "globs" => globs.sort_by { |e| [e.root, e.pattern] }.map(&:to_h),
244
313
  "plugins" => sort_entries(plugins, "id").map(&:to_h)
245
314
  }
246
315
  end
@@ -291,6 +360,15 @@ module Rigor
291
360
  false
292
361
  end
293
362
 
363
+ # ADR-60 WD3 — re-runs the entry's glob + digest and compares
364
+ # against the recorded value. Any failure reads as stale
365
+ # (recompute), never a crash.
366
+ def glob_entry_fresh?(entry)
367
+ GlobEntry.digest_for(root: entry.root, pattern: entry.pattern) == entry.value
368
+ rescue StandardError
369
+ false
370
+ end
371
+
294
372
  def sort_entries(entries, key)
295
373
  entries.sort_by { |e| e.to_h.fetch(key).to_s }
296
374
  end
@@ -29,7 +29,8 @@ module Rigor
29
29
 
30
30
  def self.file_entries(loader)
31
31
  roots = loader.signature_paths +
32
- Rigor::Environment::RbsLoader.vendored_gem_sig_paths
32
+ Rigor::Environment::RbsLoader.vendored_gem_sig_paths +
33
+ Rigor::Environment::RbsLoader.core_overlay_sig_paths
33
34
  roots.flat_map do |root|
34
35
  next [] unless root.directory?
35
36