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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
- data/lib/rigor/analysis/check_rules.rb +149 -70
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +18 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- data/lib/rigor/analysis/runner.rb +434 -37
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +50 -49
- data/lib/rigor/cache/incremental_snapshot.rb +147 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +99 -1
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +2 -7
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +125 -43
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +13 -3
- data/lib/rigor/environment/rbs_loader.rb +76 -3
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +140 -20
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +99 -59
- data/lib/rigor/inference/narrowing.rb +202 -5
- data/lib/rigor/inference/scope_indexer.rb +134 -7
- data/lib/rigor/inference/statement_evaluator.rb +105 -26
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/base.rb +20 -4
- data/lib/rigor/plugin/registry.rb +39 -1
- data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope.rb +123 -9
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +17 -0
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +5 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
- data/sig/rigor/cache.rbs +19 -0
- data/sig/rigor/inference.rbs +22 -0
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +5 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +6 -1
- 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.",
|