rigortype 0.3.1 → 0.3.3

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/04-diagnostics.md +8 -0
  15. data/docs/manual/12-caching.md +17 -5
  16. data/docs/manual/15-type-protection-coverage.md +122 -1
  17. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  19. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  20. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  21. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  22. data/lib/rigor/analysis/check_rules.rb +107 -28
  23. data/lib/rigor/analysis/incremental_session.rb +156 -11
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  25. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  26. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  27. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  28. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  30. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  31. data/lib/rigor/analysis/runner.rb +78 -7
  32. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  33. data/lib/rigor/bleeding_edge.rb +132 -7
  34. data/lib/rigor/cache/descriptor.rb +6 -1
  35. data/lib/rigor/cache/engine_source.rb +162 -0
  36. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  37. data/lib/rigor/cli/check_command.rb +49 -28
  38. data/lib/rigor/cli/coverage_command.rb +15 -2
  39. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  40. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  41. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  42. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  43. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  44. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  45. data/lib/rigor/cli/protection_renderer.rb +13 -0
  46. data/lib/rigor/cli/protection_report.rb +11 -3
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  48. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  49. data/lib/rigor/configuration.rb +45 -6
  50. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  51. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  52. data/lib/rigor/environment/rbs_loader.rb +434 -75
  53. data/lib/rigor/environment.rb +28 -13
  54. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  55. data/lib/rigor/inference/expression_typer.rb +62 -4
  56. data/lib/rigor/inference/fork_map.rb +47 -4
  57. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  58. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  59. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  60. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
  61. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  62. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
  63. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  64. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  65. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  66. data/lib/rigor/inference/mutation_widening.rb +30 -25
  67. data/lib/rigor/inference/narrowing.rb +3 -1
  68. data/lib/rigor/inference/optimistic_origin.rb +123 -0
  69. data/lib/rigor/inference/protection_scanner.rb +18 -2
  70. data/lib/rigor/inference/receiver_alias.rb +57 -0
  71. data/lib/rigor/inference/scope_indexer.rb +197 -55
  72. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  73. data/lib/rigor/inference/statement_evaluator.rb +55 -4
  74. data/lib/rigor/language_server/buffer_table.rb +22 -0
  75. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  76. data/lib/rigor/language_server/project_context.rb +45 -0
  77. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  78. data/lib/rigor/language_server/server.rb +20 -1
  79. data/lib/rigor/language_server.rb +1 -0
  80. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  81. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  82. data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
  83. data/lib/rigor/protection/dependency_closure.rb +59 -0
  84. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  85. data/lib/rigor/protection/discovery_seed.rb +116 -0
  86. data/lib/rigor/protection/kill_signature.rb +31 -0
  87. data/lib/rigor/protection/mutation_cache.rb +355 -0
  88. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  89. data/lib/rigor/protection/mutator.rb +26 -4
  90. data/lib/rigor/runtime/jit.rb +63 -1
  91. data/lib/rigor/scope/discovery_index.rb +9 -0
  92. data/lib/rigor/scope.rb +114 -10
  93. data/lib/rigor/sig_gen/generator.rb +130 -31
  94. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  95. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  96. data/lib/rigor/sig_gen/renderer.rb +20 -1
  97. data/lib/rigor/sig_gen/write_result.rb +6 -4
  98. data/lib/rigor/sig_gen/writer.rb +12 -0
  99. data/lib/rigor/sig_gen.rb +1 -0
  100. data/lib/rigor/source/node_walker.rb +12 -0
  101. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  102. data/lib/rigor/type/nominal.rb +5 -2
  103. data/lib/rigor/type/singleton.rb +4 -1
  104. data/lib/rigor/version.rb +1 -1
  105. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  106. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  107. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  108. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  109. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  110. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  111. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  112. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  113. data/sig/rigor/inference/void_origin.rbs +6 -3
  114. data/sig/rigor/scope.rbs +12 -0
  115. data/sig/rigor.rbs +4 -0
  116. metadata +21 -1
@@ -389,16 +389,13 @@ module Rigor
389
389
  # copy-on-write-inherited session and writes the Marshal'd payload to `out_path`. `exit!` skips
390
390
  # `at_exit` / stdio flush — the payload is already durable on disk by then.
391
391
  def run_fork_worker(session, slice, out_path)
392
- # Re-arm deferred YJIT in the child. The parent (`check` / `coverage`) armed `enable_after` on a
393
- # background thread, but `fork` copies only the calling thread the sleeping deadline thread does not
394
- # survive into the child, so a worker forked before the deadline fires would otherwise run its whole
395
- # (dominant) analysis slice un-JITted no matter how long it takes. If the parent already enabled YJIT
396
- # before forking (a slow env build pushed the fork past the deadline), the child inherited the enabled
397
- # state and this is a no-op; otherwise the child times its own slice and JITs the tail once it proves
398
- # long enough — the same amortization contract, per worker. Parallel mode is opted into for large
399
- # projects, so a worker slice reliably outlasts the deadline; a small-project worker finishes first and
400
- # stays at no-YJIT parity, exactly as the sequential path would.
401
- Runtime::Jit.enable_after(Runtime::Jit.deadline_seconds)
392
+ # Re-arm deferred YJIT in the child: the parent's deadline thread does not survive `fork`, so a worker
393
+ # forked before the deadline fires would otherwise run its whole (dominant) analysis slice un-JITted
394
+ # no matter how long it takes. {Runtime::Jit.rearm_after_fork} carries what is *left* of the parent's
395
+ # window, so the run's amortization deadline stays one deadline rather than one per worker. Parallel
396
+ # mode is opted into for large projects, so a slice reliably outlasts the remainder; a small-project
397
+ # worker finishes first and stays at no-YJIT parity, exactly as the sequential path would.
398
+ Runtime::Jit.rearm_after_fork
402
399
  results = slice.to_h { |path| [path, session.analyze(path)] }
403
400
  payload = { results: results, reporters: session.drain_reporters,
404
401
  dependencies: session.drain_dependencies }
@@ -186,7 +186,9 @@ module Rigor
186
186
  # Translates a prebuilt {ProjectScan} snapshot supplied to `Runner.new(prebuilt: ...)` into a
187
187
  # {Result} the runner adopts the same way it adopts a fresh pre-pass run. The discovery tables are
188
188
  # not part of the snapshot (the LSP path seeds an empty project scope), and `Runner#ensure_project_discovery`
189
- # is a no-op under `prebuilt`, so they stay at their frozen-empty constructor defaults.
189
+ # is a no-op under `prebuilt`, so they stay at their frozen-empty constructor defaults — unless the
190
+ # caller opted into `Runner.new(discovery_seed:)` (issue #260), which seeds the per-file scopes
191
+ # directly and leaves these ivars alone.
190
192
  def adopt_prebuilt(scan)
191
193
  Result.new(
192
194
  plugin_registry: scan.plugin_registry,
@@ -40,6 +40,7 @@ require_relative "runner/run_snapshots"
40
40
  require_relative "runner/project_pre_passes"
41
41
  require_relative "runner/pool_coordinator"
42
42
  require_relative "runner/diagnostic_aggregator"
43
+ require_relative "runner/buffer_pool_dispatcher"
43
44
 
44
45
  module Rigor
45
46
  module Analysis
@@ -95,12 +96,19 @@ module Rigor
95
96
  # pass a shared env so per-publish work doesn't repeat the `Environment.for_project` build (bundler
96
97
  # / lockfile / collection discovery, RbsLoader construction). Pool mode ignores the override — each
97
98
  # worker continues to build its own Environment.
99
+ # @param discovery_seed [Hash, nil] issue #260 — opt-in cross-file discovery tables, keyed by
100
+ # {Scope::DiscoveryIndex} slot name, seeded onto every per-file scope through
101
+ # `project_scope_seed_tables`. The ONE deliberate exception to "a `prebuilt:` runner carries no
102
+ # discovery tables": {Protection::DiagnosticOracle} threads the table set Tier 2's site filter already
103
+ # judges anchors against, so a site admitted because a sibling-file class resolved is also a site the
104
+ # oracle can kill at. nil (the default) leaves the prebuilt/LSP contract byte-identical.
98
105
  def initialize(configuration:, explain: false, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
99
106
  cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
100
107
  plugin_requirer: nil, workers: 0, collect_stats: true,
101
108
  buffer: nil, prebuilt: nil, environment: nil,
102
109
  record_dependencies: false, record_self_calls: false, analyze_only: nil,
103
- seed_bundles: nil, collect_seed_bundles: false)
110
+ seed_bundles: nil, collect_seed_bundles: false, param_inferred_types: nil,
111
+ discovery_seed: nil)
104
112
  @configuration = configuration
105
113
  @explain = explain
106
114
  @cache_store = enforce_read_only_cache(cache_store, buffer)
@@ -140,6 +148,16 @@ module Rigor
140
148
  @collect_seed_bundles = collect_seed_bundles
141
149
  @restored_seed_bundles = seed_bundles || {}
142
150
  @seed_bundles = {}.freeze
151
+ # ADR-67 WD6c lift — a precomputed inferred-param table. When the incremental session already ran the
152
+ # collector (it must, to diff the table against its snapshot BEFORE deciding the re-analyse closure),
153
+ # it hands the result here so `seed_parameter_inference` seeds without a second whole-project collect
154
+ # — and so the table the diff was decided on and the table this run seeds from are the SAME object,
155
+ # not merely an equal recomputation. nil (the default) keeps the runner self-sufficient.
156
+ @param_inferred_types_override = param_inferred_types
157
+ # Issue #260 — the opt-in cross-file discovery seed (see the `discovery_seed:` doc above). Frozen and
158
+ # never mutated; `project_scope_seed_tables` starts from a copy of it and lets any table this run
159
+ # actually computed win.
160
+ @discovery_seed = discovery_seed&.freeze
143
161
  @file_dependencies = {}
144
162
  @plugin_registry = Plugin::Registry::EMPTY
145
163
  @dependency_source_index = DependencySourceInference::Index::EMPTY
@@ -324,6 +342,32 @@ module Rigor
324
342
  result.transform_values(&:freeze).freeze
325
343
  end
326
344
 
345
+ # ADR-67 WD6c lift — the inferred-param table this run seeded from (frozen; empty when
346
+ # `parameter_inference:` is off or the pre-pass failed soft). The incremental session reads it back
347
+ # after a baseline so the snapshot records the seeds the cached diagnostics were computed under.
348
+ def param_inferred_types
349
+ @project_param_inferred_types
350
+ end
351
+
352
+ # ADR-67 WD6c lift — computes the whole-project inferred-param table without running an analysis.
353
+ # The incremental session calls this BEFORE deciding its re-analyse closure: the table's diff against
354
+ # the snapshot's stored table is what invalidates a callee whose seeds moved because a *caller* file
355
+ # changed. Same collector invocation as {#seed_parameter_inference} (one round, same workers), so the
356
+ # session-computed table and an in-run collect are byte-identical by construction. Fails soft to the
357
+ # empty table — which the caller's diff then treats as "every stored entry removed", the conservative
358
+ # direction (those callees re-check).
359
+ def collect_param_inference_table(files)
360
+ return {}.freeze unless @configuration.parameter_inference
361
+
362
+ environment = @pool_coordinator.resolve_sequential_environment(source_files: files)
363
+ Inference::ParameterInferenceCollector.collect(
364
+ files: files, environment: environment,
365
+ target_ruby: @configuration.target_ruby, max_rounds: 1, workers: @workers
366
+ )
367
+ rescue StandardError
368
+ {}.freeze
369
+ end
370
+
327
371
  # ADR-89 WD2 — the per-method observed-key return summaries the run's ADR-84 return memo just captured.
328
372
  # For each project method with live memo entries, a bounded `{ keys:, returns:, effects: }` summary the
329
373
  # incremental session persists: `keys` the observed `[receiver, arg_types]` type tuples, `returns` their
@@ -511,6 +555,11 @@ module Rigor
511
555
  def seed_parameter_inference(expansion, environment)
512
556
  return environment unless @configuration.parameter_inference
513
557
 
558
+ if @param_inferred_types_override
559
+ @project_param_inferred_types = @param_inferred_types_override
560
+ return environment
561
+ end
562
+
514
563
  files = expansion.fetch(:files)
515
564
  environment ||= @pool_coordinator.resolve_sequential_environment(source_files: files)
516
565
  @project_param_inferred_types = Inference::ParameterInferenceCollector.collect(
@@ -638,7 +687,9 @@ module Rigor
638
687
  # surface. The cross-file discovery tables are NOT carried here — `#run` (prebuilt-less) and
639
688
  # `adopt_prebuilt` both leave them at their frozen-empty constructor defaults, and the analysis path
640
689
  # fills them lazily via {#ensure_project_discovery}. The prebuilt (LSP) path never fills them, matching
641
- # the original adopt behaviour that seeded an empty project scope.
690
+ # the original adopt behaviour that seeded an empty project scope — with ONE deliberate exception, the
691
+ # opt-in `discovery_seed:` constructor seam (issue #260), which bypasses the ivars entirely and rides
692
+ # `project_scope_seed_tables`. A prebuilt runner constructed without it is unchanged.
642
693
  def apply_pre_passes_result(result)
643
694
  @plugin_registry = result.plugin_registry
644
695
  @dependency_source_index = result.dependency_source_index
@@ -669,7 +720,9 @@ module Rigor
669
720
  # Internal: builds the deferred cross-file discovery tables at most once per run and adopts them.
670
721
  # Memoised on `@project_discovery_done` (reset at the start of `#run`). No-op under `@prebuilt` — the
671
722
  # LSP path deliberately seeds an empty project scope from a snapshot that carries no discovery tables,
672
- # so forcing a build there would change that contract. Called eagerly from `#run` for the recording /
723
+ # so forcing a build there would change that contract. A caller that DOES want cross-file knowledge under
724
+ # `prebuilt:` supplies it explicitly through `discovery_seed:` (issue #260) rather than by re-walking the
725
+ # project here. Called eagerly from `#run` for the recording /
673
726
  # subset (ADR-46) modes and lazily from `#assemble_run_diagnostics` on the analysis path, so a warm
674
727
  # cache HIT (which never assembles) never pays the double parse.
675
728
  def ensure_project_discovery(expansion)
@@ -757,11 +810,18 @@ module Rigor
757
810
  def target_files(expansion)
758
811
  files = expansion.fetch(:files)
759
812
  # ADR-46 slice 2 — restrict the analyzed set to the affected closure while the pre-pass (run
760
- # separately over `expansion`'s full file list) keeps the cross-file index complete. Buffer mode
761
- # takes precedence — its single logical path is the analyzed set.
762
- files = files.select { |path| @analyze_only.include?(path) } if @analyze_only
813
+ # separately over `expansion`'s full file list) keeps the cross-file index complete.
814
+ if @analyze_only
815
+ # Editor mode option B (#146) with BOTH set, the closure wins and the buffer is one member of it.
816
+ # The logical path joins even when it is not on disk under `paths:` (design § "Failure envelope"),
817
+ # the same allowance option A makes below.
818
+ files = files.select { |path| @analyze_only.include?(path) }
819
+ files |= [@buffer.logical_path] if @buffer && @analyze_only.include?(@buffer.logical_path)
820
+ return files
821
+ end
763
822
  return files if @buffer.nil?
764
823
 
824
+ # Editor mode option A — no closure, so the buffer's single logical path IS the analyzed set.
765
825
  [@buffer.logical_path]
766
826
  end
767
827
 
@@ -1023,8 +1083,12 @@ module Rigor
1023
1083
  # The cross-file pre-pass tables {#seed_project_scope} applies, as a plain Hash so the fork-pool path
1024
1084
  # can hand the same seed to its {WorkerSession} (whose per-file scopes would otherwise miss every
1025
1085
  # cross-file def — ADR-15 sequential-equivalence contract).
1086
+ #
1087
+ # Issue #260 — an opt-in `discovery_seed:` is the BASE of the result, so every table this run computed
1088
+ # for itself still wins. Under `prebuilt:` (the only caller that passes one today) the run computes
1089
+ # none, so the seed applies wholesale.
1026
1090
  def project_scope_seed_tables
1027
- tables = {}
1091
+ tables = discovery_seed_base
1028
1092
  # ADR-84 WD2 — the run-scope token rides the same seed so the fork/Ractor `WorkerSession` scopes
1029
1093
  # bucket identically to the sequential path.
1030
1094
  tables[:run_generation] = @run_generation if @run_generation
@@ -1055,6 +1119,13 @@ module Rigor
1055
1119
  tables
1056
1120
  end
1057
1121
 
1122
+ # Issue #260 — the mutable starting point {#project_scope_seed_tables} fills in: a copy of the opt-in
1123
+ # `discovery_seed:` when one was supplied, otherwise the empty Hash every other run has always started
1124
+ # from. Extracted so the seed costs {#project_scope_seed_tables} no branch of its complexity budget.
1125
+ def discovery_seed_base
1126
+ @discovery_seed ? @discovery_seed.dup : {}
1127
+ end
1128
+
1058
1129
  # ADR-46 — seed the instance + singleton `"path:line"` def-source tables (each only when non-empty).
1059
1130
  # Extracted to keep {#project_scope_seed_tables} under the complexity budget. The singleton table (slice 4
1060
1131
  # extension) rides the same seed so a pooled `WorkerSession` records singleton symbol edges identically.
@@ -35,7 +35,8 @@ module Rigor
35
35
  Diagnostic.new(
36
36
  path: diagnostic.path, line: diagnostic.line, column: diagnostic.column,
37
37
  message: diagnostic.message, severity: resolved, rule: diagnostic.rule,
38
- source_family: diagnostic.source_family
38
+ source_family: diagnostic.source_family, receiver_type: diagnostic.receiver_type,
39
+ method_name: diagnostic.method_name, project_definition_site: diagnostic.project_definition_site
39
40
  )
40
41
  end
41
42
  end
@@ -11,29 +11,86 @@ module Rigor
11
11
  #
12
12
  # The WD2 foundation slice wired the surface end-to-end (`bleeding_edge:` config, the
13
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.
14
+ # {Configuration::SeverityProfile.resolve}), so a *severity* discipline lands as a single
15
+ # {FEATURES} entry with no engine plumbing — as the first one,
16
+ # `reject-unparseable-signatures`, does.
17
+ #
18
+ # A queued change whose effect is not a severity move — a measurement, an algorithm, or a
19
+ # default that changes while every rule keeps its severity — is a `:behaviour` feature
20
+ # instead. It carries no severity map; its call sites ask
21
+ # {Configuration#bleeding_edge_active?} whether the id is adopted for the run.
22
+ #
23
+ # A `:behaviour` feature MUST NOT change the output of `rigor check` analysis unless its
24
+ # feature id is folded into the analysis-cache identity. Rationale, verified 2026-08-01:
25
+ # severity features are safe because severity is stamped POST-cache — {Analysis::SeverityStamp}
26
+ # (ADR-87 WD4) stores the authored severity and applies the profile + bleeding-edge overrides
27
+ # identically on the miss path and the warm-hit path, so a warm HIT re-resolves under the
28
+ # current selector. A behaviour feature that altered analysis results themselves would poison
29
+ # warm caches across selector changes, because the selector is not part of the cache key. The
30
+ # two queued consumers (#253, #254) change what a separate command measures, not what `check`
31
+ # analyses, so neither is affected.
16
32
  #
17
33
  # Each feature carries a **stable feature id** — part of the ADR-50 WD1 contract
18
34
  # vocabulary: the config, the `show` command, and the eventual CHANGELOG migration note all
19
- # name the same id, and a feature graduates to default-on at a major by being removed from
20
- # {FEATURES}.
35
+ # name the same id, and a feature graduates to default-on at a major (ADR-50 § WD7) by
36
+ # moving from {FEATURES} to {GRADUATED}.
21
37
  module BleedingEdge
38
+ # The two kinds a queued change can take. `:severity` composes through
39
+ # {Configuration::SeverityProfile.resolve}; `:behaviour` is read at a call site through
40
+ # {Configuration#bleeding_edge_active?}.
41
+ KINDS = %i[severity behaviour].freeze
42
+
43
+ # The severity map a `:behaviour` feature carries: none.
44
+ NO_SEVERITY_OVERRIDES = {}.freeze
45
+
22
46
  # One queued change.
23
47
  #
48
+ # The two kinds are exclusive by construction: a `:severity` feature MUST name at least
49
+ # one rule, and a `:behaviour` feature MUST name none — a behaviour switch that also
50
+ # moved a severity would be two changes wearing one id, and the id is what a CHANGELOG
51
+ # migration note and a user's `bleeding_edge:` list both key on.
52
+ #
24
53
  # @!attribute id
25
54
  # @return [String] the stable feature id (contract vocabulary).
26
55
  # @!attribute summary
27
- # @return [String] a one-line description of what it changes.
56
+ # @return [String] a one-line description of what it changes. For a `:behaviour`
57
+ # feature this is the *whole* explanation — there is no severity diff to read.
58
+ # @!attribute kind
59
+ # @return [Symbol] one of {KINDS}.
28
60
  # @!attribute severity_overrides
29
61
  # @return [Hash{String => Symbol}] canonical rule id → the severity this feature
30
62
  # imposes. Composed *below* the user's own `severity_overrides:` and *above* the
31
- # active `severity_profile` (see {Configuration::SeverityProfile.resolve}).
32
- Feature = Data.define(:id, :summary, :severity_overrides) do
63
+ # active `severity_profile` (see {Configuration::SeverityProfile.resolve}). Empty for
64
+ # a `:behaviour` feature.
65
+ Feature = Data.define(:id, :summary, :kind, :severity_overrides) do
66
+ def initialize(id:, summary:, kind:, severity_overrides: NO_SEVERITY_OVERRIDES)
67
+ raise ArgumentError, "kind must be one of #{KINDS.inspect}, got #{kind.inspect}" unless KINDS.include?(kind)
68
+
69
+ if kind == :severity && severity_overrides.empty?
70
+ raise ArgumentError, "bleeding-edge feature #{id.inspect} is :severity but overrides no rule"
71
+ end
72
+ if kind == :behaviour && !severity_overrides.empty?
73
+ raise ArgumentError, "bleeding-edge feature #{id.inspect} is :behaviour but carries severity_overrides"
74
+ end
75
+
76
+ super
77
+ end
78
+
79
+ # @return [Boolean]
80
+ def severity?
81
+ kind == :severity
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def behaviour?
86
+ kind == :behaviour
87
+ end
88
+
33
89
  def to_h
34
90
  {
35
91
  "id" => id,
36
92
  "summary" => summary,
93
+ "kind" => kind.to_s,
37
94
  "severity_overrides" => severity_overrides.transform_values(&:to_s)
38
95
  }
39
96
  end
@@ -48,6 +105,7 @@ module Rigor
48
105
  FEATURES = [
49
106
  Feature.new(
50
107
  id: "reject-unparseable-signatures",
108
+ kind: :severity,
51
109
  summary: "A broken `signature_paths:` RBS set fails the run instead of degrading it silently. An " \
52
110
  "unparseable `.rbs` is otherwise skipped with a warning, and a duplicate-declaration " \
53
111
  "conflict (a file that parses fine but collides on resolve — typically against Rigor's " \
@@ -61,6 +119,7 @@ module Rigor
61
119
  ),
62
120
  Feature.new(
63
121
  id: "use-of-void-value",
122
+ kind: :severity,
64
123
  summary: "Using a value recovered from an author-declared `-> void` return in value context (an " \
65
124
  "assignment right-hand side, a call receiver, or an argument) becomes a `:warning`. An " \
66
125
  "explicit `-> void` is the strongest possible \"do not rely on this return\" signal, so " \
@@ -70,9 +129,51 @@ module Rigor
70
129
  severity_overrides: {
71
130
  "static.value-use.void" => :warning
72
131
  }.freeze
132
+ ),
133
+ Feature.new(
134
+ id: "discovery-seeded-mutation-sites",
135
+ kind: :behaviour,
136
+ summary: "`rigor coverage --protection --mutation` (Tier 2) measures against the same cross-file " \
137
+ "project discovery Tier 1 already seeds, instead of an empty scope — both when picking the " \
138
+ "sites and when re-analysing each breakage to decide whether it was caught. A call whose " \
139
+ "receiver is a project class declared in a *sibling* file (`Post.where`, " \
140
+ "`Rigor::Protection::Mutator.new`) then resolves to the type it really has rather than " \
141
+ "`Dynamic`, so the site is measured instead of dropped — and a breakage there can actually " \
142
+ "be caught. This makes the two tiers judge a site " \
143
+ "by one standard, but it ADDS sites to the denominator, so the reported effectiveness ratio " \
144
+ "goes DOWN on the same code — and `--threshold=RATIO` exits 1 when that ratio falls below a " \
145
+ "number pinned in CI. Off by default for that reason: it is a queued change for the next " \
146
+ "major, not a fix you should be opted into mid-release."
147
+ ),
148
+ Feature.new(
149
+ id: "dependent-closure-kill-oracle",
150
+ kind: :behaviour,
151
+ summary: "`rigor coverage --protection --mutation` (Tier 2) decides a breakage was caught when the " \
152
+ "diagnostic appears anywhere in the mutated file OR the files that depend on it, instead of " \
153
+ "in the mutated file alone. Changing what a method returns is caught in its *callers* — the " \
154
+ "cross-file reach the analyzer exists for — and that catch is scored as a miss today. The " \
155
+ "measurement re-analyses the dependent closure (ADR-46's dependency graph) against the " \
156
+ "mutated bytes, so those catches count. It can only ADD kills, never remove one, so the " \
157
+ "reported ratio moves up or not at all; a recorded ratio nonetheless stops being comparable " \
158
+ "with one measured without it. It costs roughly a third more wall time per mutant, and on " \
159
+ "the two corpora measured so far (Rigor's own `lib`, redmine `app/models`) it added no " \
160
+ "kills — every surviving breakage there is one the analyzer reports nowhere at all, not one " \
161
+ "it reports in a caller."
73
162
  )
74
163
  ].freeze
75
164
 
165
+ # ADR-50 § WD7 — the ids that have already graduated to default-on.
166
+ #
167
+ # A feature graduates at a major by moving *here* from {FEATURES} rather than by simply
168
+ # disappearing: {Configuration#bleeding_edge_active?} then answers an unconditional `true`
169
+ # for the id, so a call site still asking about it keeps the graduated behaviour and gate
170
+ # cleanup can lag graduation by as many releases as it takes. The id also stays in the
171
+ # contract vocabulary the CHANGELOG migration note keys on. Entries are removed only once
172
+ # no call site names them.
173
+ #
174
+ # @return [Array<String>]
175
+ GRADUATED = [].freeze
176
+
76
177
  module_function
77
178
 
78
179
  # @return [Array<Feature>] the whole overlay.
@@ -91,6 +192,19 @@ module Rigor
91
192
  FEATURES.find { |f| f.id == id }
92
193
  end
93
194
 
195
+ # @param id [String]
196
+ # @return [Boolean] whether the id has graduated to default-on ({GRADUATED}).
197
+ def graduated?(id)
198
+ GRADUATED.include?(id)
199
+ end
200
+
201
+ # @param id [String]
202
+ # @return [Boolean] whether the id names a feature this gem knows at all — queued or
203
+ # graduated. Distinct from "adopted"; see {Configuration#bleeding_edge_active?}.
204
+ def known_id?(id)
205
+ graduated?(id) || FEATURES.any? { |f| f.id == id }
206
+ end
207
+
94
208
  # Resolves a normalized `bleeding_edge:` selector (see {Configuration#bleeding_edge}) to
95
209
  # the active {Feature} list. Unknown ids in a `list` / `except` selector are simply
96
210
  # absent from the overlay and contribute nothing — symmetric with how
@@ -125,6 +239,17 @@ module Rigor
125
239
  end.freeze
126
240
  end
127
241
 
242
+ # The ids the active features expose to {Configuration#bleeding_edge_active?}, as a frozen
243
+ # `Set` so a call site on the hot path pays a hash lookup rather than an Array scan.
244
+ # Precomputed once per Configuration; frozen (with frozen members) so the carrier stays
245
+ # `Ractor.shareable?` across the worker boundary.
246
+ #
247
+ # @param selector [Hash] see {#active_features}.
248
+ # @return [Set<String>]
249
+ def active_ids_for(selector)
250
+ Set.new(active_features(selector).map(&:id)).freeze
251
+ end
252
+
128
253
  # Feature ids named by a selector that are NOT in the overlay (typo / graduated / from a
129
254
  # newer gem). Surfaced by `rigor show-bleedingedge` as a hint; never an error.
130
255
  #
@@ -33,7 +33,12 @@ module Rigor
33
33
  # content digests. Old entries must read as misses so the first writable run rebuilds them in the new
34
34
  # format for a clean one-shot migration (the #57 marker discipline: the bump clears the root and
35
35
  # reclaims the unreadable bytes).
36
- SCHEMA_VERSION = 5
36
+ # v6: #237 — `append_stub_declarations` now emits the declaration KIND each referenced-type stub needs
37
+ # (`interface` / `type` / `module` / `class`) and validates each declaration on its own, so a project
38
+ # whose RBS dangles an interface or type-alias reference gets stubs where an older Rigor discarded the
39
+ # whole batch and cached an env in which those signatures are inert. Same reasoning as v3: the
40
+ # marshalled env is the cached value, so it MUST be rebuilt for the fix to take effect.
41
+ SCHEMA_VERSION = 6
37
42
 
38
43
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
39
44
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "../version"
6
+
7
+ module Rigor
8
+ module Cache
9
+ # Issue #285 — the identity of the ENGINE'S OWN SOURCE, as a cache-key slot.
10
+ #
11
+ # Every Rigor cache whose value is a function of what the analyzer COMPUTES (the ADR-45 run-result
12
+ # cache, the #134 mutation-result cache) keys on `Rigor::VERSION`. For a gem installed from RubyGems
13
+ # that is exact — the version pins the bytes. For anyone running an edited working tree it is not, and
14
+ # the failure is invisible: a warm `rigor check` replays the pre-edit diagnostics verbatim, so a
15
+ # before/after measurement of an engine change reports `0 new, 0 gone` no matter what the change does.
16
+ # That shape of false zero survived into #152's FP evaluation before it was caught.
17
+ #
18
+ # ## The two regimes
19
+ #
20
+ # {.identity} answers `nil` for a tree whose identity `Rigor::VERSION` already fixes — a RubyGems
21
+ # install, recognised by its `<gem_home>/gems/rigortype-<VERSION>` directory layout. The caller then
22
+ # adds NO slot, so a released gem's cache key is byte-identical to the one it had before this module
23
+ # existed and pays not one syscall for it. Everything else — a contributor's checkout, a `bundle add
24
+ # rigor, github:` clone (where two commits share one `Rigor::VERSION`), a `path:` gem — is treated as
25
+ # mutable and gets a content digest of the engine's source tree.
26
+ #
27
+ # The predicate is deliberately POSITIVE about being pinned, so every case it fails to recognise lands
28
+ # on the safe side: an unrecognised release pays one directory walk and one extra cache generation,
29
+ # while an unrecognised checkout would have served a stale answer.
30
+ #
31
+ # ## Why a content digest and not a stat tuple
32
+ #
33
+ # A `(size, mtime, ctime, inode)` walk is ~3× cheaper (measured: 5.4 ms vs 17 ms over this repo's 539
34
+ # engine files), and ADR-87 WD1 trusts exactly that tuple for FRESHNESS. It cannot be used here:
35
+ # {Descriptor::FileEntry} states the rule directly — a stat tuple carries machine-local, per-run
36
+ # nondeterministic data and MUST NOT enter a descriptor used as a cache KEY. On the freshness side a
37
+ # moved tuple falls back to the recorded digest, so a fresh checkout of unchanged content still
38
+ # validates; a KEY has no such fallback, so keying on stat would make every CI run and every branch
39
+ # switch a total miss for the `github:`-tracking users this module exists to protect.
40
+ #
41
+ # ## Never a wrong hit
42
+ #
43
+ # A mutable tree whose digest cannot be computed raises {Unavailable} rather than answering `nil`.
44
+ # Both callers already rescue a malformed key into "no cache for this run", which is the only sound
45
+ # reading: falling back to the version-only key would restore precisely the blind spot being fixed.
46
+ module EngineSource
47
+ # Raised when the tree is not version-pinned AND its source cannot be digested. Deliberately not
48
+ # rescued in this module — see the class doc's last paragraph.
49
+ class Unavailable < StandardError; end
50
+
51
+ # The RubyGems package name. `<name>-<version>` is the install directory layout {.version_pinned?}
52
+ # recognises; the constant is not read from the gemspec because this file loads on the boot-slimming
53
+ # probe path, which must not touch RubyGems' specification machinery.
54
+ GEM_NAME = "rigortype"
55
+
56
+ # Engine source, relative to the gem root. `plugins/` ships inside the same gem and its recognisers
57
+ # move diagnostics exactly as `lib/` does, so an edit there must invalidate too; `examples/`,
58
+ # `apps/` and `tool/` are not loaded by an analysis and stay out.
59
+ SOURCE_DIRECTORIES = %w[lib plugins].freeze
60
+
61
+ # The one directory whose absence means "this is not an engine tree". `plugins/` may legitimately be
62
+ # missing (a slimmed install contributes nothing); a missing `lib/` would silently shrink the digest
63
+ # to whatever else happened to be there, which is exactly the weakening this module forbids.
64
+ REQUIRED_DIRECTORY = "lib"
65
+
66
+ module_function
67
+
68
+ # @return [String] the gem root — the directory holding `lib/`, three levels above this file.
69
+ def root
70
+ @root ||= File.expand_path("../../..", __dir__)
71
+ end
72
+
73
+ # @param root [String] the gem root, defaulted through {.root} so a spec can relocate the tree.
74
+ # @return [String, nil] a digest identifying the engine's current source, or nil when the tree is
75
+ # version-pinned and the caller should add no slot at all.
76
+ # @raise [Unavailable] when a mutable tree's source cannot be read.
77
+ def identity(root = self.root)
78
+ return nil if version_pinned?(root)
79
+
80
+ digest_tree(root)
81
+ end
82
+
83
+ # {.identity} for THIS process's engine, computed once. Every production caller wants this; the
84
+ # uncached {.identity} stays the computation, and the seam a spec relocates.
85
+ #
86
+ # A memo, not a per-call walk, on two grounds.
87
+ #
88
+ # Cost. #285 could afford the walk because the two callers it wired reached it at most twice per
89
+ # process. Adding {IncrementalSnapshot.fingerprint} (#289) breaks that: {Protection::MutationCache}
90
+ # builds a fingerprint per snapshot-root candidate, so one `rigor coverage --protection --mutation
91
+ # PATH` reaches this five times — 90 ms of walking for a value that cannot differ between the calls.
92
+ #
93
+ # Correctness, which is the stronger reason. The digest exists to identify the engine that COMPUTED a
94
+ # cached value, and that engine is fixed when the process finishes requiring — nothing an edit does to
95
+ # `lib/` mid-run changes the code already running. Re-reading the tree would eventually key values
96
+ # against source that never produced them, so the memo is the more faithful answer, not merely the
97
+ # cheaper one. That is also why a fork-pool worker inheriting it is right: `PoolCoordinator` builds one
98
+ # session on the parent and forks children that copy-on-write inherit its image, so parent and child
99
+ # run the same engine by construction — and the fingerprints are computed on the parent before a pool
100
+ # exists at all. The same reading covers the long-running `rigor lsp` process.
101
+ #
102
+ # {Unavailable} propagates and is deliberately NOT memoised: the ivar is only assigned on success.
103
+ def process_identity
104
+ return @process_identity if defined?(@process_identity)
105
+
106
+ @process_identity = identity
107
+ end
108
+
109
+ # Discards the {.process_identity} memo; production code MUST NOT call this — a run that recomputed
110
+ # mid-flight would key cached values against source that did not compute them, which is the whole
111
+ # argument for the memo. It exists because a spec process is many logical "processes", and a memo that
112
+ # outlived one example would silently ignore the next one's stub of {.root} / {.identity} and pass for
113
+ # the wrong reason. `spec_helper` calls it before every example so no spec has to know it is here.
114
+ def reset_process_identity!
115
+ remove_instance_variable(:@process_identity) if defined?(@process_identity)
116
+ end
117
+
118
+ # True when `Rigor::VERSION` already pins this tree's bytes: an immutable RubyGems install, laid out
119
+ # as `…/gems/rigortype-<VERSION>`. The `.git` probe is the belt to that braces — a working tree that
120
+ # somehow occupies a release-shaped path is still a working tree.
121
+ def version_pinned?(root)
122
+ return false unless File.basename(root) == "#{GEM_NAME}-#{Rigor::VERSION}"
123
+ return false unless File.basename(File.dirname(root)) == "gems"
124
+
125
+ !File.exist?(File.join(root, ".git"))
126
+ end
127
+
128
+ # A SHA-256 over every engine `.rb` file: its ROOT-RELATIVE path (so the digest survives moving or
129
+ # re-cloning the checkout) followed by its bytes, in sorted path order.
130
+ #
131
+ # The walk itself is not memoised — {.process_identity} is where a production caller gets the
132
+ # once-per-process value, and this stays the computation so a spec can point it at another tree.
133
+ def digest_tree(root)
134
+ unless File.directory?(File.join(root, REQUIRED_DIRECTORY))
135
+ raise Unavailable, "#{root} has no #{REQUIRED_DIRECTORY}/ to identify the engine by"
136
+ end
137
+
138
+ digest = Digest::SHA256.new
139
+ prefix = "#{root}#{File::SEPARATOR}"
140
+ count = 0
141
+ source_files(root).each do |path|
142
+ digest << path.delete_prefix(prefix) << "\0"
143
+ digest.file(path)
144
+ count += 1
145
+ end
146
+ raise Unavailable, "no engine source found under #{root}" if count.zero?
147
+
148
+ digest.hexdigest
149
+ rescue SystemCallError, IOError => e
150
+ raise Unavailable, "engine source under #{root} could not be read: #{e.message}"
151
+ end
152
+
153
+ # Sorted absolute paths of every engine `.rb` file, across the directories that exist.
154
+ def source_files(root)
155
+ SOURCE_DIRECTORIES.flat_map do |relative|
156
+ directory = File.join(root, relative)
157
+ File.directory?(directory) ? Dir.glob(File.join(directory, "**", "*.rb")) : []
158
+ end.sort
159
+ end
160
+ end
161
+ end
162
+ end