rigortype 0.0.1

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/node_walker"
6
+ require_relative "../type"
7
+ require_relative "diagnostic"
8
+
9
+ module Rigor
10
+ module Analysis
11
+ # First-preview catalogue of `rigor check` diagnostic rules.
12
+ #
13
+ # The rules are intentionally narrow: they fire ONLY when the
14
+ # engine is confident enough to make a useful claim, and they
15
+ # MUST NOT raise on unrecognised AST shapes, RBS gaps, or
16
+ # missing scope information. Each rule consumes the per-node
17
+ # scope index produced by
18
+ # `Rigor::Inference::ScopeIndexer.index` and yields zero or
19
+ # more `Rigor::Analysis::Diagnostic` values.
20
+ #
21
+ # The first shipped rule, `UndefinedMethodOnTypedReceiver`,
22
+ # flags an explicit-receiver `Prism::CallNode` whose receiver
23
+ # statically resolves to a `Type::Nominal` or `Type::Singleton`
24
+ # known to the analyzer's RBS environment AND whose method
25
+ # name does not appear on that class's instance / singleton
26
+ # method table. This is the canonical "type check" signal
27
+ # ("Foo has no method bar"), but it explicitly does NOT fire
28
+ # for:
29
+ #
30
+ # - implicit-self calls (no `node.receiver`) — too noisy
31
+ # without per-method RBS for every helper in the class.
32
+ # - dynamic / unknown receivers (`Dynamic[T]`, `Top`, `Union`)
33
+ # — by definition we cannot enumerate the method set.
34
+ # - shape carriers (`Tuple`, `HashShape`, `Constant`) — their
35
+ # dispatch goes through `ShapeDispatch` / `ConstantFolding`
36
+ # which the rule does not yet model.
37
+ # - receivers whose class name is NOT registered in the
38
+ # loader (RBS-blind environments, unknown stdlib).
39
+ #
40
+ # The above list is the deliberate conservative envelope of
41
+ # the first preview; later slices broaden it.
42
+ # rubocop:disable Metrics/ModuleLength
43
+ module CheckRules
44
+ module_function
45
+
46
+ # Yields diagnostics for every unrecognised method call on
47
+ # a typed receiver in `root`'s subtree. The caller MUST
48
+ # have already produced `scope_index` through
49
+ # `Rigor::Inference::ScopeIndexer.index(root, default_scope:)`.
50
+ #
51
+ # @param path [String] used to populate
52
+ # `Diagnostic#path`; the rule does not open files.
53
+ # @param root [Prism::Node]
54
+ # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
55
+ # @return [Array<Rigor::Analysis::Diagnostic>]
56
+ def diagnose(path:, root:, scope_index:) # rubocop:disable Metrics/CyclomaticComplexity
57
+ diagnostics = []
58
+ Source::NodeWalker.each(root) do |node|
59
+ next unless node.is_a?(Prism::CallNode)
60
+
61
+ diagnostic = undefined_method_diagnostic(path, node, scope_index)
62
+ diagnostics << diagnostic if diagnostic
63
+
64
+ arity_diagnostic = wrong_arity_diagnostic(path, node, scope_index)
65
+ diagnostics << arity_diagnostic if arity_diagnostic
66
+
67
+ nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
68
+ diagnostics << nil_diagnostic if nil_diagnostic
69
+
70
+ dump_diagnostic = dump_type_diagnostic(path, node, scope_index)
71
+ diagnostics << dump_diagnostic if dump_diagnostic
72
+
73
+ assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
74
+ diagnostics << assert_diagnostic if assert_diagnostic
75
+ end
76
+ diagnostics
77
+ end
78
+
79
+ # rubocop:disable Metrics/ClassLength
80
+ class << self
81
+ private
82
+
83
+ def undefined_method_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
84
+ return nil if call_node.receiver.nil?
85
+
86
+ scope = scope_index[call_node]
87
+ return nil if scope.nil?
88
+
89
+ receiver_type = scope.type_of(call_node.receiver)
90
+ class_name = concrete_class_name(receiver_type)
91
+ return nil if class_name.nil?
92
+
93
+ # Slice 7 phase 12 — suppress when the user has
94
+ # declared the method in source (instance `def`,
95
+ # `def self.foo`, or recognised `define_method`).
96
+ kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
97
+ return nil if scope.discovered_method?(class_name, call_node.name, kind)
98
+
99
+ loader = scope.environment.rbs_loader
100
+ return nil if loader.nil?
101
+ return nil unless loader.class_known?(class_name)
102
+
103
+ # When the loader cannot build a class definition for a
104
+ # name it nominally knows (constant-decl aliases such
105
+ # as `YAML` → `Psych`, or RBS-build failures for
106
+ # malformed signatures), we cannot enumerate methods
107
+ # so we MUST NOT emit a false positive. Skip the rule
108
+ # in that case.
109
+ return nil unless definition_available?(loader, receiver_type, class_name)
110
+
111
+ method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
112
+ return nil if method_def
113
+
114
+ build_undefined_method_diagnostic(path, call_node, receiver_type)
115
+ end
116
+
117
+ # Returns a qualified class name for the in-scope check.
118
+ # Nominal / Singleton carry a single-class identity
119
+ # directly. Constant projects to its value's class.
120
+ # Tuple projects to "Array" and HashShape to "Hash" so
121
+ # arity / dispatch checks against the underlying class
122
+ # still apply. Dynamic / Top / Union / Bot do not name a
123
+ # single class and return nil to skip the rule.
124
+ def concrete_class_name(type)
125
+ case type
126
+ when Type::Nominal, Type::Singleton then type.class_name
127
+ when Type::Tuple then "Array"
128
+ when Type::HashShape then "Hash"
129
+ when Type::Constant then constant_class_name(type.value)
130
+ end
131
+ end
132
+
133
+ CONSTANT_CLASSES = {
134
+ Integer => "Integer", Float => "Float", String => "String",
135
+ Symbol => "Symbol", Range => "Range",
136
+ TrueClass => "TrueClass", FalseClass => "FalseClass",
137
+ NilClass => "NilClass"
138
+ }.freeze
139
+ private_constant :CONSTANT_CLASSES
140
+
141
+ def constant_class_name(value)
142
+ CONSTANT_CLASSES.each { |klass, name| return name if value.is_a?(klass) }
143
+ nil
144
+ end
145
+
146
+ def definition_available?(loader, receiver_type, class_name)
147
+ if receiver_type.is_a?(Type::Singleton)
148
+ !loader.singleton_definition(class_name).nil?
149
+ else
150
+ !loader.instance_definition(class_name).nil?
151
+ end
152
+ rescue StandardError
153
+ false
154
+ end
155
+
156
+ def lookup_method(loader, receiver_type, class_name, method_name)
157
+ if receiver_type.is_a?(Type::Singleton)
158
+ loader.singleton_method(class_name: class_name, method_name: method_name)
159
+ else
160
+ loader.instance_method(class_name: class_name, method_name: method_name)
161
+ end
162
+ rescue StandardError
163
+ # The loader is best-effort and may raise on malformed
164
+ # RBS. Treat any failure as "method exists" so we do
165
+ # NOT emit a false positive when our knowledge of the
166
+ # receiver class is structurally incomplete.
167
+ true
168
+ end
169
+
170
+ # Slice 7 phase 11 — wrong-arity diagnostic. Fires when
171
+ # an explicit-receiver `Prism::CallNode` resolves to a
172
+ # method whose declared overloads do not admit the
173
+ # supplied positional argument count. The rule applies
174
+ # ONLY to the simplest overload shape (single overload,
175
+ # no `rest_positionals`, no keyword parameters, no
176
+ # block-required positionals); calls with `*splat`
177
+ # arguments, keyword arguments, or block-pass arguments
178
+ # are silently skipped to avoid false positives. The
179
+ # check piggybacks on the same scope-index lookup used
180
+ # by `undefined_method_diagnostic`; it returns nil
181
+ # when the call's receiver / RBS coverage / call shape
182
+ # disqualifies the rule.
183
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
184
+ def wrong_arity_diagnostic(path, call_node, scope_index)
185
+ return nil if call_node.receiver.nil?
186
+ return nil unless plain_positional_call?(call_node)
187
+
188
+ scope = scope_index[call_node]
189
+ return nil if scope.nil?
190
+
191
+ receiver_type = scope.type_of(call_node.receiver)
192
+ class_name = concrete_class_name(receiver_type)
193
+ return nil if class_name.nil?
194
+
195
+ kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
196
+ return nil if scope.discovered_method?(class_name, call_node.name, kind)
197
+
198
+ loader = scope.environment.rbs_loader
199
+ return nil if loader.nil?
200
+ return nil unless loader.class_known?(class_name)
201
+ return nil unless definition_available?(loader, receiver_type, class_name)
202
+
203
+ method_def = lookup_method(loader, receiver_type, class_name, call_node.name)
204
+ return nil if method_def.nil? || method_def == true
205
+
206
+ arity_envelope = compute_arity_envelope(method_def)
207
+ return nil if arity_envelope.nil?
208
+
209
+ actual = (call_node.arguments&.arguments || []).size
210
+ min, max = arity_envelope
211
+ return nil if actual.between?(min, max)
212
+
213
+ build_arity_diagnostic(path, call_node, class_name, min, max, actual)
214
+ end
215
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
216
+
217
+ def plain_positional_call?(call_node)
218
+ arguments = call_node.arguments
219
+ return true if arguments.nil?
220
+
221
+ arguments.arguments.all? { |arg| simple_positional?(arg) }
222
+ end
223
+
224
+ def simple_positional?(arg)
225
+ return false if arg.is_a?(Prism::SplatNode)
226
+ return false if arg.is_a?(Prism::KeywordHashNode)
227
+ return false if arg.is_a?(Prism::BlockArgumentNode)
228
+ return false if arg.is_a?(Prism::ForwardingArgumentsNode)
229
+
230
+ true
231
+ end
232
+
233
+ # Returns `[min, max]` positional-argument arity for the
234
+ # method (across all overloads), or nil when the rule
235
+ # does not apply. We disqualify only when the method
236
+ # uses required keyword arguments (which the caller MUST
237
+ # pass at the call site, and our plain-positional check
238
+ # would not have caught) or trailing positionals (rare,
239
+ # complex). `optional_keywords` and `rest_keywords` do
240
+ # NOT affect positional arity. `rest_positionals` raises
241
+ # `max` to `Float::INFINITY`.
242
+ def compute_arity_envelope(method_def)
243
+ mins = []
244
+ maxes = []
245
+ method_def.method_types.each do |mt|
246
+ function = mt.type
247
+ return nil unless arity_eligible?(function)
248
+
249
+ min_arity = function.required_positionals.size
250
+ max_arity =
251
+ if function.rest_positionals
252
+ Float::INFINITY
253
+ else
254
+ min_arity + function.optional_positionals.size
255
+ end
256
+ mins << min_arity
257
+ maxes << max_arity
258
+ end
259
+ return nil if mins.empty?
260
+
261
+ [mins.min, maxes.max]
262
+ end
263
+
264
+ def arity_eligible?(function)
265
+ function.required_keywords.empty? && function.trailing_positionals.empty?
266
+ end
267
+
268
+ # Slice 7 phase 14 — nil-receiver diagnostic. Fires when
269
+ # the receiver type is a `Type::Union` containing a
270
+ # nil-bearing member (`Constant[nil]` or
271
+ # `Nominal[NilClass]`) AND the called method does not
272
+ # exist on `NilClass`. This is the canonical "you forgot
273
+ # to nil-check before calling X" signal: the engine has
274
+ # proved that on at least one execution path the receiver
275
+ # is nil, and the call would raise NoMethodError.
276
+ #
277
+ # The rule deliberately ignores receivers that are
278
+ # exactly `Constant[nil]` / `Nominal[NilClass]` (those
279
+ # are already covered by `undefined_method_diagnostic`)
280
+ # and union receivers where every member already
281
+ # disqualifies the call (avoid duplicating the
282
+ # undefined-method diagnostic).
283
+ def nil_receiver_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
284
+ return nil if call_node.receiver.nil?
285
+ # Safe-navigation calls (`recv&.method`) already
286
+ # short-circuit on nil at runtime, so a nil-bearing
287
+ # receiver is not a bug for them.
288
+ return nil if call_node.safe_navigation?
289
+ # Restrict to direct local-variable reads. Local
290
+ # narrowing (Slice 6 phase 1) is the only narrowing
291
+ # surface that can prove a guard like
292
+ # `return if x.nil?` removed nil from the union, so
293
+ # firing on chained / method-call receivers would
294
+ # produce false positives we cannot suppress.
295
+ return nil unless call_node.receiver.is_a?(Prism::LocalVariableReadNode)
296
+
297
+ scope = scope_index[call_node]
298
+ return nil if scope.nil?
299
+
300
+ receiver_type = scope.type_of(call_node.receiver)
301
+ return nil unless receiver_type.is_a?(Type::Union)
302
+
303
+ loader = scope.environment.rbs_loader
304
+ return nil if loader.nil?
305
+
306
+ return nil unless union_contains_nil?(receiver_type)
307
+ return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, loader, scope)
308
+ return nil if nil_class_has_method?(call_node.name, loader)
309
+
310
+ build_nil_receiver_diagnostic(path, call_node)
311
+ end
312
+
313
+ def union_contains_nil?(union)
314
+ union.members.any? { |member| nil_member?(member) }
315
+ end
316
+
317
+ def nil_member?(member)
318
+ (member.is_a?(Type::Constant) && member.value.nil?) ||
319
+ (member.is_a?(Type::Nominal) && member.class_name == "NilClass")
320
+ end
321
+
322
+ # The non-nil members must collectively support the
323
+ # method (i.e. for every non-nil member, the method
324
+ # exists on its class via RBS or in-source discovery).
325
+ # Without this guard, the rule would also fire on calls
326
+ # that are unsound on the non-nil branch — that is the
327
+ # `undefined_method_diagnostic` rule's job, and we want
328
+ # exactly one diagnostic per offending call site.
329
+ def union_method_present_on_non_nil?(union, method_name, loader, scope)
330
+ non_nil_members = union.members.reject { |m| nil_member?(m) }
331
+ return false if non_nil_members.empty?
332
+
333
+ non_nil_members.all? { |m| method_present_anywhere?(m, method_name, loader, scope) }
334
+ end
335
+
336
+ def method_present_anywhere?(member, method_name, loader, scope)
337
+ class_name = concrete_class_name(member)
338
+ return true if class_name.nil? # Dynamic / Top / Bot — be permissive.
339
+ return true if scope.discovered_method?(class_name, method_name, :instance)
340
+ return true unless loader.class_known?(class_name)
341
+ return true unless definition_available?(loader, member, class_name)
342
+
343
+ !lookup_method(loader, member, class_name, method_name).nil?
344
+ end
345
+
346
+ def nil_class_has_method?(method_name, loader)
347
+ return false unless loader.class_known?("NilClass")
348
+
349
+ definition = loader.instance_definition("NilClass")
350
+ return false if definition.nil?
351
+
352
+ !definition.methods[method_name.to_sym].nil?
353
+ end
354
+
355
+ # Slice 7 phase 19 — PHPStan-style `dump_type(value)`.
356
+ # When the engine recognises a call to `dump_type` (with
357
+ # any of the supported receiver shapes — implicit self
358
+ # after `include Rigor::Testing`, `Rigor::Testing.dump_type`,
359
+ # or `Rigor.dump_type`), it emits an `:info` diagnostic
360
+ # showing the inferred type of the argument expression.
361
+ # The diagnostic does NOT count toward `Result#error_count`
362
+ # so a fixture peppered with `dump_type` calls still
363
+ # passes `rigor check`.
364
+ def dump_type_diagnostic(path, call_node, scope_index)
365
+ return nil unless rigor_testing_call?(call_node, :dump_type)
366
+ return nil if call_node.arguments.nil? || call_node.arguments.arguments.empty?
367
+
368
+ arg = call_node.arguments.arguments.first
369
+ scope = scope_index[arg] || scope_index[call_node]
370
+ return nil if scope.nil?
371
+
372
+ type = scope.type_of(arg)
373
+ location = call_node.message_loc || call_node.location
374
+ Diagnostic.new(
375
+ path: path,
376
+ line: location.start_line,
377
+ column: location.start_column + 1,
378
+ message: "dump_type: #{type.describe(:short)}",
379
+ severity: :info
380
+ )
381
+ end
382
+
383
+ # Slice 7 phase 19 — PHPStan-style `assert_type("...", value)`.
384
+ # The first argument MUST be a string literal containing
385
+ # the expected `Type#describe(:short)` rendering. When
386
+ # the inferred type's short description does not equal
387
+ # the expected literal, an `:error`-severity diagnostic
388
+ # is emitted; matching calls produce no output. This
389
+ # lets a fixture document its expected types inline:
390
+ # subsequent `rigor check` runs flag any drift.
391
+ def assert_type_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity
392
+ return nil unless rigor_testing_call?(call_node, :assert_type)
393
+ return nil if call_node.arguments.nil? || call_node.arguments.arguments.size < 2
394
+
395
+ expected_node = call_node.arguments.arguments.first
396
+ return nil unless expected_node.is_a?(Prism::StringNode)
397
+
398
+ value_node = call_node.arguments.arguments[1]
399
+ scope = scope_index[value_node] || scope_index[call_node]
400
+ return nil if scope.nil?
401
+
402
+ actual = scope.type_of(value_node).describe(:short)
403
+ expected = expected_node.unescaped.to_s
404
+ return nil if actual == expected
405
+
406
+ build_assert_type_diagnostic(path, call_node, expected, actual)
407
+ end
408
+
409
+ # Recognises any of:
410
+ # `dump_type(x)` (implicit self after `include Rigor::Testing`)
411
+ # `Testing.dump_type(x)`
412
+ # `Rigor.dump_type(x)`
413
+ # `Rigor::Testing.dump_type(x)`
414
+ # The receiver check is purely structural — we do not
415
+ # consult RBS — because the helpers are no-op stubs the
416
+ # user MAY shadow with their own definition; a name
417
+ # clash is the deliberate trade-off for ergonomic
418
+ # invocation.
419
+ RIGOR_TESTING_RECEIVERS = ["Rigor", "Rigor::Testing", "Testing"].freeze
420
+ private_constant :RIGOR_TESTING_RECEIVERS
421
+
422
+ def rigor_testing_call?(call_node, method_name)
423
+ return false unless call_node.name == method_name
424
+
425
+ receiver = call_node.receiver
426
+ return true if receiver.nil?
427
+
428
+ name = constant_name_of(receiver)
429
+ return false if name.nil?
430
+
431
+ RIGOR_TESTING_RECEIVERS.include?(name)
432
+ end
433
+
434
+ def constant_name_of(node)
435
+ case node
436
+ when Prism::ConstantReadNode then node.name.to_s
437
+ when Prism::ConstantPathNode then render_constant_path(node)
438
+ end
439
+ end
440
+
441
+ def render_constant_path(node)
442
+ parent = node.parent
443
+ base = constant_name_of(parent)
444
+ return nil if parent && base.nil?
445
+
446
+ parent ? "#{base}::#{node.name}" : node.name.to_s
447
+ end
448
+
449
+ def build_assert_type_diagnostic(path, call_node, expected, actual)
450
+ location = call_node.message_loc || call_node.location
451
+ Diagnostic.new(
452
+ path: path,
453
+ line: location.start_line,
454
+ column: location.start_column + 1,
455
+ message: "assert_type mismatch: expected #{expected.inspect}, got #{actual.inspect}",
456
+ severity: :error
457
+ )
458
+ end
459
+
460
+ def build_nil_receiver_diagnostic(path, call_node)
461
+ location = call_node.message_loc || call_node.location
462
+ Diagnostic.new(
463
+ path: path,
464
+ line: location.start_line,
465
+ column: location.start_column + 1,
466
+ message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
467
+ severity: :error
468
+ )
469
+ end
470
+
471
+ # rubocop:disable Metrics/ParameterLists
472
+ def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
473
+ location = call_node.message_loc || call_node.location
474
+ range = min == max ? min.to_s : "#{min}..#{max}"
475
+ method_label = "`#{call_node.name}' on #{class_name}"
476
+ message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
477
+ Diagnostic.new(
478
+ path: path,
479
+ line: location.start_line,
480
+ column: location.start_column + 1,
481
+ message: message,
482
+ severity: :error
483
+ )
484
+ end
485
+ # rubocop:enable Metrics/ParameterLists
486
+
487
+ def build_undefined_method_diagnostic(path, call_node, receiver_type)
488
+ location = call_node.message_loc || call_node.location
489
+ rendered_receiver = receiver_type.describe
490
+ Diagnostic.new(
491
+ path: path,
492
+ line: location.start_line,
493
+ column: location.start_column + 1,
494
+ message: "undefined method `#{call_node.name}' for #{rendered_receiver}",
495
+ severity: :error
496
+ )
497
+ end
498
+ end
499
+ # rubocop:enable Metrics/ClassLength
500
+ end
501
+ # rubocop:enable Metrics/ModuleLength
502
+ end
503
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ class Diagnostic
6
+ attr_reader :path, :line, :column, :message, :severity
7
+
8
+ def initialize(path:, line:, column:, message:, severity: :error)
9
+ @path = path
10
+ @line = line
11
+ @column = column
12
+ @message = message
13
+ @severity = severity
14
+ end
15
+
16
+ def error?
17
+ severity == :error
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ "path" => path,
23
+ "line" => line,
24
+ "column" => column,
25
+ "severity" => severity.to_s,
26
+ "message" => message
27
+ }
28
+ end
29
+
30
+ def to_s
31
+ "#{path}:#{line}:#{column}: #{severity}: #{message}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # Immutable storage for flow-sensitive facts attached to a Scope snapshot.
6
+ #
7
+ # The first implementation keeps the bucket model deliberately small:
8
+ # callers can record local-binding and relational facts, invalidate all
9
+ # facts that mention a target, and conservatively join two stores by
10
+ # retaining only facts that both incoming edges share.
11
+ class FactStore
12
+ BUCKETS = %i[
13
+ local_binding
14
+ captured_local
15
+ object_content
16
+ global_storage
17
+ dynamic_origin
18
+ relational
19
+ ].freeze
20
+
21
+ Target = Data.define(:kind, :name) do
22
+ def self.local(name)
23
+ new(kind: :local, name: name.to_sym)
24
+ end
25
+
26
+ def initialize(kind:, name:)
27
+ super(kind: kind.to_sym, name: name)
28
+ end
29
+ end
30
+
31
+ Fact = Data.define(:bucket, :target, :predicate, :payload, :polarity, :stability) do
32
+ def initialize(bucket:, target:, predicate:, payload: nil, polarity: :positive, stability: :local_binding)
33
+ bucket = bucket.to_sym
34
+ raise ArgumentError, "unknown fact bucket #{bucket.inspect}" unless BUCKETS.include?(bucket)
35
+
36
+ super(
37
+ bucket: bucket,
38
+ target: target,
39
+ predicate: predicate.to_sym,
40
+ payload: payload,
41
+ polarity: polarity.to_sym,
42
+ stability: stability.to_sym
43
+ )
44
+ end
45
+ end
46
+
47
+ attr_reader :facts
48
+
49
+ class << self
50
+ def empty
51
+ @empty ||= new
52
+ end
53
+ end
54
+
55
+ def initialize(facts: [])
56
+ @facts = normalize(facts)
57
+ freeze
58
+ end
59
+
60
+ def empty?
61
+ facts.empty?
62
+ end
63
+
64
+ def with_fact(fact)
65
+ self.class.new(facts: facts + [fact])
66
+ end
67
+
68
+ def with_local_fact(name, predicate:, payload: nil, bucket: :local_binding, polarity: :positive)
69
+ with_fact(
70
+ Fact.new(
71
+ bucket: bucket,
72
+ target: Target.local(name),
73
+ predicate: predicate,
74
+ payload: payload,
75
+ polarity: polarity,
76
+ stability: :local_binding
77
+ )
78
+ )
79
+ end
80
+
81
+ def facts_for(target: nil, bucket: nil)
82
+ selected_bucket = bucket&.to_sym
83
+ facts.select do |fact|
84
+ (target.nil? || fact_targets(fact).include?(target)) &&
85
+ (selected_bucket.nil? || fact.bucket == selected_bucket)
86
+ end
87
+ end
88
+
89
+ def invalidate_target(target, buckets: nil)
90
+ selected = buckets&.map(&:to_sym)
91
+ kept = facts.reject do |fact|
92
+ fact_targets(fact).include?(target) && (selected.nil? || selected.include?(fact.bucket))
93
+ end
94
+ return self if kept == facts
95
+
96
+ self.class.new(facts: kept)
97
+ end
98
+
99
+ def join(other)
100
+ unless other.is_a?(FactStore)
101
+ raise ArgumentError, "join requires a Rigor::Analysis::FactStore, got #{other.class}"
102
+ end
103
+
104
+ self.class.new(facts: facts.select { |fact| other.facts.include?(fact) })
105
+ end
106
+
107
+ def ==(other)
108
+ other.is_a?(FactStore) && facts == other.facts
109
+ end
110
+ alias eql? ==
111
+
112
+ def hash
113
+ [FactStore, facts].hash
114
+ end
115
+
116
+ private
117
+
118
+ def normalize(raw_facts)
119
+ unique = []
120
+ raw_facts.each do |fact|
121
+ raise ArgumentError, "expected Rigor::Analysis::FactStore::Fact, got #{fact.class}" unless fact.is_a?(Fact)
122
+
123
+ unique << fact unless unique.include?(fact)
124
+ end
125
+ unique.freeze
126
+ end
127
+
128
+ def fact_targets(fact)
129
+ Array(fact.target)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ class Result
6
+ attr_reader :diagnostics
7
+
8
+ def initialize(diagnostics: [])
9
+ @diagnostics = diagnostics
10
+ end
11
+
12
+ def success?
13
+ diagnostics.none?(&:error?)
14
+ end
15
+
16
+ def error_count
17
+ diagnostics.count(&:error?)
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ "success" => success?,
23
+ "error_count" => error_count,
24
+ "diagnostics" => diagnostics.map(&:to_h)
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end