rigortype 0.1.16 → 0.1.17

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  3. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  4. data/lib/rigor/analysis/check_rules.rb +149 -70
  5. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  6. data/lib/rigor/analysis/diagnostic.rb +18 -0
  7. data/lib/rigor/analysis/incremental.rb +162 -0
  8. data/lib/rigor/analysis/incremental_session.rb +337 -0
  9. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  10. data/lib/rigor/analysis/runner.rb +434 -37
  11. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  13. data/lib/rigor/cache/descriptor.rb +50 -49
  14. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  15. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  16. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  17. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  18. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  19. data/lib/rigor/cache/rbs_environment.rb +2 -8
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  21. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  22. data/lib/rigor/cache/store.rb +99 -1
  23. data/lib/rigor/cli/annotate_command.rb +2 -7
  24. data/lib/rigor/cli/baseline_command.rb +2 -7
  25. data/lib/rigor/cli/command.rb +47 -0
  26. data/lib/rigor/cli/coverage_command.rb +3 -23
  27. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  28. data/lib/rigor/cli/diff_command.rb +3 -7
  29. data/lib/rigor/cli/explain_command.rb +2 -7
  30. data/lib/rigor/cli/lsp_command.rb +3 -7
  31. data/lib/rigor/cli/mcp_command.rb +3 -7
  32. data/lib/rigor/cli/options.rb +57 -0
  33. data/lib/rigor/cli/plugin_command.rb +3 -7
  34. data/lib/rigor/cli/plugins_command.rb +2 -7
  35. data/lib/rigor/cli/renderable.rb +26 -0
  36. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  37. data/lib/rigor/cli/skill_command.rb +3 -7
  38. data/lib/rigor/cli/triage_command.rb +2 -7
  39. data/lib/rigor/cli/type_of_command.rb +5 -38
  40. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  41. data/lib/rigor/cli/type_scan_command.rb +3 -23
  42. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  43. data/lib/rigor/cli.rb +125 -43
  44. data/lib/rigor/configuration/dependencies.rb +18 -1
  45. data/lib/rigor/configuration/severity_profile.rb +22 -3
  46. data/lib/rigor/configuration.rb +13 -3
  47. data/lib/rigor/environment/rbs_loader.rb +76 -3
  48. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  49. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  50. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  51. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  52. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  53. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  54. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  55. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  58. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  59. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  64. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  69. data/lib/rigor/inference/expression_typer.rb +140 -20
  70. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  71. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  72. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  73. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  74. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  75. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  76. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  77. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  78. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  79. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  80. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  81. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  82. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  83. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  84. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  85. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  86. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  87. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  88. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  89. data/lib/rigor/inference/method_dispatcher.rb +99 -59
  90. data/lib/rigor/inference/narrowing.rb +202 -5
  91. data/lib/rigor/inference/scope_indexer.rb +134 -7
  92. data/lib/rigor/inference/statement_evaluator.rb +105 -26
  93. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  94. data/lib/rigor/language_server/completion_provider.rb +4 -4
  95. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  96. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  97. data/lib/rigor/language_server/hover_provider.rb +4 -4
  98. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  99. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  100. data/lib/rigor/plugin/base.rb +20 -4
  101. data/lib/rigor/plugin/registry.rb +39 -1
  102. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  103. data/lib/rigor/rbs_extended.rb +39 -0
  104. data/lib/rigor/scope.rb +123 -9
  105. data/lib/rigor/type/acceptance_router.rb +19 -0
  106. data/lib/rigor/type/accepts_result.rb +3 -10
  107. data/lib/rigor/type/app.rb +3 -7
  108. data/lib/rigor/type/bot.rb +2 -3
  109. data/lib/rigor/type/bound_method.rb +5 -12
  110. data/lib/rigor/type/combinator.rb +17 -0
  111. data/lib/rigor/type/constant.rb +2 -3
  112. data/lib/rigor/type/data_class.rb +80 -0
  113. data/lib/rigor/type/data_instance.rb +100 -0
  114. data/lib/rigor/type/difference.rb +5 -10
  115. data/lib/rigor/type/dynamic.rb +5 -10
  116. data/lib/rigor/type/hash_shape.rb +5 -15
  117. data/lib/rigor/type/integer_range.rb +5 -10
  118. data/lib/rigor/type/intersection.rb +5 -10
  119. data/lib/rigor/type/nominal.rb +5 -10
  120. data/lib/rigor/type/refined.rb +5 -10
  121. data/lib/rigor/type/singleton.rb +5 -10
  122. data/lib/rigor/type/top.rb +2 -3
  123. data/lib/rigor/type/tuple.rb +5 -10
  124. data/lib/rigor/type/union.rb +5 -10
  125. data/lib/rigor/type.rb +2 -0
  126. data/lib/rigor/value_semantics.rb +77 -0
  127. data/lib/rigor/version.rb +1 -1
  128. data/lib/rigor.rb +1 -0
  129. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  130. data/sig/rigor/cache.rbs +19 -0
  131. data/sig/rigor/inference.rbs +22 -0
  132. data/sig/rigor/rbs_extended.rbs +2 -0
  133. data/sig/rigor/scope.rbs +5 -0
  134. data/sig/rigor/type.rbs +58 -1
  135. data/sig/rigor.rbs +6 -1
  136. metadata +22 -1
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # ADR-46 slice 2 — the pure set-algebra core of the incremental step,
6
+ # kept side-effect-free and Runner-independent so the soundness
7
+ # property it encodes is unit-testable without the analysis machinery.
8
+ #
9
+ # Given the files that changed since the baseline run and the
10
+ # baseline's `dependents` index ({Runner#file_dependents}), the
11
+ # **affected closure** the body tier must re-analyse is the changed set
12
+ # plus every file that read a declaration or method body from a changed
13
+ # file. Every other file is served from the per-file diagnostic cache.
14
+ #
15
+ # The soundness invariant (the {Runner}-driven `--verify-incremental`
16
+ # gate and the spec assert it): for an edit whose declaration-structure
17
+ # fingerprint is unchanged (a method-body edit — no symbol created,
18
+ # destroyed, moved, or re-parented), the set of files whose diagnostics
19
+ # actually change is a SUBSET of {affected}. A file outside the closure
20
+ # whose diagnostics changed would be served stale — a manufactured
21
+ # false positive/negative, the failure mode this design exists to
22
+ # prevent. Structural edits (fingerprint changed) are out of this
23
+ # tier's scope — they widen via the negative-dependency / full fallback
24
+ # path (slice 3).
25
+ module Incremental
26
+ module_function
27
+
28
+ # Inverts a per-consumer source map (`consumer → enumerable of source
29
+ # files it read from`) into the `dependents` index (`source → Set of
30
+ # consumers that read from it`). The reverse edge the incremental step
31
+ # walks. Returns a frozen hash of frozen Sets; a missing key reads as
32
+ # nil (the default proc is dropped before freezing).
33
+ def invert(sources_by_consumer)
34
+ index = Hash.new { |hash, key| hash[key] = Set.new }
35
+ sources_by_consumer.each do |consumer, sources|
36
+ sources.each { |source| index[source] << consumer }
37
+ end
38
+ index.default_proc = nil
39
+ index.each_value(&:freeze)
40
+ index.freeze
41
+ end
42
+
43
+ # The closure the body tier re-analyses. `changed` is any Enumerable
44
+ # of paths; `dependents` maps a source path to the Set of files that
45
+ # read from it (missing key → no dependents). Returns a frozen Set.
46
+ def affected(changed, dependents)
47
+ closure = changed.to_set
48
+ changed.each { |file| closure.merge(dependents[file] || []) }
49
+ closure.freeze
50
+ end
51
+
52
+ # ADR-46 slice 4 — inverts a per-consumer symbol-sources map
53
+ # (`consumer → { source_path → Set<"ClassName#method"> }`) into the
54
+ # symbol-level dependents index: `[source_path, symbol] → Set<consumer>`.
55
+ # Used by {affected_with_symbols} to limit fan-out to callers of
56
+ # symbols that actually changed rather than all callers of the file.
57
+ def invert_symbols(symbol_sources_by_consumer)
58
+ index = Hash.new { |h, k| h[k] = Set.new }
59
+ symbol_sources_by_consumer.each do |consumer, sources_by_file|
60
+ sources_by_file.each do |source, symbols|
61
+ symbols.each { |sym| index[[source, sym]] << consumer }
62
+ end
63
+ end
64
+ index.default_proc = nil
65
+ index.each_value(&:freeze)
66
+ index.freeze
67
+ end
68
+
69
+ # ADR-46 slice 4 — given a set of changed file paths and two per-file
70
+ # symbol fingerprint maps (before and after), returns the frozen Set of
71
+ # `[path, symbol]` pairs whose fingerprints differ (added, removed, or
72
+ # body-changed).
73
+ def changed_symbol_pairs(changed_files, fingerprints_before, fingerprints_after)
74
+ pairs = Set.new
75
+ changed_files.each do |path|
76
+ before = fingerprints_before[path] || {}
77
+ after = fingerprints_after[path] || {}
78
+ (before.keys | after.keys).each do |sym|
79
+ pairs << [path, sym] if before[sym] != after[sym]
80
+ end
81
+ end
82
+ pairs.freeze
83
+ end
84
+
85
+ # ADR-46 slice 4 — the symbol-granularity affected closure.
86
+ #
87
+ # A consumer is included when:
88
+ # (a) it is itself a changed file,
89
+ # (b) it has an ancestry dep on a changed file (always re-checked — file-level), or
90
+ # (c) it has a symbol dep on a `[file, symbol]` pair that changed.
91
+ #
92
+ # Consumers that only have symbol deps on a changed file, and none of
93
+ # their tracked symbols changed, are NOT included — the slice 4 precision win.
94
+ def affected_with_symbols(changed_files, changed_pairs, symbol_dependents, ancestry_dependents)
95
+ closure = changed_files.to_set
96
+ changed_files.each { |file| closure.merge(ancestry_dependents[file] || []) }
97
+ changed_pairs.each { |pair| closure.merge(symbol_dependents[pair] || []) }
98
+ closure.freeze
99
+ end
100
+
101
+ # ADR-46 slice 3 — the symbol keys (`"ClassName#method"`) that are
102
+ # present in a changed file's after-fingerprints but were absent from
103
+ # its before-fingerprints: a symbol that *appeared* in this edit. A
104
+ # symbol that merely moved between files still appears here for the
105
+ # destination file, but its negative-dependents set is empty (nobody
106
+ # missed a name that already resolved elsewhere), so the over-report
107
+ # costs nothing. Returns a frozen Set of symbol-key Strings.
108
+ def appeared_symbols(changed_files, fingerprints_before, fingerprints_after)
109
+ appeared = Set.new
110
+ changed_files.each do |path|
111
+ before = fingerprints_before[path] || {}
112
+ after = fingerprints_after[path] || {}
113
+ (after.keys - before.keys).each { |sym| appeared << sym }
114
+ end
115
+ appeared.freeze
116
+ end
117
+
118
+ # ADR-46 slice 3 — the qualified class/module names *declared* in a
119
+ # changed file's after-state that were absent from its before-state: a
120
+ # class that appeared in this edit. (For an added file the before-set
121
+ # is empty, so every class it declares appears.) A class that merely
122
+ # moved files still appears here, but its negative-dependents are empty,
123
+ # so the over-report costs nothing. Returns a frozen Set of qualified
124
+ # class-name Strings. `decls_before` / `decls_after` map a path to its
125
+ # Set of declared class names.
126
+ def appeared_classes(changed_files, decls_before, decls_after)
127
+ appeared = Set.new
128
+ changed_files.each do |path|
129
+ before = decls_before[path] || Set.new
130
+ after = decls_after[path] || Set.new
131
+ appeared.merge(after - before)
132
+ end
133
+ appeared.freeze
134
+ end
135
+
136
+ # ADR-46 slice 3 — the consumers to re-check because a name they
137
+ # looked up and *missed* (a negative dependency) now resolves. `keys`
138
+ # is the set of negative-dependency keys (`"toplevel:foo"` /
139
+ # `"method:C#m"`) the appeared symbols would satisfy; `negative_dependents`
140
+ # maps each key to the Set of consumers that recorded the miss. Returns
141
+ # a frozen Set of consumer paths.
142
+ def negative_closure(keys, negative_dependents)
143
+ closure = Set.new
144
+ keys.each { |key| closure.merge(negative_dependents[key] || []) }
145
+ closure.freeze
146
+ end
147
+
148
+ # The files whose per-file diagnostics differ between two runs.
149
+ # Each argument maps a path to its diagnostic list; diagnostics are
150
+ # compared structurally via {Diagnostic#to_h} so identity / ordering
151
+ # of the objects themselves does not matter. A file present in one
152
+ # run and absent (zero diagnostics) in the other counts as changed.
153
+ def changed_files(before_by_file, after_by_file)
154
+ (before_by_file.keys | after_by_file.keys).each_with_object(Set.new) do |path, changed|
155
+ before = (before_by_file[path] || []).map(&:to_h)
156
+ after = (after_by_file[path] || []).map(&:to_h)
157
+ changed << path unless before == after
158
+ end.freeze
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "incremental"
5
+ require_relative "../cache/incremental_snapshot"
6
+ require_relative "../inference/scope_indexer"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ # ADR-46 slice 2 — the in-memory incremental orchestrator that composes
11
+ # the recorded dependency graph ({Runner#file_dependents}), the affected
12
+ # closure ({Incremental.affected}), and the subset-analysis hook
13
+ # ({Runner} `analyze_only:`) into a working incremental re-check.
14
+ #
15
+ # `#baseline` runs a full analysis with dependency recording and keeps,
16
+ # per analyzed file, its diagnostics (the cache), its content digest,
17
+ # and the per-file source set (to maintain the dependents index across
18
+ # rounds). `#recheck` digests the files again, computes the changed set
19
+ # ΔF, re-analyzes only `ΔF ∪ dependents[ΔF]`, and serves every other
20
+ # analyzed file from the cache — the body tier.
21
+ #
22
+ # The invariant the verify harness (and the spec) assert: `#recheck`'s
23
+ # merged diagnostics are byte-identical (as a sorted set) to a full
24
+ # `--no-cache` re-analysis of the edited tree. This is the
25
+ # `--verify-incremental` acceptance gate, here without disk persistence
26
+ # or CLI wiring (the cache is in-process). It models the body tier only:
27
+ # an edit that adds / removes / moves a *file* is outside the analyzed
28
+ # set it maintains and falls to a fresh {#baseline} (the structural tier
29
+ # is a later slice).
30
+ class IncrementalSession
31
+ # The outcome of a {#recheck}: the merged diagnostics plus the file
32
+ # sets, so a caller (or the verify gate) can report what was
33
+ # re-analyzed versus served from cache.
34
+ Recheck = Data.define(:diagnostics, :changed, :affected, :reused)
35
+
36
+ # @param paths [Array<String>, nil] explicit analysis roots; nil
37
+ # (the default) uses the configuration's `paths:`.
38
+ def initialize(configuration:, paths: nil)
39
+ @configuration = configuration
40
+ @paths = paths
41
+ @cache = {} # analyzed path => [Diagnostic]
42
+ @sources = {} # analyzed path => Set<source path it read from>
43
+ @digests = {} # analyzed path => content digest at last analysis
44
+ @analyzed = [] # the project files analyzed last round
45
+ @dependents = {} # inverted @sources (file-level)
46
+ # ADR-46 slice 4 — symbol-granularity tracking.
47
+ @symbol_sources = {} # consumer => { source_path => Set<"ClassName#method"> }
48
+ @ancestry_sources = {} # consumer => Set<source_path> (class-ancestry deps)
49
+ @symbol_fingerprints = {} # path => { "ClassName#method" => sha256_hex }
50
+ @symbol_dependents = {} # [source, symbol] => Set<consumer>
51
+ @ancestry_dependents = {} # source => Set<consumer> (inverted ancestry_sources)
52
+ # ADR-46 slice 3 — negative (missing) dependencies: a consumer that
53
+ # looked up a name and resolved nothing must be re-checked when that
54
+ # name later appears (e.g. a `call.unresolved-toplevel` whose target
55
+ # is defined by a later edit).
56
+ @missing = {} # consumer => Set<"kind:name"> it looked up and missed
57
+ @negative_dependents = {} # "kind:name" => Set<consumer> (inverted @missing)
58
+ @class_decls = {} # path => Set<qualified class name declared in the file>
59
+ end
60
+
61
+ # The project files analyzed at the last baseline / recheck — the set
62
+ # a verify pass partitions and the merge subtracts the affected
63
+ # closure from.
64
+ def analyzed_files
65
+ @analyzed
66
+ end
67
+
68
+ # Full baseline analysis with recording. Returns the run's
69
+ # diagnostics; populates the in-process cache + dependency state.
70
+ def baseline
71
+ runner = build_runner(record_dependencies: true)
72
+ diagnostics = run_runner(runner).diagnostics
73
+ @analyzed = runner.analyzed_files
74
+ absorb_dependency_graph(runner)
75
+ @cache = per_file(diagnostics)
76
+ @digests = @analyzed.to_h { |path| [path, digest(path)] }
77
+ diagnostics
78
+ end
79
+
80
+ # Re-check after on-disk edits, including files added or removed since
81
+ # the last run (the structural tier). Re-analyzes only the affected
82
+ # closure and serves the rest from cache; refreshes the cache +
83
+ # dependency state so a subsequent #recheck sees the new world.
84
+ def recheck
85
+ previous = @analyzed
86
+ current = current_files
87
+ added = current - previous
88
+ removed = previous - current
89
+ changed = (current & previous).reject { |path| digest(path) == @digests[path] }
90
+ affected = affected_closure(changed, added, removed)
91
+ analyze_set = affected & current
92
+ runner = build_runner(analyze_only: analyze_set, record_dependencies: true)
93
+ fresh = run_runner(runner).diagnostics
94
+ reused = (current & previous) - affected.to_a
95
+ merged = fresh + reused.flat_map { |path| @cache[path] || [] }
96
+ absorb(runner, fresh, current, analyze_set, removed)
97
+ Recheck.new(diagnostics: merged, changed: changed.to_set, affected: affected, reused: reused.to_set)
98
+ end
99
+
100
+ # The frozen set of files a #recheck must re-analyse: the
101
+ # symbol/ancestry-granularity closure of the changed files (slice 4),
102
+ # the added files themselves, the consumers of any symbol / class that
103
+ # *appeared* in a changed OR added file (slice 3 — a now-defined
104
+ # `call.unresolved-toplevel` target or `def.override-*` ancestor), and
105
+ # the consumers of every removed file (which now miss what it provided).
106
+ # An added file has no before-state, so all its symbols / classes appear.
107
+ def affected_closure(changed, added, removed)
108
+ scan = changed + added
109
+ new_fps = symbol_fingerprints_for(scan)
110
+ new_class_decls = class_declarations_for(scan)
111
+ changed_pairs = Incremental.changed_symbol_pairs(changed, @symbol_fingerprints, new_fps)
112
+ base = if changed_pairs.any? || changed.any? { |f| @ancestry_dependents[f] }
113
+ Incremental.affected_with_symbols(changed, changed_pairs, @symbol_dependents, @ancestry_dependents)
114
+ else
115
+ Incremental.affected(changed, @dependents)
116
+ end
117
+ closure = base | added.to_set | negative_affected(scan, new_fps, new_class_decls)
118
+ removed.each { |path| closure |= @dependents[path] || Set.new }
119
+ closure.freeze
120
+ end
121
+
122
+ # The current project file set (cheap directory expansion, no analysis),
123
+ # used to detect files added / removed since the last run.
124
+ def current_files
125
+ runner = build_runner
126
+ @paths ? runner.analysis_file_set(@paths) : runner.analysis_file_set
127
+ end
128
+
129
+ # Verification engine (the `--verify-incremental` gate): with NO
130
+ # source edit, re-analyze `subset` fresh and serve every other
131
+ # analyzed file from the baseline cache. Because nothing on disk
132
+ # changed, the merged result MUST equal a full analysis — so this
133
+ # exercises the subset-analysis and cache-merge paths against a
134
+ # known-good oracle (a full `--no-cache` run) for an arbitrary
135
+ # partition, without mutating session state. Returns the merged
136
+ # diagnostics.
137
+ def reanalyze_subset(subset)
138
+ affected = subset.to_set
139
+ runner = build_runner(analyze_only: affected)
140
+ fresh = run_runner(runner).diagnostics
141
+ reused = @analyzed - affected.to_a
142
+ fresh + reused.flat_map { |path| @cache[path] || [] }
143
+ end
144
+
145
+ # Cross-process incremental run (the `--incremental` flag's engine).
146
+ # With a disk `snapshot` whose `fingerprint` matches, restore the
147
+ # prior per-file state and `#recheck` (re-analyze only the changed
148
+ # closure, serve the rest from the restored cache); otherwise run a
149
+ # full `#baseline`. Either way, persist the updated snapshot for the
150
+ # next process. Returns `[diagnostics, warm]` — `warm` is true when a
151
+ # snapshot was restored. A nil `fingerprint` (uncomputable inputs)
152
+ # disables persistence: a plain full run.
153
+ def run_incremental(snapshot:, fingerprint:)
154
+ restored = fingerprint && snapshot.load(fingerprint: fingerprint)
155
+ if restored
156
+ restore(restored)
157
+ diagnostics = recheck.diagnostics
158
+ warm = true
159
+ else
160
+ diagnostics = baseline
161
+ warm = false
162
+ end
163
+ snapshot.save(fingerprint: fingerprint, payload: to_payload) if fingerprint
164
+ [diagnostics, warm]
165
+ end
166
+
167
+ private
168
+
169
+ # Adopt a persisted snapshot's per-file state as this session's
170
+ # baseline (the warm-start path).
171
+ def restore(payload)
172
+ @analyzed = payload.analyzed
173
+ @cache = payload.cache
174
+ @sources = payload.sources
175
+ @digests = payload.digests
176
+ @dependents = Incremental.invert(@sources)
177
+ # ADR-46 slice 4 — restore symbol-granularity state if present in the
178
+ # payload (absent in snapshots written before slice 4 → fall back to
179
+ # file-level dependents, which is always sound).
180
+ @symbol_sources = payload.symbol_sources || {}
181
+ @ancestry_sources = payload.ancestry_sources || {}
182
+ @symbol_fingerprints = payload.symbol_fingerprints || {}
183
+ # ADR-46 slice 3 — restore negative edges if present (absent in
184
+ # pre-slice-3 snapshots → empty, which only loses the appeared-symbol
185
+ # re-check refinement; the fingerprint still drops the snapshot on a
186
+ # file add/remove, so it is never unsound).
187
+ @missing = payload.missing || {}
188
+ @class_decls = payload.class_decls || {}
189
+ @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
190
+ @ancestry_dependents = Incremental.invert(@ancestry_sources)
191
+ @negative_dependents = Incremental.invert(@missing)
192
+ end
193
+
194
+ def to_payload
195
+ Cache::IncrementalSnapshot::Payload.new(
196
+ cache: @cache, sources: @sources, digests: @digests, analyzed: @analyzed,
197
+ symbol_sources: @symbol_sources, ancestry_sources: @ancestry_sources,
198
+ symbol_fingerprints: @symbol_fingerprints, missing: @missing,
199
+ class_decls: @class_decls
200
+ )
201
+ end
202
+
203
+ # Fold a #recheck's fresh results back into the cache + graph so the
204
+ # session is correct across multiple edits: the analyzed set gets fresh
205
+ # diagnostics + digests + dependency edges, removed files are evicted
206
+ # from every map, and the analyzed-file list advances to `current`.
207
+ def absorb(runner, fresh, current, analyze_set, removed)
208
+ removed.each { |path| forget(path) }
209
+ @analyzed = current
210
+ fresh_by_file = per_file(fresh)
211
+ analyze_set.each do |path|
212
+ @cache[path] = fresh_by_file[path] || []
213
+ @digests[path] = digest(path)
214
+ end
215
+ absorb_dependency_graph(runner)
216
+ end
217
+
218
+ # Evict a removed file from every per-file map so its stale diagnostics
219
+ # are never served and it drops out of the inverted dependency indexes.
220
+ def forget(path)
221
+ @cache.delete(path)
222
+ @digests.delete(path)
223
+ @sources.delete(path)
224
+ @symbol_sources.delete(path)
225
+ @ancestry_sources.delete(path)
226
+ @missing.delete(path)
227
+ @symbol_fingerprints.delete(path)
228
+ # @class_decls is wholesale-replaced from the (removed-excluding)
229
+ # pre-pass in absorb_dependency_graph, and is frozen, so no delete.
230
+ end
231
+
232
+ # Fold a runner's dependency recording (file-level and symbol-level) back
233
+ # into the session's graph state. Rebuilds all derived indexes.
234
+ def absorb_dependency_graph(runner)
235
+ runner.file_dependencies.each do |path, record|
236
+ @sources[path] = record.sources.dup
237
+ @symbol_sources[path] = record.symbol_sources.transform_values(&:dup)
238
+ @ancestry_sources[path] = record.ancestry_sources.dup
239
+ @missing[path] = record.missing.dup
240
+ end
241
+ @dependents = Incremental.invert(@sources)
242
+ @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
243
+ @ancestry_dependents = Incremental.invert(@ancestry_sources)
244
+ @negative_dependents = Incremental.invert(@missing)
245
+ @symbol_fingerprints.merge!(runner.symbol_fingerprints)
246
+ # Wholesale replace (the subset runner's pre-pass is complete): a file
247
+ # that lost its last class must drop out of the map so a later re-add
248
+ # registers as an appearance.
249
+ @class_decls = runner.class_declarations
250
+ end
251
+
252
+ # Compute per-symbol body fingerprints for `paths` via a quick indexing
253
+ # re-pass (Prism parse + def extraction, no type inference). Returns a
254
+ # hash of the form `{ path => { "ClassName#method" => sha256_hex } }`.
255
+ # Used by {#recheck} to detect which symbols in a changed file actually
256
+ # changed, so only their callers are added to the affected closure.
257
+ def symbol_fingerprints_for(paths)
258
+ return {} if paths.empty?
259
+
260
+ index = Inference::ScopeIndexer.discovered_def_index_for_paths(paths)
261
+ def_nodes = index[:def_nodes]
262
+ def_sources = index[:def_sources]
263
+ result = Hash.new { |h, k| h[k] = {} }
264
+ def_sources.each do |class_name, methods|
265
+ methods.each do |method_sym, path_line|
266
+ path = path_line.split(":", 2).first
267
+ node = def_nodes.dig(class_name, method_sym)
268
+ next unless node
269
+
270
+ result[path]["#{class_name}##{method_sym}"] =
271
+ Digest::SHA256.hexdigest(node.location.slice)
272
+ end
273
+ end
274
+ result.transform_values(&:freeze).freeze
275
+ end
276
+
277
+ # ADR-46 slice 3 — the consumers to re-check because a symbol that
278
+ # appeared in a changed file resolves a prior missed lookup. Maps each
279
+ # appeared `"ClassName#method"` to the negative-dependency key it would
280
+ # satisfy (`toplevel:foo` for a top-level def, `method:C#m` otherwise),
281
+ # then unions the recorded negative-dependents of those keys.
282
+ def negative_affected(changed, new_fingerprints, new_class_decls)
283
+ appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
284
+ appeared_classes = Incremental.appeared_classes(changed, @class_decls, new_class_decls)
285
+ keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
286
+ keys.concat(appeared_classes.map { |klass| "class:#{klass.split('::').last}" })
287
+ Incremental.negative_closure(keys, @negative_dependents)
288
+ end
289
+
290
+ # The qualified class/module names declared in `paths`, via the same
291
+ # quick indexing re-pass {#symbol_fingerprints_for} uses (Prism parse +
292
+ # declaration extraction, no inference). `{ path => Set<class name> }`.
293
+ def class_declarations_for(paths)
294
+ return {} if paths.empty?
295
+
296
+ index = Inference::ScopeIndexer.discovered_def_index_for_paths(paths)
297
+ result = Hash.new { |hash, key| hash[key] = Set.new }
298
+ index[:class_sources].each do |class_name, files|
299
+ files.each { |file| result[file] << class_name }
300
+ end
301
+ result.transform_values(&:freeze).freeze
302
+ end
303
+
304
+ TOP_LEVEL_KEY = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
305
+ private_constant :TOP_LEVEL_KEY
306
+
307
+ def negative_key_for(symbol)
308
+ class_name, method = symbol.split("#", 2)
309
+ class_name == TOP_LEVEL_KEY ? "toplevel:#{method}" : "method:#{symbol}"
310
+ end
311
+
312
+ def build_runner(**)
313
+ Runner.new(configuration: @configuration, cache_store: nil, **)
314
+ end
315
+
316
+ # Run the runner over the session's explicit paths (or, when none were
317
+ # given, the configuration's `paths:` via `Runner#run`'s default).
318
+ def run_runner(runner)
319
+ @paths ? runner.run(@paths) : runner.run
320
+ end
321
+
322
+ # Group diagnostics by their file path, keeping only those whose path
323
+ # is an analyzed project file — run-level streams (the gem-RBS info
324
+ # diagnostic, keyed on `.rigor.yml`) are recomputed fresh every run
325
+ # and must not be served from the per-file cache.
326
+ def per_file(diagnostics)
327
+ diagnostics.group_by(&:path).slice(*@analyzed)
328
+ end
329
+
330
+ def digest(path)
331
+ Digest::SHA256.hexdigest(File.read(path))
332
+ rescue StandardError
333
+ "missing"
334
+ end
335
+ end
336
+ end
337
+ end
@@ -78,6 +78,29 @@ module Rigor
78
78
  since: "0.0.1"
79
79
  ),
80
80
 
81
+ CheckRules::RULE_SELF_UNDEFINED_METHOD => Entry.new(
82
+ id: CheckRules::RULE_SELF_UNDEFINED_METHOD,
83
+ summary: "Implicit-self call resolves to no method on a confidently-closed class.",
84
+ fires_when: [
85
+ "The call is an implicit-self call (no explicit receiver) inside a class body.",
86
+ "The engine's own resolution (RBS dispatch + the user-class ancestor walk) found nothing.",
87
+ "The enclosing class is a STANDALONE project class: no superclass and no `include`/`prepend`.",
88
+ "It defines no `method_missing` and no dynamic `attr_*(*splat)` accessor.",
89
+ "It is not a plugin-declared open receiver (ADR-26)."
90
+ ],
91
+ does_not_fire_when: [
92
+ "The enclosing scope is a `module` (a mixin contract — methods may come from includers).",
93
+ "The class has a superclass or mixes in a module (surface extends beyond this file — a later slice).",
94
+ "`self` is `Dynamic` / top-level (the gradual guarantee), or the method exists via any project signal.",
95
+ "Off in every shipped profile pending the external corpus FP gate — opt in via `severity_overrides:`."
96
+ ],
97
+ suppression: "`# rigor:disable call.self-undefined-method`, or enable/disable via " \
98
+ "`severity_overrides: { call.self-undefined-method: warning }` in `.rigor.yml`.",
99
+ severity_authored: :warning,
100
+ severity_by_profile: { lenient: :off, balanced: :off, strict: :off },
101
+ since: "0.1.17"
102
+ ),
103
+
81
104
  CheckRules::RULE_WRONG_ARITY => Entry.new(
82
105
  id: CheckRules::RULE_WRONG_ARITY,
83
106
  summary: "Call's positional argument count is outside the declared overloads' envelope.",
@@ -226,6 +249,31 @@ module Rigor
226
249
  since: "0.1.2"
227
250
  ),
228
251
 
252
+ CheckRules::RULE_UNREACHABLE_CLAUSE => Entry.new(
253
+ id: CheckRules::RULE_UNREACHABLE_CLAUSE,
254
+ summary: "A `case` / `when` clause the flow engine's narrowing proves can never match.",
255
+ fires_when: [
256
+ "The subject is a `case <local>` (`LocalVariableReadNode`), the only shape the engine narrows.",
257
+ "Every `when` condition is a class / module constant (`when String` / `when MyClass`).",
258
+ "The clause's narrowed body subject is `Type::Bot` — disjoint from the subject (`when String` " \
259
+ "over an `Integer`) or already exhausted by an earlier clause (prior-exhaustion)."
260
+ ],
261
+ does_not_fire_when: [
262
+ "The subject's type at case entry is `Dynamic` (disjointness is never provable under gradual " \
263
+ "`Dynamic`, preserving the gradual guarantee) or already `Bot` (dead code, not a clause error).",
264
+ "A `when` condition is not a class / module constant — `when nil`, ranges, regexps, and " \
265
+ "arbitrary expressions are out of the WD1 scope.",
266
+ "The clause sits inside a `WhileNode` / `UntilNode` / `ForNode` / `BlockNode` (mutation tracking " \
267
+ "through those is incomplete), or its body is empty (no useful location)."
268
+ ],
269
+ suppression: "`# rigor:disable unreachable-clause` on the dead-clause body line.",
270
+ severity_authored: :warning,
271
+ # ADR-47 WD4: balanced stays :info (one notch below its `flow.*`
272
+ # siblings' :warning) until the regression-corpus FP gate is green.
273
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :warning },
274
+ since: "0.1.17"
275
+ ),
276
+
229
277
  CheckRules::RULE_DEAD_ASSIGNMENT => Entry.new(
230
278
  id: CheckRules::RULE_DEAD_ASSIGNMENT,
231
279
  summary: "Local variable assigned in a method body but never read.",