rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -57,7 +57,8 @@ module Rigor
57
57
  # a parameter-only inline `# @rbs` annotation) is the ABSENCE of a return statement, not the author's
58
58
  # word about it — the opposite of `void`. It is treated like no declaration at all: the inferred return
59
59
  # is proposed as `tighter-return`, carrying `untyped` as the declared spelling so `--diff` still shows a
60
- # declaration existed (#995; see {#declared_untyped?}).
60
+ # declaration existed (#995; see {#declared_untyped?}). A value literal the body proves (Integer, String,
61
+ # Symbol) is widened to its class there unless the `untyped` is only the inline reader's synthesis (#1489).
61
62
  # - A `tighter-return` over the class's own declaration replaces the return and nothing else: the rest of
62
63
  # the line is the declaration's, spelled as written, so `--overwrite` never changes which calls the
63
64
  # signature accepts. A declaration with more than one overload, or one spelled as an `alias`, is left
@@ -1416,14 +1417,53 @@ module Rigor
1416
1417
  declared.is_a?(Type::Dynamic)
1417
1418
  end
1418
1419
 
1419
- # Proposed without {#compare_against_declared}'s tightening guards: a declared `untyped` return carries no
1420
- # information to weigh {#tighter?} or {#literal_decline?} against, the same position a method with no
1421
- # declaration at all is in. The line is built as for any tightening ({#tighter_return_line}), and
1422
- # `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag says one existed.
1420
+ # Proposed without {#tighter?}: a declared `untyped` return carries no information to weigh it against, the
1421
+ # same position a method with no declaration at all is in. Over a declaration the author wrote, though, a
1422
+ # VALUE literal the body proves (`-> 1`, `-> "x"`, `-> :k`) is an implementation detail the `untyped` was
1423
+ # abstracting over, so it is widened to its class (`1 | 2` to `Integer`, `"S" | nil` to `(String | nil)`; #1489).
1424
+ # `nil`, `true` and `false` are honest types and stay, as does a computed `Float`. An `untyped` that is only
1425
+ # the inline reader's synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the
1426
+ # strictest carrier. `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag
1427
+ # says a declaration existed.
1423
1428
  def declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: nil)
1429
+ unless synthesized_declaration?(method_def)
1430
+ widened = widen_value_literals(inferred)
1431
+ unless widened.equal?(inferred)
1432
+ # A `rendered` line was spelled from the unwidened type; there is nothing to rebuild it from.
1433
+ return equivalent(path, def_node, class_name, kind, inferred, "untyped") if rendered
1434
+
1435
+ inferred = widened
1436
+ end
1437
+ end
1438
+
1424
1439
  tighter_return_candidate(path, def_node, class_name, kind, inferred, method_def, "untyped", rendered)
1425
1440
  end
1426
1441
 
1442
+ VALUE_LITERAL_CLASSES = [Integer, String, Symbol].freeze
1443
+ private_constant :VALUE_LITERAL_CLASSES
1444
+
1445
+ # The same object back when nothing needed widening.
1446
+ def widen_value_literals(type)
1447
+ case type
1448
+ when Type::Constant
1449
+ return type unless VALUE_LITERAL_CLASSES.any? { |klass| type.value.instance_of?(klass) }
1450
+
1451
+ Type::Combinator.nominal_of(type.value.class.name)
1452
+ when Type::Union
1453
+ members = type.members.map { |member| widen_value_literals(member) }
1454
+ members.each_with_index.all? { |m, i| m.equal?(type.members[i]) } ? type : Type::Combinator.union(*members)
1455
+ else
1456
+ type
1457
+ end
1458
+ end
1459
+
1460
+ # A declaration only the inline reader's synthesis (a `virtual:` buffer) supplies — ADR-93 gives every `def` an
1461
+ # `untyped` member, annotated or not — is no word of the author's about the return, so it keeps the proposal
1462
+ # a method with no declaration at all gets: the strictest carrier the body proves.
1463
+ def synthesized_declaration?(method_def)
1464
+ method_def.defs.all? { |type_def| type_def.member.location&.buffer&.name.to_s.start_with?("virtual:") }
1465
+ end
1466
+
1427
1467
  def build_declared_return(method_def)
1428
1468
  translated = method_def.method_types.filter_map { |mt| translate_method_type_return(mt) }
1429
1469
  return nil if translated.empty?
@@ -20,7 +20,26 @@ module Rigor
20
20
  # First-found wins on duplicate declarations across files; RBS itself allows the same class to be declared
21
21
  # in multiple files for additive member contributions, but the writer only needs one canonical target per
22
22
  # class.
23
+ #
24
+ # ADR-122 WD2 adds the member level: `(class_name, method_name, kind) → sig_file_path`, so a member the project
25
+ # already declares in one file is updated there rather than inserted a second time into the class's
26
+ # first-found file (#1488).
23
27
  class LayoutIndex
28
+ # The `[method_name, kind]` keys a member declaration answers for, `kind` being `:instance` or
29
+ # `:singleton`. A `def self?.f` answers for both kinds; an `attr_accessor` answers for its reader and its
30
+ # writer. Shared with {Writer}, which asks the same question of the target file it is merging into.
31
+ def self.member_keys(member)
32
+ case member
33
+ when RBS::AST::Members::MethodDefinition
34
+ kinds = member.kind == :singleton_instance ? %i[instance singleton] : [member.kind]
35
+ kinds.map { |kind| [member.name, kind] }
36
+ when RBS::AST::Members::AttrReader then [[member.name, member.kind]]
37
+ when RBS::AST::Members::AttrWriter then [[:"#{member.name}=", member.kind]]
38
+ when RBS::AST::Members::AttrAccessor then [[member.name, member.kind], [:"#{member.name}=", member.kind]]
39
+ else []
40
+ end
41
+ end
42
+
24
43
  # @param signature_paths —
25
44
  # the `.rigor.yml`-configured signature directories.
26
45
  # When `nil` or empty, falls back to `<project_root>/sig`
@@ -29,6 +48,7 @@ module Rigor
29
48
  def initialize(signature_paths:, project_root: Dir.pwd)
30
49
  @signature_paths = resolve_paths(signature_paths, project_root)
31
50
  @index = nil
51
+ @members = nil
32
52
  end
33
53
 
34
54
  # @param class_name — fully-qualified Ruby class
@@ -40,6 +60,13 @@ module Rigor
40
60
  index[class_name]
41
61
  end
42
62
 
63
+ # @return absolute path of the first sig file (in the same order as {#file_for}) that declares
64
+ # `method_name` of `kind` on `class_name`, or `nil` when no file does.
65
+ def member_file_for(class_name, method_name, kind)
66
+ index
67
+ @members[[class_name, method_name, kind]]
68
+ end
69
+
43
70
  def empty?
44
71
  index.empty?
45
72
  end
@@ -60,6 +87,7 @@ module Rigor
60
87
 
61
88
  def build_index
62
89
  accumulator = {}
90
+ @members = {}
63
91
  Array(@signature_paths).each do |dir|
64
92
  base = Pathname(dir)
65
93
  next unless base.directory?
@@ -68,6 +96,7 @@ module Rigor
68
96
  index_file(Pathname(rbs_file), accumulator)
69
97
  end
70
98
  end
99
+ @members.freeze
71
100
  accumulator.freeze
72
101
  end
73
102
 
@@ -97,6 +126,13 @@ module Rigor
97
126
  local_name = decl.name.to_s.sub(/\A::/, "")
98
127
  full = prefix.empty? ? local_name : "#{prefix.join('::')}::#{local_name}"
99
128
  accumulator[full] ||= rbs_path
129
+ decl.members.each do |member|
130
+ # An `overloading?` member (`def f: ... | ...`) adds to a declaration elsewhere; routing a candidate to
131
+ # it would replace the user's overload.
132
+ next if member.is_a?(RBS::AST::Members::MethodDefinition) && member.overloading?
133
+
134
+ self.class.member_keys(member).each { |name, kind| @members[[full, name, kind]] ||= rbs_path }
135
+ end
100
136
 
101
137
  record_decls(decl.members, prefix + [local_name], rbs_path, accumulator)
102
138
  end
@@ -68,6 +68,14 @@ module Rigor
68
68
  @declared_annotations + @annotations + [@rbs].compact
69
69
  end
70
70
 
71
+ # The RBS member kind `rbs` declares: `:singleton_instance` for a `def self?.` line (a `module_function`
72
+ # method, whose `kind` stays `:instance`), `kind` otherwise. The writer replaces only a member of this
73
+ # kind, so an overwrite neither drops a `self?.` member's singleton half nor adds one beside a split
74
+ # `def self.f` / `def f` pair (#1488).
75
+ def rbs_kind
76
+ kind == :instance && rbs.to_s.start_with?("def self?.") ? :singleton_instance : kind
77
+ end
78
+
71
79
  # A copy carrying a different annotation decision. The generator's effect pass rebuilds rather than
72
80
  # mutates, because a candidate is frozen the moment it is built.
73
81
  def with_effect_annotation(annotations, reason)
@@ -37,8 +37,11 @@ module Rigor
37
37
  # returned instead of the 1:1 mirror.
38
38
  # @return absolute path of the target `.rbs`
39
39
  # file for the candidate.
40
- def target_for(source_path, class_name: nil)
41
- existing = existing_target_for(class_name)
40
+ #
41
+ # A member the signature tree already declares (`method_name` / `kind`, ADR-122 WD2) is routed to the file
42
+ # that declares it, even when another file declares its class first (#1488).
43
+ def target_for(source_path, class_name: nil, method_name: nil, kind: nil)
44
+ existing = existing_member_target_for(class_name, method_name, kind) || existing_target_for(class_name)
42
45
  return existing if existing
43
46
 
44
47
  rel_to_root = source_relative_to_root(source_path)
@@ -46,6 +49,19 @@ module Rigor
46
49
  sig_root_dir / "#{stripped.sub_ext('')}.rbs"
47
50
  end
48
51
 
52
+ # `kind` may be `:singleton_instance` (a `def self?.` candidate), which routes to whichever file declares
53
+ # either half.
54
+ def existing_member_target_for(class_name, method_name, kind)
55
+ return nil if class_name.nil? || method_name.nil? || @layout_index.nil?
56
+
57
+ kinds = kind == :singleton_instance ? %i[instance singleton] : [kind]
58
+ kinds.each do |k|
59
+ file = @layout_index.member_file_for(class_name, method_name, k)
60
+ return file if file
61
+ end
62
+ nil
63
+ end
64
+
49
65
  def existing_target_for(class_name)
50
66
  return nil if class_name.nil? || @layout_index.nil?
51
67
 
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "rbs"
5
5
 
6
6
  require_relative "classification"
7
+ require_relative "layout_index"
7
8
  require_relative "superclass_spelling"
8
9
  require_relative "write_result"
9
10
 
@@ -62,8 +63,10 @@ module Rigor
62
63
 
63
64
  @global_namespace_kinds = build_namespace_kinds(candidates)
64
65
  @global_superclasses = build_superclasses(candidates)
65
- emittable.group_by { |c| @path_mapper.target_for(c.path, class_name: c.class_name) }
66
- .map { |target, group| write_target(target, group) }
66
+ grouped = emittable.group_by do |c|
67
+ @path_mapper.target_for(c.path, class_name: c.class_name, method_name: c.method_name, kind: c.rbs_kind)
68
+ end
69
+ grouped.map { |target, group| write_target(target, group) }
67
70
  end
68
71
 
69
72
  # @param candidates — only
@@ -562,7 +565,10 @@ module Rigor
562
565
  state.applied.concat(methods)
563
566
  insert_namespace_chain(state, class_name, kinds, supers, methods)
564
567
  else
565
- state.source = merge_into_existing_class(state.source, decl, methods, state)
568
+ # A class reopened in one file answers for every member of every opening, not just the first one's
569
+ # (#1488): a member another opening declares is user-authored, not new.
570
+ declared = find_class_decls(state.decls, class_name).flat_map { |d| collect_member_pairs(d) }
571
+ state.source = merge_into_existing_class(state.source, decl, methods, state, declared)
566
572
  state.decls = parse_signature(state.source) || state.decls
567
573
  end
568
574
  end
@@ -589,12 +595,25 @@ module Rigor
589
595
  nil
590
596
  end
591
597
 
598
+ # Every declaration of `qualified_name` in the file, in {#find_class_decl}'s order (so the first is the one
599
+ # it returns).
600
+ def find_class_decls(decls, qualified_name, prefix = [], found = [])
601
+ decls.each do |decl|
602
+ next unless decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)
603
+
604
+ local = decl.name.to_s.sub(/\A::/, "")
605
+ full = prefix.empty? ? local : "#{prefix.join('::')}::#{local}"
606
+ found << decl if full == qualified_name
607
+ find_class_decls(decl.members, qualified_name, prefix + [local], found)
608
+ end
609
+ found
610
+ end
611
+
592
612
  def ends_with_newline?(source)
593
613
  source.end_with?("\n")
594
614
  end
595
615
 
596
- def merge_into_existing_class(source, decl, methods, state)
597
- existing_pairs = collect_member_pairs(decl)
616
+ def merge_into_existing_class(source, decl, methods, state, existing_pairs)
598
617
  new_methods, conflicting = partition_against_existing(methods, existing_pairs)
599
618
 
600
619
  source = insert_into_class(source, decl, new_methods)
@@ -617,31 +636,20 @@ module Rigor
617
636
  end
618
637
 
619
638
  # Returns a list of `[method_name (Symbol), kind (Symbol)]` pairs for every method-like member in the
620
- # declaration. ADR-14 slice 4 recognises `MethodDefinition`'s `:instance` / `:singleton` kind plus the
621
- # three `attr_*` declaration kinds so a source-side `attr_reader :name` and an RBS-side
622
- # `attr_reader name: T` are treated as the same member (i.e. user-authored).
639
+ # declaration ({LayoutIndex.member_keys}). ADR-14 slice 4 recognises the three `attr_*` declaration kinds
640
+ # so a source-side `attr_reader :name` and an RBS-side `attr_reader name: T` are treated as the same member
641
+ # (i.e. user-authored); a `def self?.f` counts as both an instance and a singleton `f` (#1488).
623
642
  def collect_member_pairs(decl)
624
- pairs = []
625
- decl.members.each { |m| collect_pairs_for_member(m, pairs) }
626
- pairs
627
- end
628
-
629
- def collect_pairs_for_member(member, pairs)
630
- case member
631
- when RBS::AST::Members::MethodDefinition
632
- pairs << [member.name, member.kind]
633
- when RBS::AST::Members::AttrReader
634
- pairs << [member.name, :instance]
635
- when RBS::AST::Members::AttrWriter
636
- pairs << [:"#{member.name}=", :instance]
637
- when RBS::AST::Members::AttrAccessor
638
- pairs << [member.name, :instance]
639
- pairs << [:"#{member.name}=", :instance]
640
- end
643
+ decl.members.flat_map { |m| LayoutIndex.member_keys(m) }
641
644
  end
642
645
 
646
+ # A `def self?.f` candidate conflicts with a declared instance OR singleton `f`: inserting it beside a lone
647
+ # `def self.f` (or `def f`) would declare that half twice (#1488).
643
648
  def partition_against_existing(methods, existing_pairs)
644
- methods.partition { |c| !existing_pairs.include?([c.method_name, c.kind]) }
649
+ methods.partition do |c|
650
+ kinds = c.rbs_kind == :singleton_instance ? %i[instance singleton] : [c.kind]
651
+ kinds.none? { |kind| existing_pairs.include?([c.method_name, kind]) }
652
+ end
645
653
  end
646
654
 
647
655
  # Inserts each new method line one column before the class declaration's `end` keyword. The insertion
@@ -710,7 +718,7 @@ module Rigor
710
718
  # part of the contract. A candidate that keeps `void` is still eligible, which is what case 2 is for —
711
719
  # the `initialize` stub is always spelled `-> void`.
712
720
  def replaces_declared_void?(candidate, decl)
713
- member = find_method_member(decl, candidate.method_name, candidate.kind)
721
+ member = find_method_member(decl, candidate)
714
722
  return false if member.nil?
715
723
 
716
724
  declared_void = member.overloads.any? do |overload|
@@ -722,7 +730,7 @@ module Rigor
722
730
  # Compares the existing member's source-side RBS text against the candidate's proposed RBS text
723
731
  # ({.fewer_untyped?}).
724
732
  def tightens_untyped?(candidate, decl, source)
725
- member = find_method_member(decl, candidate.method_name, candidate.kind)
733
+ member = find_method_member(decl, candidate)
726
734
  return false if member.nil?
727
735
 
728
736
  self.class.fewer_untyped?(candidate.rbs, source[member.location.start_pos...member.location.end_pos])
@@ -737,7 +745,7 @@ module Rigor
737
745
  # the same method (ADR-112 WD4), which it replaces in the inline attribute's own spelling; an
738
746
  # `overloading?` member (`| ...`) is never its target.
739
747
  def replaceable_member(decl, candidate)
740
- member = find_method_member(decl, candidate.method_name, candidate.kind)
748
+ member = find_method_member(decl, candidate)
741
749
  return member unless candidate.classification == Classification::INLINE_OVERWRITE
742
750
 
743
751
  member = nil if member.respond_to?(:overloading?) && member.overloading?
@@ -746,15 +754,20 @@ module Rigor
746
754
 
747
755
  def find_attr_member(decl, method_name, kind)
748
756
  decl.members.find do |m|
749
- pairs = []
750
- collect_pairs_for_member(m, pairs) unless m.is_a?(RBS::AST::Members::MethodDefinition)
751
- m.respond_to?(:kind) && m.kind == kind && pairs.any? { |name, _| name == method_name }
757
+ next false if m.is_a?(RBS::AST::Members::MethodDefinition)
758
+
759
+ LayoutIndex.member_keys(m).include?([method_name, kind])
752
760
  end
753
761
  end
754
762
 
755
- def find_method_member(decl, method_name, kind)
763
+ # Matches on the candidate's {MethodCandidate#rbs_kind}, so a `def self?.f` proposal finds only a
764
+ # `def self?.f` member: a `def f` beside a `def self.f`, or a `def self?.f` under a plain `def f`
765
+ # proposal, is left as the user wrote it. An `overloading?` member (`def f: ... | ...`) adds to a
766
+ # declaration elsewhere rather than being one, so it is never replaced (#1488).
767
+ def find_method_member(decl, candidate)
756
768
  decl.members.find do |m|
757
- m.is_a?(RBS::AST::Members::MethodDefinition) && m.name == method_name && m.kind == kind
769
+ m.is_a?(RBS::AST::Members::MethodDefinition) && m.name == candidate.method_name &&
770
+ m.kind == candidate.rbs_kind && !m.overloading?
758
771
  end
759
772
  end
760
773
 
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.4.2"
4
+ VERSION = "0.4.4"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rigor/source/node_children"
4
+ require "rigor/source/symbol_name"
4
5
 
5
6
  require "prism"
6
7
 
@@ -81,11 +82,16 @@ module Rigor
81
82
  # fixed point rather than recursing keeps a cyclic `class A < B; class B < A` source — which Ruby
82
83
  # rejects but a static parse can still be handed — from recursing forever.
83
84
  def close_over_roots(candidates)
84
- by_name = candidates.to_h { |entry| [entry.class_name, entry] }
85
+ groups = candidates.group_by(&:class_name)
86
+ # Files that name different superclasses for one class are a TypeError in Ruby, so no ancestry
87
+ # reading of that class is correct. It is declined outright, and so is everything beneath it.
88
+ conflicted = groups.filter_map { |name, group| name if group.map(&:superclass_name).uniq.size > 1 }.to_set
89
+ by_name = groups.except(*conflicted).transform_values { |group| merge_reopened(group) }
85
90
  reached = @base_classes.to_set
86
91
  loop do
87
92
  grown = candidates.select do |entry|
88
- !reached.include?(entry.class_name) && reached.include?(entry.superclass_name)
93
+ !conflicted.include?(entry.class_name) && !reached.include?(entry.class_name) &&
94
+ reached.include?(entry.superclass_name)
89
95
  end
90
96
  break if grown.empty?
91
97
 
@@ -94,6 +100,21 @@ module Rigor
94
100
  reached.filter_map { |name| by_name[name] }
95
101
  end
96
102
 
103
+ # A class declared in several files that each name its superclass is one class to Ruby, so its names
104
+ # are the union of every file's. Keeping only the last file's entry let a sibling that declared nothing
105
+ # usable shadow the valid one. The `own_method_names` union assumes every reopening file is loaded; a
106
+ # file Zeitwerk shadows is not modelled. A superclass-less reopening is not a candidate (see visit_class).
107
+ def merge_reopened(entries)
108
+ return entries.first if entries.size == 1
109
+
110
+ first = entries.first
111
+ first.with(
112
+ declared_names: entries.flat_map(&:declared_names).uniq.sort.freeze,
113
+ object_reads: entries.flat_map(&:object_reads).uniq.sort.freeze,
114
+ own_method_names: entries.flat_map(&:own_method_names).uniq.sort.freeze
115
+ )
116
+ end
117
+
97
118
  def read_safely(path)
98
119
  @io_boundary.read_file(path)
99
120
  rescue Plugin::AccessDeniedError, Errno::ENOENT
@@ -180,9 +201,14 @@ module Rigor
180
201
  receiver.arguments.nil?
181
202
  end
182
203
 
204
+ # A name whose bytes no Symbol can hold (`attributes :"\xff"` in a file that does not parse cleanly)
205
+ # is dropped here, so it never reaches the index or the `project_defines?` lookup that would raise.
206
+ # The file's other names are kept: a recoverable syntax error elsewhere in it does not void them.
183
207
  def symbol_arguments(node)
184
208
  (node.arguments&.arguments || []).filter_map do |argument|
185
- argument.unescaped if argument.is_a?(Prism::SymbolNode)
209
+ next unless argument.is_a?(Prism::SymbolNode)
210
+
211
+ Rigor::Source::SymbolName.of(argument.unescaped)&.to_s
186
212
  end
187
213
  end
188
214
 
@@ -239,7 +239,11 @@ module Rigor
239
239
  def project_defines?(class_name, method_name, scope)
240
240
  return false unless scope.respond_to?(:user_def_through_ancestors)
241
241
 
242
- !scope.user_def_through_ancestors(class_name, method_name.to_sym).first.nil?
242
+ # A name no Symbol can hold cannot be a method the project defines (#1831): decline, do not raise.
243
+ method = Rigor::Source::SymbolName.of(method_name.to_s)
244
+ return false if method.nil?
245
+
246
+ !scope.user_def_through_ancestors(class_name, method).first.nil?
243
247
  end
244
248
 
245
249
  # `REST::AccountSerializer` offers two readings — the namespaced `REST::Account` and the
@@ -168,10 +168,14 @@ module Rigor
168
168
 
169
169
  # ADR-45 WD1b (#613 / #630) — the classification probes go through the boundary, so an entry that
170
170
  # is not there yet (or stops being a directory) is a recorded dependency of the scan's input set.
171
+ # #1803 — so is the walk: `IoBoundary#glob` records which `.rb` files the entry holds, and a file
172
+ # `exclude:` keeps out of analysis, which no analysed-file row covers, reaches the run-result caches
173
+ # when it appears or disappears. The matches are spelled under `entry`, as `Dir.glob` spelled them.
171
174
  def scannable_paths(services)
172
175
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
173
176
  if io_boundary.directory?(entry)
174
- Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
177
+ prefix = "#{File.absolute_path(entry)}/"
178
+ io_boundary.glob(entry, "**/*.rb").map { |path| File.join(entry, path.delete_prefix(prefix)) }
175
179
  elsif io_boundary.file?(entry) && entry.end_with?(".rb")
176
180
  [entry]
177
181
  else
@@ -142,10 +142,14 @@ module Rigor
142
142
 
143
143
  # ADR-45 WD1b (#613 / #630) — the classification probes go through the boundary, so an entry that
144
144
  # is not there yet (or stops being a directory) is a recorded dependency of the scan's input set.
145
+ # #1803 — so is the walk: `IoBoundary#glob` records which `.rb` files the entry holds, and a file
146
+ # `exclude:` keeps out of analysis, which no analysed-file row covers, reaches the run-result caches
147
+ # when it appears or disappears. The matches are spelled under `entry`, as `Dir.glob` spelled them.
145
148
  def scannable_paths(services)
146
149
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
147
150
  if io_boundary.directory?(entry)
148
- Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
151
+ prefix = "#{File.absolute_path(entry)}/"
152
+ io_boundary.glob(entry, "**/*.rb").map { |path| File.join(entry, path.delete_prefix(prefix)) }
149
153
  elsif io_boundary.file?(entry) && entry.end_with?(".rb")
150
154
  [entry]
151
155
  else
@@ -5,13 +5,20 @@ require "prism"
5
5
  module Rigor
6
6
  module Plugin
7
7
  class FFI < Base
8
+ # Issue #1803 — the walk lists and reads through the plugin's {Rigor::Plugin::IoBoundary} (`io:`), so every
9
+ # `.rb` the catalog was built from is a dependency row of the `:ffi_catalog` producer's cache entry and of
10
+ # every run-result cache built on the run. The producer declares no `watch:`, so before this its entry
11
+ # carried no row at all and was served after any edit, an analysed file's included, until `--no-cache`.
8
12
  class Discoverer
9
- def self.discover(root:)
10
- new(root: root).discover
13
+ RUBY_GLOB = "**/*.rb"
14
+
15
+ def self.discover(root:, io:)
16
+ new(root: root, io: io).discover
11
17
  end
12
18
 
13
- def initialize(root:)
19
+ def initialize(root:, io:)
14
20
  @root = root
21
+ @io = io
15
22
  end
16
23
 
17
24
  def discover
@@ -24,7 +31,7 @@ module Rigor
24
31
  callbacks = {}
25
32
 
26
33
  ruby_files.each do |file_path|
27
- parse_result = Prism.parse_file(file_path)
34
+ parse_result = parse(file_path)
28
35
  next unless parse_result.value
29
36
 
30
37
  walk_tree(parse_result.value, nil) do |node, mod_name|
@@ -61,9 +68,24 @@ module Rigor
61
68
  private
62
69
 
63
70
  def ruby_files
64
- return [] unless @root && File.directory?(@root)
71
+ return [] unless @root && @io.directory?(@root.to_s)
72
+
73
+ boundary_glob.reject { |p| p.include?("/vendor/") || p.include?("/.git/") }
74
+ end
75
+
76
+ # One `:names` `**/*.rb` row over the root, `vendor/` included: a per-entry walk that skipped it recorded
77
+ # names only, so a top-level entry that changed kind under the same name went unseen. The boundary
78
+ # answers absolute paths; they are spelled back under `@root` as `Dir.glob` spells them, so the filter
79
+ # above sees the same strings it always did.
80
+ def boundary_glob
81
+ root = @root.to_s
82
+ prefix = "#{File.absolute_path(root)}/"
83
+ @io.glob(root, RUBY_GLOB).map { |path| File.join(root, path.delete_prefix(prefix)) }
84
+ end
65
85
 
66
- Dir.glob(File.join(@root, "**", "*.rb")).reject { |p| p.include?("/vendor/") || p.include?("/.git/") }
86
+ # The glob's rows record which files match; this read's row carries an edit.
87
+ def parse(file_path)
88
+ Prism.parse(@io.read_file(file_path))
67
89
  end
68
90
 
69
91
  def walk_tree(node, current_module, &)
@@ -32,7 +32,7 @@ module Rigor
32
32
 
33
33
  producer :ffi_catalog do
34
34
  root = config["root"] || Dir.pwd
35
- Discoverer.discover(root: root)
35
+ Discoverer.discover(root: root, io: io_boundary)
36
36
  end
37
37
 
38
38
  # Dynamic return rule for attached FFI functions
@@ -151,10 +151,14 @@ module Rigor
151
151
 
152
152
  # ADR-45 WD1b (#613 / #630) — the classification probes go through the boundary, so an entry that
153
153
  # is not there yet (or stops being a directory) is a recorded dependency of the scan's input set.
154
+ # #1803 — so is the walk: `IoBoundary#glob` records which `.rb` files the entry holds, and a file
155
+ # `exclude:` keeps out of analysis, which no analysed-file row covers, reaches the run-result caches
156
+ # when it appears or disappears. The matches are spelled under `entry`, as `Dir.glob` spelled them.
154
157
  def scannable_paths(services)
155
158
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
156
159
  if io_boundary.directory?(entry)
157
- Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
160
+ prefix = "#{File.absolute_path(entry)}/"
161
+ io_boundary.glob(entry, "**/*.rb").map { |path| File.join(entry, path.delete_prefix(prefix)) }
158
162
  elsif io_boundary.file?(entry) && entry.end_with?(".rb")
159
163
  [entry]
160
164
  else