rigortype 0.1.9 → 0.1.11
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 +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +180 -1
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "rigor/plugin"
|
|
5
|
+
|
|
6
|
+
require_relative "sorbet/method_signature"
|
|
7
|
+
require_relative "sorbet/catalog"
|
|
8
|
+
require_relative "sorbet/type_translator"
|
|
9
|
+
require_relative "sorbet/sig_parser"
|
|
10
|
+
require_relative "sorbet/catalog_walker"
|
|
11
|
+
require_relative "sorbet/assertion_recognizer"
|
|
12
|
+
require_relative "sorbet/absurd_recognizer"
|
|
13
|
+
require_relative "sorbet/sigil_detector"
|
|
14
|
+
|
|
15
|
+
module Rigor
|
|
16
|
+
module Plugin
|
|
17
|
+
# rigor-sorbet — ingests Sorbet `sig { ... }` blocks as
|
|
18
|
+
# method-signature contributions to Rigor's analyzer.
|
|
19
|
+
#
|
|
20
|
+
# ADR-11 slice 1 — first deliverable. Recognises:
|
|
21
|
+
#
|
|
22
|
+
# - `sig { params(x: Integer).returns(String) }` above a
|
|
23
|
+
# `def foo(x)` definition, contributing the parsed return
|
|
24
|
+
# type at every call site.
|
|
25
|
+
# - The `void` terminus and the `abstract` / `override` /
|
|
26
|
+
# `overridable` / `final` modifiers (recorded on the
|
|
27
|
+
# {MethodSignature} for slice ≥2).
|
|
28
|
+
# - `class Foo` / `module Foo::Bar` / `class << self`
|
|
29
|
+
# nesting; `def self.foo` is recognised as a singleton
|
|
30
|
+
# method.
|
|
31
|
+
#
|
|
32
|
+
# Slice 1 vocabulary is the bare minimum to round-trip the
|
|
33
|
+
# most common sig shapes; the {TypeTranslator} table
|
|
34
|
+
# documents what's covered. Anything else (T.proc / T::Array
|
|
35
|
+
# / T.class_of / T::Struct) degrades silently to
|
|
36
|
+
# `Dynamic[top]` for now — slice 3 widens the translator.
|
|
37
|
+
#
|
|
38
|
+
# Architecture: per-run `Catalog` is built lazily on first
|
|
39
|
+
# access by walking every configured `paths:` entry's `.rb`
|
|
40
|
+
# files plus every `rbi_paths:` entry's `.rbi` files (slice
|
|
41
|
+
# 4) via the plugin's `IoBoundary`. The catalog is frozen
|
|
42
|
+
# after the first build and consulted by
|
|
43
|
+
# `#flow_contribution_for` at every call site. RBI files
|
|
44
|
+
# share the catalog with project-source sigs — both produce
|
|
45
|
+
# `MethodSignature` entries keyed by
|
|
46
|
+
# `(class_name, method_name, kind)`. When a key collides
|
|
47
|
+
# across files, the last-walked sig wins (ordering is
|
|
48
|
+
# platform-dependent: `Dir.glob` returns directory entries
|
|
49
|
+
# in filesystem order). Sorbet's full shim-override
|
|
50
|
+
# semantics — `sorbet/rbi/shims/` overriding
|
|
51
|
+
# `sorbet/rbi/gems/` — lands in a later slice once the
|
|
52
|
+
# catalog gains per-source provenance.
|
|
53
|
+
#
|
|
54
|
+
# The plugin emits `plugin.sorbet.parse-error` warnings for
|
|
55
|
+
# malformed sig blocks (no block / empty block / no
|
|
56
|
+
# `returns` or `void` terminus / two consecutive sigs / sig
|
|
57
|
+
# not followed by a def) but never aborts a run.
|
|
58
|
+
#
|
|
59
|
+
# ## Configuration
|
|
60
|
+
#
|
|
61
|
+
# plugins:
|
|
62
|
+
# - gem: rigor-sorbet
|
|
63
|
+
# config:
|
|
64
|
+
# paths: ["lib", "app"] # directories to scan for `.rb` sigs; defaults to `paths:`
|
|
65
|
+
# rbi_paths: ["sorbet/rbi"] # directories to scan for `.rbi` files; default shown
|
|
66
|
+
#
|
|
67
|
+
# The `paths:` config key narrows the plugin's `.rb` walk;
|
|
68
|
+
# omit it to inherit the project-wide `paths:` value. The
|
|
69
|
+
# `rbi_paths:` key controls where Sorbet's RBI tree is read
|
|
70
|
+
# from — defaults to `sorbet/rbi/` per Tapioca's standard
|
|
71
|
+
# layout (`gems/`, `annotations/`, `dsl/`, `shims/`). Set
|
|
72
|
+
# to `[]` to opt out of RBI loading entirely.
|
|
73
|
+
class Sorbet < Rigor::Plugin::Base
|
|
74
|
+
manifest(
|
|
75
|
+
id: "sorbet",
|
|
76
|
+
version: "0.1.0",
|
|
77
|
+
description: "Ingests Sorbet `sig` blocks as method-signature contributions.",
|
|
78
|
+
config_schema: {
|
|
79
|
+
"paths" => :array,
|
|
80
|
+
"rbi_paths" => :array,
|
|
81
|
+
"enforce_sigil" => :boolean
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Default RBI directory tree. Matches the layout
|
|
86
|
+
# `tapioca init` generates — see Sorbet's `rbi.md`. Slice 4
|
|
87
|
+
# walks every `.rbi` file under these roots recursively;
|
|
88
|
+
# the four standard Tapioca subdirectories
|
|
89
|
+
# (`gems` / `annotations` / `dsl` / `shims`) are picked
|
|
90
|
+
# up as a side effect of recursing into the parent root.
|
|
91
|
+
DEFAULT_RBI_PATHS = ["sorbet/rbi"].freeze
|
|
92
|
+
|
|
93
|
+
def init(services)
|
|
94
|
+
@services = services
|
|
95
|
+
@configured_paths = Array(config.fetch("paths", services.configuration.paths)).map(&:to_s)
|
|
96
|
+
@rbi_paths = Array(config.fetch("rbi_paths", DEFAULT_RBI_PATHS)).map(&:to_s)
|
|
97
|
+
# Default `true` — only files marked `# typed: true` /
|
|
98
|
+
# `:strict` / `:strong` contribute their sigs. Set to
|
|
99
|
+
# `false` to record every file's sigs regardless of
|
|
100
|
+
# sigil (current behaviour pre-this-config).
|
|
101
|
+
@enforce_sigil = config.fetch("enforce_sigil", true)
|
|
102
|
+
# ADR-11 deferred follow-up — per-call-site assertion
|
|
103
|
+
# gating. Catalog harvest's `@sigil_by_path` cache is
|
|
104
|
+
# consulted at every `flow_contribution_for` call so
|
|
105
|
+
# `T.let` / `T.cast` / `T.must` / `T.bind` /
|
|
106
|
+
# `T.assert_type!` only fire in files Sorbet itself
|
|
107
|
+
# would enforce (`# typed: true` / `:strict` /
|
|
108
|
+
# `:strong`). When `@enforce_sigil` is off (the user
|
|
109
|
+
# opted out at harvest time), the gate also opens at
|
|
110
|
+
# every call site — current behaviour. Files whose
|
|
111
|
+
# sigil hasn't been observed yet (e.g. the catalog
|
|
112
|
+
# hasn't run, or the call site is in a fixture /
|
|
113
|
+
# synthetic path the harvest didn't see) treat
|
|
114
|
+
# missing-info as enforced — failing-open is friendlier
|
|
115
|
+
# for spec ergonomics than failing-closed.
|
|
116
|
+
@sigil_by_path = {}
|
|
117
|
+
@catalog = nil
|
|
118
|
+
@parse_errors_by_path = {}
|
|
119
|
+
@catalog_built = false
|
|
120
|
+
# ADR-11 slice 6 — Prism nodes for `T.absurd` calls
|
|
121
|
+
# we observed in `flow_contribution_for` to be
|
|
122
|
+
# *reachable* (i.e., their discriminant didn't narrow
|
|
123
|
+
# to `bot`). `diagnostics_for_file` walks the per-file
|
|
124
|
+
# AST and surfaces these as `plugin.sorbet.absurd-reachable`
|
|
125
|
+
# warnings. Hash is keyed on the Prism node's
|
|
126
|
+
# `object_id` because the runner only parses each file
|
|
127
|
+
# once per run, so identity is stable across the two
|
|
128
|
+
# plugin hooks.
|
|
129
|
+
@reachable_absurd_nodes = {}.compare_by_identity
|
|
130
|
+
# ADR-11 light follow-up — `T.reveal_type` calls
|
|
131
|
+
# observed in `flow_contribution_for`, paired with the
|
|
132
|
+
# display string for the inferred type at the call site.
|
|
133
|
+
# Mirrors the absurd-node compare-by-identity hash;
|
|
134
|
+
# `diagnostics_for_file` surfaces each entry as a
|
|
135
|
+
# `plugin.sorbet.reveal-type` `:info` diagnostic.
|
|
136
|
+
@reveal_type_calls = {}.compare_by_identity
|
|
137
|
+
# T.bind / T.assert_type! priority slice 1 —
|
|
138
|
+
# `T.assert_type!` calls observed in
|
|
139
|
+
# `flow_contribution_for` whose static subtype check
|
|
140
|
+
# FAILED, paired with the inferred + asserted type
|
|
141
|
+
# display strings. Same compare-by-identity discipline.
|
|
142
|
+
# `diagnostics_for_file` walks the file AST for
|
|
143
|
+
# `T.assert_type!` calls and surfaces matching entries
|
|
144
|
+
# as `plugin.sorbet.assert-type-mismatch` `:error`
|
|
145
|
+
# diagnostics.
|
|
146
|
+
@assert_type_mismatches = {}.compare_by_identity
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
150
|
+
ensure_catalog
|
|
151
|
+
# The catalog records errors under the canonicalised
|
|
152
|
+
# (realpath-resolved) form; the runner may pass the
|
|
153
|
+
# symlink-bearing form here. Look up under both so the
|
|
154
|
+
# match is symlink-agnostic.
|
|
155
|
+
errors = @parse_errors_by_path[path] || @parse_errors_by_path[canonicalize(path)] || []
|
|
156
|
+
diagnostics = errors.map { |error| parse_error_diagnostic(path, error) }
|
|
157
|
+
diagnostics.concat(absurd_reachable_diagnostics(path, root))
|
|
158
|
+
diagnostics.concat(reveal_type_diagnostics(path, root))
|
|
159
|
+
diagnostics.concat(assert_type_mismatch_diagnostics(path, root))
|
|
160
|
+
diagnostics
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ADR-11 slice 1 — return-type contribution from the
|
|
164
|
+
# parsed `sig { ... }` block. Resolves the receiver in two
|
|
165
|
+
# passes:
|
|
166
|
+
#
|
|
167
|
+
# 1. Constant receiver (`User.find(...)`) → singleton-side
|
|
168
|
+
# catalog lookup.
|
|
169
|
+
# 2. Nominal receiver-type (`user.name` where `user`'s
|
|
170
|
+
# inferred type is `Nominal["User"]`) → instance-side
|
|
171
|
+
# catalog lookup.
|
|
172
|
+
#
|
|
173
|
+
# Implicit-self calls (no receiver, current-class method)
|
|
174
|
+
# are deferred to slice 2 — slice 1 covers the common case
|
|
175
|
+
# where the sig is on the called method's own class.
|
|
176
|
+
def flow_contribution_for(call_node:, scope:)
|
|
177
|
+
return nil unless call_node.is_a?(Prism::CallNode)
|
|
178
|
+
|
|
179
|
+
# ADR-11 slice 6 — `T.absurd(x)` exhaustiveness. Always
|
|
180
|
+
# contributes a `bot` return + raise effect (matches
|
|
181
|
+
# Sorbet's runtime behaviour); when the discriminant
|
|
182
|
+
# *isn't* narrowed to `bot` at this scope, also records
|
|
183
|
+
# the call node so `diagnostics_for_file` can surface a
|
|
184
|
+
# `plugin.sorbet.absurd-reachable` warning.
|
|
185
|
+
if AbsurdRecognizer.absurd_call?(call_node)
|
|
186
|
+
@reachable_absurd_nodes[call_node] = true unless AbsurdRecognizer.exhaustive?(call_node, scope)
|
|
187
|
+
return AbsurdRecognizer.contribution(call_node, manifest.id)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# ADR-11 slice 2 — `T.let` / `T.cast` / `T.must` /
|
|
191
|
+
# `T.unsafe` are checked first because they're cheaper
|
|
192
|
+
# to recognise (no catalog walk required) and they
|
|
193
|
+
# win over any cataloged signature: the user explicitly
|
|
194
|
+
# asserted the type at the call site. The light
|
|
195
|
+
# follow-up extends the recogniser to `T.must_because`
|
|
196
|
+
# (alias of `T.must`) and `T.reveal_type` (passes the
|
|
197
|
+
# type through; the human-facing diagnostic is recorded
|
|
198
|
+
# here for `diagnostics_for_file` to emit).
|
|
199
|
+
#
|
|
200
|
+
# Per-call-site sigil gating: with `enforce_sigil: true`
|
|
201
|
+
# (default), assertions only fire in files Sorbet itself
|
|
202
|
+
# would enforce. Files at `# typed: false` (or
|
|
203
|
+
# sigil-less, which Sorbet treats as `:false`) skip the
|
|
204
|
+
# assertion path entirely so the dispatcher continues
|
|
205
|
+
# through the next tier as if the wrapper weren't
|
|
206
|
+
# there. The catalog tier already gates by sigil at
|
|
207
|
+
# harvest time; this closes the matching gap for
|
|
208
|
+
# caller-side recognition.
|
|
209
|
+
ensure_catalog
|
|
210
|
+
if assertion_enforced_here?(scope)
|
|
211
|
+
assertion = AssertionRecognizer.recognize(
|
|
212
|
+
call_node: call_node, scope: scope, plugin_id: manifest.id
|
|
213
|
+
)
|
|
214
|
+
if assertion
|
|
215
|
+
record_reveal_type_call(call_node, assertion.return_type) if call_node.name == :reveal_type
|
|
216
|
+
record_assert_type_check(call_node, scope) if call_node.name == :assert_type!
|
|
217
|
+
return assertion
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
return nil if @catalog.nil? || @catalog.empty?
|
|
222
|
+
|
|
223
|
+
signature = lookup_signature(call_node, scope)
|
|
224
|
+
return nil if signature.nil?
|
|
225
|
+
|
|
226
|
+
return_type = signature.return_type
|
|
227
|
+
return nil if return_type.nil?
|
|
228
|
+
|
|
229
|
+
Rigor::FlowContribution.new(
|
|
230
|
+
return_type: return_type,
|
|
231
|
+
provenance: Rigor::FlowContribution::Provenance.new(
|
|
232
|
+
source_family: "plugin.#{manifest.id}",
|
|
233
|
+
plugin_id: manifest.id,
|
|
234
|
+
node: call_node,
|
|
235
|
+
descriptor: nil
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
# ADR-11 deferred follow-up — per-call-site assertion
|
|
243
|
+
# gating. With `enforce_sigil: false`, the gate is fully
|
|
244
|
+
# open (matches the pre-feature behaviour). With
|
|
245
|
+
# `enforce_sigil: true` (default), the caller file's
|
|
246
|
+
# sigil must reach `:true` / `:strict` / `:strong` for
|
|
247
|
+
# assertions to fire. Three honest fallbacks:
|
|
248
|
+
#
|
|
249
|
+
# - `scope.source_path` is nil — synthetic call sites
|
|
250
|
+
# (specs, virtual-node fixtures) have no file context.
|
|
251
|
+
# Default to enforced so existing recogniser tests
|
|
252
|
+
# keep working.
|
|
253
|
+
# - the path is canonicalised to a form not in
|
|
254
|
+
# `@sigil_by_path` — the harvest never saw this file
|
|
255
|
+
# (out-of-tree call site, or a path the
|
|
256
|
+
# `configured_paths` config excluded). Sorbet itself
|
|
257
|
+
# has no opinion on such files; default to enforced
|
|
258
|
+
# so the recogniser still fires.
|
|
259
|
+
# - the path IS in `@sigil_by_path` but at `:false` /
|
|
260
|
+
# `:ignore` — gate closes.
|
|
261
|
+
def assertion_enforced_here?(scope)
|
|
262
|
+
return true unless @enforce_sigil
|
|
263
|
+
|
|
264
|
+
path = scope&.source_path
|
|
265
|
+
return true if path.nil?
|
|
266
|
+
return true unless @catalog_built
|
|
267
|
+
|
|
268
|
+
level = @sigil_by_path[path] || @sigil_by_path[canonicalize(path)]
|
|
269
|
+
return true if level.nil?
|
|
270
|
+
|
|
271
|
+
SigilDetector.enforced?(level)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def lookup_signature(call_node, scope)
|
|
275
|
+
receiver = call_node.receiver
|
|
276
|
+
method_name = call_node.name
|
|
277
|
+
return nil if method_name.nil?
|
|
278
|
+
|
|
279
|
+
if (singleton_target = constant_receiver_name(receiver))
|
|
280
|
+
# `Post.find(...)` — direct singleton method, or
|
|
281
|
+
# `extend M` lifting `M#find` to the extending class.
|
|
282
|
+
chain_lookup(singleton_target, method_name, anchor_kind: :singleton, mixin_kind: :extend)
|
|
283
|
+
elsif receiver
|
|
284
|
+
instance_chain_lookup(receiver, method_name, scope)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def instance_chain_lookup(receiver_node, method_name, scope)
|
|
289
|
+
return nil if scope.nil?
|
|
290
|
+
|
|
291
|
+
receiver_type = scope.type_of(receiver_node)
|
|
292
|
+
return nil unless receiver_type.is_a?(Rigor::Type::Nominal)
|
|
293
|
+
|
|
294
|
+
chain_lookup(receiver_type.class_name, method_name, anchor_kind: :instance, mixin_kind: :include)
|
|
295
|
+
rescue StandardError
|
|
296
|
+
# `scope.type_of` can raise on unrecognised synthetic
|
|
297
|
+
# nodes; degrade to "no contribution" rather than
|
|
298
|
+
# bubbling the failure into the dispatcher.
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# ADR-11 slice 8 — chain-aware catalog lookup.
|
|
303
|
+
#
|
|
304
|
+
# For instance-side calls (`post.body`):
|
|
305
|
+
# - `anchor_kind: :instance` (try `Post#body` first)
|
|
306
|
+
# - `mixin_kind: :include` (then walk Post's `include`d
|
|
307
|
+
# modules and try `Foo#body` on each)
|
|
308
|
+
#
|
|
309
|
+
# For singleton-side calls (`Post.find`):
|
|
310
|
+
# - `anchor_kind: :singleton` (try `Post.find` first)
|
|
311
|
+
# - `mixin_kind: :extend` (then walk Post's `extend`ed
|
|
312
|
+
# modules and try `Foo#find` *as :instance* — `extend
|
|
313
|
+
# Foo` lifts Foo's INSTANCE methods to the extending
|
|
314
|
+
# class's SINGLETON methods, matching Ruby's MRO).
|
|
315
|
+
def chain_lookup(class_name, method_name, anchor_kind:, mixin_kind:)
|
|
316
|
+
each_class_form(class_name).each do |form|
|
|
317
|
+
sig = @catalog.lookup(class_name: form, method_name: method_name, kind: anchor_kind)
|
|
318
|
+
return sig if sig
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
visited = Set.new
|
|
322
|
+
queue = mixin_modules_for(class_name, mixin_kind).dup
|
|
323
|
+
|
|
324
|
+
until queue.empty?
|
|
325
|
+
candidate = queue.shift
|
|
326
|
+
next unless visited.add?(candidate)
|
|
327
|
+
|
|
328
|
+
forms_for_mixin(class_name, candidate).each do |form|
|
|
329
|
+
sig = @catalog.lookup(class_name: form, method_name: method_name, kind: :instance)
|
|
330
|
+
return sig if sig
|
|
331
|
+
|
|
332
|
+
# Transitive: an `include` inside the mixed-in
|
|
333
|
+
# module is also inherited by the host class.
|
|
334
|
+
mixin_modules_for(form, :include).each do |inner|
|
|
335
|
+
queue << inner unless visited.include?(inner)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# `Post` and `::Post` are routinely confused at the catalog
|
|
344
|
+
# boundary (the walker records the lexical name; user code
|
|
345
|
+
# often writes the rooted form). Try both at every lookup.
|
|
346
|
+
def each_class_form(class_name)
|
|
347
|
+
[class_name, "::#{class_name}"]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Resolution forms for a mixed-in module name. Tapioca's
|
|
351
|
+
# generated DSL RBIs use the nested form
|
|
352
|
+
# (`class Post; module GeneratedAttributeMethods; ...; end`);
|
|
353
|
+
# hand-written shims often use the top-level form
|
|
354
|
+
# (`module GeneratedAttributeMethods; ...; end` outside any
|
|
355
|
+
# class); explicit rooting (`::GeneratedAttributeMethods`)
|
|
356
|
+
# is occasionally seen. Try all three.
|
|
357
|
+
def forms_for_mixin(host_class, mixin_name)
|
|
358
|
+
if mixin_name.start_with?("::")
|
|
359
|
+
[mixin_name, mixin_name.delete_prefix("::")]
|
|
360
|
+
else
|
|
361
|
+
["#{host_class}::#{mixin_name}", mixin_name, "::#{mixin_name}"]
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def mixin_modules_for(class_name, kind)
|
|
366
|
+
each_class_form(class_name).flat_map { |form| @catalog.mixins_for(form)[kind] }.uniq
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def constant_receiver_name(node)
|
|
370
|
+
case node
|
|
371
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
372
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def constant_path_name(node)
|
|
377
|
+
parts = []
|
|
378
|
+
current = node
|
|
379
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
380
|
+
parts.unshift(current.name.to_s)
|
|
381
|
+
current = current.parent
|
|
382
|
+
end
|
|
383
|
+
case current
|
|
384
|
+
when nil then "::#{parts.join('::')}"
|
|
385
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def ensure_catalog
|
|
390
|
+
return @catalog if @catalog_built
|
|
391
|
+
|
|
392
|
+
catalog = Catalog.new
|
|
393
|
+
# Project source — `.rb` only.
|
|
394
|
+
@configured_paths.each { |root| harvest_path(root, catalog, extensions: %w[.rb]) }
|
|
395
|
+
# Sorbet RBI tree — `.rbi` only. Slice 4 of ADR-11.
|
|
396
|
+
@rbi_paths.each { |root| harvest_path(root, catalog, extensions: %w[.rbi]) }
|
|
397
|
+
catalog.freeze!
|
|
398
|
+
@catalog = catalog
|
|
399
|
+
@catalog_built = true
|
|
400
|
+
catalog
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# @param root [String] directory or single file.
|
|
404
|
+
# @param catalog [Catalog]
|
|
405
|
+
# @param extensions [Array<String>] file extensions to
|
|
406
|
+
# accept (e.g. `[".rb"]` for project source,
|
|
407
|
+
# `[".rbi"]` for Sorbet RBI tree).
|
|
408
|
+
def harvest_path(root, catalog, extensions:)
|
|
409
|
+
absolute = canonicalize(root)
|
|
410
|
+
if File.directory?(absolute)
|
|
411
|
+
extensions.each do |ext|
|
|
412
|
+
Dir.glob(File.join(absolute, "**", "*#{ext}")).each do |path|
|
|
413
|
+
harvest_file(canonicalize(path), catalog)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
elsif File.file?(absolute) && extensions.any? { |ext| absolute.end_with?(ext) }
|
|
417
|
+
# `paths:` may list individual files (the demos do
|
|
418
|
+
# this); walk them directly rather than skipping.
|
|
419
|
+
harvest_file(absolute, catalog)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Canonicalises a path through `File.realpath` so it
|
|
424
|
+
# matches the form `Plugin::TrustPolicy#allow_read?` sees
|
|
425
|
+
# (the runner builds the policy's roots from `Dir.pwd`,
|
|
426
|
+
# which has symlinks resolved on macOS — `/tmp` →
|
|
427
|
+
# `/private/tmp` etc.). Falls back to `File.expand_path`
|
|
428
|
+
# when realpath fails (e.g. the path no longer exists).
|
|
429
|
+
def canonicalize(path)
|
|
430
|
+
expanded = File.expand_path(path)
|
|
431
|
+
File.exist?(expanded) ? File.realpath(expanded) : expanded
|
|
432
|
+
rescue StandardError
|
|
433
|
+
expanded
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def harvest_file(path, catalog)
|
|
437
|
+
contents = io_boundary.read_file(path)
|
|
438
|
+
return if contents.nil?
|
|
439
|
+
|
|
440
|
+
# ADR-11 slice 5 — honour Sorbet's `# typed: ignore`
|
|
441
|
+
# magic comment by skipping the file entirely.
|
|
442
|
+
level = SigilDetector.detect(contents)
|
|
443
|
+
# Per-call-site assertion gating consults this map at
|
|
444
|
+
# `flow_contribution_for`. Recorded BEFORE the ignored
|
|
445
|
+
# short-circuit so a `# typed: ignore` file still
|
|
446
|
+
# reports its level to the gate (the gate then chooses
|
|
447
|
+
# to suppress assertions there too — `ignore` is
|
|
448
|
+
# stricter than `false`).
|
|
449
|
+
@sigil_by_path[path] = level
|
|
450
|
+
return if SigilDetector.ignored?(level)
|
|
451
|
+
|
|
452
|
+
result = Prism.parse(contents)
|
|
453
|
+
return unless result.errors.empty?
|
|
454
|
+
|
|
455
|
+
# `enforce_sigil` follow-up — when on (default), files
|
|
456
|
+
# at `:false` (or sigil-less, which Sorbet treats as
|
|
457
|
+
# `:false`) are STILL walked so parse-error diagnostics
|
|
458
|
+
# surface, but sigs flow into a discardable catalog
|
|
459
|
+
# rather than the per-run one. Sorbet itself doesn't
|
|
460
|
+
# enforce types at `# typed: false`, and Rigor mirrors
|
|
461
|
+
# that for sig contributions. Assertion recognisers
|
|
462
|
+
# (`T.let` / `T.cast` / `T.must` / `T.bind` /
|
|
463
|
+
# `T.assert_type!`) stay live regardless of sigil — the
|
|
464
|
+
# user wrote those deliberately.
|
|
465
|
+
sig_catalog = if @enforce_sigil && !SigilDetector.enforced?(level)
|
|
466
|
+
Catalog.new
|
|
467
|
+
else
|
|
468
|
+
catalog
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
errors = CatalogWalker.walk(root: result.value, catalog: sig_catalog, path: path)
|
|
472
|
+
@parse_errors_by_path[path] = errors unless errors.empty?
|
|
473
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
474
|
+
# Skip files outside the trusted read scope or that
|
|
475
|
+
# vanished between glob and read; the plugin produces
|
|
476
|
+
# no output for them.
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Walks the per-file AST looking for `T.absurd(x)` call
|
|
481
|
+
# nodes and emits a `plugin.sorbet.absurd-reachable`
|
|
482
|
+
# warning for any whose object identity matches
|
|
483
|
+
# `@reachable_absurd_nodes` (populated during the engine's
|
|
484
|
+
# earlier pass through `flow_contribution_for`). Pops
|
|
485
|
+
# matched entries so a duplicate run doesn't double-emit.
|
|
486
|
+
def absurd_reachable_diagnostics(path, root)
|
|
487
|
+
return [] if @reachable_absurd_nodes.empty?
|
|
488
|
+
|
|
489
|
+
diagnostics = []
|
|
490
|
+
walk_for_absurd(root) do |call_node|
|
|
491
|
+
next unless @reachable_absurd_nodes.delete(call_node)
|
|
492
|
+
|
|
493
|
+
diagnostics << absurd_diagnostic(path, call_node)
|
|
494
|
+
end
|
|
495
|
+
diagnostics
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def walk_for_absurd(node, &)
|
|
499
|
+
return unless node.is_a?(Prism::Node)
|
|
500
|
+
|
|
501
|
+
yield node if node.is_a?(Prism::CallNode) && AbsurdRecognizer.absurd_call?(node)
|
|
502
|
+
node.compact_child_nodes.each { |child| walk_for_absurd(child, &) }
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def absurd_diagnostic(path, call_node)
|
|
506
|
+
location = call_node.location
|
|
507
|
+
Rigor::Analysis::Diagnostic.new(
|
|
508
|
+
path: path,
|
|
509
|
+
line: location.start_line,
|
|
510
|
+
column: location.start_column + 1,
|
|
511
|
+
message: "`T.absurd` is reachable: the discriminant did not narrow to `T.noreturn`. " \
|
|
512
|
+
"Either add the missing case branch above the `else`, or remove the " \
|
|
513
|
+
"`T.absurd(...)` call.",
|
|
514
|
+
severity: :warning,
|
|
515
|
+
rule: "absurd-reachable"
|
|
516
|
+
)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# ADR-11 light follow-up — `T.reveal_type(expr)` records
|
|
520
|
+
# the inferred type at recogniser time so the per-file
|
|
521
|
+
# diagnostic hook can surface the human-facing message.
|
|
522
|
+
# The reveal call's contribution already preserved the
|
|
523
|
+
# inferred type for downstream chaining; this hash carries
|
|
524
|
+
# the *display* string that the diagnostic shows.
|
|
525
|
+
def record_reveal_type_call(call_node, return_type)
|
|
526
|
+
@reveal_type_calls[call_node] = display_for_type(return_type)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def display_for_type(type)
|
|
530
|
+
# `Type#describe` is the human-facing display contract
|
|
531
|
+
# used by `rigor type-of`'s text renderer.
|
|
532
|
+
return "untyped" if type.nil?
|
|
533
|
+
|
|
534
|
+
type.respond_to?(:describe) ? type.describe : type.inspect
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def reveal_type_diagnostics(path, root)
|
|
538
|
+
return [] if @reveal_type_calls.empty?
|
|
539
|
+
|
|
540
|
+
diagnostics = []
|
|
541
|
+
walk_for_reveal_type(root) do |call_node|
|
|
542
|
+
display = @reveal_type_calls.delete(call_node)
|
|
543
|
+
next if display.nil?
|
|
544
|
+
|
|
545
|
+
diagnostics << reveal_type_diagnostic(path, call_node, display)
|
|
546
|
+
end
|
|
547
|
+
diagnostics
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def walk_for_reveal_type(node, &)
|
|
551
|
+
return unless node.is_a?(Prism::Node)
|
|
552
|
+
|
|
553
|
+
if node.is_a?(Prism::CallNode) && node.name == :reveal_type &&
|
|
554
|
+
TypeTranslator.sorbet_t_namespaced?(node.receiver)
|
|
555
|
+
yield node
|
|
556
|
+
end
|
|
557
|
+
node.compact_child_nodes.each { |child| walk_for_reveal_type(child, &) }
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def reveal_type_diagnostic(path, call_node, display)
|
|
561
|
+
location = call_node.location
|
|
562
|
+
Rigor::Analysis::Diagnostic.new(
|
|
563
|
+
path: path,
|
|
564
|
+
line: location.start_line,
|
|
565
|
+
column: location.start_column + 1,
|
|
566
|
+
message: "`T.reveal_type` inferred type: #{display}",
|
|
567
|
+
severity: :info,
|
|
568
|
+
rule: "reveal-type"
|
|
569
|
+
)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# T.bind / T.assert_type! priority slice 1 — runs the
|
|
573
|
+
# static subtype check at recogniser time and records the
|
|
574
|
+
# call only when the inferred type is *provably
|
|
575
|
+
# incompatible* with the asserted type. Gradual
|
|
576
|
+
# consistency rules (`Inference::Acceptance.accepts(...)`
|
|
577
|
+
# mode `:gradual`): a `Dynamic[top]` inferred type
|
|
578
|
+
# silences the check; a definite `:no` records for
|
|
579
|
+
# diagnostic emission; `:maybe` (uncertain) is treated as
|
|
580
|
+
# "trust the user" and silenced — the runtime check is
|
|
581
|
+
# there for those cases.
|
|
582
|
+
def record_assert_type_check(call_node, scope)
|
|
583
|
+
check = AssertionRecognizer.assert_type_check(call_node, scope)
|
|
584
|
+
return if check.nil?
|
|
585
|
+
|
|
586
|
+
inferred, asserted = check
|
|
587
|
+
return if inferred.nil?
|
|
588
|
+
|
|
589
|
+
result = Rigor::Inference::Acceptance.accepts(asserted, inferred)
|
|
590
|
+
return unless result.no?
|
|
591
|
+
|
|
592
|
+
@assert_type_mismatches[call_node] = [display_for_type(inferred), display_for_type(asserted)]
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def assert_type_mismatch_diagnostics(path, root)
|
|
596
|
+
return [] if @assert_type_mismatches.empty?
|
|
597
|
+
|
|
598
|
+
diagnostics = []
|
|
599
|
+
walk_for_assert_type(root) do |call_node|
|
|
600
|
+
recorded = @assert_type_mismatches.delete(call_node)
|
|
601
|
+
next if recorded.nil?
|
|
602
|
+
|
|
603
|
+
diagnostics << assert_type_mismatch_diagnostic(path, call_node, *recorded)
|
|
604
|
+
end
|
|
605
|
+
diagnostics
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def walk_for_assert_type(node, &)
|
|
609
|
+
return unless node.is_a?(Prism::Node)
|
|
610
|
+
|
|
611
|
+
if node.is_a?(Prism::CallNode) && node.name == :assert_type! &&
|
|
612
|
+
TypeTranslator.sorbet_t_namespaced?(node.receiver)
|
|
613
|
+
yield node
|
|
614
|
+
end
|
|
615
|
+
node.compact_child_nodes.each { |child| walk_for_assert_type(child, &) }
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def assert_type_mismatch_diagnostic(path, call_node, inferred_display, asserted_display)
|
|
619
|
+
location = call_node.location
|
|
620
|
+
Rigor::Analysis::Diagnostic.new(
|
|
621
|
+
path: path,
|
|
622
|
+
line: location.start_line,
|
|
623
|
+
column: location.start_column + 1,
|
|
624
|
+
message: "`T.assert_type!` failed: inferred type #{inferred_display} is not " \
|
|
625
|
+
"compatible with asserted type #{asserted_display}.",
|
|
626
|
+
severity: :error,
|
|
627
|
+
rule: "assert-type-mismatch"
|
|
628
|
+
)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def parse_error_diagnostic(path, error)
|
|
632
|
+
location = error.node.location
|
|
633
|
+
Rigor::Analysis::Diagnostic.new(
|
|
634
|
+
path: path,
|
|
635
|
+
line: location.start_line,
|
|
636
|
+
column: location.start_column + 1,
|
|
637
|
+
message: parse_error_message(error.kind),
|
|
638
|
+
severity: :warning,
|
|
639
|
+
rule: "parse-error"
|
|
640
|
+
)
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def parse_error_message(kind)
|
|
644
|
+
case kind
|
|
645
|
+
when :no_block then "Sorbet `sig` call missing a block."
|
|
646
|
+
when :empty_block then "Sorbet `sig` block is empty."
|
|
647
|
+
when :missing_returns_or_void
|
|
648
|
+
"Sorbet `sig` block must end in `.returns(...)` or `.void`."
|
|
649
|
+
when :duplicate_sig
|
|
650
|
+
"Two `sig` blocks in a row; the first one has no following method definition."
|
|
651
|
+
when :dangling_sig
|
|
652
|
+
"`sig` block is not immediately followed by a method definition."
|
|
653
|
+
else "Sorbet `sig` block did not parse (#{kind})."
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
Rigor::Plugin.register(Sorbet)
|
|
659
|
+
end
|
|
660
|
+
end
|