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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a2903b58458e4dafb9acfb05574d615031a79b8b7b521b02029bd30b07ef433
4
- data.tar.gz: cf13a88ee96fe6a3acb471fb8f8936407b28bae6bd92852e8263b11ec0738870
3
+ metadata.gz: 0eaff9cf0ef65d44ceb3666a23fb77003a3dbb0361d890e1d2991ef6539499de
4
+ data.tar.gz: e7fdc58be21409504965f35479559d26bcf4726ba0feabe3fd5128bcffe8419b
5
5
  SHA512:
6
- metadata.gz: 5761ae7907222592d4fd8a7c747e24f414c95fc2ce2ce2b0385a8277b0860aaa89c76c2da94b37575f3a79ac6dddbd4fd21fd16f8064ded1aecbb0e5fadb68f7
7
- data.tar.gz: 8c98d8abd24b9eacef42ba5dbcade9481427a03b61c57306efb273b34ca7b2cbdc73535d3c7495aba90b13051274f9df715a6064f98e9ffb9bdbc995e573560e
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). **Twenty-four worked examples** ship
449
- under [`examples/`](examples/) — each is a fully-shaped plugin
450
- gem with a runnable demo and an end-to-end integration spec.
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 teaching examples** (focus on a single
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
- - [`rigor-typescript-utility-types`](examples/rigor-typescript-utility-types/)
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`](examples/rigor-sinatra/) — **Tier A**
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`](examples/rigor-dry-struct/) — **Tier C**
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`](examples/rigor-devise/) — **Tier B**
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`](examples/rigor-rails-routes/),
495
- [`rigor-rails-i18n`](examples/rigor-rails-i18n/),
496
- [`rigor-actionmailer`](examples/rigor-actionmailer/),
497
- [`rigor-activejob`](examples/rigor-activejob/).
498
- - Tier 2: [`rigor-actionpack`](examples/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`](examples/rigor-factorybot/),
501
- [`rigor-activerecord`](examples/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`](examples/rigor-pundit/),
505
- [`rigor-sidekiq`](examples/rigor-sidekiq/),
506
- [`rigor-rspec`](examples/rigor-rspec/),
507
- [`rigor-actioncable`](examples/rigor-actioncable/).
508
- - Parallel: [`rigor-sorbet`](examples/rigor-sorbet/) — ingests
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
- [`examples/README.md`](examples/README.md) is the plugin
514
- authoring landing page comparison table, recommended reading
515
- order, and the architectural map of which surface each example
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
- Twenty-four worked plugin examples now ship under
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) for the comparison
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
- first_class = ivar_class_for(writes.first[:type])
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[1..].filter_map do |write|
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