rigortype 0.1.6 → 0.1.7
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/README.md +40 -29
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/check_rules.rb +57 -2
- data/lib/rigor/builtins/static_return_refinements.rb +23 -1
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli.rb +70 -3
- data/lib/rigor/configuration.rb +21 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +22 -0
- data/lib/rigor/environment.rb +13 -0
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/expression_typer.rb +17 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
- data/lib/rigor/inference/method_dispatcher.rb +20 -3
- data/lib/rigor/inference/narrowing.rb +103 -1
- data/lib/rigor/inference/scope_indexer.rb +53 -7
- data/lib/rigor/inference/statement_evaluator.rb +66 -5
- data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor.rbs +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0eaff9cf0ef65d44ceb3666a23fb77003a3dbb0361d890e1d2991ef6539499de
|
|
4
|
+
data.tar.gz: e7fdc58be21409504965f35479559d26bcf4726ba0feabe3fd5128bcffe8419b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94aae7605ca3243e7226e6f2e1c844f141d3ef04995751718e08ef5fb9dfa550455c6c87420e731332b765ee262442ed2608b5f0d7b05a25b982615b993114e5
|
|
7
|
+
data.tar.gz: f2dedba8fb33b9f7d98ddaa4debcec042edf56396c22a791ce8897736839c559240cb20158916f7c2bc5f483da06c1bebb7212ec2f24b16c3554164f849da621
|
data/README.md
CHANGED
|
@@ -445,19 +445,20 @@ plugin-supplied type-vocabulary resolvers, and
|
|
|
445
445
|
[ADR-16](docs/adr/16-macro-expansion.md) macro / DSL expansion
|
|
446
446
|
substrate (declarative Tier A block-as-method / Tier B
|
|
447
447
|
trait-inlining-registry / Tier C heredoc-template / Tier D
|
|
448
|
-
external-file inclusion).
|
|
449
|
-
|
|
450
|
-
|
|
448
|
+
external-file inclusion). Production plugins ship under
|
|
449
|
+
[`plugins/`](plugins/) — each is a fully-shaped plugin gem
|
|
450
|
+
with a runnable demo and an end-to-end integration spec.
|
|
451
|
+
Plugin-contract walkthroughs (deliberately simplified
|
|
452
|
+
virtual use cases that spotlight one architectural surface
|
|
453
|
+
per example) live under [`examples/`](examples/).
|
|
451
454
|
|
|
452
|
-
**Plugin-contract
|
|
453
|
-
extension-point):
|
|
455
|
+
**Plugin-contract walkthroughs** (`examples/`, focus on a
|
|
456
|
+
single extension-point):
|
|
454
457
|
|
|
455
458
|
- [`rigor-deprecations`](examples/rigor-deprecations/) —
|
|
456
459
|
smallest possible plugin (~80 lines); config-driven rules.
|
|
457
460
|
- [`rigor-lisp-eval`](examples/rigor-lisp-eval/) — typing literal
|
|
458
461
|
AST arguments at a method call.
|
|
459
|
-
- [`rigor-statesman`](examples/rigor-statesman/) — two-pass DSL
|
|
460
|
-
analysis (collect declarations, then validate references).
|
|
461
462
|
- [`rigor-pattern`](examples/rigor-pattern/) — plugin →
|
|
462
463
|
analyzer collaboration via `Scope#type_of` and the
|
|
463
464
|
literal-string carrier.
|
|
@@ -465,7 +466,13 @@ extension-point):
|
|
|
465
466
|
tracking through arithmetic.
|
|
466
467
|
- [`rigor-routes`](examples/rigor-routes/) — `Plugin::IoBoundary`
|
|
467
468
|
reads under `TrustPolicy` plus cache producers.
|
|
468
|
-
|
|
469
|
+
|
|
470
|
+
**Other production plugins for type-language extension** (`plugins/`):
|
|
471
|
+
|
|
472
|
+
- [`rigor-statesman`](plugins/rigor-statesman/) — two-pass DSL
|
|
473
|
+
analysis (collect declarations, then validate references)
|
|
474
|
+
for the Statesman state-machine gem.
|
|
475
|
+
- [`rigor-typescript-utility-types`](plugins/rigor-typescript-utility-types/)
|
|
469
476
|
— `Plugin::TypeNodeResolver` chain wiring TS-canonical names
|
|
470
477
|
(`Pick` / `Omit` / `Partial` / `Required` / `Readonly`) onto
|
|
471
478
|
Rigor's shape-projection type functions.
|
|
@@ -473,16 +480,16 @@ extension-point):
|
|
|
473
480
|
**Macro expansion substrate consumers** (ADR-16 — declarative
|
|
474
481
|
manifest entries, no walker code):
|
|
475
482
|
|
|
476
|
-
- [`rigor-sinatra`](
|
|
483
|
+
- [`rigor-sinatra`](plugins/rigor-sinatra/) — **Tier A**
|
|
477
484
|
block-as-method. Recognises Sinatra's nine class-level HTTP
|
|
478
485
|
verb methods and narrows the route block's `self_type` so
|
|
479
486
|
bare `params` / `redirect` / `halt` resolve through
|
|
480
487
|
`Sinatra::Base`'s RBS.
|
|
481
|
-
- [`rigor-dry-struct`](
|
|
488
|
+
- [`rigor-dry-struct`](plugins/rigor-dry-struct/) — **Tier C**
|
|
482
489
|
heredoc-template. Synthesises a reader on every `Dry::Struct`
|
|
483
490
|
subclass for each `attribute :name, T` / `attribute? :name, T`
|
|
484
491
|
call.
|
|
485
|
-
- [`rigor-devise`](
|
|
492
|
+
- [`rigor-devise`](plugins/rigor-devise/) — **Tier B**
|
|
486
493
|
trait-inlining registry mirroring `lib/devise/modules.rb`.
|
|
487
494
|
Each `devise :strategy_a, :strategy_b` call explodes the
|
|
488
495
|
included module's RBS instance methods onto the calling model
|
|
@@ -491,28 +498,30 @@ manifest entries, no walker code):
|
|
|
491
498
|
|
|
492
499
|
**Rails ecosystem plugins** (Tier 1 + Tier 2 + Tier 3 + Sorbet):
|
|
493
500
|
|
|
494
|
-
- Tier 1: [`rigor-rails-routes`](
|
|
495
|
-
[`rigor-rails-i18n`](
|
|
496
|
-
[`rigor-actionmailer`](
|
|
497
|
-
[`rigor-activejob`](
|
|
498
|
-
- Tier 2: [`rigor-actionpack`](
|
|
501
|
+
- Tier 1: [`rigor-rails-routes`](plugins/rigor-rails-routes/),
|
|
502
|
+
[`rigor-rails-i18n`](plugins/rigor-rails-i18n/),
|
|
503
|
+
[`rigor-actionmailer`](plugins/rigor-actionmailer/),
|
|
504
|
+
[`rigor-activejob`](plugins/rigor-activejob/).
|
|
505
|
+
- Tier 2: [`rigor-actionpack`](plugins/rigor-actionpack/)
|
|
499
506
|
(4 phases — routes / filters / renders / strong-params),
|
|
500
|
-
[`rigor-factorybot`](
|
|
501
|
-
[`rigor-activerecord`](
|
|
507
|
+
[`rigor-factorybot`](plugins/rigor-factorybot/),
|
|
508
|
+
[`rigor-activerecord`](plugins/rigor-activerecord/) —
|
|
502
509
|
publishes `:model_index` via ADR-9 for the other two
|
|
503
510
|
to consume.
|
|
504
|
-
- Tier 3: [`rigor-pundit`](
|
|
505
|
-
[`rigor-sidekiq`](
|
|
506
|
-
[`rigor-rspec`](
|
|
507
|
-
[`rigor-actioncable`](
|
|
508
|
-
- Parallel: [`rigor-sorbet`](
|
|
511
|
+
- Tier 3: [`rigor-pundit`](plugins/rigor-pundit/),
|
|
512
|
+
[`rigor-sidekiq`](plugins/rigor-sidekiq/),
|
|
513
|
+
[`rigor-rspec`](plugins/rigor-rspec/),
|
|
514
|
+
[`rigor-actioncable`](plugins/rigor-actioncable/).
|
|
515
|
+
- Parallel: [`rigor-sorbet`](plugins/rigor-sorbet/) — ingests
|
|
509
516
|
Sorbet `sig` / `T.let` / `T.cast` / `T.must` / `T.bind` /
|
|
510
517
|
`T.assert_type!` / `T.reveal_type` / `T.absurd` and RBI
|
|
511
518
|
files as type sources.
|
|
512
519
|
|
|
513
|
-
[`
|
|
514
|
-
|
|
515
|
-
|
|
520
|
+
[`plugins/README.md`](plugins/README.md) is the production
|
|
521
|
+
plugin catalogue (Rails / RSpec / dry-rb / Sorbet / etc.) and
|
|
522
|
+
[`examples/README.md`](examples/README.md) is the walkthrough
|
|
523
|
+
catalogue — comparison table, recommended reading order, and
|
|
524
|
+
the architectural map of which surface each walkthrough
|
|
516
525
|
exercises. The binding contract for the plugin API lives in
|
|
517
526
|
[`docs/adr/2-extension-api.md`](docs/adr/2-extension-api.md);
|
|
518
527
|
the slice-by-slice normative specs are under
|
|
@@ -568,10 +577,12 @@ while the inference surface stabilises. Forward-looking commitments
|
|
|
568
577
|
- **DEFAULT_LIBRARIES stdlib coverage expansion** — out-of-the-box RBS classes available 1,273 → 1,427 (+154); 31 additional stdlib libraries auto-load.
|
|
569
578
|
- **`is_a?(C)` lexical-nesting constant resolution** — predicate-narrowing now mirrors Ruby's `Module.nesting`-driven lookup.
|
|
570
579
|
|
|
571
|
-
|
|
580
|
+
Production plugins ship under [`plugins/`](plugins/) (Rails /
|
|
581
|
+
RSpec / dry-rb / Sorbet / etc.) — see
|
|
582
|
+
[`plugins/README.md`](plugins/README.md) for the catalogue.
|
|
583
|
+
Plugin-contract walkthroughs ship under
|
|
572
584
|
[`examples/`](examples/) — see
|
|
573
|
-
[`examples/README.md`](examples/README.md)
|
|
574
|
-
table.
|
|
585
|
+
[`examples/README.md`](examples/README.md).
|
|
575
586
|
|
|
576
587
|
## Contributing
|
|
577
588
|
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
# ADR-22 Slice 1 — PHPStan-shaped per-project baseline.
|
|
8
|
+
#
|
|
9
|
+
# Loads `.rigor-baseline.yml`, filters a current run's
|
|
10
|
+
# diagnostic stream against the recorded buckets, and emits
|
|
11
|
+
# an `(surfaced, silenced_count)` pair for the CLI to render.
|
|
12
|
+
#
|
|
13
|
+
# Two row shapes are accepted (WD1):
|
|
14
|
+
#
|
|
15
|
+
# # rule-ID row — bucket key (path, qualified_rule)
|
|
16
|
+
# - file: app/models/user.rb
|
|
17
|
+
# rule: call.undefined-method
|
|
18
|
+
# count: 3
|
|
19
|
+
#
|
|
20
|
+
# # message-pattern row — bucket key
|
|
21
|
+
# # (path, qualified_rule, message_regex)
|
|
22
|
+
# - file: app/lib/sig.rb
|
|
23
|
+
# rule: call.undefined-method
|
|
24
|
+
# message: "undefined method `merge' for Array"
|
|
25
|
+
# count: 1
|
|
26
|
+
#
|
|
27
|
+
# ## Semantics per (file, rule [, message]) bucket (WD4)
|
|
28
|
+
#
|
|
29
|
+
# actual <= count → ALL diagnostics in the bucket are silenced.
|
|
30
|
+
# actual > count → ALL diagnostics in the bucket surface
|
|
31
|
+
# (not just the excess delta — the bucket
|
|
32
|
+
# has crossed its threshold; the team's
|
|
33
|
+
# review focus shifts from "which N is new"
|
|
34
|
+
# to "what's going on with this rule in
|
|
35
|
+
# this file as a whole").
|
|
36
|
+
#
|
|
37
|
+
# ## Filter pipeline position (WD6)
|
|
38
|
+
#
|
|
39
|
+
# The baseline filter runs LAST among the diagnostic-suppression
|
|
40
|
+
# layers:
|
|
41
|
+
#
|
|
42
|
+
# emit → `# rigor:disable` (per-line)
|
|
43
|
+
# → `# rigor:disable-file`
|
|
44
|
+
# → severity_profile re-stamp
|
|
45
|
+
# → baseline filter (this class)
|
|
46
|
+
# → output
|
|
47
|
+
#
|
|
48
|
+
# ## Loading (WD2 (b))
|
|
49
|
+
#
|
|
50
|
+
# `Baseline.load` is called by the CLI when it has resolved
|
|
51
|
+
# an explicit baseline path (from `--baseline=PATH` on the
|
|
52
|
+
# CLI or `baseline: <path>` in `.rigor.yml`). The presence
|
|
53
|
+
# of `.rigor-baseline.yml` on disk alone never triggers a
|
|
54
|
+
# load — that's the CLI / Configuration's job to enforce.
|
|
55
|
+
class Baseline
|
|
56
|
+
# The bucket key is intentionally tuple-shaped so rule-ID
|
|
57
|
+
# rows and message-pattern rows can coexist in a single
|
|
58
|
+
# multimap. `message` is `nil` for rule-ID rows; a Regexp
|
|
59
|
+
# for message-pattern rows.
|
|
60
|
+
# `count` shadows Struct#count; intentional — `count` is the
|
|
61
|
+
# PHPStan-compatible field name and we don't use the
|
|
62
|
+
# Enumerable-style `Struct#count` on Bucket instances.
|
|
63
|
+
Bucket = Struct.new(:file, :rule, :message_regex, :count, keyword_init: true) # rubocop:disable Lint/StructNewOverride
|
|
64
|
+
|
|
65
|
+
CURRENT_VERSION = 1
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# Load a baseline file from disk. Returns `nil` when the
|
|
69
|
+
# path is nil (the caller's "no baseline configured"
|
|
70
|
+
# state). Raises {LoadError} on malformed content;
|
|
71
|
+
# callers translate to a user-facing diagnostic.
|
|
72
|
+
def load(path)
|
|
73
|
+
return nil if path.nil?
|
|
74
|
+
return new([]) unless File.exist?(path)
|
|
75
|
+
|
|
76
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol])
|
|
77
|
+
parse_loaded(raw, path: path)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build a baseline from a current run's diagnostic stream.
|
|
81
|
+
# `match_mode:` is `:rule` (default) or `:message`. The
|
|
82
|
+
# message-mode generator passes literal messages through
|
|
83
|
+
# `Regexp.escape` so generated rows never accidentally
|
|
84
|
+
# over-match on punctuation.
|
|
85
|
+
def from_diagnostics(diagnostics, match_mode: :rule)
|
|
86
|
+
raise ArgumentError, "match_mode must be :rule or :message" unless %i[rule message].include?(match_mode)
|
|
87
|
+
|
|
88
|
+
grouped = group_for_baseline(diagnostics, match_mode)
|
|
89
|
+
buckets = grouped.map do |key, entries|
|
|
90
|
+
Bucket.new(
|
|
91
|
+
file: key[0],
|
|
92
|
+
rule: key[1],
|
|
93
|
+
message_regex: key[2],
|
|
94
|
+
count: entries.size
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
new(buckets)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def parse_loaded(raw, path:)
|
|
103
|
+
raise LoadError, "#{path}: expected a Hash at top level, got #{raw.class}" unless raw.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
version = raw["version"]
|
|
106
|
+
unless version == CURRENT_VERSION
|
|
107
|
+
raise LoadError, "#{path}: unsupported `version: #{version.inspect}` (expected #{CURRENT_VERSION})"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
rows = raw["ignored"] || []
|
|
111
|
+
raise LoadError, "#{path}: `ignored:` must be an Array" unless rows.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
new(rows.each_with_index.map { |row, idx| parse_row(row, path: path, index: idx) })
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_row(row, path:, index:)
|
|
117
|
+
raise LoadError, "#{path}: ignored[#{index}] must be a Hash" unless row.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
file = row["file"] or raise LoadError, "#{path}: ignored[#{index}] missing `file:`"
|
|
120
|
+
rule = row["rule"] or raise LoadError, "#{path}: ignored[#{index}] missing `rule:`"
|
|
121
|
+
count = row["count"]
|
|
122
|
+
unless count.is_a?(Integer) && count.positive?
|
|
123
|
+
raise LoadError, "#{path}: ignored[#{index}] `count:` must be a positive Integer (got #{count.inspect})"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
message_regex = nil
|
|
127
|
+
if (message = row["message"])
|
|
128
|
+
message_regex = compile_message_regex(message, path: path, index: index)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
Bucket.new(file: file, rule: rule, message_regex: message_regex, count: count)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def compile_message_regex(source, path:, index:)
|
|
135
|
+
Regexp.new(source.to_s)
|
|
136
|
+
rescue RegexpError => e
|
|
137
|
+
raise LoadError, "#{path}: ignored[#{index}] `message:` is not a valid Regexp: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Returns Hash{[file, rule, regex_or_nil] => Array<Diagnostic>}.
|
|
141
|
+
# In message mode, each unique message gets its own bucket;
|
|
142
|
+
# in rule mode, every diagnostic for a (file, rule) pair
|
|
143
|
+
# contributes to a single bucket regardless of message.
|
|
144
|
+
def group_for_baseline(diagnostics, match_mode)
|
|
145
|
+
diagnostics.each_with_object({}) do |diag, into|
|
|
146
|
+
next if diag.qualified_rule.nil?
|
|
147
|
+
next if diag.path.nil?
|
|
148
|
+
|
|
149
|
+
key = case match_mode
|
|
150
|
+
when :rule
|
|
151
|
+
[diag.path, diag.qualified_rule, nil]
|
|
152
|
+
when :message
|
|
153
|
+
[diag.path, diag.qualified_rule, message_pattern_for(diag.message)]
|
|
154
|
+
end
|
|
155
|
+
(into[key] ||= []) << diag
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Generates a Regexp source string for the baseline row.
|
|
160
|
+
# The string is `Regexp.escape`d so the YAML round-trip
|
|
161
|
+
# produces a regex that matches the literal message.
|
|
162
|
+
# Users hand-editing the row can replace the escaped
|
|
163
|
+
# form with a pattern.
|
|
164
|
+
def message_pattern_for(message)
|
|
165
|
+
Regexp.new(Regexp.escape(message.to_s))
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
class LoadError < StandardError; end
|
|
170
|
+
|
|
171
|
+
attr_reader :buckets
|
|
172
|
+
|
|
173
|
+
def initialize(buckets)
|
|
174
|
+
@buckets = buckets.freeze
|
|
175
|
+
# For each (file, qualified_rule) pair, two arrays:
|
|
176
|
+
# - rule-ID rows (message_regex == nil)
|
|
177
|
+
# - message-pattern rows (message_regex != nil)
|
|
178
|
+
# The matcher walks message-pattern rows first (tighter
|
|
179
|
+
# match takes precedence); diagnostics that don't match
|
|
180
|
+
# any message row fall through to the rule-ID row if
|
|
181
|
+
# one exists.
|
|
182
|
+
@by_pair = buckets.group_by { |b| [b.file, b.rule] }.freeze
|
|
183
|
+
freeze
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Apply the baseline filter to a diagnostic stream.
|
|
187
|
+
#
|
|
188
|
+
# Returns a 2-tuple:
|
|
189
|
+
# - `surfaced` — the diagnostics that survived the filter
|
|
190
|
+
# (new findings + entire over-threshold buckets).
|
|
191
|
+
# - `silenced_count` — how many diagnostics the baseline
|
|
192
|
+
# suppressed (for the WD7 stderr summary line).
|
|
193
|
+
def filter(diagnostics)
|
|
194
|
+
return [diagnostics, 0] if buckets.empty?
|
|
195
|
+
|
|
196
|
+
grouped = group_diagnostics_for_filtering(diagnostics)
|
|
197
|
+
surfaced = []
|
|
198
|
+
silenced_count = 0
|
|
199
|
+
|
|
200
|
+
grouped.each_value do |entries|
|
|
201
|
+
bucket = entries[:bucket]
|
|
202
|
+
diags = entries[:diagnostics]
|
|
203
|
+
# No matching bucket → all surface as new findings.
|
|
204
|
+
# `actual <= count` → all silenced (within threshold,
|
|
205
|
+
# WD4). `actual > count` → all surface (over
|
|
206
|
+
# threshold, WD4).
|
|
207
|
+
if bucket && diags.size <= bucket.count
|
|
208
|
+
silenced_count += diags.size
|
|
209
|
+
else
|
|
210
|
+
surfaced.concat(diags)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Diagnostics that lacked a rule or a path bypass the
|
|
215
|
+
# baseline entirely (the baseline can't address them).
|
|
216
|
+
unkeyable = diagnostics.reject { |d| d.qualified_rule && d.path }
|
|
217
|
+
[surfaced + unkeyable, silenced_count]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A single bucket's drift state for slice 2 inspection.
|
|
221
|
+
# `status` is one of:
|
|
222
|
+
#
|
|
223
|
+
# - `:within` — `actual <= count` (silenced by the filter).
|
|
224
|
+
# - `:over` — `actual > count` (over threshold; surfaced
|
|
225
|
+
# in the regular `rigor check` output).
|
|
226
|
+
# - `:cleared` — `actual == 0` (the bucket can be pruned).
|
|
227
|
+
# - `:reducible` — `0 < actual < count` (the bucket's count
|
|
228
|
+
# can be tightened; future `regenerate`
|
|
229
|
+
# slice 5 handles this).
|
|
230
|
+
DriftRow = Struct.new(:bucket, :actual_count, :status, keyword_init: true) do
|
|
231
|
+
def delta
|
|
232
|
+
actual_count - bucket.count
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Walk the current diagnostic stream and report
|
|
237
|
+
# bucket-level drift. Each baseline bucket becomes one
|
|
238
|
+
# DriftRow regardless of whether the current run still
|
|
239
|
+
# matches it.
|
|
240
|
+
#
|
|
241
|
+
# @param diagnostics [Array<Diagnostic>] current run's
|
|
242
|
+
# diagnostic stream (PRE-filter — pass the raw
|
|
243
|
+
# `result.diagnostics` from `Runner#run`, not the
|
|
244
|
+
# post-baseline surface).
|
|
245
|
+
# @return [Array<DriftRow>] one entry per baseline bucket,
|
|
246
|
+
# in baseline-file order.
|
|
247
|
+
def audit(diagnostics)
|
|
248
|
+
counts = Hash.new(0)
|
|
249
|
+
diagnostics.each do |diag|
|
|
250
|
+
next if diag.qualified_rule.nil? || diag.path.nil?
|
|
251
|
+
|
|
252
|
+
bucket = claim_bucket_for(diag)
|
|
253
|
+
counts[bucket_key(bucket)] += 1 if bucket
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
buckets.map do |bucket|
|
|
257
|
+
actual = counts[bucket_key(bucket)]
|
|
258
|
+
DriftRow.new(bucket: bucket, actual_count: actual, status: status_for(actual, bucket.count))
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Returns a new Baseline with the given buckets dropped.
|
|
263
|
+
# Used by `rigor baseline prune` (slice 2) to remove
|
|
264
|
+
# cleared buckets (`actual == 0`) from the on-disk file.
|
|
265
|
+
def without(buckets_to_drop)
|
|
266
|
+
dropset = buckets_to_drop.to_set
|
|
267
|
+
self.class.new(buckets.reject { |b| dropset.include?(b) })
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Serialise to a YAML string. The generator path writes
|
|
271
|
+
# this through `File.write`; the dump format is stable
|
|
272
|
+
# across versions of this class as long as the bucket
|
|
273
|
+
# shape is unchanged.
|
|
274
|
+
def to_yaml
|
|
275
|
+
rows = buckets.map do |bucket|
|
|
276
|
+
row = { "file" => bucket.file, "rule" => bucket.rule }
|
|
277
|
+
row["message"] = bucket.message_regex.source if bucket.message_regex
|
|
278
|
+
row["count"] = bucket.count
|
|
279
|
+
row
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
document = { "version" => CURRENT_VERSION, "ignored" => rows }
|
|
283
|
+
YAML.dump(document)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The number of buckets recorded. Useful for the CLI
|
|
287
|
+
# summary on `generate`.
|
|
288
|
+
def size
|
|
289
|
+
buckets.size
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def empty?
|
|
293
|
+
buckets.empty?
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def status_for(actual, count)
|
|
299
|
+
return :cleared if actual.zero?
|
|
300
|
+
return :over if actual > count
|
|
301
|
+
return :within if actual == count
|
|
302
|
+
|
|
303
|
+
:reducible
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def bucket_key(bucket)
|
|
307
|
+
[bucket.file, bucket.rule, bucket.message_regex&.source]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def group_diagnostics_for_filtering(diagnostics)
|
|
311
|
+
# First pass: bin each diagnostic into the bucket that
|
|
312
|
+
# claims it. Message-pattern rows take precedence over
|
|
313
|
+
# rule-ID rows because they're more specific. A
|
|
314
|
+
# diagnostic that matches no row goes into a synthetic
|
|
315
|
+
# "no-bucket" bin keyed by (file, rule).
|
|
316
|
+
bins = {}
|
|
317
|
+
diagnostics.each do |diag|
|
|
318
|
+
next if diag.qualified_rule.nil? || diag.path.nil?
|
|
319
|
+
|
|
320
|
+
bucket = claim_bucket_for(diag)
|
|
321
|
+
key = if bucket
|
|
322
|
+
[bucket.file, bucket.rule,
|
|
323
|
+
bucket.message_regex&.source]
|
|
324
|
+
else
|
|
325
|
+
[diag.path, diag.qualified_rule, :__none__]
|
|
326
|
+
end
|
|
327
|
+
bin = (bins[key] ||= { bucket: bucket, diagnostics: [] })
|
|
328
|
+
bin[:diagnostics] << diag
|
|
329
|
+
end
|
|
330
|
+
bins
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def claim_bucket_for(diagnostic)
|
|
334
|
+
candidates = @by_pair[[diagnostic.path, diagnostic.qualified_rule]]
|
|
335
|
+
return nil if candidates.nil? || candidates.empty?
|
|
336
|
+
|
|
337
|
+
# Tighter (message-pattern) buckets first, then the
|
|
338
|
+
# rule-ID bucket as fallback.
|
|
339
|
+
message_buckets, rule_buckets = candidates.partition(&:message_regex)
|
|
340
|
+
message_buckets.each do |b|
|
|
341
|
+
return b if b.message_regex.match?(diagnostic.message.to_s)
|
|
342
|
+
end
|
|
343
|
+
rule_buckets.first
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -228,10 +228,26 @@ module Rigor
|
|
|
228
228
|
def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
|
|
229
229
|
return [] if writes.size < 2
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
# Skip past leading `NilClass` writes when establishing
|
|
232
|
+
# the canonical type. The common nullable-slot idiom
|
|
233
|
+
# (`@x = nil` placeholder in `initialize` / a default
|
|
234
|
+
# state slot, then `@x = :foo` on first concrete state)
|
|
235
|
+
# would otherwise fire a false positive on every
|
|
236
|
+
# concrete write because `first_class` was `NilClass`
|
|
237
|
+
# and every subsequent `Symbol` / `String` / `Hash`
|
|
238
|
+
# write triggered the divergence rule. The first
|
|
239
|
+
# concrete (non-nil) write is the canonical type;
|
|
240
|
+
# additional `NilClass` writes are still tolerated
|
|
241
|
+
# downstream by the existing `other_class == "NilClass"`
|
|
242
|
+
# check (the nullable-slot resets to nil between work).
|
|
243
|
+
canonical = writes.find { |w| ivar_class_for(w[:type]) != "NilClass" }
|
|
244
|
+
return [] if canonical.nil?
|
|
245
|
+
|
|
246
|
+
first_class = ivar_class_for(canonical[:type])
|
|
232
247
|
return [] if first_class.nil?
|
|
233
248
|
|
|
234
|
-
writes
|
|
249
|
+
canonical_index = writes.index(canonical)
|
|
250
|
+
writes[(canonical_index + 1)..].filter_map do |write|
|
|
235
251
|
other_class = ivar_class_for(write[:type])
|
|
236
252
|
next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class
|
|
237
253
|
|
|
@@ -358,9 +374,27 @@ module Rigor
|
|
|
358
374
|
method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
|
|
359
375
|
return nil if method_def
|
|
360
376
|
|
|
377
|
+
# Module-mixin fallback (mirror of
|
|
378
|
+
# `MethodDispatcher#user_class_fallback_receiver`'s module
|
|
379
|
+
# path): an instance method on a module-mixin like
|
|
380
|
+
# `PP::ObjectMixin` observes Kernel / Object methods
|
|
381
|
+
# through every concrete includer's ancestor chain, so an
|
|
382
|
+
# unresolved `self.inspect` / `self.respond_to?` /
|
|
383
|
+
# `self.class` MUST NOT fire `undefined-method`. Retry
|
|
384
|
+
# against Object before the rule fires.
|
|
385
|
+
return nil if module_mixin_receiver?(receiver_type, scope) &&
|
|
386
|
+
lookup_method(receiver_type, "Object", call_node.name, scope)
|
|
387
|
+
|
|
361
388
|
build_undefined_method_diagnostic(path, call_node, receiver_type)
|
|
362
389
|
end
|
|
363
390
|
|
|
391
|
+
def module_mixin_receiver?(receiver_type, scope)
|
|
392
|
+
return false unless receiver_type.is_a?(Type::Nominal)
|
|
393
|
+
return false if scope.environment.nil?
|
|
394
|
+
|
|
395
|
+
scope.environment.rbs_module?(receiver_type.class_name)
|
|
396
|
+
end
|
|
397
|
+
|
|
364
398
|
# Returns a qualified class name for the in-scope check.
|
|
365
399
|
# Nominal / Singleton carry a single-class identity
|
|
366
400
|
# directly. Constant projects to its value's class.
|
|
@@ -1042,8 +1076,29 @@ module Rigor
|
|
|
1042
1076
|
# (no splat / kw / block-pass / forwarded).
|
|
1043
1077
|
# - Per-argument: skip when EITHER side is `Dynamic`
|
|
1044
1078
|
# (the call cannot be statically refuted).
|
|
1079
|
+
# Ruby's universal-equality methods accept any object
|
|
1080
|
+
# per the `Object#==(other) → bool` /
|
|
1081
|
+
# `Object#eql?(other) → bool` contract. Even when a
|
|
1082
|
+
# subclass overrides `==` to compare specific shapes
|
|
1083
|
+
# (URI::Generic#==(URI::Generic), Time#==(Time), …),
|
|
1084
|
+
# the runtime convention is to RETURN false for
|
|
1085
|
+
# type-mismatched arguments rather than raise. RBS sigs
|
|
1086
|
+
# that declare a tight parameter type therefore over-
|
|
1087
|
+
# specify; checking arguments against them produces
|
|
1088
|
+
# spurious mismatches such as
|
|
1089
|
+
# `URI::Generic#==(URI::Generic)`
|
|
1090
|
+
# called with `URI::HTTP | nil`
|
|
1091
|
+
# tdiary-core's `config_uri == referer_uri` (where
|
|
1092
|
+
# `referer_uri` is `URI.parse(...) if condition`, hence
|
|
1093
|
+
# union-with-nil) is the canonical case. Skip arg
|
|
1094
|
+
# checking on these methods entirely; the call is
|
|
1095
|
+
# well-formed by Ruby's contract.
|
|
1096
|
+
UNIVERSAL_EQUALITY_METHODS = %i[== != eql? equal? <=>].to_set.freeze
|
|
1097
|
+
private_constant :UNIVERSAL_EQUALITY_METHODS
|
|
1098
|
+
|
|
1045
1099
|
def argument_type_diagnostic(path, call_node, scope_index)
|
|
1046
1100
|
return nil if call_node.receiver.nil?
|
|
1101
|
+
return nil if UNIVERSAL_EQUALITY_METHODS.include?(call_node.name)
|
|
1047
1102
|
return nil unless plain_positional_call?(call_node)
|
|
1048
1103
|
|
|
1049
1104
|
scope = scope_index[call_node]
|
|
@@ -53,6 +53,9 @@ module Rigor
|
|
|
53
53
|
).freeze
|
|
54
54
|
private_constant :NON_EMPTY_STRING_OR_NIL
|
|
55
55
|
|
|
56
|
+
NON_EMPTY_STRING = Type::Combinator.non_empty_string.freeze
|
|
57
|
+
private_constant :NON_EMPTY_STRING
|
|
58
|
+
|
|
56
59
|
# `Kernel#__dir__` returns the canonical directory of the
|
|
57
60
|
# source file the call appears in, or `nil` when the file
|
|
58
61
|
# is invalid / not available (typically `-e` and similar
|
|
@@ -62,12 +65,31 @@ module Rigor
|
|
|
62
65
|
KERNEL_DIR = ->(_arg_types) { NON_EMPTY_STRING_OR_NIL }
|
|
63
66
|
private_constant :KERNEL_DIR
|
|
64
67
|
|
|
68
|
+
# `File.expand_path(path, ?dir_string)` always returns an
|
|
69
|
+
# absolute path string. Even `File.expand_path("")` expands
|
|
70
|
+
# to the current working directory's absolute path. The
|
|
71
|
+
# upstream RBS row is `(path file_name, ?path dir_string) ->
|
|
72
|
+
# String`; the refinement tightens to `non-empty-string`.
|
|
73
|
+
#
|
|
74
|
+
# `File.dirname(path, ?level)` always returns at least `"."`
|
|
75
|
+
# (or `"/"` for absolute roots), so the return is never the
|
|
76
|
+
# empty string. Upstream RBS returns `String`; the refinement
|
|
77
|
+
# tightens to `non-empty-string`.
|
|
78
|
+
#
|
|
79
|
+
# `File.basename` is intentionally NOT refined: it returns
|
|
80
|
+
# `""` for `File.basename("")`, so `non-empty-string` would
|
|
81
|
+
# be unsound.
|
|
82
|
+
FILE_NON_EMPTY = ->(_arg_types) { NON_EMPTY_STRING }
|
|
83
|
+
private_constant :FILE_NON_EMPTY
|
|
84
|
+
|
|
65
85
|
# Frozen ((owner_class_name, method_name, kind) => handler)
|
|
66
86
|
# table. The kind tag is `:both`, `:singleton`, or
|
|
67
87
|
# `:instance`. New entries SHOULD prefer `:both` unless the
|
|
68
88
|
# singleton- and instance-side shapes genuinely differ.
|
|
69
89
|
OVERRIDES = {
|
|
70
|
-
["Kernel", :__dir__, :both] => KERNEL_DIR
|
|
90
|
+
["Kernel", :__dir__, :both] => KERNEL_DIR,
|
|
91
|
+
["File", :expand_path, :singleton] => FILE_NON_EMPTY,
|
|
92
|
+
["File", :dirname, :singleton] => FILE_NON_EMPTY
|
|
71
93
|
}.freeze
|
|
72
94
|
private_constant :OVERRIDES
|
|
73
95
|
|