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
@@ -377,7 +377,7 @@ module Rigor
377
377
  # Parse a SKILL.md's leading `---`-delimited YAML frontmatter. Returns {} on any missing or malformed block so the
378
378
  # catalogue degrades to a name-only entry rather than raising.
379
379
  def frontmatter(path)
380
- text = File.read(path)
380
+ text = File.read(path, encoding: Encoding::UTF_8)
381
381
  return {} unless text.start_with?("---\n")
382
382
 
383
383
  closing = text.index("\n---\n", 4)
@@ -220,7 +220,8 @@ module Rigor
220
220
  # so probes against files outside the current process's CWD still see the right `sig/` tree.
221
221
  # Issue #1700 — an editor buffer is scanned for required features in place of the file it stands for.
222
222
  def project_environment(files, configuration, buffer = nil)
223
- ProjectEnvironment.build(configuration: configuration, source_files: files,
223
+ patched = ProjectEnvironment.project_patched_methods(configuration, buffer: buffer)
224
+ ProjectEnvironment.build(configuration: configuration, source_files: files, project_patched_methods: patched,
224
225
  feature_sources: ProjectEnvironment.buffer_sources(buffer))
225
226
  end
226
227
 
@@ -49,8 +49,8 @@ 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)
52
+ establish_root(configuration)
53
+ scan_cache = open_scan_cache(configuration)
54
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))
@@ -68,6 +68,47 @@ module Rigor
68
68
 
69
69
  private
70
70
 
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
+
71
112
  def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:, shadows:)
72
113
  Analysis::Reachability::Graph.new(
73
114
  declarations: declarations, references: references, dynamic_uses: dynamic_uses, shadows: shadows,
@@ -111,21 +152,52 @@ module Rigor
111
152
  # references resolve to first (#1732).
112
153
  # Every file is consulted every run — an unchanged one contributes its cached scan, so the
113
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).
114
162
  def scan(paths, configuration, cache)
115
- declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
116
- declarations = []
117
- shadows = []
118
- references = []
119
- dynamic_uses = []
120
- (declaration_files + reference_files(paths, configuration)).sort.each do |file|
121
- 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) }
122
167
  next if result.nil?
123
168
 
124
- (declaration_files.include?(file) ? declarations : shadows).concat(result.declarations)
125
- references.concat(result.references)
126
- 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) }
127
171
  end
128
- [declarations, references, dynamic_uses, shadows]
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}")
129
201
  end
130
202
 
131
203
  # The reference corpus is the PROJECT, not the analysed paths. A constant declared in `lib/` is commonly
@@ -133,21 +205,80 @@ module Rigor
133
205
  # treating those as absent is what manufactured artifacts on two of three corpus targets in #345.
134
206
  # Widening the *declaration* set the same way would cancel the gain (measured: +1 / −3 / +2 candidates,
135
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.
136
211
  def reference_files(_paths, configuration)
137
- relative = Analysis::Reachability::ProjectFiles.own(Dir.glob(REFERENCE_GLOB, base: Dir.pwd), Dir.pwd)
138
- absolute = relative.map { |rel| File.absolute_path(rel) }
139
- 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)
140
215
  end
141
216
 
142
- 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)
143
221
  Analysis::Reachability::Scan.call(path: file, source: File.read(file),
144
222
  target_ruby: configuration.target_ruby,
145
- root: File.absolute_path(Dir.pwd))
223
+ role: role_of(file, real))
146
224
  rescue SystemCallError
147
225
  nil
148
226
  end
149
227
 
150
- # 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:
151
282
  # `configuration.paths` are expanded to absolute, so a user-written `--entry-point=lib/entry.rb` would
152
283
  # otherwise never match anything and silently produce a root set of zero.
153
284
  # A constant named only from the project's own `sig/` is referenced — the #345 probe reported exactly this
@@ -181,8 +312,8 @@ module Rigor
181
312
  names = declarations.map(&:fqn)
182
313
  return [] if names.empty?
183
314
 
184
- Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
185
- absolute = File.absolute_path(rel)
315
+ project_files(TEMPLATE_GLOB).flat_map do |absolute|
316
+ rel = relative_to_root(absolute)
186
317
  haystack = cache.serve(:runs, absolute) { constant_bearing_text(File.read(absolute).scrub) }
187
318
  names.filter_map do |fqn|
188
319
  next unless haystack.include?(fqn)
@@ -213,10 +344,12 @@ module Rigor
213
344
  return [] if globs.empty?
214
345
 
215
346
  matcher = Analysis::Reachability::ProjectFiles.method(:entry_point_match?)
216
- cwd = "#{File.absolute_path(Dir.pwd)}/"
217
347
  declarations.filter_map do |d|
218
- relative = d.path.delete_prefix(cwd)
219
- 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) } }
220
353
  end
221
354
  end
222
355
 
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,
@@ -291,18 +292,36 @@ module Rigor
291
292
 
292
293
  # The settings `rigor init` writes: the defaults, with `test_paths:` spelled out as the test roots found now, so
293
294
  # the project's config says where its tests are rather than leaving it to auto-detection. The roots are written
294
- # 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).
295
298
  #
296
299
  # `target_ruby` is left out (the template writes it commented): a written key is a statement about the runtime
297
300
  # (#1692), and the starter must not make one the user never chose.
298
301
  def init_settings(path)
302
+ config_dir = config_dir_of(path)
299
303
  settings = Configuration::DEFAULTS.except("target_ruby")
304
+ settings = settings.merge("paths" => roots_relative_to(settings.fetch("paths"), config_dir))
300
305
  test_paths = Configuration.new.resolved_test_paths
301
306
  return settings if test_paths.empty?
302
307
 
303
- config_dir = Pathname(File.absolute_path(File.dirname(path)))
304
- relative = test_paths.map { |root| Pathname(root).relative_path_from(config_dir).to_s }
305
- settings.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 }
306
325
  end
307
326
 
308
327
  def run_annotate
@@ -317,6 +336,12 @@ module Rigor
317
336
  TypeOfCommand.new(argv: @argv, out: @out, err: @err).run
318
337
  end
319
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
+
320
345
  def run_trace
321
346
  require_relative "cli/trace_command"
322
347
 
@@ -463,6 +488,7 @@ module Rigor
463
488
  init Create a starter .rigor.yml
464
489
  annotate Print FILE with each line's last-expression type
465
490
  type-of Print inferred types at FILE:LINE[:COL] positions
491
+ lens Map a file's declarations with their types and provenance (experimental)
466
492
  trace Replay how the engine typed FILE as a terminal animation
467
493
  type-scan Report Scope#type_of coverage across PATHs
468
494
  effects Report each method's effect labels, and the committed effect snapshot
@@ -26,11 +26,12 @@ module Rigor
26
26
  #
27
27
  # Every check is held to the same bar that {SignaturePathAudit} set: it mirrors the
28
28
  # loader's own acceptance test, so a warning means the loader really did load nothing, and
29
- # it never fires on a setup that works. In particular the rule-token check only flags a
30
- # token under a built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*` rule id
31
- # (whose family Rigor cannot statically enumerate, since a `node_rule` block emits any
32
- # `rule:` string it likes) is left alone, so disabling a plugin rule is never mistaken for
33
- # a typo.
29
+ # it never fires on a setup that works. In particular the `severity_overrides:` check only
30
+ # flags a key under a built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*`
31
+ # rule id (whose family Rigor cannot statically enumerate, since a `node_rule` block emits
32
+ # any `rule:` string it likes) is left alone. The `disable:` check goes further because the
33
+ # list's reach is enumerable (issue #1953): a plugin's row answers only to its qualified
34
+ # `plugin.<id>.<rule>` id, so a bare plugin rule there is inert (see {.disable_token_warning}).
34
35
  module ConfigAudit
35
36
  # One config-level finding. `kind` discriminates the source key (`:signature_path`,
36
37
  # `:bundled_plugin_signature_path`, `:library`, `:disabled_rule`, `:severity_override`,
@@ -146,18 +147,13 @@ module Rigor
146
147
  []
147
148
  end
148
149
 
149
- # `disable:` tokens and `severity_overrides:` keys that name no rule. Restricted to
150
- # tokens under a built-in family so a plugin rule id is never mis-flagged (see the module
151
- # docstring).
150
+ # `disable:` tokens that reach no row ({.disable_token_warning}) and `severity_overrides:` keys
151
+ # that name no rule. An override key is flagged only under a built-in family, so a plugin
152
+ # rule id is never mis-flagged (see the module docstring).
152
153
  def self.rule_token_warnings(configuration)
154
+ plugin_ids = configured_plugin_ids(configuration.plugins)
153
155
  disable = Array(configuration.disabled_rules).filter_map do |token|
154
- next unless inert_builtin_token?(token.to_s)
155
-
156
- Warning.new(
157
- kind: :disabled_rule,
158
- message: "disable: #{token.to_s.inspect} is not a recognized rule id; the suppression has no effect",
159
- fields: { "token" => token.to_s }
160
- )
156
+ disable_token_warning(token.to_s, plugin_ids)
161
157
  end
162
158
  overrides = configuration.severity_overrides.keys.filter_map do |key|
163
159
  next unless inert_builtin_token?(key.to_s)
@@ -171,6 +167,106 @@ module Rigor
171
167
  disable + overrides
172
168
  end
173
169
 
170
+ # Issue #1953 — the run-level rows the diagnostic policy keeps on `severity_overrides:` and the
171
+ # baseline. `disable:` does not reach them by any spelling, so naming one there is inert.
172
+ RESERVED_RUN_LEVEL_FAMILIES = %w[plugin_loader plugin_trust rbs.coverage dynamic.dependency-source].freeze
173
+ RESERVED_RUN_LEVEL_IDS = %w[load-error runtime-error pool-degraded].freeze
174
+ private_constant :RESERVED_RUN_LEVEL_FAMILIES, :RESERVED_RUN_LEVEL_IDS
175
+
176
+ # A warning for a `disable:` token that can reach no row, or nil. Since issue #1953 the list's
177
+ # reach is exactly enumerable for three shapes, and those are what this flags:
178
+ #
179
+ # - a token naming a reserved run-level row ({RESERVED_RUN_LEVEL_FAMILIES} /
180
+ # {RESERVED_RUN_LEVEL_IDS}), which the list never reaches;
181
+ # - a `plugin.`-qualified token naming no plugin `plugins:` configures — checked only when
182
+ # every entry's manifest id is known without loading it ({.configured_plugin_ids});
183
+ # - any other token that is not `all`, a family, a legacy alias or a catalogue / `pre-eval.*`
184
+ # id. A bare id never reaches a plugin's row, so a plugin's bare rule is inert here too.
185
+ #
186
+ # A token under a non-check engine family (`rbs_extended.*`, `rbs.*`, `dynamic.*`) or a known
187
+ # non-catalogue engine id stays unflagged: under-warning is this module's FP-safe direction.
188
+ def self.disable_token_warning(token, plugin_ids)
189
+ return nil if effective_disable_token?(token)
190
+
191
+ reason = disable_token_problem(token, plugin_ids)
192
+ return nil if reason.nil?
193
+
194
+ Warning.new(kind: :disabled_rule, message: "disable: #{token.inspect} #{reason}",
195
+ fields: { "token" => token })
196
+ end
197
+
198
+ def self.disable_token_problem(token, plugin_ids)
199
+ if reserved_run_level_token?(token)
200
+ "names a run-level row, which `disable:` does not reach; use `severity_overrides:` or a baseline"
201
+ elsif token.start_with?(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
202
+ unless plugin_ids.nil? || plausible_plugin_token?(token, plugin_ids)
203
+ "names no plugin `plugins:` configures; the suppression has no effect"
204
+ end
205
+ elsif inert_builtin_token?(token)
206
+ "is not a recognized rule id; the suppression has no effect"
207
+ elsif !non_check_engine_token?(token)
208
+ "is not a recognized rule id; the suppression has no effect " \
209
+ "(if this is a plugin's rule, write it as `plugin.<id>.<rule>`)"
210
+ end
211
+ end
212
+ private_class_method :disable_token_problem
213
+
214
+ def self.effective_disable_token?(token)
215
+ return true if token == "all"
216
+
217
+ !Array(Analysis::CheckRules.resolve_rule_token(token)).empty?
218
+ end
219
+ private_class_method :effective_disable_token?
220
+
221
+ def self.reserved_run_level_token?(token)
222
+ RESERVED_RUN_LEVEL_IDS.include?(token) ||
223
+ RESERVED_RUN_LEVEL_FAMILIES.any? { |family| token == family || token.start_with?("#{family}.") }
224
+ end
225
+ private_class_method :reserved_run_level_token?
226
+
227
+ def self.non_check_engine_token?(token)
228
+ Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_IDS.include?(token) ||
229
+ Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_FAMILIES.include?(token.split(".").first)
230
+ end
231
+ private_class_method :non_check_engine_token?
232
+
233
+ # `plugin.<id>` or `plugin.<id>.<rule>` for a configured id. A manifest id may itself hold a
234
+ # dot, so the token is matched against each id rather than split.
235
+ def self.plausible_plugin_token?(token, plugin_ids)
236
+ rest = token.delete_prefix(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
237
+ plugin_ids.any? { |id| rest == id || rest.start_with?("#{id}.") }
238
+ end
239
+ private_class_method :plausible_plugin_token?
240
+
241
+ # The manifest id of every `plugins:` entry, or nil when one cannot be known without loading
242
+ # the plugin. An explicit `id:` is the id; a bundled plugin's id is its gem name minus the
243
+ # `rigor-` prefix ({Plugin::FirstParty}); a third-party gem named without `id:` may register
244
+ # any id, so its presence turns the plugin-token check off. An `enabled: false` entry counts:
245
+ # the user has found the plugin, and under-warning is the FP-safe direction.
246
+ def self.configured_plugin_ids(plugins)
247
+ Array(plugins).each_with_object(Set.new) do |raw, ids|
248
+ entry = raw.is_a?(Hash) ? raw.to_h { |key, value| [key.to_s, value] } : { "gem" => raw.to_s }
249
+ if entry["id"]
250
+ ids << entry["id"].to_s
251
+ elsif bundled_plugin_gem?(entry["gem"].to_s)
252
+ ids << entry["gem"].to_s.delete_prefix(BUNDLED_GEM_PREFIX)
253
+ else
254
+ return nil
255
+ end
256
+ end
257
+ end
258
+ private_class_method :configured_plugin_ids
259
+
260
+ BUNDLED_GEM_PREFIX = "rigor-"
261
+ private_constant :BUNDLED_GEM_PREFIX
262
+
263
+ def self.bundled_plugin_gem?(gem)
264
+ return false unless gem.start_with?(BUNDLED_GEM_PREFIX)
265
+
266
+ File.file?(File.join(SignaturePathAudit::BUNDLED_PLUGINS_ROOT, gem, "lib", "#{gem}.rb"))
267
+ end
268
+ private_class_method :bundled_plugin_gem?
269
+
174
270
  # True when `token` looks like a built-in rule id but matches none — its first segment
175
271
  # is a built-in family yet it is neither a bare family wildcard nor a known canonical id.
176
272
  # A token whose family is not built-in (a plugin / `rbs_extended.*` rule, or a bare
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../configuration_error"
4
+
3
5
  module Rigor
4
6
  class Configuration
5
7
  # Parsed `dependencies:` section of `.rigor.yml`. Per
@@ -56,7 +58,7 @@ module Rigor
56
58
  # Hash with `source_inference:` and / or `budget_per_gem:` / `budget_overrun_strategy:` present.
57
59
  def self.from_h(data)
58
60
  return new([]) if data.nil?
59
- raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
61
+ raise ConfigurationError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
60
62
 
61
63
  raw_entries = coerce_source_inference(data["source_inference"])
62
64
  entries, warnings = dedupe_entries(raw_entries)
@@ -139,7 +141,7 @@ module Rigor
139
141
  return [] if value.nil? || value == false
140
142
 
141
143
  unless value.is_a?(Array)
142
- raise ArgumentError,
144
+ raise ConfigurationError,
143
145
  "dependencies.source_inference: must be a list of entries " \
144
146
  "(or false / omitted to disable), got #{value.inspect}"
145
147
  end
@@ -149,7 +151,7 @@ module Rigor
149
151
 
150
152
  def coerce_entry(raw)
151
153
  unless raw.is_a?(Hash)
152
- raise ArgumentError,
154
+ raise ConfigurationError,
153
155
  "dependencies.source_inference[] entry must be a Hash, got #{raw.inspect}"
154
156
  end
155
157
 
@@ -162,7 +164,7 @@ module Rigor
162
164
 
163
165
  def coerce_gem(value)
164
166
  unless value.is_a?(String) && !value.empty?
165
- raise ArgumentError,
167
+ raise ConfigurationError,
166
168
  "dependencies.source_inference[].gem must be a non-empty String, got #{value.inspect}"
167
169
  end
168
170
 
@@ -170,28 +172,34 @@ module Rigor
170
172
  end
171
173
 
172
174
  def coerce_mode(value)
173
- mode = (value || "when_missing").to_sym
175
+ mode = config_symbol(value || "when_missing")
174
176
  return mode if VALID_MODES.include?(mode)
175
177
 
176
- raise ArgumentError,
178
+ raise ConfigurationError,
177
179
  "dependencies.source_inference[].mode must be one of " \
178
180
  "#{VALID_MODES.inspect}, got #{value.inspect}"
179
181
  end
180
182
 
183
+ # The Symbol a String / Symbol value names, or nil for any other YAML shape (a number, a list, a mapping), which
184
+ # the caller then reports as not one of its valid values rather than crashing on `#to_sym`.
185
+ def config_symbol(value)
186
+ value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
187
+ end
188
+
181
189
  def coerce_roots(raw)
182
190
  roots = Array(raw.fetch("roots", DEFAULT_ROOTS)).map(&:to_s).freeze
183
191
  return roots unless roots.empty?
184
192
 
185
- raise ArgumentError,
193
+ raise ConfigurationError,
186
194
  "dependencies.source_inference[].roots must not be empty when supplied " \
187
195
  "(omit the key to fall back to the default #{DEFAULT_ROOTS.inspect})"
188
196
  end
189
197
 
190
198
  def coerce_budget_overrun_strategy(value)
191
- symbol = value.to_sym
199
+ symbol = config_symbol(value)
192
200
  return symbol if VALID_BUDGET_OVERRUN_STRATEGIES.include?(symbol)
193
201
 
194
- raise ArgumentError,
202
+ raise ConfigurationError,
195
203
  "dependencies.budget_overrun_strategy must be one of " \
196
204
  "#{VALID_BUDGET_OVERRUN_STRATEGIES.inspect}, got #{value.inspect}"
197
205
  end
@@ -201,13 +209,13 @@ module Rigor
201
209
  # MIN_BUDGET_PER_GEM .. MAX_BUDGET_PER_GEM (i.e. 0.25× – 4× of the default).
202
210
  def coerce_budget_per_gem(value)
203
211
  unless value.is_a?(Integer)
204
- raise ArgumentError,
212
+ raise ConfigurationError,
205
213
  "dependencies.budget_per_gem must be an Integer, " \
206
214
  "got #{value.inspect}"
207
215
  end
208
216
 
209
217
  unless value.between?(MIN_BUDGET_PER_GEM, MAX_BUDGET_PER_GEM)
210
- raise ArgumentError,
218
+ raise ConfigurationError,
211
219
  "dependencies.budget_per_gem must be in the range " \
212
220
  "#{MIN_BUDGET_PER_GEM}..#{MAX_BUDGET_PER_GEM}, " \
213
221
  "got #{value.inspect}"
@@ -356,6 +356,13 @@ module Rigor
356
356
  data = DEFAULTS.merge(raw)
357
357
  end
358
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
359
366
  end
360
367
 
361
368
  # ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
@@ -746,6 +753,14 @@ module Rigor
746
753
  copy.freeze
747
754
  end
748
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
+
749
764
  # #1777 — a sibling Configuration with only `baseline:` unset, every other field (including the
750
765
  # explicitness of `target_ruby`) shared with the receiver. `rigor baseline generate` analyses with
751
766
  # this so the generating run sees exactly the configuration `rigor check` will, minus the filter it
@@ -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