rigortype 0.1.14 → 0.1.15

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: b1dbcd9b168a06cc8d5f26e0a096f19496c3cebdc8864fb56d966741b8a42577
4
- data.tar.gz: 39e428c763e8d8cac6d9743d40d5d654bfaec56362d41e338a6dac974dff27cf
3
+ metadata.gz: 231749c95c76ed2647fb26973b926e0330953a1eb818b2f363acfbd241cab418
4
+ data.tar.gz: 46a2f8dd756c64c1f49996d5a84716c2089f7af9d3f8d4f0e1af079fad72da54
5
5
  SHA512:
6
- metadata.gz: 6cd6a4d0b52fcd2e1e2bacddd6120154ccbe075ef86615c38d7e6d1d942ea158f55abaeb03f01c0a23315a187076912315af98ce74faa4c174c4f6c5c98eac74
7
- data.tar.gz: 1d7b600e3dd97a38bf1ac08231a7aa07635210723adf349c014d9100ccc2b0a868deb8a6479d99207e1c0b5e4cf07c158f0f0cb3bda325ec5c7d143e4f5ae90b
6
+ metadata.gz: bfd342d93895c61d6afbac3323ea7e7b79648ca0fec370e05c68b864769107ef2dbe0c9540ea0410c73499b7fdde3187166edcb01158ce9c9991a114ff63ffa8
7
+ data.tar.gz: 5a5b01c1fb25acc2f2fb416e5e4eb7ceb9dd15a90975ad7851aa6183c28716059d5331c58b95b8ec09936db43275118934ebbea84d64d443d35b0296e9dcf906
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:
@@ -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
@@ -401,7 +419,24 @@ module Rigor
401
419
  return nil if module_mixin_receiver?(receiver_type, scope) &&
402
420
  lookup_method(receiver_type, "Object", call_node.name, scope)
403
421
 
404
- build_undefined_method_diagnostic(path, call_node, receiver_type)
422
+ definition_site = project_definition_site(scope, class_name, call_node.name, kind)
423
+ build_undefined_method_diagnostic(path, call_node, receiver_type, definition_site, class_name)
424
+ end
425
+
426
+ # ADR-17 — when the project itself defines this method on the
427
+ # receiver class somewhere in the analyzed file set (a reopened
428
+ # core/stdlib/gem class the dispatcher does not apply cross-
429
+ # file), return that `"path:line"` site so the diagnostic points
430
+ # at `pre_eval:` instead of reading as a bare unresolved call.
431
+ # Instance-side only (the cross-file def-source index tracks
432
+ # `def` instance methods); the diagnostic still fires — Rigor
433
+ # does not auto-apply project monkey-patches (the full-project
434
+ # pre-pass is deferred per ADR-17 slice 5) — but it is now
435
+ # actionable rather than mistakable for a typo.
436
+ def project_definition_site(scope, class_name, method_name, kind)
437
+ return nil unless kind == :instance
438
+
439
+ scope.user_def_site_for(class_name, method_name)
405
440
  end
406
441
 
407
442
  def module_mixin_receiver?(receiver_type, scope)
@@ -493,7 +528,8 @@ module Rigor
493
528
  "`def` or a monkey-patch on Object/Kernel, list that file in " \
494
529
  "`.rigor.yml`'s `pre_eval:` (ADR-17) so the analyzer sees it.",
495
530
  severity: :warning,
496
- rule: RULE_UNRESOLVED_TOPLEVEL
531
+ rule: RULE_UNRESOLVED_TOPLEVEL,
532
+ method_name: call_node.name.to_s
497
533
  )
498
534
  end
499
535
 
@@ -1319,18 +1355,33 @@ module Rigor
1319
1355
  )
1320
1356
  end
1321
1357
 
1322
- def build_undefined_method_diagnostic(path, call_node, receiver_type)
1358
+ def build_undefined_method_diagnostic(path, call_node, receiver_type, definition_site = nil, class_name = nil)
1323
1359
  location = call_node.message_loc || call_node.location
1324
1360
  rendered_receiver = receiver_type.describe
1361
+ message = "undefined method `#{call_node.name}' for #{rendered_receiver}"
1362
+ # ADR-17 — when the project itself defines this method on the
1363
+ # receiver class somewhere in the file set, name the site and
1364
+ # point at `pre_eval:`. Rigor does not apply project monkey-
1365
+ # patches cross-file automatically, so the diagnostic still
1366
+ # fires, but the enriched message makes it actionable (and
1367
+ # `rigor triage` keys on the structured `project_definition_site`
1368
+ # field to recommend `pre_eval:` with high confidence).
1369
+ if definition_site
1370
+ def_owner = class_name || rendered_receiver
1371
+ message += "; the project defines `#{def_owner}##{call_node.name}' at " \
1372
+ "#{definition_site} — Rigor does not apply project monkey-patches " \
1373
+ "cross-file; list that file in `.rigor.yml`'s `pre_eval:` (ADR-17)"
1374
+ end
1325
1375
  Diagnostic.new(
1326
1376
  rule: RULE_UNDEFINED_METHOD,
1327
1377
  path: path,
1328
1378
  line: location.start_line,
1329
1379
  column: location.start_column + 1,
1330
- message: "undefined method `#{call_node.name}' for #{rendered_receiver}",
1380
+ message: message,
1331
1381
  severity: :error,
1332
1382
  receiver_type: rendered_receiver,
1333
- method_name: call_node.name.to_s
1383
+ method_name: call_node.name.to_s,
1384
+ project_definition_site: definition_site
1334
1385
  )
1335
1386
  end
1336
1387
 
@@ -1480,6 +1531,353 @@ module Rigor
1480
1531
  severity: severity
1481
1532
  )
1482
1533
  end
1534
+
1535
+ # ADR-35 slice 1 — `def.override-visibility-reduced`. The
1536
+ # Liskov signature rule for visibility: an instance-method
1537
+ # override MUST NOT reduce the visibility it inherits
1538
+ # (public → protected/private, or protected → private),
1539
+ # because a caller holding the supertype that invokes the
1540
+ # method breaks when handed the subtype.
1541
+ #
1542
+ # Slice-1 scope (ADR-35 WD1, visibility carve-out): both the
1543
+ # override and the shadowed method must have a STATICALLY
1544
+ # OBSERVABLE visibility. The override's visibility is read
1545
+ # from the source-discovered table; the parent is resolved
1546
+ # against the project-discovered ancestor chain (user-source
1547
+ # classes / modules only — RBS-known ancestors, whose
1548
+ # accessibility RBS models as public/private only, are a
1549
+ # deferred follow-on). When either side is not observable
1550
+ # the rule stays silent.
1551
+ def override_visibility_diagnostic(path, def_node, scope_index)
1552
+ return nil unless def_node.receiver.nil? # instance methods only
1553
+
1554
+ scope = scope_index[def_node]
1555
+ return nil if scope.nil?
1556
+
1557
+ self_type = scope.self_type
1558
+ return nil unless self_type.respond_to?(:class_name)
1559
+
1560
+ class_name = self_type.class_name.to_s
1561
+ method_name = def_node.name
1562
+
1563
+ override_visibility = scope.discovered_method_visibility(class_name, method_name)
1564
+ return nil if override_visibility.nil?
1565
+
1566
+ parent = nearest_ancestor_visibility(scope, class_name, method_name)
1567
+ return nil if parent.nil?
1568
+
1569
+ parent_class, parent_visibility = parent
1570
+ # Unknown ancestor visibility (e.g. the defining file was not
1571
+ # in the analyzed set) → cannot prove a reduction, stay silent.
1572
+ return nil if parent_visibility.nil?
1573
+ return nil unless visibility_reduced?(parent_visibility, override_visibility)
1574
+
1575
+ build_override_visibility_diagnostic(
1576
+ path, def_node, parent_class, parent_visibility, override_visibility
1577
+ )
1578
+ end
1579
+
1580
+ # Returns true when `override_visibility` is strictly more
1581
+ # restrictive than `parent_visibility` under the
1582
+ # public > protected > private ordering.
1583
+ def visibility_reduced?(parent_visibility, override_visibility)
1584
+ parent_rank = VISIBILITY_RANK[parent_visibility]
1585
+ override_rank = VISIBILITY_RANK[override_visibility]
1586
+ return false if parent_rank.nil? || override_rank.nil?
1587
+
1588
+ override_rank < parent_rank
1589
+ end
1590
+
1591
+ # Breadth-first walk of the project-discovered ancestor chain
1592
+ # (included / prepended modules first, then the superclass —
1593
+ # Ruby's MRO ordering), yielding each resolved ancestor class
1594
+ # name nearest-first. Returns the first truthy value the block
1595
+ # produces, or nil. Cross-file: the chain is followed through
1596
+ # the scope tables the runner seeds from the project pre-pass
1597
+ # (ADR-24 WD1). Cycle-guarded and node-count-capped. Mirrors
1598
+ # `ExpressionTyper#resolve_user_def_through_ancestors`.
1599
+ def each_project_ancestor(scope, class_name)
1600
+ queue = ancestor_class_names(scope, class_name)
1601
+ seen = { class_name.to_s => true }
1602
+ visited = 0
1603
+ until queue.empty?
1604
+ current = queue.shift
1605
+ next if current.nil? || seen[current]
1606
+
1607
+ seen[current] = true
1608
+ visited += 1
1609
+ return nil if visited > OVERRIDE_ANCESTOR_WALK_LIMIT
1610
+
1611
+ result = yield current
1612
+ return result if result
1613
+
1614
+ ancestor_class_names(scope, current).each { |name| queue.push(name) }
1615
+ end
1616
+ nil
1617
+ end
1618
+
1619
+ # `[defining_class, visibility]` for the nearest user-source
1620
+ # ancestor that defines an instance method `method_name`, or nil.
1621
+ def nearest_ancestor_visibility(scope, class_name, method_name)
1622
+ each_project_ancestor(scope, class_name) do |ancestor|
1623
+ # Stop at the nearest ancestor that DEFINES the method; its
1624
+ # visibility may be nil (unknown) — the caller treats unknown
1625
+ # as "cannot prove a reduction" and stays silent. Never
1626
+ # fabricate `:public` from a missing entry (that produced a
1627
+ # large false-positive cluster on cross-file Rails concerns).
1628
+ [ancestor, scope.discovered_method_visibility(ancestor, method_name)] if scope.user_def_for(ancestor,
1629
+ method_name)
1630
+ end
1631
+ end
1632
+
1633
+ # Direct ancestors of `class_name` as project-discovered,
1634
+ # qualified names: included / prepended modules first, then
1635
+ # the superclass. As-written names are resolved against the
1636
+ # subclass's lexical nesting; names that resolve to no
1637
+ # project class/module (RBS-known / third-party) are dropped.
1638
+ def ancestor_class_names(scope, class_name)
1639
+ names = []
1640
+ scope.includes_of(class_name).each do |raw|
1641
+ resolved = resolve_override_ancestor_name(scope, class_name, raw)
1642
+ names << resolved if resolved
1643
+ end
1644
+ raw_super = scope.superclass_of(class_name)
1645
+ if raw_super
1646
+ resolved_super = resolve_override_ancestor_name(scope, class_name, raw_super)
1647
+ names << resolved_super if resolved_super
1648
+ end
1649
+ names
1650
+ end
1651
+
1652
+ def resolve_override_ancestor_name(scope, subclass_qualified, raw_ancestor)
1653
+ segments = subclass_qualified.to_s.split("::")
1654
+ (segments.length - 1).downto(0) do |i|
1655
+ candidate = (segments[0, i] + [raw_ancestor]).join("::")
1656
+ return candidate if known_user_class?(scope, candidate)
1657
+ end
1658
+ nil
1659
+ end
1660
+
1661
+ def known_user_class?(scope, name)
1662
+ scope.discovered_superclasses.key?(name) ||
1663
+ scope.discovered_def_nodes.key?(name) ||
1664
+ scope.discovered_includes.key?(name)
1665
+ end
1666
+
1667
+ def build_override_visibility_diagnostic(path, def_node, parent_class, parent_visibility, override_visibility)
1668
+ location = def_node.name_loc || def_node.location
1669
+ Diagnostic.new(
1670
+ rule: RULE_OVERRIDE_VISIBILITY_REDUCED,
1671
+ path: path,
1672
+ line: location.start_line,
1673
+ column: location.start_column + 1,
1674
+ message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1675
+ "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1676
+ "breaks substitutability",
1677
+ severity: :warning
1678
+ )
1679
+ end
1680
+
1681
+ # ADR-35 slice 2 — `def.override-return-widened`. The Liskov
1682
+ # signature rule for returns (covariance): an override may
1683
+ # *narrow* the return it inherits (return a more specific type)
1684
+ # but MUST NOT *widen* it. A caller holding the supertype uses
1685
+ # the result as the parent's return type; a wider override
1686
+ # return breaks that use.
1687
+ #
1688
+ # WD1 gate (proper, type-direction): both the override and the
1689
+ # shadowed ancestor method must carry an explicitly-authored
1690
+ # RBS signature. The override side is gated by
1691
+ # `defined_on?` (the RBS method is declared on the overriding
1692
+ # class itself, not merely inherited); the parent side is the
1693
+ # nearest project-discovered ancestor whose RBS declares the
1694
+ # method. Inference-only either side → silent.
1695
+ #
1696
+ # Fires only on a proven (`:no`) widening; generic / `untyped`
1697
+ # / `self` parent returns degrade to `Dynamic[Top]` and accept
1698
+ # everything, so they stay silent (FP-safe). `self`/`instance`
1699
+ # are translated with `self_type: nil` on both sides, so a
1700
+ # parent `-> self` and an override `-> self` never fire.
1701
+ def override_return_widened_diagnostic(path, def_node, scope_index)
1702
+ return nil unless def_node.receiver.nil? # instance methods only (singleton: follow-on)
1703
+
1704
+ scope = scope_index[def_node]
1705
+ return nil if scope.nil?
1706
+
1707
+ self_type = scope.self_type
1708
+ return nil unless self_type.respond_to?(:class_name)
1709
+
1710
+ class_name = self_type.class_name.to_s
1711
+ method_name = def_node.name
1712
+
1713
+ override_method = safe_instance_method_definition(class_name, method_name, scope)
1714
+ return nil if override_method.nil?
1715
+ return nil unless defined_on?(override_method, class_name)
1716
+
1717
+ parent = nearest_ancestor_method_def(scope, class_name, method_name)
1718
+ return nil if parent.nil?
1719
+
1720
+ parent_class, parent_method = parent
1721
+ override_return = declared_return_union(override_method, scope.environment)
1722
+ parent_return = declared_return_union(parent_method, scope.environment)
1723
+ return nil if override_return.nil? || parent_return.nil?
1724
+ return nil if dynamic_top?(parent_return) # untyped / unbound-generic parent contract
1725
+
1726
+ return nil unless parent_return.accepts(override_return).no?
1727
+
1728
+ build_override_return_widened_diagnostic(
1729
+ path, def_node, parent_class, parent_return, override_return
1730
+ )
1731
+ end
1732
+
1733
+ # `[defining_class, RBS::Definition::Method]` for the nearest
1734
+ # project-discovered ancestor whose RBS declares `method_name`
1735
+ # (not the starting class's own declaration), or nil.
1736
+ def nearest_ancestor_method_def(scope, class_name, method_name)
1737
+ each_project_ancestor(scope, class_name) do |ancestor|
1738
+ method_def = safe_instance_method_definition(ancestor, method_name, scope)
1739
+ [ancestor, method_def] if method_def && !defined_on?(method_def, class_name)
1740
+ end
1741
+ end
1742
+
1743
+ def safe_instance_method_definition(class_name, method_name, scope)
1744
+ Reflection.instance_method_definition(class_name, method_name, scope: scope)
1745
+ rescue StandardError
1746
+ nil
1747
+ end
1748
+
1749
+ # True when `method_def`'s RBS declaration lives on `class_name`
1750
+ # itself (rather than being inherited from an ancestor).
1751
+ def defined_on?(method_def, class_name)
1752
+ defined_in = method_def.defined_in
1753
+ return false if defined_in.nil?
1754
+
1755
+ normalize_class_name(defined_in.to_s) == normalize_class_name(class_name)
1756
+ end
1757
+
1758
+ def normalize_class_name(name)
1759
+ name.to_s.delete_prefix("::")
1760
+ end
1761
+
1762
+ def build_override_return_widened_diagnostic(path, def_node, parent_class, parent_return, override_return)
1763
+ location = def_node.name_loc || def_node.location
1764
+ Diagnostic.new(
1765
+ rule: RULE_OVERRIDE_RETURN_WIDENED,
1766
+ path: path,
1767
+ line: location.start_line,
1768
+ column: location.start_column + 1,
1769
+ message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1770
+ "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1771
+ "breaks substitutability",
1772
+ severity: :warning
1773
+ )
1774
+ end
1775
+
1776
+ # ADR-35 slice 3 — `def.override-param-narrowed`. The Liskov
1777
+ # signature rule for parameters (contravariance): an override
1778
+ # may *widen* a parameter (accept a supertype — accepting more
1779
+ # is safe) but MUST NOT *narrow* it. A caller holding the
1780
+ # supertype passes a parent-typed argument; a narrowed override
1781
+ # parameter cannot accept it.
1782
+ #
1783
+ # Direction (ADR-35 WD3, corrected): fire on
1784
+ # `override_param.accepts(parent_param) == :no` — the override's
1785
+ # (narrowed) slot cannot accept the wider parent argument type.
1786
+ # WD4: type comparison at matching POSITIONAL parameter indices
1787
+ # only; arity / keyword-requiredness divergence is out of scope
1788
+ # for v1. Same WD1 both-sides-authored gate as slice 2;
1789
+ # `untyped` / unbound-generic / interface parent params degrade
1790
+ # to `Dynamic[Top]` and are skipped (FP-safe). To avoid
1791
+ # overload-arm ambiguity, both sides must have exactly one
1792
+ # method type.
1793
+ def override_param_narrowed_diagnostic(path, def_node, scope_index)
1794
+ return nil unless def_node.receiver.nil? # instance methods only
1795
+
1796
+ scope = scope_index[def_node]
1797
+ return nil if scope.nil?
1798
+
1799
+ self_type = scope.self_type
1800
+ return nil unless self_type.respond_to?(:class_name)
1801
+
1802
+ class_name = self_type.class_name.to_s
1803
+ method_name = def_node.name
1804
+
1805
+ override_method = safe_instance_method_definition(class_name, method_name, scope)
1806
+ return nil if override_method.nil?
1807
+ return nil unless defined_on?(override_method, class_name)
1808
+
1809
+ parent = nearest_ancestor_method_def(scope, class_name, method_name)
1810
+ return nil if parent.nil?
1811
+
1812
+ parent_class, parent_method = parent
1813
+ override_params = positional_param_types(override_method)
1814
+ parent_params = positional_param_types(parent_method)
1815
+ return nil if override_params.nil? || parent_params.nil?
1816
+
1817
+ index = first_narrowed_param_index(override_params, parent_params)
1818
+ return nil if index.nil?
1819
+
1820
+ build_override_param_narrowed_diagnostic(
1821
+ path, def_node, parent_class, index, parent_params[index], override_params[index]
1822
+ )
1823
+ end
1824
+
1825
+ # Translated positional (required + optional) parameter types of
1826
+ # a method's single method type, or nil when the method is
1827
+ # overloaded (multiple method types — arm mapping is ambiguous)
1828
+ # or the parameter list is not introspectable. Per-position
1829
+ # translation failures yield `nil` at that slot (skipped by the
1830
+ # comparison). `self`/`instance` translate with `self_type: nil`
1831
+ # (→ `Dynamic[Top]`), matching the return-side handling.
1832
+ def positional_param_types(method_def)
1833
+ method_types = method_def.method_types
1834
+ return nil unless method_types.size == 1
1835
+
1836
+ func = method_types.first.type
1837
+ return nil unless func.respond_to?(:required_positionals)
1838
+
1839
+ (func.required_positionals + func.optional_positionals).map do |param|
1840
+ Inference::RbsTypeTranslator.translate(
1841
+ param.type, self_type: nil, instance_type: nil, type_vars: {}
1842
+ )
1843
+ rescue StandardError
1844
+ nil
1845
+ end
1846
+ end
1847
+
1848
+ # Index of the first positional parameter the override narrows
1849
+ # relative to the parent, or nil. A position is a violation when
1850
+ # the override's slot cannot accept the parent's argument type
1851
+ # (`override_param.accepts(parent_param) == :no`). Positions
1852
+ # where either side is missing/untranslatable, or the parent
1853
+ # type degraded to `Dynamic[Top]` (untyped / unbound generic /
1854
+ # interface), are skipped.
1855
+ def first_narrowed_param_index(override_params, parent_params)
1856
+ count = [override_params.size, parent_params.size].min
1857
+ count.times do |i|
1858
+ override_param = override_params[i]
1859
+ parent_param = parent_params[i]
1860
+ next if override_param.nil? || parent_param.nil?
1861
+ next if dynamic_top?(parent_param) || dynamic_top?(override_param)
1862
+
1863
+ return i if override_param.accepts(parent_param).no?
1864
+ end
1865
+ nil
1866
+ end
1867
+
1868
+ def build_override_param_narrowed_diagnostic(path, def_node, parent_class, index, parent_param, override_param)
1869
+ location = def_node.name_loc || def_node.location
1870
+ Diagnostic.new(
1871
+ rule: RULE_OVERRIDE_PARAM_NARROWED,
1872
+ path: path,
1873
+ line: location.start_line,
1874
+ column: location.start_column + 1,
1875
+ message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
1876
+ "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
1877
+ "(overrides #{parent_class}##{def_node.name}); breaks substitutability",
1878
+ severity: :warning
1879
+ )
1880
+ end
1483
1881
  end
1484
1882
  # rubocop:enable Metrics/ClassLength
1485
1883
  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,7 @@ 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
50
60
  end
51
61
 
52
62
  def error?
@@ -65,7 +75,7 @@ module Rigor
65
75
  end
66
76
 
67
77
  def to_h
68
- {
78
+ base = {
69
79
  "path" => path,
70
80
  "line" => line,
71
81
  "column" => column,
@@ -74,6 +84,8 @@ module Rigor
74
84
  "source_family" => source_family.to_s,
75
85
  "message" => message
76
86
  }
87
+ base["project_definition_site"] = project_definition_site if project_definition_site
88
+ base
77
89
  end
78
90
 
79
91
  # Text rendering for `rigor check`. The qualified rule
@@ -288,6 +288,86 @@ module Rigor
288
288
  since: "0.1.2"
289
289
  ),
290
290
 
291
+ CheckRules::RULE_OVERRIDE_VISIBILITY_REDUCED => Entry.new(
292
+ id: CheckRules::RULE_OVERRIDE_VISIBILITY_REDUCED,
293
+ summary: "Instance-method override reduces the visibility it inherits from an ancestor.",
294
+ fires_when: [
295
+ "An instance `def` shadows a same-name instance method defined by a project-discovered " \
296
+ "ancestor (included/prepended module or superclass, cross-file).",
297
+ "The override's source-discovered visibility is strictly more restrictive than the " \
298
+ "ancestor's (public → protected/private, or protected → private).",
299
+ "Both visibilities are statically observable from project source."
300
+ ],
301
+ does_not_fire_when: [
302
+ "Override raises or preserves visibility (only reductions break substitutability).",
303
+ "The shadowed method lives on an RBS-known / third-party ancestor (RBS models only " \
304
+ "public/private; RBS-parent visibility is a deferred follow-on).",
305
+ "`def self.foo` singleton methods (visibility is instance-side only).",
306
+ "The `private def foo; end` wrap-around form (not yet tracked by the visibility walker)."
307
+ ],
308
+ suppression: "`# rigor:disable def.override-visibility-reduced` on the override.",
309
+ severity_authored: :warning,
310
+ severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
311
+ since: "0.1.15"
312
+ ),
313
+
314
+ CheckRules::RULE_OVERRIDE_RETURN_WIDENED => Entry.new(
315
+ id: CheckRules::RULE_OVERRIDE_RETURN_WIDENED,
316
+ summary: "Instance-method override widens the return type it inherits from an ancestor.",
317
+ fires_when: [
318
+ "An instance `def` with an authored RBS signature overrides a same-name method whose " \
319
+ "RBS signature is declared by a project-discovered ancestor (module or superclass).",
320
+ "The override's declared return is not acceptable where the ancestor's declared return " \
321
+ "is expected (`parent_return.accepts(override_return)` is `:no`) — a covariance violation."
322
+ ],
323
+ does_not_fire_when: [
324
+ "Either side lacks an authored RBS signature (WD1 both-sides-authored gate).",
325
+ "The override narrows or preserves the return (covariant-safe).",
326
+ "The ancestor's return is `untyped` / `self` / an unbound generic (degrades to " \
327
+ "`Dynamic[Top]`, which accepts everything — FP-safe).",
328
+ "The subtype relationship between the two return types is not resolvable from loaded " \
329
+ "Ruby classes / their ancestors (a user-only class hierarchy degrades to `:maybe` and " \
330
+ "stays silent — the check has reach over core / stdlib / loadable-gem hierarchies).",
331
+ "`def self.foo` singleton methods (instance-side only in v1).",
332
+ "The shadowed method lives only on an RBS-known / third-party ancestor not in the " \
333
+ "project-discovered chain (user-source ancestor scope in v1)."
334
+ ],
335
+ suppression: "`# rigor:disable def.override-return-widened` on the override.",
336
+ severity_authored: :warning,
337
+ severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
338
+ since: "0.1.15"
339
+ ),
340
+
341
+ CheckRules::RULE_OVERRIDE_PARAM_NARROWED => Entry.new(
342
+ id: CheckRules::RULE_OVERRIDE_PARAM_NARROWED,
343
+ summary: "Instance-method override narrows a parameter type it inherits from an ancestor.",
344
+ fires_when: [
345
+ "An instance `def` with an authored RBS signature overrides a same-name method whose " \
346
+ "RBS signature is declared by a project-discovered ancestor (module or superclass).",
347
+ "At some matching positional parameter index, the override's slot cannot accept the " \
348
+ "ancestor's parameter type (`override_param.accepts(parent_param)` is `:no`) — a " \
349
+ "contravariance violation (the override narrowed the parameter)."
350
+ ],
351
+ does_not_fire_when: [
352
+ "Either side lacks an authored RBS signature (WD1 both-sides-authored gate).",
353
+ "The override widens or preserves the parameter (contravariant-safe).",
354
+ "Either side is overloaded (more than one method type — arm mapping is ambiguous).",
355
+ "The ancestor's parameter is `untyped` / an unbound generic / an interface (degrades " \
356
+ "to `Dynamic[Top]`, which is passable to anything — FP-safe).",
357
+ "The subtype relationship between the two parameter types is not resolvable from loaded " \
358
+ "Ruby classes / their ancestors (a user-only class hierarchy degrades to `:maybe` and " \
359
+ "stays silent — the check has reach over core / stdlib / loadable-gem hierarchies).",
360
+ "Arity / keyword-requiredness divergence (out of scope for v1 — positional types only).",
361
+ "`def self.foo` singleton methods (instance-side only in v1).",
362
+ "The shadowed method lives only on an RBS-known / third-party ancestor (user-source " \
363
+ "ancestor scope in v1)."
364
+ ],
365
+ suppression: "`# rigor:disable def.override-param-narrowed` on the override.",
366
+ severity_authored: :warning,
367
+ severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
368
+ since: "0.1.15"
369
+ ),
370
+
291
371
  CheckRules::RULE_IVAR_WRITE_MISMATCH => Entry.new(
292
372
  id: CheckRules::RULE_IVAR_WRITE_MISMATCH,
293
373
  summary: "Same instance variable assigned a different concrete class within one class.",