rigortype 0.4.1 → 0.4.3

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 (230) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +18 -5
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/02-cli-reference.md +19 -1
  27. data/docs/manual/03-configuration.md +7 -5
  28. data/docs/manual/04-diagnostics.md +2 -1
  29. data/docs/manual/05-inspecting-types.md +50 -0
  30. data/docs/manual/06-baseline.md +2 -0
  31. data/docs/manual/07-plugins.md +1 -1
  32. data/docs/manual/12-caching.md +18 -1
  33. data/docs/manual/14-rails-quickstart.md +2 -2
  34. data/docs/manual/18-removing-dead-code.md +41 -4
  35. data/docs/manual/plugins/README.md +6 -1
  36. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  37. data/docs/manual/plugins/rigor-alba.md +60 -0
  38. data/docs/manual/plugins/rigor-minitest.md +22 -4
  39. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  40. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  41. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  42. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  43. data/lib/rigor/analysis/check_rules/rule_ids.rb +11 -0
  44. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  45. data/lib/rigor/analysis/check_rules.rb +292 -59
  46. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  47. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  48. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  49. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  50. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  51. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  52. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  53. data/lib/rigor/analysis/incremental.rb +29 -9
  54. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  55. data/lib/rigor/analysis/incremental_run_slot.rb +6 -4
  56. data/lib/rigor/analysis/incremental_session.rb +103 -23
  57. data/lib/rigor/analysis/path_expansion.rb +62 -6
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  59. data/lib/rigor/analysis/reachability/graph.rb +241 -37
  60. data/lib/rigor/analysis/reachability/scan.rb +490 -77
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  64. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  65. data/lib/rigor/analysis/run_stats.rb +28 -6
  66. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +113 -6
  67. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -19
  68. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  70. data/lib/rigor/analysis/runner.rb +62 -13
  71. data/lib/rigor/analysis/worker_session.rb +8 -5
  72. data/lib/rigor/cache/engine_source.rb +21 -7
  73. data/lib/rigor/cache/incremental_snapshot.rb +55 -5
  74. data/lib/rigor/cli/annotate_command.rb +5 -0
  75. data/lib/rigor/cli/baseline_command.rb +7 -18
  76. data/lib/rigor/cli/check_command.rb +140 -40
  77. data/lib/rigor/cli/docs_command.rb +5 -4
  78. data/lib/rigor/cli/lens_command.rb +144 -0
  79. data/lib/rigor/cli/lsp_command.rb +5 -2
  80. data/lib/rigor/cli/skill_command.rb +3 -3
  81. data/lib/rigor/cli/skill_describe.rb +1 -1
  82. data/lib/rigor/cli/trace_command.rb +5 -0
  83. data/lib/rigor/cli/type_of_command.rb +6 -3
  84. data/lib/rigor/cli/unused_command.rb +166 -27
  85. data/lib/rigor/cli.rb +39 -5
  86. data/lib/rigor/configuration/severity_profile.rb +6 -0
  87. data/lib/rigor/configuration.rb +61 -2
  88. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  89. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  90. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  91. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  92. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  93. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  94. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  95. data/lib/rigor/environment/rbs_loader.rb +316 -14
  96. data/lib/rigor/environment/required_features.rb +208 -0
  97. data/lib/rigor/environment.rb +33 -8
  98. data/lib/rigor/inference/acceptance.rb +28 -3
  99. data/lib/rigor/inference/builtins/cruby_definers.rb +78 -0
  100. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  101. data/lib/rigor/inference/captured_locals.rb +43 -3
  102. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  103. data/lib/rigor/inference/expression_typer.rb +261 -29
  104. data/lib/rigor/inference/global_write_census.rb +7 -4
  105. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  106. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  107. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  108. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +286 -0
  109. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  110. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  111. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  112. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  113. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  114. data/lib/rigor/inference/match_rebinding.rb +1 -1
  115. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  116. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  117. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  118. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  119. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  120. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  121. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  122. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  123. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  124. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  125. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  126. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  127. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  128. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +247 -28
  129. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  130. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  131. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  132. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  133. data/lib/rigor/inference/method_dispatcher.rb +96 -14
  134. data/lib/rigor/inference/module_function_state.rb +2 -1
  135. data/lib/rigor/inference/narrowing.rb +20 -6
  136. data/lib/rigor/inference/object_mixins.rb +180 -0
  137. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  138. data/lib/rigor/inference/project_patched_methods.rb +12 -7
  139. data/lib/rigor/inference/project_patched_scanner.rb +108 -26
  140. data/lib/rigor/inference/refine_census.rb +239 -0
  141. data/lib/rigor/inference/refine_self.rb +317 -0
  142. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  143. data/lib/rigor/inference/refined_dispatch/own_method.rb +475 -0
  144. data/lib/rigor/inference/refined_dispatch.rb +320 -0
  145. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  146. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  147. data/lib/rigor/inference/scope_indexer.rb +827 -65
  148. data/lib/rigor/inference/statement_evaluator.rb +294 -51
  149. data/lib/rigor/inference/string_mutation.rb +9 -3
  150. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  151. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  152. data/lib/rigor/inference/version_guard.rb +19 -10
  153. data/lib/rigor/language_server/project_context.rb +87 -5
  154. data/lib/rigor/language_server/server.rb +18 -0
  155. data/lib/rigor/lens/anchor.rb +115 -0
  156. data/lib/rigor/lens/contract_reader.rb +118 -0
  157. data/lib/rigor/lens/document.rb +113 -0
  158. data/lib/rigor/lens/file_reader.rb +433 -0
  159. data/lib/rigor/lens/members.rb +153 -0
  160. data/lib/rigor/lens/parameters.rb +91 -0
  161. data/lib/rigor/lens/plugins.rb +40 -0
  162. data/lib/rigor/lens/run.rb +280 -0
  163. data/lib/rigor/lens/slot.rb +79 -0
  164. data/lib/rigor/lens.rb +40 -0
  165. data/lib/rigor/plugin/inflector.rb +1 -1
  166. data/lib/rigor/plugin/io_boundary.rb +42 -3
  167. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  168. data/lib/rigor/project_environment.rb +48 -6
  169. data/lib/rigor/protection/mutation_cache.rb +3 -2
  170. data/lib/rigor/protection/mutator.rb +1 -1
  171. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  172. data/lib/rigor/reflection/constant_path.rb +13 -2
  173. data/lib/rigor/reflection.rb +40 -8
  174. data/lib/rigor/scope/discovery_index.rb +40 -1
  175. data/lib/rigor/scope/resolution_chain.rb +159 -11
  176. data/lib/rigor/scope.rb +211 -21
  177. data/lib/rigor/sig_gen/classification.rb +4 -1
  178. data/lib/rigor/sig_gen/generator.rb +100 -10
  179. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  180. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  181. data/lib/rigor/source/alias_names.rb +52 -0
  182. data/lib/rigor/source/literals.rb +3 -2
  183. data/lib/rigor/source/symbol_name.rb +20 -0
  184. data/lib/rigor/type/combinator.rb +34 -2
  185. data/lib/rigor/type/float_range.rb +4 -1
  186. data/lib/rigor/type/nominal.rb +6 -1
  187. data/lib/rigor/version.rb +1 -1
  188. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  189. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  190. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  191. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  192. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  193. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  194. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  195. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  196. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  197. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  198. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  199. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  200. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  201. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  202. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  203. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  204. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  205. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  206. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  207. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  208. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  209. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  210. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  211. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  212. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  213. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  214. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  215. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  216. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  217. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  218. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  219. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  220. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  221. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  222. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  223. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  224. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  225. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  226. data/sig/rigor/scope.rbs +30 -4
  227. data/sig/rigor/source.rbs +4 -0
  228. data/sig/rigor.rbs +9 -4
  229. data/skills/rigor-project-init/references/02-configure.md +2 -2
  230. metadata +83 -1
@@ -49,9 +49,9 @@ module Rigor
49
49
 
50
50
  configuration = Configuration.load(options.fetch(:config))
51
51
  paths = @argv.empty? ? configuration.paths : @argv
52
- scan_cache = Analysis::Reachability::ScanCache.open(configuration.cache_path,
53
- target_ruby: configuration.target_ruby)
54
- declarations, references, dynamic_uses = scan(paths, configuration, scan_cache)
52
+ establish_root(configuration)
53
+ scan_cache = open_scan_cache(configuration)
54
+ declarations, references, dynamic_uses, shadows = scan(paths, configuration, scan_cache)
55
55
  references.concat(signature_references(configuration))
56
56
  dynamic_uses.concat(template_mentions(declarations, scan_cache))
57
57
 
@@ -59,7 +59,8 @@ module Rigor
59
59
  cache_store: cache_store(configuration))
60
60
  references.concat(plugin_references(contribution.references))
61
61
  graph = build_graph(configuration, options, contribution,
62
- declarations: declarations, references: references, dynamic_uses: dynamic_uses)
62
+ declarations: declarations, references: references, dynamic_uses: dynamic_uses,
63
+ shadows: shadows)
63
64
  emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
64
65
  scan_cache.save
65
66
  0
@@ -67,9 +68,50 @@ module Rigor
67
68
 
68
69
  private
69
70
 
70
- def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:)
71
+ # A scan's role and the spelling it records depend on the root and the working directory (#1760): a role
72
+ # reads every spelling below either ({#role_of}), and a relative path argument is recorded as written
73
+ # relative to the working directory.
74
+ def open_scan_cache(configuration)
75
+ cwd = canonical(Dir.pwd)
76
+ spelled = cwd == @root ? "" : ":#{cwd}"
77
+ Analysis::Reachability::ScanCache.open(configuration.cache_path, target_ruby: configuration.target_ruby,
78
+ root: "#{@root}#{spelled}")
79
+ end
80
+
81
+ # The root, and its spellings: the realpath and the logical directory it was named by.
82
+ def establish_root(configuration)
83
+ @root = project_root(configuration)
84
+ logical = @root == canonical(Dir.pwd) ? Dir.pwd : configuration.exclude_base
85
+ @root_spellings = [@root, File.absolute_path(logical)].uniq
86
+ end
87
+
88
+ # The project root (#1760), realpath'd: the config file's directory, unless the config sits below the
89
+ # working directory (`--config=ci/rigor.yml` from the project root), where the working directory is the
90
+ # project. Run from a subdirectory, or from a directory unrelated to the config, it is the config's.
91
+ # The root never rises above the config's directory or the working directory, so the role rules, which
92
+ # anchor `qa/`, `e2e/` and `features/` at the root, keep reading the project; an analysed path outside
93
+ # it reads as `../…`.
94
+ def project_root(configuration)
95
+ base = canonical(configuration.exclude_base)
96
+ cwd = canonical(Dir.pwd)
97
+ inside?(base, cwd) && base != cwd ? cwd : base
98
+ end
99
+
100
+ def inside?(path, dir)
101
+ path.start_with?("#{dir.chomp('/')}/")
102
+ end
103
+
104
+ # The directories the wider corpus is read from: the root, and the working directory when it lies
105
+ # outside the root. Reading the working directory too keeps the corpus no narrower than it was when
106
+ # the working directory alone was read; a reference only ever makes a declaration more reachable.
107
+ def corpus_dirs
108
+ cwd = canonical(Dir.pwd)
109
+ cwd == @root || inside?(cwd, @root) ? [@root] : [@root, cwd]
110
+ end
111
+
112
+ def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:, shadows:)
71
113
  Analysis::Reachability::Graph.new(
72
- declarations: declarations, references: references, dynamic_uses: dynamic_uses,
114
+ declarations: declarations, references: references, dynamic_uses: dynamic_uses, shadows: shadows,
73
115
  root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
74
116
  foreign: foreign_predicate(configuration)
75
117
  )
@@ -106,22 +148,56 @@ module Rigor
106
148
  end
107
149
 
108
150
  # Declarations come from the analysed paths; references additionally from the wider corpus (WD7).
151
+ # A wider-corpus file's own declarations come back as shadows: never nodes, only what that file's own
152
+ # references resolve to first (#1732).
109
153
  # Every file is consulted every run — an unchanged one contributes its cached scan, so the
110
154
  # whole-project completeness the `--incremental` refusal protects is unaffected.
155
+ #
156
+ # A file is parsed once however it is spelled (#1760), but every spelling of it keeps what it referred
157
+ # to under its own path and role, exactly as when each spelling was scanned separately: dropping a
158
+ # spelling lost its role, and with it a production reference a `spec/` symlink to the same file would
159
+ # otherwise have hidden. Only the declarations are taken once, from the first analysed spelling, which
160
+ # is the one the report prints; any other spelling's declarations are shadows, as a wider-corpus
161
+ # file's are (#1732).
111
162
  def scan(paths, configuration, cache)
112
- declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
113
- declarations = []
114
- references = []
115
- dynamic_uses = []
116
- (declaration_files + reference_files(paths, configuration)).sort.each do |file|
117
- result = cache.serve(:scan, file) { read_and_scan(file, configuration) }
163
+ out = { declarations: [], references: [], dynamic_uses: [], shadows: [] }
164
+ file_spellings(paths, configuration).sort.each do |real, list|
165
+ primary, analysed = list.find { |_, declared| declared } || list.first
166
+ result = cache.serve(:scan, primary) { read_and_scan(primary, real, configuration) }
118
167
  next if result.nil?
119
168
 
120
- declarations.concat(result.declarations) if declaration_files.include?(file)
121
- references.concat(result.references)
122
- dynamic_uses.concat(result.dynamic_uses)
169
+ out[analysed ? :declarations : :shadows].concat(result.declarations)
170
+ list.map(&:first).each { |file| add_spelling(out, result, file, real, primary) }
123
171
  end
124
- [declarations, references, dynamic_uses]
172
+ out.values_at(:declarations, :references, :dynamic_uses, :shadows)
173
+ end
174
+
175
+ def add_spelling(out, result, file, real, primary)
176
+ out[:shadows].concat(result.declarations.map { |d| d.with(path: file) }) unless file == primary
177
+ role = role_of(file, real)
178
+ out[:references].concat(result.references.map { |r| r.with(path: file, role: role) })
179
+ out[:dynamic_uses].concat(result.dynamic_uses.map { |u| respelled(u, file, role) })
180
+ end
181
+
182
+ # `{realpath => [[spelling, analysed?], ...]}`: the analysed spellings first, in `paths:` order, then
183
+ # the wider corpus's.
184
+ def file_spellings(paths, configuration)
185
+ analysed = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns,
186
+ base: configuration.exclude_base)
187
+ spellings = Hash.new { |hash, real| hash[real] = [] }
188
+ analysed.each { |file| spellings[canonical(file)] << [file, true] }
189
+ reference_files(paths, configuration).each { |file| spellings[canonical(file)] << [file, false] }
190
+ spellings = spellings.transform_values { |list| list.uniq(&:first) }
191
+ @analysed_spellings = spellings.transform_values do |list|
192
+ list.filter_map do |file, analysed|
193
+ file if analysed
194
+ end
195
+ end
196
+ spellings
197
+ end
198
+
199
+ def respelled(use, file, role)
200
+ use.with(path: file, role: role, site: use.site && "#{file}:#{use.line}")
125
201
  end
126
202
 
127
203
  # The reference corpus is the PROJECT, not the analysed paths. A constant declared in `lib/` is commonly
@@ -129,20 +205,80 @@ module Rigor
129
205
  # treating those as absent is what manufactured artifacts on two of three corpus targets in #345.
130
206
  # Widening the *declaration* set the same way would cancel the gain (measured: +1 / −3 / +2 candidates,
131
207
  # ADR-102 WD2), which is exactly why the two corpora are separated rather than both widened.
208
+ #
209
+ # The files are read from every {#corpus_dirs} entry (#1760): running from a subdirectory or through a
210
+ # symlinked spelling reads the same project.
132
211
  def reference_files(_paths, configuration)
133
- relative = Analysis::Reachability::ProjectFiles.own(Dir.glob(REFERENCE_GLOB, base: Dir.pwd), Dir.pwd)
134
- absolute = relative.map { |rel| File.absolute_path(rel) }
135
- Analysis::PathExpansion.reject_excluded(absolute, configuration.exclude_patterns).to_set
212
+ absolute = project_files(REFERENCE_GLOB)
213
+ Analysis::PathExpansion.reject_excluded(absolute, configuration.exclude_patterns,
214
+ base: configuration.exclude_base)
136
215
  end
137
216
 
138
- def read_and_scan(file, configuration)
217
+ # The role is read from the file's realpath relative to the project root (#1760), so `test/helper.rb`
218
+ # is test code whichever directory the command runs from. A file outside the root reads as `../…`,
219
+ # which names no directory above the root.
220
+ def read_and_scan(file, real, configuration)
139
221
  Analysis::Reachability::Scan.call(path: file, source: File.read(file),
140
- target_ruby: configuration.target_ruby)
222
+ target_ruby: configuration.target_ruby,
223
+ role: role_of(file, real))
141
224
  rescue SystemCallError
142
225
  nil
143
226
  end
144
227
 
145
- # A glob is matched against the declaration's path AND its path relative to the working directory:
228
+ # Absolute paths of the project's own files matching `glob`, under every corpus directory.
229
+ def project_files(glob)
230
+ corpus_dirs.flat_map do |dir|
231
+ Analysis::Reachability::ProjectFiles.own(Dir.glob(glob, base: dir), dir).map { |rel| File.join(dir, rel) }
232
+ end.uniq
233
+ end
234
+
235
+ def canonical(path)
236
+ File.realpath(path)
237
+ rescue SystemCallError
238
+ File.absolute_path(path)
239
+ end
240
+
241
+ # A file's role (#1760), read from its spellings as written below the project root or the working
242
+ # directory: the first one below the root decides, and any of them reading as test code makes it test
243
+ # code, so a `features/` tree below a subdirectory run's working directory stays test code as it was when
244
+ # the working directory was the only root. The realpath is read only when no written spelling lies below
245
+ # either: a symlink whose target runs through a `test/` directory must not turn the production files
246
+ # reached through it into test code.
247
+ def role_of(file, real)
248
+ spellings = below(file, @root_spellings) + below(file, cwd_spellings)
249
+ spellings = below(real, @root_spellings) + below(real, cwd_spellings) if spellings.empty?
250
+ role_for = Analysis::Reachability::Scan.method(:role_for)
251
+ return :test if spellings.uniq.any? { |path| role_for.call(path) == :test }
252
+
253
+ role_for.call(spellings.first || relative_to_root(real))
254
+ end
255
+
256
+ # `path` relative to every directory in `dirs` it lies below.
257
+ def below(path, dirs)
258
+ absolute = File.absolute_path(path)
259
+ dirs.filter_map { |dir| absolute.delete_prefix("#{dir.chomp('/')}/") if inside?(absolute, dir) }.uniq
260
+ end
261
+
262
+ # The working directory as its realpath and, when the shell's `PWD` names it, as that logical spelling.
263
+ def cwd_spellings
264
+ @cwd_spellings ||= begin
265
+ logical = ENV.fetch("PWD", nil)
266
+ real = canonical(Dir.pwd)
267
+ logical && canonical(logical) == real ? [real, Dir.pwd, logical].uniq : [real, Dir.pwd].uniq
268
+ end
269
+ end
270
+
271
+ def relative_to_root(real)
272
+ prefix = "#{@root.chomp('/')}/"
273
+ return real.delete_prefix(prefix) if real.start_with?(prefix)
274
+
275
+ Pathname.new(real).relative_path_from(Pathname.new(@root)).to_s
276
+ rescue ArgumentError
277
+ real
278
+ end
279
+
280
+ # A glob is matched against every analysed spelling of the declaration's file, and each of them and its
281
+ # realpath relative to the project root (#1760) and to the working directory:
146
282
  # `configuration.paths` are expanded to absolute, so a user-written `--entry-point=lib/entry.rb` would
147
283
  # otherwise never match anything and silently produce a root set of zero.
148
284
  # A constant named only from the project's own `sig/` is referenced — the #345 probe reported exactly this
@@ -176,8 +312,8 @@ module Rigor
176
312
  names = declarations.map(&:fqn)
177
313
  return [] if names.empty?
178
314
 
179
- Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
180
- absolute = File.absolute_path(rel)
315
+ project_files(TEMPLATE_GLOB).flat_map do |absolute|
316
+ rel = relative_to_root(absolute)
181
317
  haystack = cache.serve(:runs, absolute) { constant_bearing_text(File.read(absolute).scrub) }
182
318
  names.filter_map do |fqn|
183
319
  next unless haystack.include?(fqn)
@@ -208,10 +344,12 @@ module Rigor
208
344
  return [] if globs.empty?
209
345
 
210
346
  matcher = Analysis::Reachability::ProjectFiles.method(:entry_point_match?)
211
- cwd = "#{File.absolute_path(Dir.pwd)}/"
212
347
  declarations.filter_map do |d|
213
- relative = d.path.delete_prefix(cwd)
214
- d.fqn if globs.any? { |g| matcher.call(g, d.path) || matcher.call(g, relative) }
348
+ real = canonical(d.path)
349
+ dirs = @root_spellings + cwd_spellings
350
+ written = @analysed_spellings.fetch(real, [d.path]) | [d.path]
351
+ spellings = [*written, relative_to_root(real), *written.flat_map { |w| below(w, dirs) }, *below(real, dirs)]
352
+ d.fqn if globs.any? { |g| spellings.any? { |path| matcher.call(g, path) } }
215
353
  end
216
354
  end
217
355
 
@@ -235,6 +373,7 @@ module Rigor
235
373
  def foreign_predicate(configuration)
236
374
  env = Environment.for_project(libraries: configuration.libraries, signature_paths: [],
237
375
  cache_store: cache_store(configuration),
376
+ required_features: ProjectEnvironment.required_features(configuration),
238
377
  **ProjectEnvironment.dependency_discovery_options(configuration))
239
378
  ->(fqn) { !env.singleton_for_name(fqn).nil? }
240
379
  rescue StandardError
data/lib/rigor/cli.rb CHANGED
@@ -51,6 +51,7 @@ module Rigor
51
51
  "init" => :run_init,
52
52
  "annotate" => :run_annotate,
53
53
  "type-of" => :run_type_of,
54
+ "lens" => :run_lens,
54
55
  "trace" => :run_trace,
55
56
  "type-scan" => :run_type_scan,
56
57
  "effects" => :run_effects,
@@ -281,20 +282,46 @@ module Rigor
281
282
  # tempfile, uri, logger, date, prism, rbs). Adding a
282
283
  # `sig/<gem>.rbs` file under `sig/` is the simplest way
283
284
  # to extend type coverage today.
285
+ #
286
+ # `target_ruby` is left commented: the default only picks the syntax Rigor parses. Set it to the lowest
287
+ # Ruby your project runs on, and Rigor also reports what that Ruby deprecates.
288
+ # target_ruby: #{Configuration::DEFAULTS.fetch('target_ruby').dump}
284
289
  #{YAML.dump(init_settings(path)).sub(/\A---\n/, '')}
285
290
  YAML
286
291
  end
287
292
 
288
293
  # The settings `rigor init` writes: the defaults, with `test_paths:` spelled out as the test roots found now, so
289
294
  # the project's config says where its tests are rather than leaving it to auto-detection. The roots are written
290
- # relative to the config file's directory, which is what {Configuration.load} resolves them against.
295
+ # relative to the config file's directory, which is what {Configuration.load} resolves them against. `paths:`
296
+ # is written the same way: the default `lib` is resolved against the physical cwd, so a config written to
297
+ # `config/rigor.yml` must say `../lib`, or it loads back as `config/lib` (#1399).
298
+ #
299
+ # `target_ruby` is left out (the template writes it commented): a written key is a statement about the runtime
300
+ # (#1692), and the starter must not make one the user never chose.
291
301
  def init_settings(path)
302
+ config_dir = config_dir_of(path)
303
+ settings = Configuration::DEFAULTS.except("target_ruby")
304
+ settings = settings.merge("paths" => roots_relative_to(settings.fetch("paths"), config_dir))
292
305
  test_paths = Configuration.new.resolved_test_paths
293
- return Configuration::DEFAULTS if test_paths.empty?
306
+ return settings if test_paths.empty?
294
307
 
295
- config_dir = Pathname(File.absolute_path(File.dirname(path)))
296
- relative = test_paths.map { |root| Pathname(root).relative_path_from(config_dir).to_s }
297
- Configuration::DEFAULTS.merge("test_paths" => relative)
308
+ settings.merge("test_paths" => roots_relative_to(test_paths, config_dir))
309
+ end
310
+
311
+ # The directory the config file's roots are written relative to. An absolute `--path` is taken through its
312
+ # physical directory: the roots are resolved against the physical cwd (`Dir.pwd` is getcwd), so a logical
313
+ # spelling through a symlink makes the lexical `..` climb a different tree than the kernel does (#1399 review).
314
+ # A relative `--path` stays lexical, as {Configuration.load} resolves it.
315
+ def config_dir_of(path)
316
+ dir = File.dirname(path)
317
+ Pathname(Pathname(path).absolute? ? File.realdirpath(dir) : File.absolute_path(dir))
318
+ end
319
+
320
+ # Each root is resolved against the physical cwd (where `rigor` reads it from), then written relative to the config
321
+ # dir. With the config at the project root, both sides are the same physical directory, so the roots come out as
322
+ # they are spelled from it (`lib`, `spec`), which is master's output for the default config.
323
+ def roots_relative_to(roots, config_dir)
324
+ roots.map { |root| Pathname(File.absolute_path(root)).relative_path_from(config_dir).to_s }
298
325
  end
299
326
 
300
327
  def run_annotate
@@ -309,6 +336,12 @@ module Rigor
309
336
  TypeOfCommand.new(argv: @argv, out: @out, err: @err).run
310
337
  end
311
338
 
339
+ def run_lens
340
+ require_relative "cli/lens_command"
341
+
342
+ LensCommand.new(argv: @argv, out: @out, err: @err).run
343
+ end
344
+
312
345
  def run_trace
313
346
  require_relative "cli/trace_command"
314
347
 
@@ -455,6 +488,7 @@ module Rigor
455
488
  init Create a starter .rigor.yml
456
489
  annotate Print FILE with each line's last-expression type
457
490
  type-of Print inferred types at FILE:LINE[:COL] positions
491
+ lens Map a file's declarations with their types and provenance (experimental)
458
492
  trace Replay how the engine typed FILE as a terminal animation
459
493
  type-scan Report Scope#type_of coverage across PATHs
460
494
  effects Report each method's effect labels, and the committed effect snapshot
@@ -38,6 +38,10 @@ module Rigor
38
38
  "call.argument-type-mismatch" => :warning,
39
39
  "call.possible-nil-receiver" => :warning,
40
40
  "call.raise-non-exception" => :warning,
41
+ # Issue #1692 — the code runs on the stated Ruby (it warns, and breaks only at the removal version), so the
42
+ # rule takes the slot of the rules that report working-but-wrong code: `flow.unreachable-branch`'s
43
+ # info / warning / error. It runs only under an explicit `target_ruby` of 4.1 or later.
44
+ "call.deprecated-ruby2-keywords" => :info,
41
45
  "flow.always-raises" => :warning,
42
46
  "flow.unreachable-branch" => :info,
43
47
  "flow.dead-assignment" => :info,
@@ -100,6 +104,7 @@ module Rigor
100
104
  "call.argument-type-mismatch" => :error,
101
105
  "call.possible-nil-receiver" => :error,
102
106
  "call.raise-non-exception" => :error,
107
+ "call.deprecated-ruby2-keywords" => :warning,
103
108
  "flow.always-raises" => :error,
104
109
  "flow.unreachable-branch" => :warning,
105
110
  "flow.dead-assignment" => :warning,
@@ -141,6 +146,7 @@ module Rigor
141
146
  "call.argument-type-mismatch" => :error,
142
147
  "call.possible-nil-receiver" => :error,
143
148
  "call.raise-non-exception" => :error,
149
+ "call.deprecated-ruby2-keywords" => :error,
144
150
  "flow.always-raises" => :error,
145
151
  "flow.unreachable-branch" => :error,
146
152
  "flow.dead-assignment" => :error,
@@ -283,6 +283,23 @@ module Rigor
283
283
  :effects_snapshot_path, :effects_snapshot_reach, :effects_snapshot_gate, :effects_tolerated,
284
284
  :effects_labels, :effects_attribution, :effects_envelopes
285
285
 
286
+ # ADR-47 WD5 amendment (#1692) — whether `.rigor.yml` (or its `includes:` chain) set `target_ruby:` itself.
287
+ # The default `"4.0"` is a parse version, not something the user said about the Ruby the project runs on.
288
+ def target_ruby_explicit?
289
+ @target_ruby_explicit
290
+ end
291
+
292
+ # ADR-47 WD5 amendment (#1692) — `target_ruby` read as a user statement about the runtime: the configured
293
+ # version when the user set it explicitly, nil when it is the default or `"latest"`. `"latest"` names the
294
+ # newest syntax this Prism build parses, which moves with a Rigor upgrade, so it states no runtime. Only the
295
+ # Ruby-deprecation rules read this; `Inference::VersionGuard` keeps the analyzer's own `RUBY_VERSION`.
296
+ def stated_runtime_ruby
297
+ return nil unless target_ruby_explicit?
298
+ return nil if target_ruby == "latest"
299
+
300
+ target_ruby
301
+ end
302
+
286
303
  # ADR-103 WD13 — whether effect collection runs. True exactly when the loaded configuration carried an
287
304
  # `effects:` block, whatever its body; `rigor effects` enables it for its own run by loading an
288
305
  # implicit `effects: {}` instead of by consulting anything else. Nothing else — no annotation, no
@@ -327,6 +344,7 @@ module Rigor
327
344
  if resolved.nil? || !File.exist?(resolved)
328
345
  data = DEFAULTS
329
346
  effects_key_present = false
347
+ target_ruby_key_present = false
330
348
  else
331
349
  # ADR-103 WD15 — captured from the RAW, pre-`DEFAULTS.merge` file (+ its `includes:` chain) because
332
350
  # `DEFAULTS` itself carries an `"effects" => false` entry: once merged, "the file never wrote
@@ -334,9 +352,17 @@ module Rigor
334
352
  # indistinguishable to {#initialize}. This is the only place that distinction still exists.
335
353
  raw = load_with_includes(resolved)
336
354
  effects_key_present = raw.key?("effects")
355
+ target_ruby_key_present = raw.key?("target_ruby")
337
356
  data = DEFAULTS.merge(raw)
338
357
  end
339
- new(autowire_default_plugins(data), effects_key_present)
358
+ new(autowire_default_plugins(data), effects_key_present, target_ruby_key_present || false)
359
+ .with_exclude_base(resolved && File.exist?(resolved) ? File.dirname(resolved) : nil)
360
+ end
361
+
362
+ # #1576 — the directory relative `exclude:` patterns are matched against: the directory of the config file in
363
+ # use (the base `paths:` resolve against), or the working directory when no config file is used.
364
+ def exclude_base
365
+ @base_dir || Dir.pwd
340
366
  end
341
367
 
342
368
  # ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
@@ -519,7 +545,13 @@ module Rigor
519
545
  # `"key" => value` hash literal — this class's usual call shape, all over the spec suite — is coerced
520
546
  # into keyword arguments by Ruby whenever the method declares ANY keyword parameter, which would break
521
547
  # every `Configuration.new("some_key" => value)` call site with an "unknown keyword" `ArgumentError`.
522
- def initialize(data = DEFAULTS, effects_key_present = data.key?("effects"))
548
+ # @param target_ruby_key_present — ADR-47 WD5 amendment (#1692) — whether the file set `target_ruby:` itself,
549
+ # captured before `DEFAULTS.merge` for the same reason as `effects_key_present` and positional for the same
550
+ # reason. See {#stated_runtime_ruby}. The default reads the key off `data`, which is right only for a hash that
551
+ # has not been through `DEFAULTS.merge` (DEFAULTS carries the key); a caller that merges passes it explicitly.
552
+ # The bare `Configuration.new` is not explicit.
553
+ def initialize(data = DEFAULTS, effects_key_present = data.key?("effects"),
554
+ target_ruby_key_present = data.key?("target_ruby") && !data.equal?(DEFAULTS))
523
555
  # Record before the per-key fetches below discard the evidence. Top level only, deliberately —
524
556
  # see {ConfigAudit.unknown_key_warnings} for why a nested check cannot key on DEFAULTS.
525
557
  @unknown_keys = (data.keys.map(&:to_s) - KNOWN_KEYS).sort.freeze
@@ -528,6 +560,7 @@ module Rigor
528
560
  plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
529
561
 
530
562
  @target_ruby = coerce_target_ruby(data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")))
563
+ @target_ruby_explicit = target_ruby_key_present ? true : false
531
564
  @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s).freeze
532
565
  user_excludes = Array(data.fetch("exclude", DEFAULTS.fetch("exclude"))).map(&:to_s)
533
566
  @exclude_patterns = (BUILTIN_EXCLUDES + user_excludes).uniq.freeze
@@ -619,6 +652,9 @@ module Rigor
619
652
  def to_h # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
620
653
  {
621
654
  "target_ruby" => target_ruby,
655
+ # A stated `target_ruby` turns on the deprecation rules a defaulted one leaves off (#1692), so the run cache
656
+ # key and the incremental fingerprint must tell the two apart even where the value matches.
657
+ "target_ruby_explicit" => target_ruby_explicit?,
622
658
  "paths" => paths,
623
659
  "exclude" => exclude_patterns - BUILTIN_EXCLUDES,
624
660
  "plugins" => plugins,
@@ -717,6 +753,29 @@ module Rigor
717
753
  copy.freeze
718
754
  end
719
755
 
756
+ # #1576 — a sibling Configuration whose relative `exclude:` patterns match against `dir` (nil: the working
757
+ # directory). {Configuration.load} sets it to the loaded config file's directory.
758
+ def with_exclude_base(dir)
759
+ copy = dup
760
+ copy.instance_variable_set(:@base_dir, dir && File.absolute_path(dir).freeze)
761
+ copy.freeze
762
+ end
763
+
764
+ # #1777 — a sibling Configuration with only `baseline:` unset, every other field (including the
765
+ # explicitness of `target_ruby`) shared with the receiver. `rigor baseline generate` analyses with
766
+ # this so the generating run sees exactly the configuration `rigor check` will, minus the filter it
767
+ # is about to replace; rebuilding from a key list let each unlisted key revert to its default.
768
+ #
769
+ # Same frozen `dup` shape as {#with_bleeding_edge}. `@baseline_path` is the only ivar
770
+ # `#initialize` derives from the `baseline:` key.
771
+ def without_baseline
772
+ return self if baseline_path.nil?
773
+
774
+ copy = dup
775
+ copy.instance_variable_set(:@baseline_path, nil)
776
+ copy.freeze
777
+ end
778
+
720
779
  # ADR-103 WD14 — the ad-hoc opt-in `rigor effects` uses when the project's configuration carries no
721
780
  # `effects:` block: a sibling Configuration with an implicit empty block, every other field shared.
722
781
  # A configuration that already enables effects is returned unchanged, so a project's own settings
@@ -118,15 +118,19 @@ module Rigor
118
118
  # `{name => LockedGem}` → set of canonical bundler gem directory basenames. Pure-Ruby gems install as
119
119
  # `<name>-<version>`; platform-specific gems install as `<name>-<version>-<platform>` (e.g.
120
120
  # `ffi-1.17.4-aarch64-linux-gnu`). Lockfile platform `"ruby"` is the pure-Ruby case; any other value is
121
- # treated as a platform tag.
121
+ # treated as a platform tag. Every locked variant contributes a directory (#1844): the set only selects
122
+ # among directories that are actually installed, so admitting another platform's name costs nothing,
123
+ # while dropping the host's would discard its `sig/`.
122
124
  def self.expected_gem_dirs(locked_gems)
123
125
  locked_gems.each_value.with_object(Set.new) do |locked, set|
124
- base = "#{locked.name}-#{locked.version}"
125
- set << if locked.platform == "ruby" || locked.platform.empty?
126
- base
127
- else
128
- "#{base}-#{locked.platform}"
129
- end
126
+ locked.variants.each do |version, platform|
127
+ base = "#{locked.name}-#{version}"
128
+ set << if platform == "ruby" || platform.empty?
129
+ base
130
+ else
131
+ "#{base}-#{platform}"
132
+ end
133
+ end
130
134
  end
131
135
  end
132
136
  private_class_method :expected_gem_dirs
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ module Rigor
6
+ class Environment
7
+ # Issue #1700 — the type names a {RequiredFeatures}-gated vendored directory declares, which
8
+ # `RbsLoader.gated_trouble` builds in its trial to decide whether the directory may stay in an environment.
9
+ # Read off the vendored files themselves: a nested `class Prime; class EratosthenesGenerator` names
10
+ # `Prime::EratosthenesGenerator`, and a reopened core class (`class Integer`) is named too, since a clash
11
+ # there fails `Integer`'s own definition.
12
+ module GatedSignatureGuard
13
+ module_function
14
+
15
+ # @param dir — the vendored directory (Pathname).
16
+ # @return the absolute-less names (`"Prime::PseudoPrimeGenerator"`) of every class and module it declares.
17
+ def type_names(dir)
18
+ Dir.glob(File.join(dir.to_s, "*.rbs")).flat_map do |file|
19
+ _, _, decls = RBS::Parser.parse_signature(File.read(file))
20
+ names(decls, nil)
21
+ rescue RBS::BaseError, SystemCallError
22
+ []
23
+ end.uniq
24
+ end
25
+
26
+ def names(decls, namespace)
27
+ decls.flat_map do |decl|
28
+ next [] unless decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)
29
+
30
+ name = qualify(namespace, decl.name)
31
+ [name] + names(decl.members, name)
32
+ end
33
+ end
34
+
35
+ def qualify(namespace, type_name)
36
+ text = type_name.to_s
37
+ return text.delete_prefix("::") if text.start_with?("::") || namespace.nil?
38
+
39
+ "#{namespace}::#{text}"
40
+ end
41
+ end
42
+ end
43
+ end