rigortype 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/lib/rigor/analysis/check_rules.rb +228 -40
  4. data/lib/rigor/analysis/diagnostic.rb +15 -1
  5. data/lib/rigor/analysis/runner.rb +183 -4
  6. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  7. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  8. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  9. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  10. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  11. data/lib/rigor/cache/store.rb +2 -0
  12. data/lib/rigor/cli.rb +9 -3
  13. data/lib/rigor/configuration/severity_profile.rb +109 -0
  14. data/lib/rigor/configuration.rb +110 -6
  15. data/lib/rigor/environment/rbs_loader.rb +89 -13
  16. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  17. data/lib/rigor/flow_contribution/element.rb +53 -0
  18. data/lib/rigor/flow_contribution/fact.rb +88 -0
  19. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  20. data/lib/rigor/flow_contribution/merger.rb +275 -0
  21. data/lib/rigor/flow_contribution.rb +51 -0
  22. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  23. data/lib/rigor/inference/expression_typer.rb +84 -5
  24. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +95 -9
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  26. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  27. data/lib/rigor/inference/narrowing.rb +105 -130
  28. data/lib/rigor/inference/scope_indexer.rb +75 -1
  29. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  30. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  31. data/lib/rigor/plugin/base.rb +241 -0
  32. data/lib/rigor/plugin/io_boundary.rb +102 -0
  33. data/lib/rigor/plugin/load_error.rb +23 -0
  34. data/lib/rigor/plugin/loader.rb +191 -0
  35. data/lib/rigor/plugin/manifest.rb +134 -0
  36. data/lib/rigor/plugin/registry.rb +50 -0
  37. data/lib/rigor/plugin/services.rb +65 -0
  38. data/lib/rigor/plugin/trust_policy.rb +99 -0
  39. data/lib/rigor/plugin.rb +61 -0
  40. data/lib/rigor/rbs_extended.rb +57 -9
  41. data/lib/rigor/reflection.rb +2 -2
  42. data/lib/rigor/version.rb +1 -1
  43. data/lib/rigor.rb +7 -0
  44. data/sig/rigor/environment.rbs +7 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/rbs_extended.rbs +2 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +7 -0
  49. metadata +18 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b054649005dbeb85c95236ebfba7fe7540e52c10c60aff095fbce81d5b589d67
4
- data.tar.gz: 9d879aa46bf37164f52f31786bade4d0ede52203d140cf0a2b1571f23160cd7d
3
+ metadata.gz: 257469533556c55164c5dbb9ea351c142ea600b17a90b6f9e632b5307bbbac08
4
+ data.tar.gz: be2141e8f53748716bd7f19e95632a1fab17fde8110dd824d981baaf2522371c
5
5
  SHA512:
6
- metadata.gz: ae684fc0749faa78117d85c75550b19e6a8e83b53172feea275d9ae1d804cec4f77d187b7ea4ae66e3035ed722bca0636622785f8537a3c42c9eb4ed9dc10b90
7
- data.tar.gz: 340b93e08cfcbf7050d6f908aa16e0508250df0cf0d727fb7f5a81f02b312248ee62e403d0046d2c8230cfb71821f4c64d63634bed3eeec276105ad4e663be17
6
+ metadata.gz: 221248b2968dcd5d11963197468407ffbc34f2c41b79aa45321a5171e8183817e1ed67fbac37c852c6f22c910b065fef7214cf5f5ef2f03ec6f3c2aef5cce213
7
+ data.tar.gz: d2b096836618fd21a44810aa7c87bad2b0fd28a90baf395dd206ece02844d65819f1ab0cc80ea7a956a4b590351a4f67572be959d8b4effc9d086ff120498e2e
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Rigor
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/rigortype.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/rigortype)
4
- ![GitHub License](https://img.shields.io/github/license/rigortype/rigor)
4
+ [![GitHub License](https://img.shields.io/github/license/rigortype/rigor)](https://github.com/rigortype/rigor/blob/master/LICENSE)
5
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/rigortype/rigor)
5
6
 
6
7
  **Inference-first static analysis for Ruby.** Add Rigor to your
7
8
  Gemfile and run `rigor check` over your code — no annotations,
@@ -207,6 +208,12 @@ genuinely proved.
207
208
 
208
209
  ### Where the type model is documented
209
210
 
211
+ - **End-user handbook** — chapter-by-chapter walkthrough of
212
+ the type model written for Ruby programmers without prior
213
+ static-typing background:
214
+ [`docs/handbook/`](docs/handbook/README.md). Start here if
215
+ you want a guided tour of how Rigor sees your code rather
216
+ than a spec deep-dive.
210
217
  - One-page mental model:
211
218
  [`docs/types.md`](docs/types.md).
212
219
  - Binding spec corpus:
@@ -366,6 +373,37 @@ The full per-release surface lives in
366
373
  analyzer guarantees live under
367
374
  [`docs/internal-spec/`](docs/internal-spec/).
368
375
 
376
+ ## Plugins (v0.1.0)
377
+
378
+ `v0.1.0` adds an extension API so projects can teach Rigor about
379
+ their own DSLs. Six worked examples ship under
380
+ [`examples/`](examples/) — each is a fully-shaped plugin gem
381
+ with a runnable demo and an end-to-end integration spec, and
382
+ each spotlights a different facet of the plugin contract:
383
+
384
+ - [`rigor-deprecations`](examples/rigor-deprecations/) —
385
+ smallest possible plugin (~80 lines); config-driven rules.
386
+ - [`rigor-lisp-eval`](examples/rigor-lisp-eval/) — typing literal
387
+ AST arguments at a method call.
388
+ - [`rigor-statesman`](examples/rigor-statesman/) — two-pass DSL
389
+ analysis (collect declarations, then validate references).
390
+ - [`rigor-pattern`](examples/rigor-pattern/) — plugin →
391
+ analyzer collaboration via `Scope#type_of` and the
392
+ literal-string carrier.
393
+ - [`rigor-units`](examples/rigor-units/) — local-variable flow
394
+ tracking through arithmetic.
395
+ - [`rigor-routes`](examples/rigor-routes/) — `Plugin::IoBoundary`
396
+ reads under `TrustPolicy` plus cache producers (slice 2 +
397
+ slice 6) — the most architecturally complete example.
398
+
399
+ [`examples/README.md`](examples/README.md) is the plugin
400
+ authoring landing page — comparison table, recommended reading
401
+ order, and the architectural map of which surface each example
402
+ exercises. The binding contract for the plugin API lives in
403
+ [`docs/adr/2-extension-api.md`](docs/adr/2-extension-api.md)
404
+ and the slice-by-slice normative specs under
405
+ [`docs/internal-spec/plugin*.md`](docs/internal-spec/).
406
+
369
407
  ## Configuration
370
408
 
371
409
  `rigor init` writes a starter `.rigor.yml`:
@@ -421,4 +459,4 @@ skill documentation contributors should know about.
421
459
 
422
460
  Mozilla Public License Version 2.0. See [`LICENSE`](LICENSE).
423
461
  </content>
424
- </invoke>
462
+ </invoke>
@@ -42,19 +42,25 @@ module Rigor
42
42
  # the first preview; later slices broaden it.
43
43
  # rubocop:disable Metrics/ModuleLength
44
44
  module CheckRules
45
- # Stable identifiers for each rule. Used by the
46
- # configuration `disable:` list and the in-source
47
- # `# rigor:disable <rule>` suppression comment system
48
- # to identify diagnostics by category. Rule identifiers
49
- # are kebab-case strings; new rules MUST register here
50
- # so user configuration can refer to them.
51
- RULE_UNDEFINED_METHOD = "undefined-method"
52
- RULE_WRONG_ARITY = "wrong-arity"
53
- RULE_ARGUMENT_TYPE = "argument-type-mismatch"
54
- RULE_NIL_RECEIVER = "possible-nil-receiver"
55
- RULE_DUMP_TYPE = "dump-type"
56
- RULE_ASSERT_TYPE = "assert-type"
57
- RULE_ALWAYS_RAISES = "always-raises"
45
+ # Canonical identifiers for each rule. Per ADR-8 §
46
+ # "Diagnostic ID family hierarchy", rule names are
47
+ # `family.rule-name` two-segment strings; the families
48
+ # group diagnostics by where they originate
49
+ # (`call.*` for call-site rules, `flow.*` for flow-analysis
50
+ # proofs, `assert.*` for runtime-assertion rules,
51
+ # `dump.*` for debug helpers, `def.*` for method-definition
52
+ # rules). Used by the configuration `disable:` list and the
53
+ # in-source `# rigor:disable <rule>` suppression comment
54
+ # system; new rules MUST register here so user configuration
55
+ # can refer to them.
56
+ RULE_UNDEFINED_METHOD = "call.undefined-method"
57
+ RULE_WRONG_ARITY = "call.wrong-arity"
58
+ RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
59
+ RULE_NIL_RECEIVER = "call.possible-nil-receiver"
60
+ RULE_DUMP_TYPE = "dump.type"
61
+ RULE_ASSERT_TYPE = "assert.type-mismatch"
62
+ RULE_ALWAYS_RAISES = "flow.always-raises"
63
+ RULE_RETURN_TYPE = "def.return-type-mismatch"
58
64
 
59
65
  ALL_RULES = [
60
66
  RULE_UNDEFINED_METHOD,
@@ -63,9 +69,46 @@ module Rigor
63
69
  RULE_NIL_RECEIVER,
64
70
  RULE_DUMP_TYPE,
65
71
  RULE_ASSERT_TYPE,
66
- RULE_ALWAYS_RAISES
72
+ RULE_ALWAYS_RAISES,
73
+ RULE_RETURN_TYPE
67
74
  ].freeze
68
75
 
76
+ # Backward-compat alias table (ADR-8 § "Backward
77
+ # compatibility"). Existing user code with
78
+ # `# rigor:disable undefined-method` /
79
+ # `disable: [undefined-method]` keeps working — the
80
+ # legacy unprefixed identifiers map to their canonical
81
+ # `family.rule-name` form here. Removing the aliases is
82
+ # a future ADR once user code has migrated; until then,
83
+ # both spellings resolve identically.
84
+ LEGACY_RULE_ALIASES = {
85
+ "undefined-method" => RULE_UNDEFINED_METHOD,
86
+ "wrong-arity" => RULE_WRONG_ARITY,
87
+ "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
88
+ "possible-nil-receiver" => RULE_NIL_RECEIVER,
89
+ "dump-type" => RULE_DUMP_TYPE,
90
+ "assert-type" => RULE_ASSERT_TYPE,
91
+ "always-raises" => RULE_ALWAYS_RAISES
92
+ }.freeze
93
+
94
+ # Family wildcard — a `<family>` token in a suppression
95
+ # comment or `disable:` list disables every rule whose
96
+ # canonical id starts with `<family>.`. Per ADR-8 § "1".
97
+ RULE_FAMILIES = %w[call flow assert dump def].freeze
98
+
99
+ # Resolves a user-supplied rule token (`undefined-method`,
100
+ # `call.undefined-method`, or the family wildcard `call`)
101
+ # to the set of canonical rule identifiers it disables.
102
+ # Returns `nil` for `"all"` (the existing wildcard meaning
103
+ # "every rule"), or for unknown tokens.
104
+ def self.resolve_rule_token(token)
105
+ return nil if token == "all"
106
+ return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
107
+ return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)
108
+
109
+ ALL_RULES.include?(token) ? [token] : []
110
+ end
111
+
69
112
  module_function
70
113
 
71
114
  # Yields diagnostics for every unrecognised method call on
@@ -78,35 +121,31 @@ module Rigor
78
121
  # @param root [Prism::Node]
79
122
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
80
123
  # @return [Array<Rigor::Analysis::Diagnostic>]
81
- def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
124
+ def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
82
125
  diagnostics = []
83
126
  Source::NodeWalker.each(root) do |node|
84
- next unless node.is_a?(Prism::CallNode)
85
-
86
- diagnostic = undefined_method_diagnostic(path, node, scope_index)
87
- diagnostics << diagnostic if diagnostic
88
-
89
- arity_diagnostic = wrong_arity_diagnostic(path, node, scope_index)
90
- diagnostics << arity_diagnostic if arity_diagnostic
91
-
92
- arg_type_diagnostic = argument_type_diagnostic(path, node, scope_index)
93
- diagnostics << arg_type_diagnostic if arg_type_diagnostic
94
-
95
- nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
96
- diagnostics << nil_diagnostic if nil_diagnostic
97
-
98
- dump_diagnostic = dump_type_diagnostic(path, node, scope_index)
99
- diagnostics << dump_diagnostic if dump_diagnostic
100
-
101
- assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
102
- diagnostics << assert_diagnostic if assert_diagnostic
103
-
104
- raises_diagnostic = always_raises_diagnostic(path, node, scope_index)
105
- diagnostics << raises_diagnostic if raises_diagnostic
127
+ if node.is_a?(Prism::CallNode)
128
+ diagnostics.concat(call_node_diagnostics(path, node, scope_index))
129
+ elsif node.is_a?(Prism::DefNode)
130
+ return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
131
+ diagnostics << return_diagnostic if return_diagnostic
132
+ end
106
133
  end
107
134
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
108
135
  end
109
136
 
137
+ def call_node_diagnostics(path, node, scope_index)
138
+ [
139
+ undefined_method_diagnostic(path, node, scope_index),
140
+ wrong_arity_diagnostic(path, node, scope_index),
141
+ argument_type_diagnostic(path, node, scope_index),
142
+ nil_receiver_diagnostic(path, node, scope_index),
143
+ dump_type_diagnostic(path, node, scope_index),
144
+ assert_type_diagnostic(path, node, scope_index),
145
+ always_raises_diagnostic(path, node, scope_index)
146
+ ].compact
147
+ end
148
+
110
149
  # v0.0.2 #6 — diagnostic suppression. Two kinds of
111
150
  # suppression compose:
112
151
  #
@@ -125,7 +164,7 @@ module Rigor
125
164
  # silence away.
126
165
  def filter_suppressed(diagnostics, comments:, disabled_rules:)
127
166
  suppressions = parse_suppression_comments(comments)
128
- disabled = disabled_rules.to_set(&:to_s)
167
+ disabled = expand_rule_tokens(disabled_rules)
129
168
 
130
169
  diagnostics.reject do |diagnostic|
131
170
  rule = diagnostic.rule
@@ -137,7 +176,7 @@ module Rigor
137
176
  end
138
177
  end
139
178
 
140
- SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w,\s-]+)/
179
+ SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w.,\s-]+)/
141
180
  private_constant :SUPPRESSION_PATTERN
142
181
 
143
182
  def parse_suppression_comments(comments)
@@ -148,11 +187,29 @@ module Rigor
148
187
  next if match.nil?
149
188
 
150
189
  rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
151
- rules.each { |rule| result[comment.location.start_line] << rule }
190
+ rules.each { |token| result[comment.location.start_line].merge(expand_token(token)) }
152
191
  end
153
192
  result
154
193
  end
155
194
 
195
+ # Expands a list of user-supplied rule tokens into the
196
+ # canonical-id set per ADR-8 § "Backward compatibility".
197
+ # `disabled_rules` accepts unprefixed legacy names
198
+ # (`undefined-method`), canonical names
199
+ # (`call.undefined-method`), and family wildcards (`call`).
200
+ def expand_rule_tokens(tokens)
201
+ Array(tokens).each_with_object(Set.new) do |token, set|
202
+ set.merge(expand_token(token.to_s))
203
+ end
204
+ end
205
+
206
+ def expand_token(token)
207
+ return ["all"] if token == "all"
208
+
209
+ resolved = resolve_rule_token(token)
210
+ resolved.nil? || resolved.empty? ? [token] : resolved
211
+ end
212
+
156
213
  # rubocop:disable Metrics/ClassLength
157
214
  class << self
158
215
  private
@@ -792,6 +849,137 @@ module Rigor
792
849
  severity: :error
793
850
  )
794
851
  end
852
+
853
+ # ADR-8 § "`def.return-type-mismatch` rule" — flags a
854
+ # `def m(...) ... end` whose body's last expression's
855
+ # type cannot satisfy the RBS-declared return type.
856
+ # Conservative envelope (v0.1.x first cut):
857
+ #
858
+ # - Skips methods without an RBS declaration. The rule
859
+ # has no contract to compare against for source-only
860
+ # methods.
861
+ # - Skips methods whose enclosing class isn't a
862
+ # `Type::Singleton` self_type that we can name (top-
863
+ # level / module-level methods land outside the rule).
864
+ # - Skips methods whose body's last expression is
865
+ # absent or types as `Dynamic[top]` (the analyzer's
866
+ # fail-soft fallback) — emitting on `Dynamic[top]`
867
+ # would be noise.
868
+ # - Compares the inferred body type against the
869
+ # declared return via `accepts?`:
870
+ # :yes → silent
871
+ # :no → emit at :error (severity_profile may
872
+ # re-stamp; default `balanced` keeps the
873
+ # authored severity).
874
+ # :maybe → emit at :warning. Promoted to :error
875
+ # under `severity_profile: strict` per
876
+ # ADR-8 § "Severity profile".
877
+ def return_type_mismatch_diagnostic(path, def_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
878
+ return nil if def_node.body.nil?
879
+
880
+ last_expr = body_last_expression(def_node.body)
881
+ return nil if last_expr.nil?
882
+
883
+ inner_scope = scope_index[last_expr] || scope_index[def_node.body] || scope_index[def_node]
884
+ return nil if inner_scope.nil?
885
+
886
+ declared = declared_return_type(def_node, scope_index)
887
+ return nil if declared.nil?
888
+
889
+ inferred = inner_scope.type_of(last_expr)
890
+ return nil if dynamic_top?(inferred)
891
+
892
+ severity = compare_return(declared, inferred)
893
+ return nil if severity.nil?
894
+
895
+ build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
896
+ end
897
+
898
+ # The body of a `def` is the last `Prism::StatementsNode`
899
+ # child (or a single expression for one-liner defs).
900
+ # Take the last statement; that's the implicit return.
901
+ def body_last_expression(body)
902
+ case body
903
+ when Prism::StatementsNode then body.body.last
904
+ when Prism::BeginNode then body_last_expression(body.statements)
905
+ else body
906
+ end
907
+ end
908
+
909
+ # Pulls the declared RBS return type for the def. The
910
+ # enclosing class name comes from the def's scope's
911
+ # `self_type`; the method name is on the def itself.
912
+ # `def self.foo` is a singleton method — dispatched
913
+ # through `Reflection.singleton_method_definition`;
914
+ # plain `def foo` uses `instance_method_definition`.
915
+ # Method overloads contribute their union of declared
916
+ # return types (any one of them satisfying the body
917
+ # silences the rule).
918
+ def declared_return_type(def_node, scope_index)
919
+ scope = scope_index[def_node]
920
+ return nil if scope.nil?
921
+
922
+ self_type = scope.self_type
923
+ return nil unless self_type.respond_to?(:class_name)
924
+
925
+ method_def =
926
+ if def_node.receiver.nil?
927
+ Reflection.instance_method_definition(self_type.class_name, def_node.name, scope: scope)
928
+ else
929
+ Reflection.singleton_method_definition(self_type.class_name, def_node.name, scope: scope)
930
+ end
931
+ return nil if method_def.nil?
932
+
933
+ declared_return_union(method_def, scope.environment)
934
+ end
935
+
936
+ def declared_return_union(method_def, _environment)
937
+ translated = method_def.method_types.filter_map do |mt|
938
+ Inference::RbsTypeTranslator.translate(
939
+ mt.type.return_type,
940
+ self_type: nil, instance_type: nil, type_vars: {}
941
+ )
942
+ rescue StandardError
943
+ nil
944
+ end
945
+ return nil if translated.empty?
946
+
947
+ translated.size == 1 ? translated.first : Type::Combinator.union(*translated)
948
+ end
949
+
950
+ def dynamic_top?(type)
951
+ type.is_a?(Type::Dynamic) || (type.respond_to?(:top?) && type.top?.yes?)
952
+ end
953
+
954
+ # Returns the severity to emit at, or nil to stay
955
+ # silent. The first-cut implementation only fires on
956
+ # proven (`:no`) mismatches; `:maybe` is treated as
957
+ # silent until the analyzer's narrowing becomes precise
958
+ # enough to avoid noise on common patterns (`{}` →
959
+ # declared `Hash[K, V]`, `Set.new` → declared
960
+ # `Set[Symbol]`, …). ADR-8's promise to emit on
961
+ # `:maybe` under `severity_profile: strict` is
962
+ # deferred to a follow-up that lands together with the
963
+ # narrowing precision improvements.
964
+ def compare_return(declared, inferred)
965
+ result = declared.accepts(inferred)
966
+ return :error if result.no?
967
+
968
+ nil
969
+ end
970
+
971
+ def build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
972
+ location = def_node.name_loc || def_node.location
973
+ Diagnostic.new(
974
+ rule: RULE_RETURN_TYPE,
975
+ path: path,
976
+ line: location.start_line,
977
+ column: location.start_column + 1,
978
+ message: "return-type mismatch on `#{def_node.name}': " \
979
+ "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
980
+ severity: severity
981
+ )
982
+ end
795
983
  end
796
984
  # rubocop:enable Metrics/ClassLength
797
985
  end
@@ -64,8 +64,22 @@ module Rigor
64
64
  }
65
65
  end
66
66
 
67
+ # Text rendering for `rigor check`. The qualified rule
68
+ # identifier (per ADR-2 § "Plugin Diagnostic Provenance" —
69
+ # `plugin.<id>.<rule>`, `rbs_extended.<rule>`,
70
+ # `generated.<provider>.<rule>`) is appended in brackets
71
+ # whenever the diagnostic carries a non-default `source_family`,
72
+ # so plugin / RBS::Extended / generated provenance is visible
73
+ # in the standard text output without changing the layout for
74
+ # built-in rules. Slice 5 (v0.1.0) wires this surface.
67
75
  def to_s
68
- "#{path}:#{line}:#{column}: #{severity}: #{message}"
76
+ base = "#{path}:#{line}:#{column}: #{severity}: #{message}"
77
+ return base if source_family == DEFAULT_SOURCE_FAMILY
78
+
79
+ qualified = qualified_rule
80
+ return base if qualified.nil?
81
+
82
+ "#{base} [#{qualified}]"
69
83
  end
70
84
  end
71
85
  end
@@ -5,6 +5,9 @@ require "prism"
5
5
  require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../cache/store"
8
+ require_relative "../plugin"
9
+ require_relative "../reflection"
10
+ require_relative "../type/combinator"
8
11
  require_relative "../inference/coverage_scanner"
9
12
  require_relative "../inference/scope_indexer"
10
13
  require_relative "../inference/method_dispatcher/file_folding"
@@ -18,7 +21,7 @@ module Rigor
18
21
  RUBY_GLOB = "**/*.rb"
19
22
  DEFAULT_CACHE_ROOT = ".rigor/cache"
20
23
 
21
- attr_reader :cache_store
24
+ attr_reader :cache_store, :plugin_registry
22
25
 
23
26
  # @param configuration [Rigor::Configuration]
24
27
  # @param explain [Boolean] surface fail-soft fallback events
@@ -30,10 +33,13 @@ module Rigor
30
33
  # v0.0.9 group A slice 1 introduces the surface; later
31
34
  # slices route real producers through it.
32
35
  def initialize(configuration:, explain: false,
33
- cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT))
36
+ cache_store: Cache::Store.new(root: DEFAULT_CACHE_ROOT),
37
+ plugin_requirer: nil)
34
38
  @configuration = configuration
35
39
  @explain = explain
36
40
  @cache_store = cache_store
41
+ @plugin_requirer = plugin_requirer
42
+ @plugin_registry = Plugin::Registry::EMPTY
37
43
  end
38
44
 
39
45
  # Walks every Ruby file under `paths`, parses it, builds a
@@ -53,16 +59,188 @@ module Rigor
53
59
  signature_paths: @configuration.signature_paths,
54
60
  cache_store: @cache_store
55
61
  )
62
+
63
+ @plugin_registry = load_plugins
56
64
  expansion = expand_paths(paths)
57
65
 
58
- diagnostics = expansion.fetch(:errors)
66
+ diagnostics = plugin_load_diagnostics
67
+ diagnostics += expansion.fetch(:errors)
59
68
  diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
60
69
 
61
- Result.new(diagnostics: diagnostics)
70
+ Result.new(diagnostics: apply_severity_profile(diagnostics))
62
71
  end
63
72
 
64
73
  private
65
74
 
75
+ # Loads project-configured plugins through {Rigor::Plugin::Loader}
76
+ # and returns the resulting {Rigor::Plugin::Registry}. Loader
77
+ # failures are isolated: each surfaces as a `:plugin_loader`
78
+ # diagnostic on the run's `Result` rather than aborting the
79
+ # analysis. Plugins that load successfully but contribute no
80
+ # protocol hooks are inert in slice 1; later v0.1.0 slices
81
+ # wire the contribution merger through this registry.
82
+ def load_plugins
83
+ return Plugin::Registry::EMPTY if @configuration.plugins.empty?
84
+
85
+ services = Plugin::Services.new(
86
+ reflection: Reflection,
87
+ type: Type::Combinator,
88
+ configuration: @configuration,
89
+ cache_store: @cache_store,
90
+ trust_policy: build_trust_policy
91
+ )
92
+ if @plugin_requirer
93
+ Plugin::Loader.load(configuration: @configuration, services: services, requirer: @plugin_requirer)
94
+ else
95
+ Plugin::Loader.load(configuration: @configuration, services: services)
96
+ end
97
+ end
98
+
99
+ # Builds the {Rigor::Plugin::TrustPolicy} for this run. Trusted
100
+ # gems are the gem-name half of every entry in
101
+ # `Configuration#plugins`. Allowed read roots default to the
102
+ # project root (CWD), the project's signature_paths, and each
103
+ # trusted gem's `Gem::Specification#full_gem_path`, plus any
104
+ # extras the user listed under `plugins_io.allowed_paths`.
105
+ # Slice 2 keeps `network_policy` `:disabled` — the only value
106
+ # the configuration accepts today.
107
+ def build_trust_policy
108
+ trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
109
+ roots = [Dir.pwd]
110
+ Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
111
+ trusted_gems.each do |gem_name|
112
+ path = trusted_gem_root(gem_name)
113
+ roots << path if path
114
+ end
115
+ @configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
116
+
117
+ Plugin::TrustPolicy.new(
118
+ trusted_gems: trusted_gems,
119
+ allowed_read_roots: roots,
120
+ network_policy: @configuration.plugins_io_network
121
+ )
122
+ end
123
+
124
+ def trusted_gem_name(entry)
125
+ case entry
126
+ when String then entry
127
+ when Hash then entry["gem"] || entry["id"]
128
+ end
129
+ end
130
+
131
+ def trusted_gem_root(gem_name)
132
+ return nil if gem_name.nil? || gem_name.empty?
133
+
134
+ spec = Gem.loaded_specs[gem_name]
135
+ spec&.full_gem_path # rigor:disable undefined-method
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ # ADR-8 § "Severity profile" — re-stamps each diagnostic's
141
+ # severity from the configured profile + per-rule
142
+ # overrides. Rules emit with their authored severity; the
143
+ # profile is the final filter. Diagnostics whose resolved
144
+ # severity is `:off` are dropped from the run result.
145
+ def apply_severity_profile(diagnostics)
146
+ diagnostics.filter_map { |diagnostic| stamp_severity(diagnostic) }
147
+ end
148
+
149
+ def stamp_severity(diagnostic)
150
+ return diagnostic if diagnostic.rule.nil?
151
+
152
+ resolved = Configuration::SeverityProfile.resolve(
153
+ rule: diagnostic.rule,
154
+ authored_severity: diagnostic.severity,
155
+ profile: @configuration.severity_profile,
156
+ overrides: @configuration.severity_overrides
157
+ )
158
+ return nil if resolved == :off
159
+ return diagnostic if resolved == diagnostic.severity
160
+
161
+ Diagnostic.new(
162
+ path: diagnostic.path,
163
+ line: diagnostic.line,
164
+ column: diagnostic.column,
165
+ message: diagnostic.message,
166
+ severity: resolved,
167
+ rule: diagnostic.rule,
168
+ source_family: diagnostic.source_family
169
+ )
170
+ end
171
+
172
+ def plugin_load_diagnostics
173
+ @plugin_registry.load_errors.map do |error|
174
+ Diagnostic.new(
175
+ path: ".rigor.yml",
176
+ line: 1,
177
+ column: 1,
178
+ message: error.message,
179
+ severity: :error,
180
+ rule: "load-error",
181
+ source_family: :plugin_loader
182
+ )
183
+ end
184
+ end
185
+
186
+ # ADR-7 § "Slice 5-A/5-B" — invokes every loaded plugin's
187
+ # per-file diagnostic emission hook
188
+ # (`Plugin::Base#diagnostics_for_file`) and re-stamps the
189
+ # returned diagnostics with
190
+ # `source_family: "plugin.<manifest.id>"` so plugin
191
+ # authors cannot accidentally publish under another
192
+ # plugin's identifier or under `:builtin`. Plugin
193
+ # exceptions are isolated per ADR-2 § "Plugin Trust and
194
+ # I/O Policy" — a raise from one plugin becomes a
195
+ # `:plugin_loader` `runtime-error` diagnostic without
196
+ # affecting other plugins or the rest of the run.
197
+ def plugin_emitted_diagnostics(path, root, scope)
198
+ return [] if @plugin_registry.empty?
199
+
200
+ @plugin_registry.plugins.flat_map do |plugin|
201
+ collect_plugin_diagnostics(plugin, path, root, scope)
202
+ end
203
+ end
204
+
205
+ def collect_plugin_diagnostics(plugin, path, root, scope)
206
+ raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
207
+ Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
208
+ rescue StandardError => e
209
+ [plugin_runtime_error_diagnostic(path, plugin, e)]
210
+ end
211
+
212
+ def stamp_plugin_diagnostic(diagnostic, plugin_id)
213
+ Diagnostic.new(
214
+ path: diagnostic.path,
215
+ line: diagnostic.line,
216
+ column: diagnostic.column,
217
+ message: diagnostic.message,
218
+ severity: diagnostic.severity,
219
+ rule: diagnostic.rule,
220
+ source_family: "plugin.#{plugin_id}"
221
+ )
222
+ end
223
+
224
+ def plugin_runtime_error_diagnostic(path, plugin, error)
225
+ plugin_id = safe_plugin_id(plugin)
226
+ Diagnostic.new(
227
+ path: path,
228
+ line: 1,
229
+ column: 1,
230
+ message: "plugin #{plugin_id.inspect} raised during diagnostics_for_file: " \
231
+ "#{error.class}: #{error.message}",
232
+ severity: :error,
233
+ rule: "runtime-error",
234
+ source_family: :plugin_loader
235
+ )
236
+ end
237
+
238
+ def safe_plugin_id(plugin)
239
+ plugin.manifest.id
240
+ rescue StandardError
241
+ plugin.class.to_s
242
+ end
243
+
66
244
  # Resolves the user-supplied path list into:
67
245
  # - `:files` — the concrete `.rb` files to analyze.
68
246
  # - `:errors` — `Diagnostic` entries for each path that
@@ -111,6 +289,7 @@ module Rigor
111
289
  comments: parse_result.comments,
112
290
  disabled_rules: @configuration.disabled_rules
113
291
  )
292
+ diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
114
293
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
115
294
  rescue Errno::ENOENT => e
116
295
  [
@@ -53,7 +53,7 @@ module Rigor
53
53
  .map { |ancestor| ancestor.name.to_s.delete_prefix("::") }
54
54
  .uniq
55
55
  .freeze
56
- rescue StandardError
56
+ rescue ::RBS::BaseError
57
57
  nil
58
58
  end
59
59
 
@@ -50,7 +50,7 @@ module Rigor
50
50
  return nil if definition.nil?
51
51
 
52
52
  definition.type_params.dup.freeze
53
- rescue StandardError
53
+ rescue ::RBS::BaseError
54
54
  nil
55
55
  end
56
56