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
@@ -169,7 +169,10 @@ module Rigor
169
169
  # file's conditional `def` as certain and a warm run would answer a definer a cold run declines.
170
170
  # 37: ADR-119 WD3 — the visibility walk fills `contested_discovered_method_visibilities`, which a pre-37 bundle
171
171
  # carries empty.
172
- SCHEMA = 37
172
+ # 38: issue #1898 — the payload gains `effect_plugin_digest`, the plugin effect-fact digest the collections were
173
+ # stamped under, which a recheck now checks before its served collections reach the envelope pass. A pre-38
174
+ # blob carries none, so the gate rejects it and the next run is cold.
175
+ SCHEMA = 38
173
176
 
174
177
  # The persisted per-file state.
175
178
  # `cache` maps an analyzed file to its diagnostics.
@@ -224,11 +227,22 @@ module Rigor
224
227
  # Issues #796 / #794 — the run-level rows a narrowed run replays instead of re-deriving; see
225
228
  # docs/internal-spec/cache.md § "Run-level rows the snapshot carries" for why exactly these two are
226
229
  # fresh under the fingerprint and why no other `.rigor.yml`-level row may join them.
230
+ # Issue #1898 — `effect_plugin_digest` is {Rigor::Effects::PluginFacts#digest} at the run that collected
231
+ # `effect_collections` (nil when collection was off). A plugin stamps its attributions and edges into a file's
232
+ # collection, and `effects_identity` is plugin-blind (it is compared before any plugin loads), so the session
233
+ # compares this one after the recheck has loaded them.
227
234
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
228
235
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
229
236
  :missing, :class_decls, :constant_decls, :seed_bundles, :plugin_fact_digest,
230
237
  :return_summaries, :param_table,
231
- :effect_collections, :effects_identity, :run_level_rows)
238
+ :effect_collections, :effects_identity, :run_level_rows,
239
+ :effect_plugin_digest) do
240
+ # Defaults to nil so a payload built without it (an older caller, a spec fixture) reads as "unknown",
241
+ # which the session treats as moved.
242
+ def initialize(effect_plugin_digest: nil, **rest)
243
+ super
244
+ end
245
+ end
232
246
 
233
247
  # Issues #796 / #794 — the two run-level rows the snapshot carries. `definition_build_failures` is the
234
248
  # reported per-class detail list ({Environment::RbsLoader#definition_build_failures}); `hkt_scan_failure`
@@ -260,7 +274,11 @@ module Rigor
260
274
  # {EngineSource.process_identity} closes it, and answers nil for a version-pinned tree — which adds no
261
275
  # part, so a released gem's fingerprint is byte-identical to the pre-#289 one and its warm snapshots
262
276
  # survive the upgrade untouched.
263
- def self.fingerprint(configuration:, roots:)
277
+ #
278
+ # Issue #1533 — `explain:` is the run's `--explain` flag. It adds `:info` rows to the per-file diagnostics
279
+ # the snapshot caches, so a snapshot written without it holds none and one written with it holds rows a
280
+ # plain run must not print. No part for the default, so every plain run's fingerprint is as it was.
281
+ def self.fingerprint(configuration:, roots:, explain: false)
264
282
  bundler_lock, collection_lock = Analysis::RunCacheKey.resolved_lockfile_paths(configuration)
265
283
  parts = [
266
284
  "engine:#{Rigor::VERSION}:#{SCHEMA}",
@@ -279,14 +297,11 @@ module Rigor
279
297
  # dependency edge records, so the whole snapshot goes. Scanned over the configured paths plus the roots'
280
298
  # files, the set the run's environment scans; no part when nothing is required, so every other
281
299
  # project's fingerprint is as it was.
282
- features = Environment::RequiredFeatures.for_configuration(
283
- configuration, Analysis::PathExpansion.ruby_files(roots, configuration.exclude_patterns)
284
- )
300
+ features = required_features(configuration, roots)
285
301
  parts << "required-features:#{features.join(',')}" unless features.empty?
286
302
  # ADR-116 WD5 — the snapshot's per-file rows and seed bundles are only as checked as the run that
287
303
  # wrote them ({Inference::DeclarationWalk::Shadow}); nil with the harness off adds no part.
288
- shadow = Analysis::ShadowHarness.cache_identity
289
- parts << "shadow-harness:#{shadow}" if shadow
304
+ parts.concat(run_mode_parts(explain))
290
305
  Digest::SHA256.hexdigest(parts.join("\x00"))
291
306
  rescue StandardError
292
307
  # {EngineSource::Unavailable} lands here too, and nil is the answer it requires rather than one it
@@ -297,6 +312,12 @@ module Rigor
297
312
  nil
298
313
  end
299
314
 
315
+ def self.required_features(configuration, roots)
316
+ files = Analysis::PathExpansion.ruby_files(roots, configuration.exclude_patterns,
317
+ base: configuration.exclude_base)
318
+ Environment::RequiredFeatures.for_configuration(configuration, files)
319
+ end
320
+
300
321
  # ADR-10 slice 3, issue #1814 — the walked gems' versions: their method catalogue, their refinement table and the
301
322
  # constants they bind reach every file's checks, and the lockfile digest above does not cover a project with none.
302
323
  # No part for a project that lists no gem, so its fingerprint is what it was.
@@ -314,6 +335,15 @@ module Rigor
314
335
  end
315
336
  private_class_method :digest_file_if_present
316
337
 
338
+ # The parts naming how the run was asked to behave rather than what it reads: the ADR-116 WD5 shadow harness
339
+ # (the snapshot's rows are only as checked as the run that wrote them; nil with the harness off) and
340
+ # `--explain` (#1533).
341
+ def self.run_mode_parts(explain)
342
+ shadow = Analysis::ShadowHarness.cache_identity
343
+ [("shadow-harness:#{shadow}" if shadow), ("explain:true" if explain)].compact
344
+ end
345
+ private_class_method :run_mode_parts
346
+
317
347
  # Content-digest every `pre_eval:` file in configuration order, so an edit to one invalidates the snapshot
318
348
  # even when it lies outside the analysed paths. A missing file digests as "absent", so creating it counts too.
319
349
  def self.digest_pre_eval(paths)
@@ -346,8 +376,20 @@ module Rigor
346
376
  end
347
377
  private_class_method :digest_signature_paths
348
378
 
349
- def initialize(root:)
350
- @path = File.join(root.to_s, "incremental", "snapshot.bin")
379
+ # Issue #1867 — one snapshot FILE per fingerprint mode. A run whose fingerprint carries run-mode parts
380
+ # (`--explain`, the ADR-116 shadow harness) keeps its own file so it neither reads nor overwrites the
381
+ # plain run's warm snapshot, and the reverse. The plain file name is unchanged. The fingerprint still
382
+ # carries the mode parts, so the files never cross-serve.
383
+ def initialize(root:, explain: false)
384
+ @path = File.join(root.to_s, "incremental", self.class.file_name(explain: explain))
385
+ end
386
+
387
+ # `snapshot.bin` for a plain run; `snapshot-<mode digest>.bin` when {run_mode_parts} is non-empty.
388
+ def self.file_name(explain: false)
389
+ parts = run_mode_parts(explain)
390
+ return "snapshot.bin" if parts.empty?
391
+
392
+ "snapshot-#{Digest::SHA256.hexdigest(parts.join("\x00"))[0, 12]}.bin"
351
393
  end
352
394
 
353
395
  attr_reader :path
@@ -406,7 +448,8 @@ module Rigor
406
448
  param_table: data[:param_table] || {},
407
449
  effect_collections: data[:effect_collections] || {},
408
450
  effects_identity: data[:effects_identity],
409
- run_level_rows: data[:run_level_rows]
451
+ run_level_rows: data[:run_level_rows],
452
+ effect_plugin_digest: data[:effect_plugin_digest]
410
453
  )
411
454
  end
412
455
  private :payload_from
@@ -431,7 +474,8 @@ module Rigor
431
474
  param_table: payload.param_table,
432
475
  effect_collections: payload.effect_collections,
433
476
  effects_identity: payload.effects_identity,
434
- run_level_rows: payload.run_level_rows
477
+ run_level_rows: payload.run_level_rows,
478
+ effect_plugin_digest: payload.effect_plugin_digest
435
479
  )
436
480
  # #1507 — every `--incremental` run that analysed anything rewrites this whole blob, so its deflate is
437
481
  # on the edit path; see {Store::DEFLATE_LEVEL}.
@@ -72,6 +72,7 @@ module Rigor
72
72
  )
73
73
  runner = invocation.runner
74
74
  raw_result = invocation.result
75
+ settle_run_stats(raw_result.stats)
75
76
  result = apply_baseline_filter(raw_result, configuration, options)
76
77
 
77
78
  coverage = compute_coverage(runner, configuration, options)
@@ -180,9 +181,9 @@ module Rigor
180
181
  # A plain `--incremental` run the session would answer as a warm recheck and write a slot for. Every other
181
182
  # shape declines, and the full path handles it exactly as before: an editor buffer (never saved), `--no-cache`
182
183
  # (no store to hold a slot), `--verify-incremental`, a worker pool (the session writes no slot for one: a
183
- # worker's plugin reads never reach the process that records them), and `--explain`, which the session
184
- # ignores today (#1533) — the slot is keyed without it, so the probe declines rather than answer for a flag
185
- # its writer never saw. The rest mirror the plain probe: `--coverage`, `--cache-stats` and the
184
+ # worker's plugin reads never reach the process that records them), and `--explain` (#1533: the session
185
+ # writes no slot for it — the slot key has no explain slot — so the probe declines rather than answer for a
186
+ # flag its writer never saw). The rest mirror the plain probe: `--coverage`, `--cache-stats` and the
186
187
  # `RIGOR_*_TRACE` probes each want the full path's own output.
187
188
  def incremental_run_hit_eligible?(configuration, options, buffer)
188
189
  options.fetch(:incremental) && buffer.nil? &&
@@ -195,6 +196,7 @@ module Rigor
195
196
  # The tail {#run_incremental_check} gives a warm run, from the slot's answer: the same banner, baseline
196
197
  # filter, output and exit code. No fact-surface note, since a warm run never has one.
197
198
  def finalize_incremental_hit(hit, configuration, options)
199
+ # The slot is written only for a run whose every row still validates, so nothing was re-analyzed.
198
200
  report_incremental_run(true, hit.file_count)
199
201
  write_incremental_result(hit.result.diagnostics, configuration, options)
200
202
  end
@@ -267,8 +269,8 @@ module Rigor
267
269
  # Every other file forms the re-analyzed subset, so the run exercises BOTH the subset-analysis path and the
268
270
  # cache-serving path.
269
271
  subset = analyzed.each_with_index.select { |_, index| index.even? }.map(&:first)
270
- incremental = normalize_diagnostics(session.reanalyze_subset(subset))
271
- full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
272
+ incremental = session.reanalyze_subset(subset).map(&:to_h)
273
+ full = verify_full_diagnostics(configuration, paths, collect_stats: options.fetch(:stats)).map(&:to_h)
272
274
 
273
275
  report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size,
274
276
  format: options.fetch(:format))
@@ -288,10 +290,11 @@ module Rigor
288
290
  nil
289
291
  else
290
292
  Cache::IncrementalSnapshot.fingerprint(
291
- configuration: configuration, roots: paths || configuration.paths
293
+ configuration: configuration, roots: paths || configuration.paths,
294
+ explain: options.fetch(:explain)
292
295
  )
293
296
  end
294
- snapshot = Cache::IncrementalSnapshot.new(root: cache_root)
297
+ snapshot = Cache::IncrementalSnapshot.new(root: cache_root, explain: options.fetch(:explain))
295
298
  store = incremental_cache_store(configuration, options, cache_root)
296
299
  session = Analysis::IncrementalSession.new(
297
300
  configuration: configuration, paths: paths,
@@ -300,7 +303,7 @@ module Rigor
300
303
  # (CLI `--workers` > `RIGOR_RACTOR_WORKERS` > `parallel.workers:` > 0) so the recheck's closure
301
304
  # re-analysis parallelises; the fork pool marshals dependency records back so the graph is sound.
302
305
  workers: CheckRunnerFactory.resolve_workers(options, configuration),
303
- buffer: buffer
306
+ buffer: buffer, explain: options.fetch(:explain)
304
307
  )
305
308
 
306
309
  return run_editor_mode_option_b(session, snapshot, fingerprint, configuration, options) if buffer
@@ -309,7 +312,8 @@ module Rigor
309
312
  # The banner's file count comes from the session's analyzed set (cold analyses all; a warm recheck's
310
313
  # `@analyzed` advances to the current file set), so a dedicated probe Runner + `Dir.glob` is no longer
311
314
  # built just to size the banner (recon §3 — the analysis tree was expanded three times per recheck).
312
- report_incremental_run(warm, session.analyzed_files.size)
315
+ # A warm banner reports the recheck's own sets (#1526), which only the session knows.
316
+ report_incremental_run(warm, session.analyzed_files.size, recheck: session.last_recheck)
313
317
  emit_incremental_fact_surface_notes(session)
314
318
  write_incremental_cache_stats(session, cache_root, store, options.fetch(:format)) if
315
319
  options.fetch(:cache_stats)
@@ -317,9 +321,47 @@ module Rigor
317
321
  write_incremental_result(diagnostics, configuration, options)
318
322
  end
319
323
 
320
- def report_incremental_run(warm, file_count)
321
- @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
322
- "(#{file_count} files)")
324
+ # #1526 — a warm banner says how many of the analysed files the recheck re-analysed, and why: `changed` is the
325
+ # edited set, `added` / `removed` the structural delta, and the rest came from the snapshot. A run served from
326
+ # the run-result slot has no recheck, which means nothing changed: every file is reused.
327
+ def report_incremental_run(warm, file_count, recheck: nil)
328
+ body = if warm
329
+ "warm — #{warm_recheck_summary(file_count, recheck)}"
330
+ else
331
+ "cold — full analysis (#{pluralize(file_count, 'file')})"
332
+ end
333
+ @err.puts("rigor: --incremental #{body}")
334
+ end
335
+
336
+ # The warm clause. `file_count` is the analysed set M (the subset under a path argument); re-analysed files
337
+ # are M minus the reused set, which includes files invalidated by inferred-parameter changes (ADR-67), not only
338
+ # the edited closure. View templates are not in M: the recheck re-runs every one, so they are appended as their
339
+ # own count rather than folded into the file counts.
340
+ def warm_recheck_summary(file_count, recheck)
341
+ reused = recheck ? recheck.reused.size : file_count
342
+ # Current files are either reused or re-analysed (the recheck's `analyze_set`), so the count is taken from
343
+ # the reused set rather than `affected`, which can also name files that were removed.
344
+ analyzed = file_count - reused
345
+ head = "re-analysed #{analyzed} of #{pluralize(file_count, 'file')}"
346
+ head += " (#{change_counts(recheck).join(', ')})" if recheck && !change_counts(recheck).empty?
347
+ head += reuse_clause(analyzed, reused)
348
+ templates = recheck ? recheck.templates : 0
349
+ templates.positive? ? "#{head}, plus #{pluralize(templates, 'view template')}" : head
350
+ end
351
+
352
+ def change_counts(recheck)
353
+ { "changed" => recheck.changed.size, "added" => recheck.added.size, "removed" => recheck.removed.size }
354
+ .filter_map { |label, count| "#{count} #{label}" if count.positive? }
355
+ end
356
+
357
+ def reuse_clause(analyzed, reused)
358
+ return ", reused all" if analyzed.zero?
359
+
360
+ reused.zero? ? "" : ", reused the rest"
361
+ end
362
+
363
+ def pluralize(count, noun)
364
+ "#{count} #{count == 1 ? noun : "#{noun}s"}"
323
365
  end
324
366
 
325
367
  # The incremental tail, shared by the full path and the ADR-45 WD2 slot hit so a served run prints exactly
@@ -366,17 +408,30 @@ module Rigor
366
408
 
367
409
  # ADR-88 WD1 — a one-line stderr note when the plugin fact surface (an ADR-9 fact, an ADR-60 producer
368
410
  # value, or an `incremental_state_fingerprint` hook) forced a full run: either it CHANGED since the
369
- # snapshot (a Sorbet sig edit, a schema change) or a contributing plugin declares NO fingerprint surface
370
- # (opaque — a full run every invocation). Both are the sound direction; the note explains why a recheck
371
- # was not warm.
411
+ # snapshot (a Sorbet sig edit, a schema change) or a plugin is opaque to the fingerprint (a full run every
412
+ # invocation): it contributes types and declares NO fingerprint surface, or a value it declares cannot be
413
+ # digested. Each opaque plugin's reason is worded per {PluginFactFingerprint::OpaqueReason}, and an
414
+ # undigestible value is named. Both are the sound direction; the note explains why a recheck was not warm.
372
415
  def emit_incremental_fact_surface_notes(session)
373
- opaque = session.opaque_plugin_ids
374
- unless opaque.empty?
375
- @err.puts("rigor: --incremental plugin#{'s' unless opaque.size == 1} #{opaque.sort.join(', ')} " \
376
- "contribute call-site types with no incremental fingerprint surface; the snapshot cannot " \
377
- "be reused (full analysis every run).")
416
+ reasons = session.opaque_reasons
417
+ surfaceless = reasons.keys.select { |id| reasons.fetch(id).kind == :no_surface }.sort
418
+ unless surfaceless.empty?
419
+ many = surfaceless.size > 1
420
+ @err.puts("rigor: --incremental plugin#{'s' if many} #{surfaceless.join(', ')} " \
421
+ "contribute#{'s' unless many} call-site types with no incremental fingerprint surface; " \
422
+ "the snapshot cannot be reused (full analysis every run).")
423
+ end
424
+ reasons.each do |id, reason|
425
+ next if reason.kind == :no_surface
426
+
427
+ @err.puts("rigor: --incremental #{reason.describe(id)}; the snapshot cannot be reused " \
428
+ "(full analysis every run).")
429
+ end
430
+ if session.fact_store_unreadable?
431
+ @err.puts("rigor: --incremental the plugin fact store could not be read; the snapshot cannot be reused " \
432
+ "(full analysis every run).")
378
433
  end
379
- return unless session.fact_surface_invalidated? && opaque.empty?
434
+ return unless session.fact_surface_invalidated? && !session.opaque_fact_surface?
380
435
 
381
436
  @err.puts("rigor: --incremental plugin fact surface changed since the snapshot; ran a full analysis.")
382
437
  end
@@ -388,8 +443,10 @@ module Rigor
388
443
  def write_incremental_cache_stats(session, cache_root, store, format)
389
444
  write_cache_stats(cache_root, store, format)
390
445
  status =
391
- if !session.opaque_plugin_ids.empty?
392
- "opaque (#{session.opaque_plugin_ids.sort.join(', ')})"
446
+ if session.opaque_fact_surface?
447
+ verdicts = session.opaque_reasons.map { |id, reason| "#{id}: #{reason.summary}" }
448
+ verdicts << "fact store: could not be read" if session.fact_store_unreadable?
449
+ "opaque (#{verdicts.join('; ')})"
393
450
  elsif session.fact_surface_invalidated?
394
451
  "changed — snapshot invalidated"
395
452
  else
@@ -398,38 +455,72 @@ module Rigor
398
455
  side_output(format).puts(" plugin fact surface: #{status}")
399
456
  end
400
457
 
401
- def verify_full_diagnostics(configuration, paths)
402
- runner = Analysis::Runner.new(configuration: configuration, cache_store: nil)
458
+ def verify_full_diagnostics(configuration, paths, collect_stats: true)
459
+ runner = Analysis::Runner.new(configuration: configuration, cache_store: nil, collect_stats: collect_stats)
403
460
  (paths ? runner.run(paths) : runner.run).diagnostics
404
461
  end
405
462
 
406
- def normalize_diagnostics(diagnostics)
407
- diagnostics.map(&:to_h).sort_by do |hash|
408
- [hash["path"].to_s, hash["line"].to_i, hash["column"].to_i, hash["rule"].to_s, hash["message"].to_s]
409
- end
410
- end
411
-
463
+ # Issue #1542 — the rows are compared as a multiset over every field each row carries, so two rows that tie on
464
+ # the sort key but differ in severity, or a duplicated row, are a set mismatch and never a silent 0/0. Only when
465
+ # the multisets agree is the order compared, and an order mismatch is reported as its own failure, with the first
466
+ # index at which the two lists diverge, so a set regression and an order regression stay distinguishable.
467
+ #
412
468
  # The OK line follows the same rule as the other side outputs (#493) — stdout under `text`, stderr
413
469
  # under a machine format. The FAILED branch below has always gone to stderr: it is a failure
414
470
  # report, and a run that produced one has no valid document to protect anyway.
415
471
  def report_verify_incremental(incremental, full, subset_size:, total:, format: "text")
416
- if incremental == full
417
- side_output(format).puts("rigor: --verify-incremental OK — incremental " \
418
- "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
419
- "matches full (#{full.size} diagnostics)")
420
- return 0
472
+ return report_verify_set_mismatch(incremental, full) unless incremental.tally == full.tally
473
+ return report_verify_order_mismatch(incremental, full) unless incremental == full
474
+
475
+ side_output(format).puts("rigor: --verify-incremental OK — incremental " \
476
+ "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
477
+ "matches full (#{full.size} diagnostics)")
478
+ 0
479
+ end
480
+
481
+ # The rows one side has more of than the other, counted by multiplicity: [x, x, y] against [x, y, y] has one
482
+ # extra x on the left and one extra y on the right.
483
+ def multiset_difference(left, right)
484
+ budget = right.tally
485
+ left.reject do |row|
486
+ next false unless budget.fetch(row, 0).positive?
487
+
488
+ budget[row] -= 1
489
+ true
421
490
  end
491
+ end
422
492
 
423
- only_incremental = incremental - full
424
- only_full = full - incremental
493
+ def report_verify_set_mismatch(incremental, full)
494
+ only_incremental = multiset_difference(incremental, full)
495
+ only_full = multiset_difference(full, incremental)
425
496
  @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
426
497
  @err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
427
498
  (only_incremental + only_full).first(10).each do |hash|
428
- @err.puts(" #{hash['path']}:#{hash['line']}:#{hash['column']}: [#{hash['rule']}] #{hash['message']}")
499
+ @err.puts(" #{diagnostic_location(hash)}")
429
500
  end
430
501
  1
431
502
  end
432
503
 
504
+ def report_verify_order_mismatch(incremental, full)
505
+ index = (0...incremental.size).find { |i| incremental[i] != full[i] }
506
+ @err.puts("rigor: --verify-incremental FAILED — same set, different order.")
507
+ @err.puts(" incremental and full report the same #{full.size} diagnostics in a different order; " \
508
+ "first divergence at index #{index}:")
509
+ @err.puts(" incremental: #{diagnostic_location(incremental[index])}")
510
+ @err.puts(" full: #{diagnostic_location(full[index])}")
511
+ 1
512
+ end
513
+
514
+ # Severity, source family and the optional fields a row may carry, so two rows that differ only in one of them
515
+ # never print as the same line.
516
+ def diagnostic_location(hash)
517
+ extra = %w[receiver_type method_name project_definition_site].filter_map do |key|
518
+ " #{key}=#{hash[key]}" if hash.key?(key)
519
+ end
520
+ "#{hash['path']}:#{hash['line']}:#{hash['column']}: #{hash['severity']} [#{hash['rule']}] " \
521
+ "(#{hash['source_family']}) #{hash['message']}#{extra.join}"
522
+ end
523
+
433
524
  # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the flag is set, ANY baseline drift fails the run — not
434
525
  # only excess drift (a bucket over threshold, which already fails via the surfaced diagnostics) but also DEFICIT
435
526
  # drift (`actual < count`: the baseline has grown looser than the code and should be regenerated). A no-op, with a
@@ -689,6 +780,7 @@ module Rigor
689
780
  data = data.dup
690
781
  data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
691
782
  Configuration.new(Configuration::DEFAULTS.merge(data), effects_key_present, target_ruby_key_present)
783
+ .with_exclude_base(path && File.exist?(path) ? File.dirname(path) : nil)
692
784
  end
693
785
 
694
786
  # ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge` CLI selection over the configured
@@ -725,6 +817,13 @@ module Rigor
725
817
  end
726
818
  end
727
819
 
820
+ # Issue #1529 — reads the run's peak RSS straight after the analysis, before coverage or any output, so the
821
+ # figure the summary prints (and the JSON `stats` block carries) covers the analysis alone. A nil `stats`
822
+ # (`--no-stats`, or a run served from a cache) reads nothing.
823
+ def settle_run_stats(stats)
824
+ stats&.peak_rss_bytes
825
+ end
826
+
728
827
  # Emits the {Analysis::RunStats} summary to STDERR so it doesn't interleave with the diagnostic stream (text or
729
828
  # JSON) on STDOUT. JSON consumers can pipe stdout cleanly; interactive users still see the summary on their tty.
730
829
  def write_run_stats(stats)
@@ -78,7 +78,7 @@ module Rigor
78
78
 
79
79
  def run_index
80
80
  if File.file?(LLMS_INDEX)
81
- @out.write(File.read(LLMS_INDEX))
81
+ @out.write(File.read(LLMS_INDEX, encoding: Encoding::UTF_8))
82
82
  0
83
83
  else
84
84
  run_list(nil)
@@ -115,11 +115,12 @@ module Rigor
115
115
  doc = resolve_doc(name)
116
116
  return doc if doc.is_a?(Integer) # error status, already reported
117
117
 
118
- # ASCII-only provenance header: the doc body is read with the external encoding (US-ASCII under a C locale), so
119
- # a UTF-8 header would set the output buffer to UTF-8 and clash with the body.
118
+ # The bundled page is UTF-8 by contract, so it is read as UTF-8 explicitly: under a C or POSIX locale the
119
+ # process default external encoding is US-ASCII, and a non-ASCII body would otherwise raise in `gsub`.
120
+ # The provenance header stays ASCII-only so it never disagrees with the rest of the output.
120
121
  @out.puts("<!-- rigor docs #{doc.fetch(:name)} (rigortype #{Rigor::VERSION}, offline) -->")
121
122
  @out.puts
122
- @out.write(DocLinks.rewrite(File.read(doc.fetch(:path)), from: doc.fetch(:path)))
123
+ @out.write(DocLinks.rewrite(File.read(doc.fetch(:path), encoding: Encoding::UTF_8), from: doc.fetch(:path)))
123
124
  0
124
125
  end
125
126
 
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ require_relative "command"
7
+ require_relative "options"
8
+ require_relative "check_runner_factory"
9
+ require_relative "../configuration"
10
+ require_relative "../lens"
11
+
12
+ module Rigor
13
+ class CLI
14
+ # `rigor lens FILE...` — a declaration map of the files with type provenance, computed as `rigor check`
15
+ # computes them (ADR-113; output contract docs/internal-spec/lens.md). Experimental until `lens/v1`.
16
+ class LensCommand < Command
17
+ USAGE = "Usage: rigor lens [--format=text|json] [--config=PATH] [--workers=N] FILE..."
18
+ FORMATS = %w[text json].freeze
19
+
20
+ def run
21
+ options = parse_options
22
+ return CLI::EXIT_USAGE if options.nil?
23
+
24
+ config = options.fetch(:config)
25
+ if config && !File.file?(config)
26
+ usage_error("no configuration file at #{config}")
27
+ return CLI::EXIT_USAGE
28
+ end
29
+
30
+ configuration = Configuration.load(config)
31
+ config_path = options.fetch(:config) || Configuration.discover
32
+ # The working directory: Rigor auto-detects sig/ and the lockfiles there (lens.md § Serialization).
33
+ root = Dir.pwd
34
+ workers = CheckRunnerFactory.resolve_workers(options, configuration)
35
+ outcome = Lens::Run.new(configuration: configuration, root: root, arguments: @argv, workers: workers).call
36
+ document = Lens::Document.new(outcome: outcome, configuration: configuration, root: root,
37
+ config_path: config_path).to_h
38
+ @err.puts("rigor lens: #{outcome.warm ? 'warm' : 'cold'} — #{@argv.size} target(s)")
39
+ render(document, options.fetch(:format))
40
+ outcome.targets.any? { |target| target.status == "ok" } ? 0 : 1
41
+ end
42
+
43
+ private
44
+
45
+ def parse_options
46
+ options = { format: "text", config: nil, workers: nil }
47
+ OptionParser.new do |opts|
48
+ opts.banner = USAGE
49
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
50
+ opts.on("--workers=N", Integer, "Worker processes for a cold run") { |value| options[:workers] = value }
51
+ Options.add_config(opts, options)
52
+ end.parse!(@argv)
53
+ return usage_error("--format must be text or json") unless FORMATS.include?(options[:format])
54
+ return usage_error("no FILE given") if @argv.empty?
55
+
56
+ options
57
+ rescue OptionParser::ParseError => e
58
+ usage_error(e.message)
59
+ end
60
+
61
+ def usage_error(message)
62
+ @err.puts("rigor lens: #{message}")
63
+ @err.puts(USAGE)
64
+ nil
65
+ end
66
+
67
+ def render(document, format)
68
+ if format == "json"
69
+ @out.puts(JSON.generate(document))
70
+ else
71
+ TextRenderer.new(document, @out).render
72
+ end
73
+ end
74
+
75
+ # The text rendering for agents and people. Its layout is not a contract; the `LINE:HASH` anchor token is.
76
+ class TextRenderer
77
+ def initialize(document, out)
78
+ @document = document
79
+ @out = out
80
+ @members = document["members"].each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |member, index|
81
+ member["sites"].each { |key| index[key] << member }
82
+ end
83
+ end
84
+
85
+ def render
86
+ @document["files"].each { |file| render_file(file) }
87
+ end
88
+
89
+ private
90
+
91
+ def render_file(file)
92
+ unless file["status"] == "ok"
93
+ @out.puts("#{file['argument']} #{file['status']}: #{file['reason']}")
94
+ return
95
+ end
96
+
97
+ omitted = file["omissions"].empty? ? "" : " (#{file['omissions'].size} omitted)"
98
+ @out.puts("#{file['path']} #{file['digest']}#{omitted}")
99
+ file["sites"].each { |site| render_site(site) }
100
+ file["omissions"].each do |omission|
101
+ @out.puts(" omitted #{omission['kind']} #{omission['name']} (line #{omission.dig('span', 'start_line')})")
102
+ end
103
+ file["diagnostics"].each do |diagnostic|
104
+ @out.puts(" #{diagnostic['line']}:#{diagnostic['column']} #{diagnostic['severity']} " \
105
+ "#{diagnostic['message']} [#{diagnostic['rule']}]")
106
+ end
107
+ end
108
+
109
+ def render_site(site)
110
+ case site["kind"]
111
+ when "class", "module" then @out.puts(" #{site['anchor']} #{site['kind']} #{site['name']}")
112
+ else
113
+ @members[site["key"]].each { |member| render_member(site, member) }
114
+ end
115
+ end
116
+
117
+ def render_member(site, member)
118
+ separator = member["scope"] == "singleton" ? "." : "#"
119
+ if member["overloads"].empty?
120
+ elsewhere = member["also_defined_at"].map { |entry| "#{entry['path']}:#{entry['line']}" }
121
+ @out.puts(" #{site['anchor']} #{site['owner']}#{separator}#{member['name']} " \
122
+ "[#{member['basis']}#{"; also #{elsewhere.join(', ')}" unless elsewhere.empty?}]")
123
+ end
124
+ member["overloads"].each do |overload|
125
+ signature = overload["rbs"] || "(signature unavailable: #{unavailable_reasons(overload).join(', ')})"
126
+ @out.puts(" #{site['anchor']} #{site['owner']}#{separator}#{member['name']}: #{signature} " \
127
+ "[#{member['basis']}#{provenance_note(overload)}]")
128
+ end
129
+ end
130
+
131
+ def provenance_note(overload)
132
+ provenances = ([overload["return"]] + (overload["params"] || []).map { |param| param["type"] })
133
+ .filter_map { |slot| slot["provenance"] }.uniq
134
+ provenances.empty? ? "" : " #{provenances.join('+')}"
135
+ end
136
+
137
+ def unavailable_reasons(overload)
138
+ ([overload["return"]] + (overload["params"] || []).map { |param| param["type"] })
139
+ .filter_map { |slot| slot["reason"] }.uniq
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -53,8 +53,11 @@ module Rigor
53
53
  def build_server(writer:, config_path:) # rubocop:disable Metrics/MethodLength
54
54
  configuration = Configuration.load(config_path)
55
55
  # ProjectContext caches Environment + Cache::Store across requests so hover / publish hit the warm path.
56
- # Invalidated by `workspace/didChangeWatchedFiles` and `workspace/didChangeConfiguration`.
57
- project_context = LanguageServer::ProjectContext.new(configuration: configuration)
56
+ # Invalidated by `workspace/didChangeWatchedFiles` and `workspace/didChangeConfiguration`; every save round
57
+ # also re-reads the configuration (the loader below) and re-baselines when its inputs moved (#1613).
58
+ project_context = LanguageServer::ProjectContext.new(
59
+ configuration: configuration, configuration_loader: -> { Configuration.load(config_path) }
60
+ )
58
61
  # Single source of truth for buffer state — threaded to Server + all three providers.
59
62
  buffer_table = LanguageServer::BufferTable.new
60
63
  debouncer = LanguageServer::Debouncer.new
@@ -12,7 +12,7 @@ module Rigor
12
12
  # Starts a long-running MCP (Model Context Protocol) server over stdio. The server exposes Rigor's analysis tools as
13
13
  # MCP tool calls over a newline-delimited JSON-RPC 2.0 stream. See ADR-33.
14
14
  #
15
- # Slice 1 ships the stdio transport with seven read-only tools: rigor_check, rigor_type_of, rigor_triage,
15
+ # The stdio transport serves eight read-only tools: rigor_check, rigor_type_of, rigor_triage, rigor_lens,
16
16
  # rigor_annotate, rigor_sig_gen, rigor_explain, rigor_coverage.
17
17
  class McpCommand < Command
18
18
  USAGE = "Usage: rigor mcp [options]"
@@ -128,7 +128,7 @@ module Rigor
128
128
 
129
129
  @out.puts(render_print_header(skill))
130
130
  @out.puts
131
- @out.write(File.read(skill.fetch(:path)))
131
+ @out.write(File.read(skill.fetch(:path), encoding: Encoding::UTF_8))
132
132
  0
133
133
  end
134
134
 
@@ -145,14 +145,14 @@ module Rigor
145
145
 
146
146
  @out.puts(render_print_header(skill))
147
147
  @out.puts
148
- @out.write(File.read(skill.fetch(:path)))
148
+ @out.write(File.read(skill.fetch(:path), encoding: Encoding::UTF_8))
149
149
 
150
150
  reference_files(skill).each do |ref|
151
151
  @out.puts
152
152
  @out.puts
153
153
  @out.puts("<!-- ===== references/#{File.basename(ref)} (bundled with rigortype #{Rigor::VERSION}) ===== -->")
154
154
  @out.puts
155
- @out.write(File.read(ref))
155
+ @out.write(File.read(ref, encoding: Encoding::UTF_8))
156
156
  end
157
157
  0
158
158
  end