rigortype 0.2.9 → 0.3.0

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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/handbook/03-narrowing.md +2 -1
  4. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  5. data/docs/handbook/06-classes.md +18 -9
  6. data/docs/handbook/08-understanding-errors.md +1 -0
  7. data/docs/handbook/11-sig-gen.md +11 -1
  8. data/docs/handbook/appendix-mypy.md +4 -3
  9. data/docs/install.md +14 -6
  10. data/docs/manual/01-installation.md +98 -4
  11. data/docs/manual/02-cli-reference.md +49 -12
  12. data/docs/manual/03-configuration.md +34 -2
  13. data/docs/manual/04-diagnostics.md +23 -0
  14. data/docs/manual/07-plugins.md +4 -4
  15. data/docs/manual/09-editor-integration.md +4 -4
  16. data/docs/manual/11-ci.md +51 -0
  17. data/docs/manual/12-caching.md +65 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  20. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  21. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  23. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  24. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  28. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  29. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  30. data/lib/rigor/analysis/check_rules.rb +557 -95
  31. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/incremental_session.rb +456 -51
  34. data/lib/rigor/analysis/path_expansion.rb +42 -0
  35. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  36. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  37. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  38. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  43. data/lib/rigor/analysis/runner.rb +343 -68
  44. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  45. data/lib/rigor/analysis/worker_session.rb +30 -2
  46. data/lib/rigor/bleeding_edge.rb +37 -8
  47. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  48. data/lib/rigor/cache/descriptor.rb +88 -28
  49. data/lib/rigor/cache/file_digest.rb +158 -0
  50. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  51. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  52. data/lib/rigor/cache/store.rb +33 -3
  53. data/lib/rigor/ci_detector.rb +90 -0
  54. data/lib/rigor/cli/annotate_command.rb +15 -9
  55. data/lib/rigor/cli/baseline_command.rb +4 -1
  56. data/lib/rigor/cli/check_command.rb +212 -27
  57. data/lib/rigor/cli/coverage_command.rb +6 -1
  58. data/lib/rigor/cli/docs_command.rb +0 -30
  59. data/lib/rigor/cli/doctor_command.rb +163 -23
  60. data/lib/rigor/cli/lsp_command.rb +5 -0
  61. data/lib/rigor/cli/mcp_command.rb +5 -0
  62. data/lib/rigor/cli/plugins_command.rb +36 -6
  63. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  64. data/lib/rigor/cli/probe_environment.rb +85 -0
  65. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  66. data/lib/rigor/cli/skill_command.rb +1 -32
  67. data/lib/rigor/cli/trace_command.rb +5 -6
  68. data/lib/rigor/cli/type_of_command.rb +9 -8
  69. data/lib/rigor/cli/type_scan_command.rb +8 -8
  70. data/lib/rigor/cli.rb +15 -4
  71. data/lib/rigor/config_audit.rb +48 -2
  72. data/lib/rigor/configuration/severity_profile.rb +31 -0
  73. data/lib/rigor/configuration.rb +156 -8
  74. data/lib/rigor/environment/default_libraries.rb +35 -0
  75. data/lib/rigor/environment/rbs_loader.rb +164 -5
  76. data/lib/rigor/environment.rb +19 -27
  77. data/lib/rigor/inference/budget_trace.rb +77 -2
  78. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  79. data/lib/rigor/inference/def_handle.rb +23 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  81. data/lib/rigor/inference/def_return_typer.rb +2 -1
  82. data/lib/rigor/inference/expression_typer.rb +361 -113
  83. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  86. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  87. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  88. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  89. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  91. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  92. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  93. data/lib/rigor/inference/mutation_widening.rb +42 -8
  94. data/lib/rigor/inference/narrowing.rb +127 -17
  95. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  97. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  98. data/lib/rigor/inference/scope_indexer.rb +451 -36
  99. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  100. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  101. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  102. data/lib/rigor/inference/void_origin.rb +25 -0
  103. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  104. data/lib/rigor/language_server/completion_provider.rb +2 -1
  105. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  106. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  107. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  108. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  109. data/lib/rigor/plugin/base.rb +9 -22
  110. data/lib/rigor/plugin/inflector.rb +12 -3
  111. data/lib/rigor/plugin/io_boundary.rb +5 -1
  112. data/lib/rigor/plugin/isolation.rb +81 -11
  113. data/lib/rigor/plugin/load_error.rb +10 -1
  114. data/lib/rigor/plugin/loader.rb +96 -14
  115. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  116. data/lib/rigor/plugin/registry.rb +32 -23
  117. data/lib/rigor/plugin.rb +26 -0
  118. data/lib/rigor/protection/mutator.rb +3 -2
  119. data/lib/rigor/reflection.rb +64 -0
  120. data/lib/rigor/runtime/jit.rb +128 -0
  121. data/lib/rigor/scope/discovery_index.rb +12 -2
  122. data/lib/rigor/scope.rb +122 -28
  123. data/lib/rigor/sig_gen/classification.rb +5 -1
  124. data/lib/rigor/sig_gen/generator.rb +38 -6
  125. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  126. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  127. data/lib/rigor/sig_gen/renderer.rb +10 -0
  128. data/lib/rigor/sig_gen/write_result.rb +9 -4
  129. data/lib/rigor/sig_gen/writer.rb +24 -2
  130. data/lib/rigor/source/node_children.rb +116 -0
  131. data/lib/rigor/source/node_locator.rb +3 -1
  132. data/lib/rigor/source/node_walker.rb +4 -2
  133. data/lib/rigor/source.rb +1 -0
  134. data/lib/rigor/type/difference.rb +28 -24
  135. data/lib/rigor/type/hash_shape.rb +34 -10
  136. data/lib/rigor/version.rb +1 -1
  137. data/lib/rigortype.rb +24 -0
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  139. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  141. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  142. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  143. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  146. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  147. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  148. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  149. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  150. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  151. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  152. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  153. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  154. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  157. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  158. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  159. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  160. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  162. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  164. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  166. data/sig/prism_node_children.rbs +9 -0
  167. data/sig/rigor/inference.rbs +7 -0
  168. data/sig/rigor/plugin/base.rbs +2 -3
  169. data/sig/rigor/reflection.rbs +1 -0
  170. data/sig/rigor/scope.rbs +12 -1
  171. data/skills/rigor-ci-setup/SKILL.md +10 -0
  172. data/skills/rigor-editor-setup/SKILL.md +11 -0
  173. data/skills/rigor-next-steps/SKILL.md +23 -2
  174. data/skills/rigor-plugin-author/SKILL.md +3 -3
  175. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  176. data/skills/rigor-plugin-review/SKILL.md +2 -2
  177. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  178. data/skills/rigor-project-init/SKILL.md +29 -3
  179. data/skills/rigor-project-init/references/01-detect.md +1 -1
  180. data/skills/rigor-project-init/references/02-configure.md +39 -5
  181. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  182. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  183. metadata +42 -24
  184. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostic"
4
+ require_relative "../configuration/severity_profile"
5
+
6
+ module Rigor
7
+ module Analysis
8
+ # ADR-8 § "Severity profile" — re-stamps each diagnostic's severity from the configured profile + per-rule
9
+ # overrides and drops any diagnostic resolving to `:off`. Rules emit with their authored severity; the
10
+ # profile is the final filter.
11
+ #
12
+ # Extracted (ADR-87 WD4) so the miss path ({Runner::DiagnosticAggregator}) and the boot-slimming hit path
13
+ # ({RunCacheProbe}, which never loads the aggregator) apply the IDENTICAL final filter — a warm HIT's
14
+ # served diagnostics are byte-identical to what a full run would print.
15
+ module SeverityStamp
16
+ module_function
17
+
18
+ def apply(diagnostics, configuration)
19
+ diagnostics.filter_map { |diagnostic| stamp(diagnostic, configuration) }
20
+ end
21
+
22
+ def stamp(diagnostic, configuration)
23
+ return diagnostic if diagnostic.rule.nil?
24
+
25
+ resolved = Configuration::SeverityProfile.resolve(
26
+ rule: diagnostic.rule,
27
+ authored_severity: diagnostic.severity,
28
+ profile: configuration.severity_profile,
29
+ overrides: configuration.severity_overrides,
30
+ bleeding_edge_overrides: configuration.bleeding_edge_severity_overrides
31
+ )
32
+ return nil if resolved == :off
33
+ return diagnostic if resolved == diagnostic.severity
34
+
35
+ Diagnostic.new(
36
+ path: diagnostic.path, line: diagnostic.line, column: diagnostic.column,
37
+ message: diagnostic.message, severity: resolved, rule: diagnostic.rule,
38
+ source_family: diagnostic.source_family
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -13,6 +13,7 @@ require_relative "../inference/coverage_scanner"
13
13
  require_relative "../inference/scope_indexer"
14
14
  require_relative "../inference/method_dispatcher/file_folding"
15
15
  require_relative "check_rules"
16
+ require_relative "dependency_recorder"
16
17
  require_relative "dependency_source_inference"
17
18
  require_relative "diagnostic"
18
19
  require_relative "erb_template_detector"
@@ -78,7 +79,7 @@ module Rigor
78
79
  def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
79
80
  plugin_blueprints: [], explain: false, buffer: nil,
80
81
  synthetic_method_index: nil, project_patched_methods: nil,
81
- project_scope_seed: {}, source_files: [])
82
+ project_scope_seed: {}, source_files: [], record_dependencies: false)
82
83
  @configuration = configuration
83
84
  @cache_store = cache_store
84
85
  @explain = explain
@@ -86,6 +87,12 @@ module Rigor
86
87
  @synthetic_method_index = synthetic_method_index
87
88
  @project_patched_methods = project_patched_methods
88
89
  @project_scope_seed = project_scope_seed || {}
90
+ # ADR-46 — when set (the `--incremental` pool path), each `#analyze` is wrapped in a
91
+ # `DependencyRecorder.record_for` so the worker captures the file's cross-file reads, and the records
92
+ # are marshalled back to the coordinator ({#drain_dependencies}). Mirrors `Runner#analyze_file`. A
93
+ # normal `--workers N` run leaves it false and pays nothing (the recorder's disabled fast path).
94
+ @record_dependencies = record_dependencies
95
+ @file_dependencies = {}
89
96
  # ADR-32 WD4 — full project file list (frozen Array<String>) for env-build-time invocation of any
90
97
  # loaded plugin's `source_rbs_synthesizer` callable.
91
98
  @source_files = source_files
@@ -132,8 +139,20 @@ module Rigor
132
139
 
133
140
  # Equivalent of {Rigor::Analysis::Runner#analyze_file} + `plugin_emitted_diagnostics` +
134
141
  # `explain_diagnostics`. Returns a flat `Array<Diagnostic>` for the file. Severity profile re-stamping
135
- # is intentionally NOT applied — that is a per-run aggregate concern handled by the caller.
142
+ # is intentionally NOT applied — that is a per-run aggregate concern handled by the caller. When
143
+ # `record_dependencies:` was set, wraps the analysis in a {DependencyRecorder} window (ADR-46) so the
144
+ # worker captures the file's cross-file reads for {#drain_dependencies}; the recorder's own disabled
145
+ # fast path makes the unwrapped case free.
136
146
  def analyze(path)
147
+ return analyze_body(path) unless @record_dependencies
148
+
149
+ diagnostics = nil
150
+ record = DependencyRecorder.record_for(path) { diagnostics = analyze_body(path) }
151
+ @file_dependencies[path] = record
152
+ diagnostics
153
+ end
154
+
155
+ def analyze_body(path)
137
156
  parse_result = parse_source(path)
138
157
  unless parse_result.errors.empty?
139
158
  return [] if ErbTemplateDetector.template?(parse_result)
@@ -161,6 +180,7 @@ module Rigor
161
180
  rescue StandardError => e
162
181
  [analyzer_error(path, "internal analyzer error: #{e.class}: #{e.message}")]
163
182
  end
183
+ private :analyze_body
164
184
 
165
185
  # Read-once snapshot of the per-worker reporters so the caller (or the eventual Phase 4b pool
166
186
  # aggregator) can merge into a single coordinator-side reporter. Both reporters dedupe at write time,
@@ -176,6 +196,14 @@ module Rigor
176
196
  }
177
197
  end
178
198
 
199
+ # ADR-46 — the per-file {DependencyRecorder::Record}s captured by this session's `#analyze` calls (empty
200
+ # unless `record_dependencies:` was set). Marshal-clean (Data of frozen Sets / String Hashes), so the
201
+ # fork pool ships them back to the coordinator, which folds them into the runner's `file_dependencies`
202
+ # exactly as the sequential recording path would.
203
+ def drain_dependencies
204
+ @file_dependencies
205
+ end
206
+
179
207
  private
180
208
 
181
209
  # Mirrors {Runner#seed_project_scope}: applies the cross-file pre-pass discovery tables the
@@ -9,11 +9,10 @@ module Rigor
9
9
  # *today's* rules are) and is versioned with the gem, NOT a user-supplied file: the
10
10
  # inspectable counterpart to PHPStan's `bleedingEdge` include.
11
11
  #
12
- # The overlay is **empty today** no discipline has yet been queued for the next major.
13
- # This module is the WD2 *foundation* (the v0.1.19 slice): the surface (`bleeding_edge:`
14
- # config, the `rigor show-bleedingedge` command, the severity-composition hook in
15
- # {Configuration::SeverityProfile.resolve}) exists and is wired end-to-end, so the first
16
- # real feature lands as a single {FEATURES} entry with no engine plumbing.
12
+ # The WD2 foundation slice wired the surface end-to-end (`bleeding_edge:` config, the
13
+ # `rigor show-bleedingedge` command, the severity-composition hook in
14
+ # {Configuration::SeverityProfile.resolve}), so a discipline lands as a single {FEATURES}
15
+ # entry with no engine plumbing as the first one, `reject-unparseable-signatures`, does.
17
16
  #
18
17
  # Each feature carries a **stable feature id** — part of the ADR-50 WD1 contract
19
18
  # vocabulary: the config, the `show` command, and the eventual CHANGELOG migration note all
@@ -40,9 +39,39 @@ module Rigor
40
39
  end
41
40
  end
42
41
 
43
- # The overlay. Empty until the first next-major discipline is queued; add a {Feature}
44
- # here (with a stable id) when one is.
45
- FEATURES = [].freeze
42
+ # The overlay.
43
+ #
44
+ # Feature ids are **kebab-case, and name the discipline rather than the rule** it happens to
45
+ # promote (`reject-unparseable-signatures`, not `rbs-quarantine-error`): a discipline may grow
46
+ # to cover more rules without its id going stale, and the id is contract vocabulary that
47
+ # outlives the rule set it started with.
48
+ FEATURES = [
49
+ Feature.new(
50
+ id: "reject-unparseable-signatures",
51
+ summary: "A broken `signature_paths:` RBS set fails the run instead of degrading it silently. An " \
52
+ "unparseable `.rbs` is otherwise skipped with a warning, and a duplicate-declaration " \
53
+ "conflict (a file that parses fine but collides on resolve — typically against Rigor's " \
54
+ "own bundled RBS) collapses the whole env with a warning; either way the run gets quieter " \
55
+ "rather than cleaner. This treats both as a build error, the way a broken source file " \
56
+ "already is.",
57
+ severity_overrides: {
58
+ "rbs.coverage.quarantined-signature" => :error,
59
+ "rbs.coverage.environment-build-failed" => :error
60
+ }.freeze
61
+ ),
62
+ Feature.new(
63
+ id: "use-of-void-value",
64
+ summary: "Using a value recovered from an author-declared `-> void` return in value context (an " \
65
+ "assignment right-hand side, a call receiver, or an argument) becomes a `:warning`. An " \
66
+ "explicit `-> void` is the strongest possible \"do not rely on this return\" signal, so " \
67
+ "the direct-dispatch case is FP-narrow; a bare-statement `void` result and a legitimate " \
68
+ "`top` value both stay silent. Off by default because a new required diagnostic is an " \
69
+ "ADR-50 WD1 compatibility change (ADR-100 WD2).",
70
+ severity_overrides: {
71
+ "static.value-use.void" => :warning
72
+ }.freeze
73
+ )
74
+ ].freeze
46
75
 
47
76
  module_function
48
77
 
@@ -19,7 +19,7 @@ module Rigor
19
19
  # Recognised body shapes (each row admits the `+` quantifier and the bounded `{n}` /
20
20
  # `{n,m}` forms with `n >= 1`):
21
21
  #
22
- # - `\d` -> decimal-int-string
22
+ # - `\d`, `[0-9]` -> decimal-int-string
23
23
  # - `\h` -> hex-int-string
24
24
  # - `[0-9a-fA-F]` -> hex-int-string
25
25
  # - `[0-9a-f]`, `[0-9A-F]` -> hex-int-string
@@ -45,6 +45,7 @@ module Rigor
45
45
  # `Ractor::IsolationError` on the first row access.
46
46
  RULES = Ractor.make_shareable([
47
47
  [/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
48
+ [/\A\[0-9\]#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
48
49
  [/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
49
50
  [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
50
51
  [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
@@ -77,6 +78,40 @@ module Rigor
77
78
  Type::Combinator.public_send(rule.last)
78
79
  end
79
80
 
81
+ # Whole-receiver regime (#164). Where {for_capture_body} narrows a named-capture body —
82
+ # sound *without* anchors because the capture boundary is itself the anchor — this maps a
83
+ # WHOLE regex `source` (as from `Regexp#source` or a literal `RegularExpressionNode`)
84
+ # matched against an entire string via `String#match?` / `=~`. The predicate proves a
85
+ # property of the whole string only when the pattern is fully anchored, so a required
86
+ # leading `\A` **and** trailing `\z` are load-bearing: both must be present, and the inner
87
+ # body must be one of the curated single-char-class shapes {for_capture_body} already
88
+ # audits.
89
+ #
90
+ # Deliberately rejected (each admits a string the carrier would misdescribe):
91
+ #
92
+ # - bare / one-sided anchoring (`\d+`, `\A\d+`, `\d+\z`) — matches a substring, not the
93
+ # whole string;
94
+ # - `\Z` (capital) — anchors before an optional trailing newline, so `"12\n"` matches
95
+ # `/\A\d+\Z/` yet is not a decimal-int-string;
96
+ # - line anchors `^` / `$` — match per-line, so `"12\nabc"` matches `/^\d+$/`;
97
+ # - any inner body outside the curated table (`\w+`, alternation, multi-class).
98
+ #
99
+ # Extended (`//x`) mode is the caller's concern — the char-class table cannot see the
100
+ # free-whitespace/`#`-comment flag from the `source` alone, so consumers bail on it before
101
+ # calling here.
102
+ #
103
+ # @param source [String, nil] the full regex source string.
104
+ # @return [Rigor::Type, nil] the matching imported refinement carrier, or `nil`.
105
+ def for_whole_pattern(source)
106
+ return nil if source.nil?
107
+ return nil unless source.start_with?('\A') && source.end_with?('\z')
108
+
109
+ inner = source[2...-2]
110
+ return nil if inner.nil? || inner.empty?
111
+
112
+ for_capture_body(inner)
113
+ end
114
+
80
115
  # Filters the bounded-quantifier forms to ones whose lower bound is at least 1 and
81
116
  # whose upper bound (if any) is at least the lower bound. Without this, `\d{0,5}` would
82
117
  # be accepted even though it admits the empty string, which is not a valid
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "json"
5
5
  require_relative "../value_semantics"
6
+ require_relative "file_digest"
6
7
 
7
8
  module Rigor
8
9
  module Cache
@@ -26,7 +27,13 @@ module Rigor
26
27
  # v4: ADR-60 WD3 added the `globs` slot ({GlobEntry}) for the record-and-validate plugin-producer cache;
27
28
  # the new slot changes `#to_canonical_hash` (and is Marshal-dumped inside `fetch_or_validate` entry
28
29
  # pairs), so entries written by an older Rigor must read as misses.
29
- SCHEMA_VERSION = 4
30
+ # v5: ADR-87 WD1/WD2 changed the dependency-side freshness format — {FileEntry} gains the `:stat`
31
+ # comparator (a packed `digest + size + mtime_ns + ctime_ns + inode + recording_instant` tuple) and
32
+ # {GlobEntry}'s aggregate signature is now a SHA-256 over per-file STAT tuples rather than per-file
33
+ # content digests. Old entries must read as misses so the first writable run rebuilds them in the new
34
+ # format for a clean one-shot migration (the #57 marker discipline: the bump clears the root and
35
+ # reclaims the unreadable bytes).
36
+ SCHEMA_VERSION = 5
30
37
 
31
38
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
32
39
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -34,7 +41,13 @@ module Rigor
34
41
  class FileEntry
35
42
  include Rigor::ValueSemantics
36
43
 
37
- VALID_COMPARATORS = %i[digest mtime exists].freeze
44
+ # `:stat` (ADR-87 WD1) is the stat-then-digest comparator for the validation-only dependency
45
+ # descriptor; its `value` packs the content digest PLUS a `(size, mtime_ns, ctime_ns, inode)` stat
46
+ # tuple and the run's recording instant, so validation can skip the SHA-256 when the stat is unmoved.
47
+ # It MUST NOT be placed in a descriptor used as a cache KEY (its value carries machine-local,
48
+ # per-run-nondeterministic stat data); {RbsDescriptor.build}'s env-cache `files` therefore stay
49
+ # `:digest`, while the {Runner} run-dependency descriptor and plugin {IoBoundary} reads use `:stat`.
50
+ VALID_COMPARATORS = %i[digest stat mtime exists].freeze
38
51
 
39
52
  attr_reader :path, :comparator, :value
40
53
 
@@ -52,6 +65,16 @@ module Rigor
52
65
  freeze
53
66
  end
54
67
 
68
+ # ADR-87 WD1 — builds a `:stat` entry, packing the supplied content `digest` with the file's live stat
69
+ # tuple. Falls back to a plain `:digest` entry when the file cannot be stat-ed (a race between the
70
+ # digest and the stat), so the resulting entry is always valid to re-validate.
71
+ def self.stat(path:, digest:)
72
+ packed = FileDigest.pack_stat(path, digest)
73
+ return new(path: path, comparator: :digest, value: digest) if packed.nil?
74
+
75
+ new(path: path, comparator: :stat, value: packed)
76
+ end
77
+
55
78
  def to_h
56
79
  { "path" => path, "comparator" => comparator.to_s, "value" => value }
57
80
  end
@@ -147,14 +170,27 @@ module Rigor
147
170
  end
148
171
  end
149
172
 
150
- # ADR-60 WD3 — one glob's-worth of watched files, digested as a single value so the entry covers content
151
- # change, addition, AND removal in one row: the digest is the SHA-256 over the sorted
152
- # `"<path>\0<sha256-of-content>\n"` rows of every file matching `File.join(root, pattern)`. A new file
153
- # adds a row, a deleted file drops one, an edit changes one all three move the digest.
154
- # {Descriptor#fresh?} re-runs the same computation and compares.
173
+ # ADR-60 WD3 / ADR-87 WD2 — one glob's-worth of watched files, covering content change, addition, AND
174
+ # removal in a single aggregate `value`. Pre-ADR-87 the value was a SHA-256 over sorted
175
+ # `"<path>\0<content-sha256>\n"` rows sound but it re-READ + re-hashed every file's CONTENT on every
176
+ # validation (the ~40 MB gitlab plugin-prepass tax). ADR-87 WD2 keeps the identical one-hash shape (small
177
+ # to Marshal, deterministic, composition-safe) but hashes STAT TUPLES instead of content: the value is a
178
+ # SHA-256 over sorted `"<path>\0<size>\0<mtime_ns>\0<ctime_ns>\0<inode>\n"` rows. {.fresh?} re-globs +
179
+ # re-stats and compares — reading ZERO file-content bytes on an unchanged tree — while any content edit
180
+ # (which moves mtime + ctime) still moves the signature. `RIGOR_STRICT_VALIDATION` / `cache.validation:
181
+ # digest` restores the content-hash signature for a filesystem whose stat cannot be trusted. A single
182
+ # aggregate hash is the right granularity here: a watched dependency is all-or-nothing (one changed file
183
+ # invalidates the producer's cache regardless), so per-file partial re-hashing bought nothing but a heavy
184
+ # per-file table to Marshal. The trade vs the old content signature is that a bare `touch` (moved stat,
185
+ # identical content) now invalidates the glob — rare, and only forces the same recompute the old form
186
+ # paid on EVERY run.
155
187
  class GlobEntry
156
188
  include Rigor::ValueSemantics
157
189
 
190
+ ROW_SEPARATOR = "\n"
191
+ FIELD_SEPARATOR = "\0"
192
+ private_constant :ROW_SEPARATOR, :FIELD_SEPARATOR
193
+
158
194
  attr_reader :root, :pattern, :value
159
195
 
160
196
  value_fields :root, :pattern, :value
@@ -168,27 +204,45 @@ module Rigor
168
204
 
169
205
  # Builds the entry for the glob's CURRENT filesystem state.
170
206
  def self.compute(root:, pattern:)
171
- new(root: root, pattern: pattern, value: digest_for(root: root, pattern: pattern))
207
+ new(root: root, pattern: pattern, value: signature_for(root: root, pattern: pattern))
172
208
  end
173
209
 
174
- # The digest the entry's `value` carries. Per-file read failures (a file vanishing between the glob and
175
- # the digest) are treated as the file being absent same race posture as
176
- # {Descriptor#file_entry_fresh?}.
177
- def self.digest_for(root:, pattern:)
178
- # Dir.glob returns sorted entries by default (sort: true), so the row order — and therefore the
179
- # digest — is stable.
210
+ # The aggregate signature the entry's `value` carries: SHA-256 over the sorted per-file rows. In the
211
+ # default (`:stat`) mode a row is the file's `(path, size, mtime_ns, ctime_ns, inode)` tuple — NO
212
+ # content read; in strict mode a row is `(path, content-sha256)`, restoring the pre-ADR-87 authority.
213
+ # Per-file stat failures (a file vanishing between the glob and the stat) drop the row — same race
214
+ # posture as {Descriptor#file_entry_fresh?}. `Dir.glob` returns sorted entries by default so the row
215
+ # orderand therefore the signature — is stable.
216
+ def self.signature_for(root:, pattern:)
217
+ strict = FileDigest.strict_validation?
180
218
  rows = Dir.glob(File.join(root, pattern)).filter_map do |path|
181
- next nil unless File.file?(path)
182
-
183
- "#{path}\0#{Digest::SHA256.file(path).hexdigest}\n"
219
+ st = File.stat(path)
220
+ next nil unless st.file?
221
+
222
+ if strict
223
+ "#{path}#{FIELD_SEPARATOR}#{FileDigest.hexdigest(path)}#{ROW_SEPARATOR}"
224
+ else
225
+ "#{path}#{FIELD_SEPARATOR}#{st.size}#{FIELD_SEPARATOR}#{FileDigest.ns_of(st.mtime)}" \
226
+ "#{FIELD_SEPARATOR}#{FileDigest.ns_of(st.ctime)}#{FIELD_SEPARATOR}#{st.ino}#{ROW_SEPARATOR}"
227
+ end
184
228
  rescue StandardError
185
229
  nil
186
230
  end
187
231
  Digest::SHA256.hexdigest(rows.join)
188
232
  end
189
233
 
234
+ # ADR-87 WD2 — fresh iff re-globbing + re-stat-ing reproduces the recorded signature. Zero file-content
235
+ # bytes are read in the default mode. Any failure reads as stale (recompute), never a crash. A stored
236
+ # signature recorded under the opposite validation mode simply mismatches and recomputes.
237
+ def self.fresh?(entry)
238
+ signature_for(root: entry.root, pattern: entry.pattern) == entry.value
239
+ rescue StandardError
240
+ false
241
+ end
242
+
190
243
  # Composition key — {.compose} unions per (root, pattern) slot; two contributions for the same slot
191
- # must agree on the digest or {Conflict} is raised.
244
+ # must agree on the value or {Conflict} is raised. Within one run the same glob stat-reads identically,
245
+ # so contributions never conflict.
192
246
  def slot_key
193
247
  "#{root}\0#{pattern}"
194
248
  end
@@ -229,10 +283,12 @@ module Rigor
229
283
  globs.all? { |entry| glob_entry_fresh?(entry) }
230
284
  end
231
285
 
232
- # File-comparator strictness ordering. `:digest` is strictest (deterministic across machines); `:mtime`
233
- # is cheaper but local; `:exists` is the weakest signal. When two contributors disagree on the
234
- # comparator for the same `path`, the stricter one wins.
235
- COMPARATOR_STRICTNESS = { digest: 2, mtime: 1, exists: 0 }.freeze
286
+ # File-comparator strictness ordering. `:stat` is strictest (it carries the content digest AND a stat
287
+ # tuple, so it validates content-authoritatively while short-circuiting on an unmoved stat); `:digest` is
288
+ # deterministic across machines; `:mtime` is cheaper but local; `:exists` is the weakest signal. Ranking
289
+ # `:stat` and `:digest` apart guarantees a path contributed under both never raises a value {Conflict}
290
+ # (their `value` strings differ by construction) — the stricter one wins and its value is used.
291
+ COMPARATOR_STRICTNESS = { stat: 3, digest: 2, mtime: 1, exists: 0 }.freeze
236
292
  private_constant :COMPARATOR_STRICTNESS
237
293
 
238
294
  # Composes any number of descriptors into a single descriptor whose slots are the union of the inputs'
@@ -313,7 +369,13 @@ module Rigor
313
369
  def file_entry_fresh?(entry)
314
370
  case entry.comparator
315
371
  when :digest
316
- File.file?(entry.path) && Digest::SHA256.file(entry.path).hexdigest == entry.value
372
+ # `FileDigest` serves a per-run memo when a run is active (validation digests overlap the
373
+ # dependency descriptor's), and falls back to a direct digest otherwise — same value either way.
374
+ File.file?(entry.path) && FileDigest.hexdigest(entry.path) == entry.value
375
+ when :stat
376
+ # ADR-87 WD1 — stat-then-digest: stat first, re-hash only when the tuple moved (or the racy window
377
+ # fires, or strict mode forces it). A stat failure raises and the outer rescue reads it as stale.
378
+ FileDigest.stat_fresh?(entry.path, entry.value)
317
379
  when :mtime
318
380
  File.exist?(entry.path) && File.mtime(entry.path).to_i.to_s == entry.value
319
381
  when :exists
@@ -325,12 +387,10 @@ module Rigor
325
387
  false
326
388
  end
327
389
 
328
- # ADR-60 WD3 — re-runs the entry's glob + digest and compares against the recorded value. Any failure
329
- # reads as stale (recompute), never a crash.
390
+ # ADR-60 WD3 / ADR-87 WD2 stat-validates the entry's per-file table against the live tree, re-hashing
391
+ # only stat-moved files. Any failure reads as stale (recompute), never a crash.
330
392
  def glob_entry_fresh?(entry)
331
- GlobEntry.digest_for(root: entry.root, pattern: entry.pattern) == entry.value
332
- rescue StandardError
333
- false
393
+ GlobEntry.fresh?(entry)
334
394
  end
335
395
 
336
396
  def sort_entries(entries, key)
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Rigor
6
+ module Cache
7
+ # Per-run content-digest memo AND the ADR-87 stat-then-digest freshness choke point. Within a single
8
+ # `Rigor::Analysis::Runner#run`, the same absolute path is SHA-256'd at most once — across every descriptor
9
+ # that digests files: the run-diagnostics dependency descriptor ({Runner#analyzed_file_entries}), the
10
+ # ADR-45 {Descriptor#fresh?} validation of that descriptor, the RBS signature tree
11
+ # ({RbsDescriptor.file_entries}), and every plugin producer's `watch:`-glob validation
12
+ # ({Descriptor::GlobEntry}). Those sets overlap heavily (an analyzed file is often also inside a plugin's
13
+ # watch glob; the RBS tree is digested by both the runner and the RBS producers), so the memo removes the
14
+ # redundant re-hashing that dominates warm-hit validation.
15
+ #
16
+ # ADR-87 WD1 layers a stat tier ON TOP of the memo: a `:stat` {Descriptor::FileEntry} packs a
17
+ # `(digest, size, mtime_ns, ctime_ns, inode)` tuple plus the run's recording instant, and {.stat_fresh?}
18
+ # validates it by stat-ing the file first and only falling back to {.hexdigest} (this memo) when the tuple
19
+ # moved or the racy window fires. On a true null build an unchanged file is a single `File::Stat` call and
20
+ # zero SHA-256 bytes. The SHA-256 digest remains the sole change AUTHORITY — the stat tier only decides
21
+ # whether the digest needs recomputing.
22
+ #
23
+ # Soundness: the memo + recording-instant + strict flag are process-local (thread-locals, so Ractor-safe
24
+ # and never crossing a fork boundary), never persisted to disk, and scoped to exactly one run via
25
+ # {.with_run} (which installs a fresh table and restores the previous one on exit — a second run, or a run
26
+ # that re-reads a file the user edited between runs, gets a clean table). Within a single run the analyzer
27
+ # already assumes the filesystem is stable — {Descriptor#fresh?} validates the whole dependency set against
28
+ # the live tree in one pass and never re-checks mid-run — so caching a coherent per-run snapshot changes
29
+ # nothing about what a run observes. Outside a run scope (no active table) every call digests directly,
30
+ # identical to a bare `Digest::SHA256.file`.
31
+ module FileDigest
32
+ MEMO_KEY = :rigor_cache_file_digest_memo
33
+ INSTANT_KEY = :rigor_cache_recording_instant
34
+ STRICT_KEY = :rigor_cache_strict_validation
35
+ private_constant :MEMO_KEY, :INSTANT_KEY, :STRICT_KEY
36
+
37
+ # Set in the environment to force the strict digest-always validation path for a single run, regardless
38
+ # of the `cache.validation` config setting (the env wins). The escape hatch for a filesystem whose stat
39
+ # tuples cannot be trusted (a coarse-timestamp mount, a restored-from-backup tree).
40
+ STRICT_ENV = "RIGOR_STRICT_VALIDATION"
41
+
42
+ # Runs `block` with a fresh per-run digest table installed, restoring whatever table was active before
43
+ # (nil on the normal top-level run; a parent table only if a run were somehow nested). Always restores,
44
+ # even on a raise, so a failed run never leaks its table into the next one. The recording instant is
45
+ # captured at run start — a file whose recorded mtime is not strictly older than it is treated as racy
46
+ # (always re-hashed), catching a file edited during the analysis window. `strict:` forces the
47
+ # digest-always path for `cache.validation: digest`.
48
+ def self.with_run(strict: false)
49
+ previous_memo = Thread.current[MEMO_KEY]
50
+ previous_instant = Thread.current[INSTANT_KEY]
51
+ previous_strict = Thread.current[STRICT_KEY]
52
+ Thread.current[MEMO_KEY] = {}
53
+ Thread.current[INSTANT_KEY] = now_ns
54
+ Thread.current[STRICT_KEY] = strict
55
+ yield
56
+ ensure
57
+ Thread.current[MEMO_KEY] = previous_memo
58
+ Thread.current[INSTANT_KEY] = previous_instant
59
+ Thread.current[STRICT_KEY] = previous_strict
60
+ end
61
+
62
+ # The SHA-256 hex digest of `path`'s current content. Served from the active per-run table when one is
63
+ # installed (computing + storing it on first request for that path), otherwise computed directly. A
64
+ # read failure (missing / unreadable file) propagates exactly as `Digest::SHA256.file` would and is NOT
65
+ # memoised, so a caller's own rescue path is unchanged.
66
+ def self.hexdigest(path)
67
+ memo = Thread.current[MEMO_KEY]
68
+ return Digest::SHA256.file(path).hexdigest if memo.nil?
69
+
70
+ memo[path] ||= Digest::SHA256.file(path).hexdigest
71
+ end
72
+
73
+ # @return [Boolean] whether digest-always validation is forced for this run. The env var wins over the
74
+ # run-scoped config flag so an operator can bypass the stat tier without editing `.rigor.yml`.
75
+ def self.strict_validation?
76
+ return true if ENV[STRICT_ENV] == "1"
77
+
78
+ Thread.current[STRICT_KEY] == true
79
+ end
80
+
81
+ # ADR-87 WD1 — packs the `:stat` FileEntry value: `"<digest> <size> <mtime_ns> <ctime_ns> <inode>
82
+ # <recording_instant_ns>"`. The digest is supplied by the caller (already computed via {.hexdigest} or
83
+ # from in-memory content). Returns nil when the file cannot be stat-ed (a race between the digest and the
84
+ # stat), so the caller can fall back to a plain `:digest` entry.
85
+ def self.pack_stat(path, digest)
86
+ st = File.stat(path)
87
+ "#{digest} #{st.size} #{ns(st.mtime)} #{ns(st.ctime)} #{st.ino} #{recording_instant_ns}"
88
+ rescue SystemCallError
89
+ nil
90
+ end
91
+
92
+ # ADR-87 WD1 — validates a `:stat` FileEntry value against the live filesystem. Stats the file first;
93
+ # when the `(size, mtime_ns, ctime_ns, inode)` tuple is unchanged AND the entry is not racy, the file is
94
+ # fresh with zero bytes hashed. When the tuple moved or the entry is racy, the recorded digest is the
95
+ # authority: the file is re-hashed and fresh iff the digest still matches (a touch — a moved stat over
96
+ # identical content — therefore validates as fresh). Any stat failure (missing / unreadable file) raises
97
+ # and the caller reads it as stale. In strict mode the stat tier is skipped entirely.
98
+ def self.stat_fresh?(path, packed)
99
+ parsed = parse_stat(packed)
100
+ return false if parsed.nil?
101
+
102
+ digest = parsed[0]
103
+ return hexdigest(path) == digest if strict_validation?
104
+
105
+ st = File.stat(path)
106
+ return true if !racy?(parsed) && tuple_matches?(st, parsed)
107
+
108
+ hexdigest(path) == digest
109
+ end
110
+
111
+ def self.recording_instant_ns
112
+ Thread.current[INSTANT_KEY] || now_ns
113
+ end
114
+
115
+ # value layout: [digest, size, mtime_ns, ctime_ns, inode, recording_instant_ns]
116
+ def self.parse_stat(packed)
117
+ parts = packed.to_s.split
118
+ return nil unless parts.size == 6
119
+
120
+ [parts[0], *parts[1..].map { |n| Integer(n, 10) }]
121
+ rescue ArgumentError
122
+ nil
123
+ end
124
+ private_class_method :parse_stat
125
+
126
+ def self.tuple_matches?(stat, parsed)
127
+ stat.size == parsed[1] && ns(stat.mtime) == parsed[2] &&
128
+ ns(stat.ctime) == parsed[3] && stat.ino == parsed[4]
129
+ end
130
+ private_class_method :tuple_matches?
131
+
132
+ # An entry is racy when its recorded mtime is not strictly older than the run's recording instant — the
133
+ # file was (or may have been) modified within the recording window, so its stat tuple cannot be trusted
134
+ # to have settled. Such an entry always re-hashes. On a nanosecond-resolution filesystem this fires only
135
+ # for a file edited during the analysis run itself.
136
+ def self.racy?(parsed)
137
+ parsed[2] >= parsed[5]
138
+ end
139
+ private_class_method :racy?
140
+
141
+ # Full-nanosecond integer for a `Time` (from `File::Stat#mtime`/`#ctime`). Public so {GlobEntry} packs
142
+ # its per-file rows with the identical resolution the FileEntry tuple uses.
143
+ def self.ns_of(time)
144
+ (time.tv_sec * 1_000_000_000) + time.tv_nsec
145
+ end
146
+
147
+ def self.ns(time)
148
+ ns_of(time)
149
+ end
150
+ private_class_method :ns
151
+
152
+ def self.now_ns
153
+ Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
154
+ end
155
+ private_class_method :now_ns
156
+ end
157
+ end
158
+ end