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 +4 -4
- data/README.md +6 -0
- data/lib/rigor/analysis/check_rules.rb +403 -5
- data/lib/rigor/analysis/diagnostic.rb +15 -3
- data/lib/rigor/analysis/rule_catalog.rb +80 -0
- data/lib/rigor/analysis/runner.rb +10 -0
- data/lib/rigor/cli/plugin_command.rb +245 -0
- data/lib/rigor/cli.rb +8 -0
- data/lib/rigor/configuration/severity_profile.rb +9 -0
- data/lib/rigor/inference/scope_indexer.rb +59 -21
- data/lib/rigor/scope.rb +27 -1
- data/lib/rigor/triage/catalogue.rb +71 -5
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/scope.rbs +3 -0
- data/skills/rigor-plugin-author/SKILL.md +20 -0
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +59 -21
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +64 -15
- data/skills/rigor-project-init/SKILL.md +72 -7
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 231749c95c76ed2647fb26973b926e0330953a1eb818b2f363acfbd241cab418
|
|
4
|
+
data.tar.gz: 46a2f8dd756c64c1f49996d5a84716c2089f7af9d3f8d4f0e1af079fad72da54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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:
|
|
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.",
|