rigortype 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/06-classes.md +1 -1
  17. data/docs/handbook/07-rbs-and-extended.md +76 -101
  18. data/docs/handbook/08-understanding-errors.md +114 -247
  19. data/docs/handbook/09-plugins.md +54 -144
  20. data/docs/handbook/README.md +5 -3
  21. data/docs/handbook/appendix-liskov.md +4 -2
  22. data/docs/handbook/appendix-phpstan.md +14 -7
  23. data/docs/handbook/appendix-steep.md +4 -2
  24. data/docs/handbook/appendix-type-theory.md +3 -1
  25. data/docs/manual/02-cli-reference.md +32 -0
  26. data/docs/manual/04-diagnostics.md +36 -4
  27. data/docs/manual/06-baseline.md +35 -1
  28. data/docs/manual/08-skills.md +6 -1
  29. data/docs/manual/09-editor-integration.md +3 -2
  30. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  31. data/docs/manual/plugins/rigor-devise.md +4 -2
  32. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  33. data/lib/rigor/analysis/check_rules.rb +34 -13
  34. data/lib/rigor/analysis/run_cache_key.rb +10 -0
  35. data/lib/rigor/analysis/runner.rb +2 -1
  36. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  37. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  38. data/lib/rigor/cache/store.rb +99 -24
  39. data/lib/rigor/cli/check_command.rb +12 -6
  40. data/lib/rigor/cli/check_invocation.rb +84 -0
  41. data/lib/rigor/cli/doctor_command.rb +6 -8
  42. data/lib/rigor/cli/skill_command.rb +21 -1
  43. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  44. data/lib/rigor/cli/skill_describe.rb +75 -9
  45. data/lib/rigor/environment/default_libraries.rb +5 -4
  46. data/lib/rigor/environment.rb +10 -1
  47. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  48. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  49. data/lib/rigor/language_server/buffer_table.rb +46 -6
  50. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  51. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  52. data/lib/rigor/language_server/server.rb +19 -9
  53. data/lib/rigor/language_server.rb +1 -0
  54. data/lib/rigor/plugin/base.rb +29 -2
  55. data/lib/rigor/sig_gen/writer.rb +183 -47
  56. data/lib/rigor/version.rb +1 -1
  57. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  58. data/sig/rigor/cache.rbs +6 -0
  59. data/sig/rigor/inference/void_origin.rbs +18 -0
  60. data/sig/rigor/plugin/base.rbs +4 -3
  61. metadata +7 -3
@@ -37,18 +37,10 @@ module Rigor
37
37
  # blob whose class layout still happens to unmarshal.
38
38
  PAYLOAD_ABI_VERSION = Rigor::VERSION
39
39
 
40
- # Whole-project producers are content-keyed, so dependency / signature churn writes a new entry and leaves
41
- # the old generation unreachable. The global 256 MB cap is intentionally generous and often never fires on
42
- # one project, so these producers get a small generation cap as a second compaction axis. Per-file / plugin
43
- # producers are deliberately absent from this table: many current entries under one producer id can be live.
44
- GENERATION_CAP_BY_PRODUCER = {
45
- "analysis.run-diagnostics" => 16,
46
- "rbs.class_ancestor_table" => 2,
47
- "rbs.class_type_param_names" => 2,
48
- "rbs.constant_type_table" => 2,
49
- "rbs.environment" => 2,
50
- "rbs.known_class_names" => 2
51
- }.freeze
40
+ # The `generation_cap:` value declaring that a producer keeps MANY live entries under one id at once
41
+ # (per-file and per-plugin producers): a generation count is not a staleness proxy there, so the
42
+ # compaction pass in {#evict!} leaves the producer alone and only the size-based LRU pass can touch it.
43
+ UNBOUNDED_GENERATIONS = :unbounded
52
44
 
53
45
  STALE_TEMP_FILE_AGE_SECONDS = 60 * 60
54
46
 
@@ -76,6 +68,14 @@ module Rigor
76
68
  @misses = 0
77
69
  @writes = 0
78
70
  @by_producer = Hash.new { |h, k| h[k] = { hits: 0, misses: 0, writes: 0 } }
71
+ # `producer_id => generation cap`, populated from the `generation_cap:` every fetch call declares (see
72
+ # {#declare_generation_cap}). This is the bridge between the id STRINGS the fetch API takes and the
73
+ # directory names {#evict!} walks: a producer id the Store never saw declared stays uncapped, which
74
+ # errs toward under-evicting. Per-instance rather than process-global on purpose — a global mutable
75
+ # registry would be a `Ractor` shareability hazard for pool mode, and the Store instance that wrote a
76
+ # generation is the one whose `evict!` compacts it (`CLI::CheckCommand` calls
77
+ # `runner.cache_store&.evict!`).
78
+ @generation_caps = {}
79
79
  # Process-level in-memory layer keyed by `(producer_id, cache_key)`. Avoids the disk read +
80
80
  # `Marshal.load` cost (the dominant share of repeated cache-hit calls per stackprof) when many
81
81
  # short-lived `Analysis::Runner` instances share one `Store` — the spec process, the LSP daemon's
@@ -161,6 +161,12 @@ module Rigor
161
161
  private_class_method :collect_producers
162
162
 
163
163
  # @param producer_id [String] stable cache namespace; only `[a-z][a-z0-9._-]*` is accepted.
164
+ # @param generation_cap [Integer, Symbol] how many generations of this producer survive a compaction
165
+ # pass — a positive `Integer` for a whole-project producer (one live entry, older ones unreachable),
166
+ # or {UNBOUNDED_GENERATIONS} for a producer with many simultaneously-live entries. REQUIRED, and
167
+ # sourced from the producer's own declaration (`RbsCacheProducer.generation_cap`,
168
+ # `RunCacheKey::GENERATION_CAP`, `Plugin::Base.producer generation_cap:`) rather than invented at the
169
+ # call site. See {#evict!}.
164
170
  # @param params [Hash] producer inputs; mixed into the cache key via {Descriptor#cache_key_for}.
165
171
  # @param descriptor [Rigor::Cache::Descriptor] the invalidation descriptor for the value being cached.
166
172
  # @param serialize [#call, nil] optional callable that turns the producer's return value into a binary
@@ -175,9 +181,10 @@ module Rigor
175
181
  # path.
176
182
  # @yieldreturn the value to cache.
177
183
  # @return the cached value (loaded from disk on hit; produced by the block on miss).
178
- def fetch_or_compute(producer_id:, params:, descriptor:,
184
+ def fetch_or_compute(producer_id:, params:, descriptor:, generation_cap:,
179
185
  serialize: nil, deserialize: nil, &block)
180
186
  validate_producer_id!(producer_id)
187
+ declare_generation_cap(producer_id, generation_cap)
181
188
  disk = ensure_schema_version!
182
189
 
183
190
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
@@ -217,8 +224,12 @@ module Rigor
217
224
  #
218
225
  # The block MUST return `[value, dependency_descriptor]`. Disk reads are not in-process-memoised —
219
226
  # validation always re-checks the filesystem — but a single run only looks up once.
220
- def fetch_or_validate(producer_id:, key_descriptor:, params: {}, serialize: nil, deserialize: nil)
227
+ #
228
+ # `generation_cap:` carries the same producer-declared compaction budget as {#fetch_or_compute}.
229
+ def fetch_or_validate(producer_id:, key_descriptor:, generation_cap:, params: {},
230
+ serialize: nil, deserialize: nil)
221
231
  validate_producer_id!(producer_id)
232
+ declare_generation_cap(producer_id, generation_cap)
222
233
  disk = ensure_schema_version!
223
234
 
224
235
  key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
@@ -243,6 +254,9 @@ module Rigor
243
254
  # to serve a run's diagnostics WITHOUT loading the inference engine — it never runs a producer block, so
244
255
  # there is nothing to write. Records a hit (for `--cache-stats` parity) but never a miss (a probe miss
245
256
  # hands off to the full path, which records its own).
257
+ #
258
+ # Takes no `generation_cap:`: a pure read creates no generation, so a run that only ever peeks has
259
+ # nothing to compact. The write path for the same producer declares the cap.
246
260
  def peek_validated(producer_id:, key_descriptor:, params: {}, deserialize: nil)
247
261
  validate_producer_id!(producer_id)
248
262
  return nil unless ensure_schema_version!
@@ -256,8 +270,15 @@ module Rigor
256
270
  validated[0]
257
271
  end
258
272
 
259
- # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only. Stale
260
- # temp file cleanup and the whole-project generation cap run regardless of `max_bytes:` — they reclaim
273
+ # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only.
274
+ #
275
+ # The generation cap of pass 2 comes from what the producers themselves declared through this Store's
276
+ # fetch calls (`generation_cap:`), NOT from a maintained table of producer ids: a producer id this
277
+ # Store never saw is left alone. That is deliberately the safe direction — a producer that was not
278
+ # consulted this run also wrote no new generation, so the only entries this can skip are ones that were
279
+ # already sitting there.
280
+ #
281
+ # Stale temp file cleanup and the generation cap run regardless of `max_bytes:` — they reclaim
261
282
  # provably-dead bytes (leaked temp files, unreachable content-keyed generations) rather than enforcing a
262
283
  # size budget, so an explicitly unbounded store (`max_bytes: nil`) still benefits from them. The
263
284
  # size-based LRU pass below stays gated on `max_bytes:` being configured: it walks all remaining
@@ -279,7 +300,7 @@ module Rigor
279
300
  entries.each do |entry|
280
301
  break if total <= @max_bytes
281
302
 
282
- total -= entry[:bytes] if unlink_entry(entry[:path])
303
+ total -= entry[:bytes] if unlink_entry_and_shard?(entry[:path])
283
304
  end
284
305
  nil
285
306
  rescue StandardError
@@ -320,6 +341,37 @@ module Rigor
320
341
  "producer_id must match #{VALID_PRODUCER_ID.inspect}, got #{producer_id.inspect}"
321
342
  end
322
343
 
344
+ # Records the producer's declared compaction budget for {#evict!}. Every fetch call carries it, so a
345
+ # producer cannot reach disk without stating one — the failure mode the previous hardcoded id table
346
+ # had (a new whole-project producer silently uncapped) is not expressible.
347
+ #
348
+ # Two DIFFERENT caps for one producer id in one process is a producer bug, not a policy: whichever the
349
+ # compaction pass picked would be arbitrary. It raises, consistent with the serializer-contract
350
+ # violations {#try_write_entry} deliberately lets through.
351
+ def declare_generation_cap(producer_id, generation_cap)
352
+ validate_generation_cap!(producer_id, generation_cap)
353
+ @monitor.synchronize do
354
+ previous = @generation_caps[producer_id]
355
+ if !previous.nil? && previous != generation_cap
356
+ raise ArgumentError,
357
+ "producer #{producer_id.inspect} declared generation_cap #{generation_cap.inspect} after " \
358
+ "#{previous.inspect}; one producer id must declare one cap"
359
+ end
360
+
361
+ @generation_caps[producer_id] = generation_cap
362
+ end
363
+ end
364
+
365
+ def validate_generation_cap!(producer_id, generation_cap)
366
+ return if generation_cap == UNBOUNDED_GENERATIONS
367
+ return if generation_cap.is_a?(Integer) && generation_cap.positive?
368
+
369
+ raise ArgumentError,
370
+ "producer #{producer_id.inspect} must declare generation_cap as a positive Integer (a " \
371
+ "whole-project producer: how many generations survive compaction) or " \
372
+ "#{UNBOUNDED_GENERATIONS.inspect} (many entries live at once), got #{generation_cap.inspect}"
373
+ end
374
+
323
375
  def entry_path(producer_id, key)
324
376
  File.join(@root, producer_id, key[0, 2], "#{key[2..]}.entry")
325
377
  end
@@ -541,7 +593,7 @@ module Rigor
541
593
  next unless File.file?(path)
542
594
  next if File.mtime(path) > cutoff
543
595
 
544
- unlink_entry(path)
596
+ unlink_entry_and_shard?(path)
545
597
  rescue StandardError
546
598
  next
547
599
  end
@@ -550,16 +602,16 @@ module Rigor
550
602
  end
551
603
 
552
604
  def evict_excess_generations(entries)
605
+ caps = @monitor.synchronize { @generation_caps.dup }
553
606
  removed = {}
554
607
  entries.group_by { |entry| entry[:producer] }.each do |producer, producer_entries|
555
- cap = GENERATION_CAP_BY_PRODUCER[producer]
608
+ cap = caps[producer]
609
+ cap = nil if cap == UNBOUNDED_GENERATIONS
556
610
  next if cap.nil? || producer_entries.size <= cap
557
611
 
558
- producer_entries.sort_by { |entry| [entry[:mtime], entry[:path]] }
559
- .first(producer_entries.size - cap)
560
- .each do |entry|
561
- removed[entry[:path]] = true if unlink_entry(entry[:path])
562
- end
612
+ excess = producer_entries.sort_by { |entry| [entry[:mtime], entry[:path]] }
613
+ .first(producer_entries.size - cap)
614
+ excess.each { |entry| removed[entry[:path]] = true if unlink_entry_and_shard?(entry[:path]) }
563
615
  end
564
616
  return entries if removed.empty?
565
617
 
@@ -573,6 +625,29 @@ module Rigor
573
625
  false
574
626
  end
575
627
 
628
+ # {#unlink_entry} plus a best-effort {#rmdir_if_empty} of the shard directory the unlinked file left
629
+ # behind. Shared by every eviction/sweep pass so an emptied shard is cleaned up wherever an entry (or
630
+ # stale temp file) is removed, per the issue #216 fossil fix.
631
+ def unlink_entry_and_shard?(path)
632
+ return false unless unlink_entry(path)
633
+
634
+ rmdir_if_empty(File.dirname(path))
635
+ true
636
+ end
637
+
638
+ # Removes `dir` — a shard directory (`entry_path`'s `key[0, 2]` component) — when the unlink that
639
+ # just preceded this call was the one to empty it. Best-effort and rescued the same way
640
+ # {#unlink_entry} is: a concurrent writer's `FileUtils.mkdir_p` recreating the shard between the
641
+ # unlink and this call (`Errno::ENOTEMPTY`) or another pass already having removed it
642
+ # (`Errno::ENOENT`) are both benign outcomes, never a reason to break the eviction/sweep pass. This
643
+ # is purely cosmetic (inode reclaim) — it never decides which ENTRIES are evicted, only tidies the
644
+ # directory left behind once they are gone.
645
+ def rmdir_if_empty(dir)
646
+ Dir.rmdir(dir)
647
+ rescue StandardError
648
+ nil
649
+ end
650
+
576
651
  # Returns an array of `{ path:, producer:, mtime:, bytes: }` hashes for every `.entry` file under the
577
652
  # cache root, skipping unreadable entries.
578
653
  def collect_entry_stats
@@ -65,11 +65,12 @@ module Rigor
65
65
  special = dispatch_special_check_mode(configuration, options, cache_root)
66
66
  return special unless special.nil?
67
67
 
68
- runner = build_check_runner(
68
+ invocation = invoke_check(
69
69
  configuration: configuration, options: options,
70
70
  buffer: buffer, cache_root: cache_root
71
71
  )
72
- raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
72
+ runner = invocation.runner
73
+ raw_result = invocation.result
73
74
  result = apply_baseline_filter(raw_result, configuration, options)
74
75
 
75
76
  coverage = compute_coverage(runner, configuration, options)
@@ -383,11 +384,16 @@ module Rigor
383
384
  @err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
384
385
  end
385
386
 
386
- def build_check_runner(configuration:, options:, buffer:, cache_root:)
387
- require_relative "check_runner_factory"
388
- CheckRunnerFactory.build(
387
+ # The primary check run, routed through the shared {CheckInvocation} entry point so `rigor doctor` and
388
+ # `rigor skill describe --deep` reach a result through this exact path rather than re-deriving it (#148).
389
+ # Required lazily for the reason the old inline `CheckRunnerFactory.build` was: it pulls the inference engine,
390
+ # and ADR-87 WD4's cache-hit fast path must reach its verdict without it.
391
+ def invoke_check(configuration:, options:, buffer:, cache_root:)
392
+ require_relative "check_invocation"
393
+ CheckInvocation.run(
389
394
  configuration: configuration, options: options,
390
- buffer: buffer, cache_root: cache_root
395
+ buffer: buffer, cache_root: cache_root,
396
+ paths: @argv.empty? ? nil : @argv
391
397
  )
392
398
  end
393
399
 
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # The one place a CLI command turns a loaded {Configuration} into an {Analysis::Result}.
6
+ #
7
+ # {CheckRunnerFactory} builds the runner; this module owns the *invocation* wrapped around it — resolving the
8
+ # analysed paths, running, and handing back both the runner (`rigor check`'s reporting tail still needs its cache
9
+ # store and stats) and the raw result. It exists so a non-`check` command — `rigor doctor`, `rigor skill describe
10
+ # --deep` — reaches a check result through the same code path `rigor check` walks, instead of re-deriving the
11
+ # plumbing (issue #148; ADR-73 § "Field-trial follow-ups" names the shared helper as the prerequisite).
12
+ #
13
+ # Nothing here is required at load time: {CheckRunnerFactory} pulls the inference engine, so both entry points
14
+ # require it lazily. A command that merely *mentions* this module therefore stays engine-free until it actually
15
+ # runs an analysis — the property ADR-87 WD4 protects for `rigor check`'s cache-hit fast path, and the property
16
+ # that keeps an un-flagged `rigor skill describe` from loading the engine at all.
17
+ module CheckInvocation
18
+ # A completed analysis. `runner` is exposed because `rigor check`'s tail reads its `cache_store` (eviction,
19
+ # `--cache-stats`) and feeds it to `--coverage`; `result` is the raw, pre-baseline-filter {Analysis::Result}.
20
+ Invocation = Data.define(:runner, :result)
21
+
22
+ # The outcome of {.attempt} — the best-effort variant. Exactly one of the two is non-nil, so a caller can never
23
+ # read "the check could not run" as "the check ran clean": `result` is nil precisely when `error` is set.
24
+ Outcome = Data.define(:result, :error) do
25
+ # @return [Boolean] true when an analysis actually completed.
26
+ def ran?
27
+ !result.nil?
28
+ end
29
+ end
30
+
31
+ # Options for a caller that wants a check *result* rather than a check *run*: no cache writes, no explain
32
+ # traces, sequential. `stats: true` because the RBS-environment signal (`stats.rbs_classes_total`) lives there.
33
+ # This is `rigor doctor`'s preset — a diagnostic command that must not churn the project's cache.
34
+ READ_ONLY_OPTIONS = { no_cache: true, explain: false, stats: true, workers: 0 }.freeze
35
+
36
+ # Options for an opt-in deep probe (`rigor skill describe --deep`). Unlike {READ_ONLY_OPTIONS} this uses the
37
+ # configured cache and worker count, i.e. it behaves exactly like `rigor check` — fast on a warm cache, and it
38
+ # WRITES `.rigor/cache`. That side effect is the whole reason the flag is opt-in.
39
+ DEEP_OPTIONS = { no_cache: false, explain: false, stats: true, workers: nil }.freeze
40
+
41
+ module_function
42
+
43
+ # Runs the analysis the way `rigor check` does and returns the runner + its raw result.
44
+ #
45
+ # @param configuration [Rigor::Configuration]
46
+ # @param options [Hash] at least `:no_cache`, `:explain`, `:stats`, `:workers` (see {CheckRunnerFactory.build}).
47
+ # @param paths [Array<String>, nil] analysed paths; nil falls back to the configuration's `paths:`.
48
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
49
+ # @param cache_root [String, nil] nil falls back to the configuration's cache path.
50
+ # @return [Invocation]
51
+ def run(configuration:, options:, paths: nil, buffer: nil, cache_root: nil)
52
+ require_relative "check_runner_factory"
53
+ runner = CheckRunnerFactory.build(
54
+ configuration: configuration,
55
+ options: options,
56
+ buffer: buffer,
57
+ cache_root: cache_root || configuration.cache_path
58
+ )
59
+ Invocation.new(runner: runner, result: runner.run(paths || configuration.paths))
60
+ end
61
+
62
+ # Best-effort variant for a *routing* caller: one that wants to sharpen a recommendation with check evidence and
63
+ # has a perfectly good answer when there is none. Loads the configuration itself and converts every way the run
64
+ # can fail outright — an unreadable / invalid config, an unloadable plugin, an engine error — into an {Outcome}
65
+ # carrying a short human-readable `error`. It never raises and never fabricates a clean result.
66
+ #
67
+ # Deliberately broad: the caller's contract is "degrade to the presence-only answer, whatever went wrong". A
68
+ # routing hint is outside the false-positive envelope, but a crash in it is not — `describe` must stay a command
69
+ # an agent can run freely.
70
+ #
71
+ # @param config_path [String, nil] path to the config file; nil uses {Configuration.discover}.
72
+ # @param options [Hash] runner options (defaults to {DEEP_OPTIONS}).
73
+ # @param paths [Array<String>, nil]
74
+ # @return [Outcome]
75
+ def attempt(config_path: nil, options: DEEP_OPTIONS, paths: nil)
76
+ require_relative "../configuration"
77
+ configuration = Configuration.load(config_path)
78
+ Outcome.new(result: run(configuration: configuration, options: options, paths: paths).result, error: nil)
79
+ rescue StandardError, LoadError => e
80
+ Outcome.new(result: nil, error: "#{e.class}: #{e.message.to_s.lines.first.to_s.strip}")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -12,7 +12,7 @@ require_relative "../plugin/loader"
12
12
  require_relative "../plugin/services"
13
13
  require_relative "../reflection"
14
14
  require_relative "../type/combinator"
15
- require_relative "check_runner_factory"
15
+ require_relative "check_invocation"
16
16
  require_relative "command"
17
17
  require_relative "options"
18
18
 
@@ -53,14 +53,12 @@ module Rigor
53
53
  findings.concat(audit_config(configuration))
54
54
 
55
55
  # 2. Run a scoped analysis to gather stats + diagnostics for the
56
- # deeper checks. Use no-cache so the probe doesn't churn disk.
57
- runner = CheckRunnerFactory.build(
56
+ # deeper checks, through the shared check-invocation entry point (#148).
57
+ # `READ_ONLY_OPTIONS` is no-cache so the probe doesn't churn disk.
58
+ result = CheckInvocation.run(
58
59
  configuration: configuration,
59
- options: { no_cache: true, explain: false, stats: true, workers: 0 },
60
- buffer: nil,
61
- cache_root: configuration.cache_path
62
- )
63
- result = runner.run(configuration.paths)
60
+ options: CheckInvocation::READ_ONLY_OPTIONS
61
+ ).result
64
62
 
65
63
  # 3. RBS environment check.
66
64
  findings.concat(check_rbs_environment(result))
@@ -36,6 +36,13 @@ module Rigor
36
36
  # version-coupled guidance is frozen into
37
37
  # the SKILL. Also spelled `describe`, and
38
38
  # surfaced top-level as `rigor describe`.
39
+ # - `rigor skill describe --deep`
40
+ # — the same report, except it runs `rigor
41
+ # check` first and lets the result pick the
42
+ # headline. Opt-in because that is slow and
43
+ # writes the cache; the un-flagged form keeps
44
+ # WD2's presence-only, side-effect-free
45
+ # contract exactly (issue #148).
39
46
  #
40
47
  # `describe` is a no-argument action, not a name-slot verb, so it stays first-class alongside `--describe`.
41
48
  class SkillCommand < Command
@@ -51,6 +58,10 @@ module Rigor
51
58
  rigor skill --path <name> Print the absolute path of the SKILL.md file for <name>
52
59
  rigor skill --list List bundled skills (name + absolute path)
53
60
  rigor skill --describe Report project state + recommend the next skill to run
61
+ (presence-only probe — never runs `rigor check`)
62
+ rigor skill describe --deep
63
+ Same, but run `rigor check` first and route the
64
+ recommendation on its result (slow; writes the cache)
54
65
 
55
66
  Examples:
56
67
  rigor skill
@@ -58,6 +69,7 @@ module Rigor
58
69
  rigor skill --full rigor-baseline-reduce
59
70
  rigor skill --path rigor-baseline-reduce
60
71
  rigor skill --describe (also: rigor describe)
72
+ rigor skill describe --deep (also: rigor describe --deep)
61
73
  USAGE
62
74
 
63
75
  # The bundled skills live at `<gem_root>/skills/`. From `lib/rigor/cli/skill_command.rb` that is three directories
@@ -159,10 +171,18 @@ module Rigor
159
171
  # probes the current project's state with cheap presence checks (it never runs `rigor check`), recommends the next
160
172
  # skill to run, and prints every bundled skill's current frontmatter description, so the `rigor-next-steps` SKILL
161
173
  # can route without copying any version-coupled guidance into itself.
174
+ #
175
+ # `--deep` is the one opt-in that breaks the "never runs `rigor check`" guarantee — and only for the invocation
176
+ # that asks for it (issue #148 / ADR-73 § "Field-trial follow-ups"). It runs a real check and lets the result
177
+ # pick the headline; the un-flagged command is unchanged, down to loading no analysis code at all.
162
178
  def run_describe
179
+ deep = !@argv.delete("--deep").nil?
180
+ unknown = @argv.first
181
+ return usage_error("unknown option for `describe`: #{unknown}") unless unknown.nil?
182
+
163
183
  require_relative "skill_describe"
164
184
 
165
- @out.puts(SkillDescribe.new(skills: discover_skills).render)
185
+ @out.puts(SkillDescribe.new(skills: discover_skills, deep: deep).render)
166
186
  0
167
187
  end
168
188
 
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check_invocation"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # `rigor skill describe --deep` — the one part of `describe` that runs an analysis (issue #148; ADR-73
8
+ # § "Field-trial follow-ups", the "headline check-awareness" open decision, shape (b)).
9
+ #
10
+ # **The boundary is this file.** ADR-73 WD2 makes `describe` presence-only and side-effect-free, and that
11
+ # guardrail binds the *un-flagged* command exactly as before: {SkillDescribe} requires this file only when
12
+ # `--deep` was passed, so a default `rigor skill describe` never loads the inference engine, never opens a
13
+ # project source file, and writes nothing. `--deep` is the opt-in that trades that purity away: it runs a real
14
+ # `rigor check` over the configured paths using the configured cache and worker count, so it is as slow as
15
+ # `rigor check` on a cold cache and it WRITES `.rigor/cache` just like `rigor check` does.
16
+ #
17
+ # The routing vocabulary is deliberately the one `describe`'s "For the agent" section already teaches (errors →
18
+ # `rigor-baseline-reduce`, a proven monkey-patch cluster → `rigor-monkeypatch-resolve`, an empty RBS environment
19
+ # or a `configuration-error` → `rigor-doctor`). `--deep` makes the headline compute the call the agent would
20
+ # otherwise make from the same evidence; it does not introduce a second taxonomy.
21
+ class SkillDeepProbe
22
+ # Diagnostics the engine emits when the *setup* is broken rather than the code — the `rigor-doctor` signal.
23
+ CONFIGURATION_ERROR_RULE = "configuration-error"
24
+
25
+ # The share of a run's errors that must be proven monkey-patches before the headline routes to
26
+ # `rigor-monkeypatch-resolve` rather than `rigor-baseline-reduce`. The agent-prompt vocabulary this reuses says
27
+ # "a monkey-patch *cluster*", and a cluster is what makes the workflow the right one: `pre_eval:` clears those
28
+ # sites wholesale, so it is the shortest path only when clearing them materially changes the count. One proven
29
+ # site beside four hundred unrelated errors is a true finding pointing at the wrong workflow — the report says
30
+ # so in the reason line either way, so nothing is lost by leaving the headline on the bigger problem.
31
+ MONKEY_PATCH_CLUSTER_SHARE = Rational(1, 3)
32
+
33
+ # What the deep check concluded.
34
+ #
35
+ # - `status` — `:skipped` (no config to check), `:error` (the check could not run), `:analyzed` (it ran).
36
+ # - `detail` — the human line printed under "## Deep check"; on `:error` it says so outright, because a check
37
+ # that could not run is NOT a clean check and the report must never let it read as one.
38
+ # - `route` / `reason` — the headline override, or nil to leave the presence-only recommendation standing.
39
+ Report = Data.define(:status, :detail, :route, :reason) do
40
+ # @return [Boolean] true when the check did not complete — the caller must then say so rather than let the
41
+ # fallback recommendation read as an all-clear.
42
+ def failed?
43
+ status == :error
44
+ end
45
+ end
46
+
47
+ # @param config [String, nil] the config filename the presence probe found, relative to `root`.
48
+ # @param root [String] project root (the analysis itself resolves paths against the process cwd, as
49
+ # `rigor check` does).
50
+ def initialize(config:, root: Dir.pwd)
51
+ @config = config
52
+ @root = root
53
+ end
54
+
55
+ # @return [Report] never raises — every failure degrades to a `:error`/`:skipped` report whose `route` is nil,
56
+ # leaving the presence-only headline in charge.
57
+ def run
58
+ if @config.nil?
59
+ return Report.new(
60
+ status: :skipped, route: nil, reason: nil,
61
+ detail: "skipped — this project has no Rigor configuration, so there is nothing to check yet."
62
+ )
63
+ end
64
+
65
+ outcome = CheckInvocation.attempt(config_path: File.join(@root, @config))
66
+ return failed(outcome.error) unless outcome.ran?
67
+
68
+ classify(outcome.result)
69
+ end
70
+
71
+ private
72
+
73
+ # A check that could not run at all. Reported as a failure, not as a clean result, and with the raw error text
74
+ # so the user can act on it — the un-run check is itself a finding, but not one precise enough to route on.
75
+ def failed(error)
76
+ Report.new(
77
+ status: :error, route: nil, reason: nil,
78
+ detail: "the check could NOT run: #{error}. This is not a clean result — the recommendation below " \
79
+ "falls back to the presence-only probe. Run `rigor check` (or `rigor doctor`) to see what failed."
80
+ )
81
+ end
82
+
83
+ def classify(result)
84
+ sites = monkey_patch_diagnostics(result)
85
+ if broken_environment?(result)
86
+ doctor_report(result)
87
+ elsif monkey_patch_cluster?(result, sites)
88
+ monkeypatch_report(result, sites)
89
+ elsif result.error_count.positive?
90
+ baseline_report(result, sites)
91
+ else
92
+ Report.new(
93
+ status: :analyzed, route: nil, reason: nil,
94
+ detail: "the check ran clean — 0 error diagnostics. The recommendation below is the presence-only one."
95
+ )
96
+ end
97
+ end
98
+
99
+ # The `rigor-doctor` signal, read exactly as `rigor doctor` itself reads it: an RBS environment that built to
100
+ # zero classes (analysis is hollow, so every other count is meaningless), or a `configuration-error` the run
101
+ # already emitted. Checked first — with a broken setup the remaining diagnostics are not evidence of anything.
102
+ def broken_environment?(result)
103
+ result.stats&.rbs_classes_total&.zero? ||
104
+ result.diagnostics.any? { |diagnostic| diagnostic.rule == CONFIGURATION_ERROR_RULE }
105
+ end
106
+
107
+ # ADR-17 / the `project-monkey-patch-known` triage recogniser: `call.undefined-method` sets
108
+ # `project_definition_site` when the project itself defines the called method on the receiver class somewhere
109
+ # in the analysed file set. That is engine-proven evidence, not a spread heuristic, which is why it routes with
110
+ # no count threshold — and why the *unproven* shapes (a bare `call.unresolved-toplevel` count, "framework calls
111
+ # typing as Dynamic") are deliberately not routed on here. Guessing a workflow from a weak signal is worse than
112
+ # leaving the generic recommendation standing.
113
+ def monkey_patch_diagnostics(result)
114
+ result.diagnostics.select(&:project_definition_site)
115
+ end
116
+
117
+ # Whether the proven sites dominate the run enough for `pre_eval:` to be the shortest path. Any proven site is
118
+ # real evidence — `project_definition_site` is engine-proven, not a spread heuristic — but evidence of a *site*
119
+ # is not evidence that clearing it is the next thing to do. Below the share, `baseline_report` still names the
120
+ # sites, so the finding survives even when the headline does not follow it.
121
+ def monkey_patch_cluster?(result, sites)
122
+ return false if sites.empty?
123
+ return true unless result.error_count.positive?
124
+
125
+ Rational(sites.size, result.error_count) >= MONKEY_PATCH_CLUSTER_SHARE
126
+ end
127
+
128
+ def doctor_report(result)
129
+ classes = result.stats&.rbs_classes_total
130
+ cause = if classes&.zero?
131
+ "the RBS environment built to 0 classes"
132
+ else
133
+ "the run emitted a `#{CONFIGURATION_ERROR_RULE}` diagnostic"
134
+ end
135
+ Report.new(
136
+ status: :analyzed, route: "rigor-doctor",
137
+ reason: "a `--deep` check found a broken setup (#{cause}) — the analysis is hollow until that is fixed, " \
138
+ "so no other finding is trustworthy yet.",
139
+ detail: "the check found a broken setup — #{cause}."
140
+ )
141
+ end
142
+
143
+ def monkeypatch_report(result, sites)
144
+ files = sites.filter_map { |d| d.project_definition_site&.sub(/:\d+\z/, "") }.uniq.sort
145
+ Report.new(
146
+ status: :analyzed, route: "rigor-monkeypatch-resolve",
147
+ reason: "a `--deep` check found #{sites.size} call site(s) that resolve to the project's own definitions " \
148
+ "in #{files.first(3).join(', ')} — reopened classes Rigor does not apply cross-file. Listing " \
149
+ "them in `pre_eval:` clears them wholesale.",
150
+ detail: "the check reported #{result.error_count} error diagnostic(s), #{sites.size} of them proven " \
151
+ "project monkey-patches (#{files.size} file(s))."
152
+ )
153
+ end
154
+
155
+ def baseline_report(result, sites = [])
156
+ aside = if sites.empty?
157
+ ""
158
+ else
159
+ " #{sites.size} of them are proven project monkey-patches — too small a share to make `pre_eval:` " \
160
+ "the shortest path, but `rigor-monkeypatch-resolve` clears them if you want them gone first."
161
+ end
162
+ Report.new(
163
+ status: :analyzed, route: "rigor-baseline-reduce",
164
+ reason: "a `--deep` check reported #{result.error_count} error diagnostic(s) — work them down (or record " \
165
+ "them in a baseline) before adding more surface.#{aside}",
166
+ detail: "the check reported #{result.error_count} error diagnostic(s), " \
167
+ "#{sites.empty? ? 'none' : sites.size} of them proven project monkey-patches."
168
+ )
169
+ end
170
+ end
171
+ end
172
+ end