rigortype 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +428 -6
  5. data/lib/rigor/analysis/diagnostic.rb +55 -3
  6. data/lib/rigor/analysis/rule_catalog.rb +80 -0
  7. data/lib/rigor/analysis/runner.rb +71 -2
  8. data/lib/rigor/analysis/worker_session.rb +3 -2
  9. data/lib/rigor/cache/descriptor.rb +6 -2
  10. data/lib/rigor/cli/plugin_command.rb +245 -0
  11. data/lib/rigor/cli/plugins_command.rb +51 -4
  12. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  13. data/lib/rigor/cli.rb +143 -5
  14. data/lib/rigor/configuration/severity_profile.rb +9 -0
  15. data/lib/rigor/environment/rbs_loader.rb +259 -1
  16. data/lib/rigor/environment.rb +8 -2
  17. data/lib/rigor/inference/budget_trace.rb +137 -0
  18. data/lib/rigor/inference/expression_typer.rb +9 -2
  19. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  20. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  21. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  22. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  23. data/lib/rigor/inference/precision_scanner.rb +60 -1
  24. data/lib/rigor/inference/scope_indexer.rb +184 -27
  25. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  26. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  27. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  28. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  29. data/lib/rigor/plugin/base.rb +321 -2
  30. data/lib/rigor/plugin/box.rb +64 -0
  31. data/lib/rigor/plugin/inflector.rb +121 -0
  32. data/lib/rigor/plugin/isolation.rb +191 -0
  33. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  34. data/lib/rigor/plugin/macro.rb +1 -0
  35. data/lib/rigor/plugin/manifest.rb +120 -23
  36. data/lib/rigor/plugin/node_context.rb +62 -0
  37. data/lib/rigor/plugin/registry.rb +10 -0
  38. data/lib/rigor/plugin.rb +3 -0
  39. data/lib/rigor/scope.rb +27 -1
  40. data/lib/rigor/sig_gen/generator.rb +2 -3
  41. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  42. data/lib/rigor/source/literals.rb +118 -0
  43. data/lib/rigor/source/node_walker.rb +26 -0
  44. data/lib/rigor/source.rb +1 -0
  45. data/lib/rigor/triage/catalogue.rb +71 -5
  46. data/lib/rigor/type/combinator.rb +6 -1
  47. data/lib/rigor/type/union.rb +65 -1
  48. data/lib/rigor/version.rb +1 -1
  49. data/lib/rigor.rb +1 -0
  50. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  51. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  52. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  53. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  54. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  55. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  56. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  57. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  58. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  62. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  63. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  66. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  67. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  68. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  69. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  70. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  71. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  72. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  73. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  74. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  75. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  76. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  77. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  78. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  81. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  82. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  85. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  86. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  87. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  88. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  89. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  90. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  91. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  92. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  93. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  94. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  95. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  96. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  97. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  98. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  99. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  100. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  101. data/sig/rigor/plugin/base.rbs +58 -3
  102. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  103. data/sig/rigor/plugin/manifest.rbs +31 -1
  104. data/sig/rigor/scope.rbs +3 -0
  105. data/sig/rigor/source.rbs +12 -0
  106. data/sig/rigor.rbs +5 -0
  107. data/skills/rigor-plugin-author/SKILL.md +33 -9
  108. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
  109. data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
  110. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  111. data/skills/rigor-project-init/SKILL.md +72 -7
  112. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
  113. metadata +53 -2
  114. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1dbcd9b168a06cc8d5f26e0a096f19496c3cebdc8864fb56d966741b8a42577
4
- data.tar.gz: 39e428c763e8d8cac6d9743d40d5d654bfaec56362d41e338a6dac974dff27cf
3
+ metadata.gz: 2fb4015697adf7cbc9d65b2ca6ff954c8cac5f784ffb9616153d4af8d2505ccc
4
+ data.tar.gz: 52ee5acb7d233c0e86c8cdca45151c5f745b4c13d7155fcb15fafdaaed1d6dc9
5
5
  SHA512:
6
- metadata.gz: 6cd6a4d0b52fcd2e1e2bacddd6120154ccbe075ef86615c38d7e6d1d942ea158f55abaeb03f01c0a23315a187076912315af98ce74faa4c174c4f6c5c98eac74
7
- data.tar.gz: 1d7b600e3dd97a38bf1ac08231a7aa07635210723adf349c014d9100ccc2b0a868deb8a6479d99207e1c0b5e4cf07c158f0f0cb3bda325ec5c7d143e4f5ae90b
6
+ metadata.gz: 4f4b64afe3dbca26f6224762a2f1a3dd0bd4cade2ea9a61a30e2fba28ed29d3bb6d68bdfa73fb450536e5c7fef45e07a10a6fd0cec693ad228a828ff30905f8e
7
+ data.tar.gz: 4b5faa0d8c7295067f5a0f3d335c1800f22659e47b0428942405b07c1d38649163090f3a461e942a82aecaab6172e774ef88ca44f9a569b1392cdeee0be04086
data/README.md CHANGED
@@ -58,6 +58,12 @@ Install Rigor in this project by following the instructions at
58
58
  https://raw.githubusercontent.com/rigortype/rigor/refs/heads/master/docs/install.md
59
59
  ```
60
60
 
61
+ Prefer to set up in your language? Find prompts for Japanese, Chinese,
62
+ Korean, Portuguese, Spanish, French, German, Italian, Vietnamese, Thai,
63
+ Indonesian, Polish, Ukrainian, Russian, Romanian, and Turkish at
64
+ [docs/manual/14-rails-quickstart.md](docs/manual/14-rails-quickstart.md#step-1--install-ruby-40-and-rigor-common-to-both-paths)
65
+ (or the [online version](https://rigor.typedduck.fail/reference/manual/14-rails-quickstart/)).
66
+
61
67
  **Manual install** — the recommended path uses
62
68
  [`mise`](https://mise.jdx.dev/), which provisions both Ruby 4.0 and
63
69
  Rigor pinned per project:
@@ -259,10 +265,12 @@ In-source suppression: `# rigor:disable <rule>` silences a single line;
259
265
 
260
266
  ## Status
261
267
 
262
- Current released version: **`v0.1.8`** (2026-05-21). The analyzer is
268
+ Current released version: **`v0.1.15`** (2026-05-29). The analyzer is
263
269
  usable on real Ruby code today; the rule catalogue is deliberately
264
270
  conservative — Rigor's stance is to surface zero false positives while
265
- the inference surface stabilises.
271
+ the inference surface stabilises. The `0.1.x` preview line has been
272
+ hardened against real OSS Rails codebases (Mastodon / Redmine / GitLab
273
+ FOSS); `v0.2.0` will open the first evaluation release.
266
274
 
267
275
  Release history: [`CHANGELOG.md`](CHANGELOG.md). Forward-looking
268
276
  commitments: [Roadmap](https://rigor.typedduck.fail/reference/roadmap/).
data/exe/rigor CHANGED
@@ -1,6 +1,25 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # ADR-39 slice 5 — the `ruby_box` plugin-isolation strategy needs the
5
+ # experimental `Ruby::Box` feature, which is a process-start flag
6
+ # (`RUBY_BOX=1`, not toggleable at runtime). When a project selects that
7
+ # strategy (`RIGOR_PLUGIN_ISOLATION=ruby_box`, or the legacy `RIGOR_BOX`
8
+ # alias) we re-exec the same command with the flag set before anything
9
+ # else loads. The `RUBY_BOX` guard prevents an infinite re-exec, and the
10
+ # inherited environment (RUBYOPT / BUNDLE_*) preserves the Bundler
11
+ # context across the exec. The `none` (default) and `process` strategies
12
+ # need no flag, so this is a no-op for them — behaviour is unchanged
13
+ # unless a project opts into `ruby_box`.
14
+ rigor_isolation = ENV["RIGOR_PLUGIN_ISOLATION"].to_s
15
+ rigor_wants_box = rigor_isolation == "ruby_box" || !ENV["RIGOR_BOX"].to_s.empty?
16
+ if rigor_wants_box && ENV["RUBY_BOX"].to_s.empty?
17
+ require "rbconfig"
18
+ ENV["RUBY_BOX"] = "1"
19
+ ENV["RIGOR_PLUGIN_ISOLATION"] = "ruby_box" if rigor_isolation.empty?
20
+ exec(RbConfig.ruby, __FILE__, *ARGV)
21
+ end
22
+
4
23
  lib = File.expand_path("../lib", __dir__)
5
24
  $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
6
25
 
@@ -67,6 +67,9 @@ module Rigor
67
67
  RULE_UNREACHABLE_BRANCH = "flow.unreachable-branch"
68
68
  RULE_RETURN_TYPE = "def.return-type-mismatch"
69
69
  RULE_VISIBILITY_MISMATCH = "def.method-visibility-mismatch"
70
+ RULE_OVERRIDE_VISIBILITY_REDUCED = "def.override-visibility-reduced"
71
+ RULE_OVERRIDE_RETURN_WIDENED = "def.override-return-widened"
72
+ RULE_OVERRIDE_PARAM_NARROWED = "def.override-param-narrowed"
70
73
  RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
71
74
  RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
72
75
  RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
@@ -85,6 +88,9 @@ module Rigor
85
88
  RULE_ALWAYS_TRUTHY_CONDITION,
86
89
  RULE_RETURN_TYPE,
87
90
  RULE_VISIBILITY_MISMATCH,
91
+ RULE_OVERRIDE_VISIBILITY_REDUCED,
92
+ RULE_OVERRIDE_RETURN_WIDENED,
93
+ RULE_OVERRIDE_PARAM_NARROWED,
88
94
  RULE_IVAR_WRITE_MISMATCH
89
95
  ].freeze
90
96
 
@@ -116,6 +122,12 @@ module Rigor
116
122
  # canonical id starts with `<family>.`. Per ADR-8 § "1".
117
123
  RULE_FAMILIES = %w[call flow assert dump def].freeze
118
124
 
125
+ # ADR-35 slice 1 — bound for the `def.override-visibility-reduced`
126
+ # ancestor walk, and the public > protected > private ordering
127
+ # used to decide whether an override reduces visibility.
128
+ OVERRIDE_ANCESTOR_WALK_LIMIT = 100
129
+ VISIBILITY_RANK = { public: 2, protected: 1, private: 0 }.freeze
130
+
119
131
  # Resolves a user-supplied rule token (`undefined-method`,
120
132
  # `call.undefined-method`, or the family wildcard `call`)
121
133
  # to the set of canonical rule identifiers it disables.
@@ -150,6 +162,12 @@ module Rigor
150
162
  when Prism::DefNode
151
163
  return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
152
164
  diagnostics << return_diagnostic if return_diagnostic
165
+ override_vis = override_visibility_diagnostic(path, node, scope_index)
166
+ diagnostics << override_vis if override_vis
167
+ override_return = override_return_widened_diagnostic(path, node, scope_index)
168
+ diagnostics << override_return if override_return
169
+ override_param = override_param_narrowed_diagnostic(path, node, scope_index)
170
+ diagnostics << override_param if override_param
153
171
  when Prism::IfNode, Prism::UnlessNode
154
172
  unreachable = unreachable_branch_diagnostic(path, node, scope_index)
155
173
  diagnostics << unreachable if unreachable
@@ -365,7 +383,16 @@ module Rigor
365
383
  # its model). Flagging an undefined method on a class
366
384
  # with an open dynamic surface is unsound, so the rule
367
385
  # skips it.
368
- return nil if open_receiver?(class_name, scope)
386
+ # An unbounded receiver surface: either a plugin-declared
387
+ # open receiver (ADR-26 — e.g. `ActiveRecord::Relation`), or
388
+ # a type Rigor synthesized (a missing-namespace module / a
389
+ # stub for a referenced-but-undeclared type) to keep a
390
+ # malformed project signature buildable. A synthesized stub's
391
+ # method table is empty only because Rigor invented it, not
392
+ # because the real type is empty (the real `DRb` has
393
+ # `start_service`), so enumerating it to prove a call
394
+ # "undefined" would be a false positive.
395
+ return nil if unbounded_receiver_surface?(class_name, scope)
369
396
 
370
397
  # Slice 7 phase 12 — suppress when the user has
371
398
  # declared the method in source (`def` /
@@ -401,7 +428,24 @@ module Rigor
401
428
  return nil if module_mixin_receiver?(receiver_type, scope) &&
402
429
  lookup_method(receiver_type, "Object", call_node.name, scope)
403
430
 
404
- build_undefined_method_diagnostic(path, call_node, receiver_type)
431
+ definition_site = project_definition_site(scope, class_name, call_node.name, kind)
432
+ build_undefined_method_diagnostic(path, call_node, receiver_type, definition_site, class_name)
433
+ end
434
+
435
+ # ADR-17 — when the project itself defines this method on the
436
+ # receiver class somewhere in the analyzed file set (a reopened
437
+ # core/stdlib/gem class the dispatcher does not apply cross-
438
+ # file), return that `"path:line"` site so the diagnostic points
439
+ # at `pre_eval:` instead of reading as a bare unresolved call.
440
+ # Instance-side only (the cross-file def-source index tracks
441
+ # `def` instance methods); the diagnostic still fires — Rigor
442
+ # does not auto-apply project monkey-patches (the full-project
443
+ # pre-pass is deferred per ADR-17 slice 5) — but it is now
444
+ # actionable rather than mistakable for a typo.
445
+ def project_definition_site(scope, class_name, method_name, kind)
446
+ return nil unless kind == :instance
447
+
448
+ scope.user_def_site_for(class_name, method_name)
405
449
  end
406
450
 
407
451
  def module_mixin_receiver?(receiver_type, scope)
@@ -493,7 +537,8 @@ module Rigor
493
537
  "`def` or a monkey-patch on Object/Kernel, list that file in " \
494
538
  "`.rigor.yml`'s `pre_eval:` (ADR-17) so the analyzer sees it.",
495
539
  severity: :warning,
496
- rule: RULE_UNRESOLVED_TOPLEVEL
540
+ rule: RULE_UNRESOLVED_TOPLEVEL,
541
+ method_name: call_node.name.to_s
497
542
  )
498
543
  end
499
544
 
@@ -530,6 +575,14 @@ module Rigor
530
575
  # loaded plugin (manifest `open_receivers:`). An open
531
576
  # class responds beyond its RBS surface, so the
532
577
  # `call.undefined-method` rule must not fire for it.
578
+ # True when the receiver class responds beyond an enumerable
579
+ # RBS method table, so proving a call "undefined" against it is
580
+ # unsound: a plugin-declared open receiver, or a Rigor-
581
+ # synthesized stub type (see `RbsLoader#synthesized_type_names`).
582
+ def unbounded_receiver_surface?(class_name, scope)
583
+ open_receiver?(class_name, scope) || synthesized_stub_receiver?(class_name, scope)
584
+ end
585
+
533
586
  def open_receiver?(class_name, scope)
534
587
  registry = scope.environment&.plugin_registry
535
588
  return false if registry.nil?
@@ -537,6 +590,13 @@ module Rigor
537
590
  registry.open_receiver?(class_name)
538
591
  end
539
592
 
593
+ def synthesized_stub_receiver?(class_name, scope)
594
+ loader = scope.environment&.rbs_loader
595
+ return false if loader.nil? || !loader.respond_to?(:synthesized_type_names)
596
+
597
+ loader.synthesized_type_names.include?(class_name.to_s.sub(/\A::/, ""))
598
+ end
599
+
540
600
  def definition_available?(receiver_type, class_name, scope)
541
601
  if receiver_type.is_a?(Type::Singleton)
542
602
  !Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
@@ -1319,18 +1379,33 @@ module Rigor
1319
1379
  )
1320
1380
  end
1321
1381
 
1322
- def build_undefined_method_diagnostic(path, call_node, receiver_type)
1382
+ def build_undefined_method_diagnostic(path, call_node, receiver_type, definition_site = nil, class_name = nil)
1323
1383
  location = call_node.message_loc || call_node.location
1324
1384
  rendered_receiver = receiver_type.describe
1385
+ message = "undefined method `#{call_node.name}' for #{rendered_receiver}"
1386
+ # ADR-17 — when the project itself defines this method on the
1387
+ # receiver class somewhere in the file set, name the site and
1388
+ # point at `pre_eval:`. Rigor does not apply project monkey-
1389
+ # patches cross-file automatically, so the diagnostic still
1390
+ # fires, but the enriched message makes it actionable (and
1391
+ # `rigor triage` keys on the structured `project_definition_site`
1392
+ # field to recommend `pre_eval:` with high confidence).
1393
+ if definition_site
1394
+ def_owner = class_name || rendered_receiver
1395
+ message += "; the project defines `#{def_owner}##{call_node.name}' at " \
1396
+ "#{definition_site} — Rigor does not apply project monkey-patches " \
1397
+ "cross-file; list that file in `.rigor.yml`'s `pre_eval:` (ADR-17)"
1398
+ end
1325
1399
  Diagnostic.new(
1326
1400
  rule: RULE_UNDEFINED_METHOD,
1327
1401
  path: path,
1328
1402
  line: location.start_line,
1329
1403
  column: location.start_column + 1,
1330
- message: "undefined method `#{call_node.name}' for #{rendered_receiver}",
1404
+ message: message,
1331
1405
  severity: :error,
1332
1406
  receiver_type: rendered_receiver,
1333
- method_name: call_node.name.to_s
1407
+ method_name: call_node.name.to_s,
1408
+ project_definition_site: definition_site
1334
1409
  )
1335
1410
  end
1336
1411
 
@@ -1480,6 +1555,353 @@ module Rigor
1480
1555
  severity: severity
1481
1556
  )
1482
1557
  end
1558
+
1559
+ # ADR-35 slice 1 — `def.override-visibility-reduced`. The
1560
+ # Liskov signature rule for visibility: an instance-method
1561
+ # override MUST NOT reduce the visibility it inherits
1562
+ # (public → protected/private, or protected → private),
1563
+ # because a caller holding the supertype that invokes the
1564
+ # method breaks when handed the subtype.
1565
+ #
1566
+ # Slice-1 scope (ADR-35 WD1, visibility carve-out): both the
1567
+ # override and the shadowed method must have a STATICALLY
1568
+ # OBSERVABLE visibility. The override's visibility is read
1569
+ # from the source-discovered table; the parent is resolved
1570
+ # against the project-discovered ancestor chain (user-source
1571
+ # classes / modules only — RBS-known ancestors, whose
1572
+ # accessibility RBS models as public/private only, are a
1573
+ # deferred follow-on). When either side is not observable
1574
+ # the rule stays silent.
1575
+ def override_visibility_diagnostic(path, def_node, scope_index)
1576
+ return nil unless def_node.receiver.nil? # instance methods only
1577
+
1578
+ scope = scope_index[def_node]
1579
+ return nil if scope.nil?
1580
+
1581
+ self_type = scope.self_type
1582
+ return nil unless self_type.respond_to?(:class_name)
1583
+
1584
+ class_name = self_type.class_name.to_s
1585
+ method_name = def_node.name
1586
+
1587
+ override_visibility = scope.discovered_method_visibility(class_name, method_name)
1588
+ return nil if override_visibility.nil?
1589
+
1590
+ parent = nearest_ancestor_visibility(scope, class_name, method_name)
1591
+ return nil if parent.nil?
1592
+
1593
+ parent_class, parent_visibility = parent
1594
+ # Unknown ancestor visibility (e.g. the defining file was not
1595
+ # in the analyzed set) → cannot prove a reduction, stay silent.
1596
+ return nil if parent_visibility.nil?
1597
+ return nil unless visibility_reduced?(parent_visibility, override_visibility)
1598
+
1599
+ build_override_visibility_diagnostic(
1600
+ path, def_node, parent_class, parent_visibility, override_visibility
1601
+ )
1602
+ end
1603
+
1604
+ # Returns true when `override_visibility` is strictly more
1605
+ # restrictive than `parent_visibility` under the
1606
+ # public > protected > private ordering.
1607
+ def visibility_reduced?(parent_visibility, override_visibility)
1608
+ parent_rank = VISIBILITY_RANK[parent_visibility]
1609
+ override_rank = VISIBILITY_RANK[override_visibility]
1610
+ return false if parent_rank.nil? || override_rank.nil?
1611
+
1612
+ override_rank < parent_rank
1613
+ end
1614
+
1615
+ # Breadth-first walk of the project-discovered ancestor chain
1616
+ # (included / prepended modules first, then the superclass —
1617
+ # Ruby's MRO ordering), yielding each resolved ancestor class
1618
+ # name nearest-first. Returns the first truthy value the block
1619
+ # produces, or nil. Cross-file: the chain is followed through
1620
+ # the scope tables the runner seeds from the project pre-pass
1621
+ # (ADR-24 WD1). Cycle-guarded and node-count-capped. Mirrors
1622
+ # `ExpressionTyper#resolve_user_def_through_ancestors`.
1623
+ def each_project_ancestor(scope, class_name)
1624
+ queue = ancestor_class_names(scope, class_name)
1625
+ seen = { class_name.to_s => true }
1626
+ visited = 0
1627
+ until queue.empty?
1628
+ current = queue.shift
1629
+ next if current.nil? || seen[current]
1630
+
1631
+ seen[current] = true
1632
+ visited += 1
1633
+ return nil if visited > OVERRIDE_ANCESTOR_WALK_LIMIT
1634
+
1635
+ result = yield current
1636
+ return result if result
1637
+
1638
+ ancestor_class_names(scope, current).each { |name| queue.push(name) }
1639
+ end
1640
+ nil
1641
+ end
1642
+
1643
+ # `[defining_class, visibility]` for the nearest user-source
1644
+ # ancestor that defines an instance method `method_name`, or nil.
1645
+ def nearest_ancestor_visibility(scope, class_name, method_name)
1646
+ each_project_ancestor(scope, class_name) do |ancestor|
1647
+ # Stop at the nearest ancestor that DEFINES the method; its
1648
+ # visibility may be nil (unknown) — the caller treats unknown
1649
+ # as "cannot prove a reduction" and stays silent. Never
1650
+ # fabricate `:public` from a missing entry (that produced a
1651
+ # large false-positive cluster on cross-file Rails concerns).
1652
+ [ancestor, scope.discovered_method_visibility(ancestor, method_name)] if scope.user_def_for(ancestor,
1653
+ method_name)
1654
+ end
1655
+ end
1656
+
1657
+ # Direct ancestors of `class_name` as project-discovered,
1658
+ # qualified names: included / prepended modules first, then
1659
+ # the superclass. As-written names are resolved against the
1660
+ # subclass's lexical nesting; names that resolve to no
1661
+ # project class/module (RBS-known / third-party) are dropped.
1662
+ def ancestor_class_names(scope, class_name)
1663
+ names = []
1664
+ scope.includes_of(class_name).each do |raw|
1665
+ resolved = resolve_override_ancestor_name(scope, class_name, raw)
1666
+ names << resolved if resolved
1667
+ end
1668
+ raw_super = scope.superclass_of(class_name)
1669
+ if raw_super
1670
+ resolved_super = resolve_override_ancestor_name(scope, class_name, raw_super)
1671
+ names << resolved_super if resolved_super
1672
+ end
1673
+ names
1674
+ end
1675
+
1676
+ def resolve_override_ancestor_name(scope, subclass_qualified, raw_ancestor)
1677
+ segments = subclass_qualified.to_s.split("::")
1678
+ (segments.length - 1).downto(0) do |i|
1679
+ candidate = (segments[0, i] + [raw_ancestor]).join("::")
1680
+ return candidate if known_user_class?(scope, candidate)
1681
+ end
1682
+ nil
1683
+ end
1684
+
1685
+ def known_user_class?(scope, name)
1686
+ scope.discovered_superclasses.key?(name) ||
1687
+ scope.discovered_def_nodes.key?(name) ||
1688
+ scope.discovered_includes.key?(name)
1689
+ end
1690
+
1691
+ def build_override_visibility_diagnostic(path, def_node, parent_class, parent_visibility, override_visibility)
1692
+ location = def_node.name_loc || def_node.location
1693
+ Diagnostic.new(
1694
+ rule: RULE_OVERRIDE_VISIBILITY_REDUCED,
1695
+ path: path,
1696
+ line: location.start_line,
1697
+ column: location.start_column + 1,
1698
+ message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1699
+ "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1700
+ "breaks substitutability",
1701
+ severity: :warning
1702
+ )
1703
+ end
1704
+
1705
+ # ADR-35 slice 2 — `def.override-return-widened`. The Liskov
1706
+ # signature rule for returns (covariance): an override may
1707
+ # *narrow* the return it inherits (return a more specific type)
1708
+ # but MUST NOT *widen* it. A caller holding the supertype uses
1709
+ # the result as the parent's return type; a wider override
1710
+ # return breaks that use.
1711
+ #
1712
+ # WD1 gate (proper, type-direction): both the override and the
1713
+ # shadowed ancestor method must carry an explicitly-authored
1714
+ # RBS signature. The override side is gated by
1715
+ # `defined_on?` (the RBS method is declared on the overriding
1716
+ # class itself, not merely inherited); the parent side is the
1717
+ # nearest project-discovered ancestor whose RBS declares the
1718
+ # method. Inference-only either side → silent.
1719
+ #
1720
+ # Fires only on a proven (`:no`) widening; generic / `untyped`
1721
+ # / `self` parent returns degrade to `Dynamic[Top]` and accept
1722
+ # everything, so they stay silent (FP-safe). `self`/`instance`
1723
+ # are translated with `self_type: nil` on both sides, so a
1724
+ # parent `-> self` and an override `-> self` never fire.
1725
+ def override_return_widened_diagnostic(path, def_node, scope_index)
1726
+ return nil unless def_node.receiver.nil? # instance methods only (singleton: follow-on)
1727
+
1728
+ scope = scope_index[def_node]
1729
+ return nil if scope.nil?
1730
+
1731
+ self_type = scope.self_type
1732
+ return nil unless self_type.respond_to?(:class_name)
1733
+
1734
+ class_name = self_type.class_name.to_s
1735
+ method_name = def_node.name
1736
+
1737
+ override_method = safe_instance_method_definition(class_name, method_name, scope)
1738
+ return nil if override_method.nil?
1739
+ return nil unless defined_on?(override_method, class_name)
1740
+
1741
+ parent = nearest_ancestor_method_def(scope, class_name, method_name)
1742
+ return nil if parent.nil?
1743
+
1744
+ parent_class, parent_method = parent
1745
+ override_return = declared_return_union(override_method, scope.environment)
1746
+ parent_return = declared_return_union(parent_method, scope.environment)
1747
+ return nil if override_return.nil? || parent_return.nil?
1748
+ return nil if dynamic_top?(parent_return) # untyped / unbound-generic parent contract
1749
+
1750
+ return nil unless parent_return.accepts(override_return).no?
1751
+
1752
+ build_override_return_widened_diagnostic(
1753
+ path, def_node, parent_class, parent_return, override_return
1754
+ )
1755
+ end
1756
+
1757
+ # `[defining_class, RBS::Definition::Method]` for the nearest
1758
+ # project-discovered ancestor whose RBS declares `method_name`
1759
+ # (not the starting class's own declaration), or nil.
1760
+ def nearest_ancestor_method_def(scope, class_name, method_name)
1761
+ each_project_ancestor(scope, class_name) do |ancestor|
1762
+ method_def = safe_instance_method_definition(ancestor, method_name, scope)
1763
+ [ancestor, method_def] if method_def && !defined_on?(method_def, class_name)
1764
+ end
1765
+ end
1766
+
1767
+ def safe_instance_method_definition(class_name, method_name, scope)
1768
+ Reflection.instance_method_definition(class_name, method_name, scope: scope)
1769
+ rescue StandardError
1770
+ nil
1771
+ end
1772
+
1773
+ # True when `method_def`'s RBS declaration lives on `class_name`
1774
+ # itself (rather than being inherited from an ancestor).
1775
+ def defined_on?(method_def, class_name)
1776
+ defined_in = method_def.defined_in
1777
+ return false if defined_in.nil?
1778
+
1779
+ normalize_class_name(defined_in.to_s) == normalize_class_name(class_name)
1780
+ end
1781
+
1782
+ def normalize_class_name(name)
1783
+ name.to_s.delete_prefix("::")
1784
+ end
1785
+
1786
+ def build_override_return_widened_diagnostic(path, def_node, parent_class, parent_return, override_return)
1787
+ location = def_node.name_loc || def_node.location
1788
+ Diagnostic.new(
1789
+ rule: RULE_OVERRIDE_RETURN_WIDENED,
1790
+ path: path,
1791
+ line: location.start_line,
1792
+ column: location.start_column + 1,
1793
+ message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1794
+ "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1795
+ "breaks substitutability",
1796
+ severity: :warning
1797
+ )
1798
+ end
1799
+
1800
+ # ADR-35 slice 3 — `def.override-param-narrowed`. The Liskov
1801
+ # signature rule for parameters (contravariance): an override
1802
+ # may *widen* a parameter (accept a supertype — accepting more
1803
+ # is safe) but MUST NOT *narrow* it. A caller holding the
1804
+ # supertype passes a parent-typed argument; a narrowed override
1805
+ # parameter cannot accept it.
1806
+ #
1807
+ # Direction (ADR-35 WD3, corrected): fire on
1808
+ # `override_param.accepts(parent_param) == :no` — the override's
1809
+ # (narrowed) slot cannot accept the wider parent argument type.
1810
+ # WD4: type comparison at matching POSITIONAL parameter indices
1811
+ # only; arity / keyword-requiredness divergence is out of scope
1812
+ # for v1. Same WD1 both-sides-authored gate as slice 2;
1813
+ # `untyped` / unbound-generic / interface parent params degrade
1814
+ # to `Dynamic[Top]` and are skipped (FP-safe). To avoid
1815
+ # overload-arm ambiguity, both sides must have exactly one
1816
+ # method type.
1817
+ def override_param_narrowed_diagnostic(path, def_node, scope_index)
1818
+ return nil unless def_node.receiver.nil? # instance methods only
1819
+
1820
+ scope = scope_index[def_node]
1821
+ return nil if scope.nil?
1822
+
1823
+ self_type = scope.self_type
1824
+ return nil unless self_type.respond_to?(:class_name)
1825
+
1826
+ class_name = self_type.class_name.to_s
1827
+ method_name = def_node.name
1828
+
1829
+ override_method = safe_instance_method_definition(class_name, method_name, scope)
1830
+ return nil if override_method.nil?
1831
+ return nil unless defined_on?(override_method, class_name)
1832
+
1833
+ parent = nearest_ancestor_method_def(scope, class_name, method_name)
1834
+ return nil if parent.nil?
1835
+
1836
+ parent_class, parent_method = parent
1837
+ override_params = positional_param_types(override_method)
1838
+ parent_params = positional_param_types(parent_method)
1839
+ return nil if override_params.nil? || parent_params.nil?
1840
+
1841
+ index = first_narrowed_param_index(override_params, parent_params)
1842
+ return nil if index.nil?
1843
+
1844
+ build_override_param_narrowed_diagnostic(
1845
+ path, def_node, parent_class, index, parent_params[index], override_params[index]
1846
+ )
1847
+ end
1848
+
1849
+ # Translated positional (required + optional) parameter types of
1850
+ # a method's single method type, or nil when the method is
1851
+ # overloaded (multiple method types — arm mapping is ambiguous)
1852
+ # or the parameter list is not introspectable. Per-position
1853
+ # translation failures yield `nil` at that slot (skipped by the
1854
+ # comparison). `self`/`instance` translate with `self_type: nil`
1855
+ # (→ `Dynamic[Top]`), matching the return-side handling.
1856
+ def positional_param_types(method_def)
1857
+ method_types = method_def.method_types
1858
+ return nil unless method_types.size == 1
1859
+
1860
+ func = method_types.first.type
1861
+ return nil unless func.respond_to?(:required_positionals)
1862
+
1863
+ (func.required_positionals + func.optional_positionals).map do |param|
1864
+ Inference::RbsTypeTranslator.translate(
1865
+ param.type, self_type: nil, instance_type: nil, type_vars: {}
1866
+ )
1867
+ rescue StandardError
1868
+ nil
1869
+ end
1870
+ end
1871
+
1872
+ # Index of the first positional parameter the override narrows
1873
+ # relative to the parent, or nil. A position is a violation when
1874
+ # the override's slot cannot accept the parent's argument type
1875
+ # (`override_param.accepts(parent_param) == :no`). Positions
1876
+ # where either side is missing/untranslatable, or the parent
1877
+ # type degraded to `Dynamic[Top]` (untyped / unbound generic /
1878
+ # interface), are skipped.
1879
+ def first_narrowed_param_index(override_params, parent_params)
1880
+ count = [override_params.size, parent_params.size].min
1881
+ count.times do |i|
1882
+ override_param = override_params[i]
1883
+ parent_param = parent_params[i]
1884
+ next if override_param.nil? || parent_param.nil?
1885
+ next if dynamic_top?(parent_param) || dynamic_top?(override_param)
1886
+
1887
+ return i if override_param.accepts(parent_param).no?
1888
+ end
1889
+ nil
1890
+ end
1891
+
1892
+ def build_override_param_narrowed_diagnostic(path, def_node, parent_class, index, parent_param, override_param)
1893
+ location = def_node.name_loc || def_node.location
1894
+ Diagnostic.new(
1895
+ rule: RULE_OVERRIDE_PARAM_NARROWED,
1896
+ path: path,
1897
+ line: location.start_line,
1898
+ column: location.start_column + 1,
1899
+ message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
1900
+ "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
1901
+ "(overrides #{parent_class}##{def_node.name}); breaks substitutability",
1902
+ severity: :warning
1903
+ )
1904
+ end
1483
1905
  end
1484
1906
  # rubocop:enable Metrics/ClassLength
1485
1907
  end
@@ -9,7 +9,7 @@ module Rigor
9
9
  DEFAULT_SOURCE_FAMILY = :builtin
10
10
 
11
11
  attr_reader :path, :line, :column, :message, :severity, :rule, :source_family,
12
- :receiver_type, :method_name
12
+ :receiver_type, :method_name, :project_definition_site
13
13
 
14
14
  # `rule:` is the stable identifier (a kebab-case string)
15
15
  # of the diagnostic's source rule. It is used by the
@@ -35,9 +35,18 @@ module Rigor
35
35
  # message wording. Both stay nil for rules that have no such
36
36
  # pair; a consumer that finds them nil falls back to message
37
37
  # parsing.
38
+ #
39
+ # `project_definition_site:` is an optional `"path:line"` string
40
+ # set by `call.undefined-method` when the project itself defines
41
+ # the called method on the receiver class somewhere in the
42
+ # analyzed file set (a reopened core/stdlib/gem class the
43
+ # dispatcher does not apply cross-file — see ADR-17). Its presence
44
+ # is the high-confidence "this is a project monkey-patch, not a
45
+ # bug" signal `rigor triage` keys on to recommend `pre_eval:`.
46
+ # Nil for every other diagnostic.
38
47
  def initialize(path:, line:, column:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
39
48
  source_family: DEFAULT_SOURCE_FAMILY,
40
- receiver_type: nil, method_name: nil)
49
+ receiver_type: nil, method_name: nil, project_definition_site: nil)
41
50
  @path = path
42
51
  @line = line
43
52
  @column = column
@@ -47,6 +56,47 @@ module Rigor
47
56
  @source_family = source_family
48
57
  @receiver_type = receiver_type
49
58
  @method_name = method_name
59
+ @project_definition_site = project_definition_site
60
+ end
61
+
62
+ # Builds a Diagnostic positioned at a Prism node. Internalises
63
+ # the load-bearing convention every caller otherwise repeats:
64
+ # the line is the node's 1-based `start_line` and the column is
65
+ # `start_column + 1` (Prism columns are 0-based; Rigor reports
66
+ # 1-based). Pass any node responding to `#location`; all other
67
+ # fields forward to `#initialize` unchanged.
68
+ #
69
+ # `Plugin::Base#diagnostic` wraps this for plugin authors (who
70
+ # must not set `source_family` — the runner stamps it); core
71
+ # rules and other producers call it directly.
72
+ def self.from_node(node, path:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
73
+ source_family: DEFAULT_SOURCE_FAMILY,
74
+ receiver_type: nil, method_name: nil, project_definition_site: nil)
75
+ from_location(
76
+ node.location, path: path, message: message, severity: severity, rule: rule,
77
+ source_family: source_family, receiver_type: receiver_type,
78
+ method_name: method_name, project_definition_site: project_definition_site
79
+ )
80
+ end
81
+
82
+ # Builds a Diagnostic from an explicit Prism location, applying
83
+ # the same 1-based `line` / `start_column + 1` convention as
84
+ # {.from_node}. Use this when the diagnostic should point at a
85
+ # *sub-location* rather than the whole node — most often a call's
86
+ # `message_loc` (the matcher / method name) instead of the
87
+ # receiver-spanning `node.location`. {.from_node} is sugar for
88
+ # `from_location(node.location, …)`.
89
+ def self.from_location(location, path:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
90
+ source_family: DEFAULT_SOURCE_FAMILY,
91
+ receiver_type: nil, method_name: nil, project_definition_site: nil)
92
+ new(
93
+ path: path,
94
+ line: location.start_line,
95
+ column: location.start_column + 1,
96
+ message: message, severity: severity, rule: rule, source_family: source_family,
97
+ receiver_type: receiver_type, method_name: method_name,
98
+ project_definition_site: project_definition_site
99
+ )
50
100
  end
51
101
 
52
102
  def error?
@@ -65,7 +115,7 @@ module Rigor
65
115
  end
66
116
 
67
117
  def to_h
68
- {
118
+ base = {
69
119
  "path" => path,
70
120
  "line" => line,
71
121
  "column" => column,
@@ -74,6 +124,8 @@ module Rigor
74
124
  "source_family" => source_family.to_s,
75
125
  "message" => message
76
126
  }
127
+ base["project_definition_site"] = project_definition_site if project_definition_site
128
+ base
77
129
  end
78
130
 
79
131
  # Text rendering for `rigor check`. The qualified rule