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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/string_scanner.rbs +6 -5
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/06-classes.md +1 -1
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -247
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/manual/02-cli-reference.md +32 -0
- data/docs/manual/04-diagnostics.md +36 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +3 -2
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +34 -13
- data/lib/rigor/analysis/run_cache_key.rb +10 -0
- data/lib/rigor/analysis/runner.rb +2 -1
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +99 -24
- data/lib/rigor/cli/check_command.rb +12 -6
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/doctor_command.rb +6 -8
- data/lib/rigor/cli/skill_command.rb +21 -1
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment.rb +10 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +46 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/server.rb +19 -9
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/sig_gen/writer.rb +183 -47
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +18 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- metadata +7 -3
data/lib/rigor/cache/store.rb
CHANGED
|
@@ -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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
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
|
-
|
|
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.
|
|
260
|
-
#
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
560
|
-
|
|
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
|
-
|
|
68
|
+
invocation = invoke_check(
|
|
69
69
|
configuration: configuration, options: options,
|
|
70
70
|
buffer: buffer, cache_root: cache_root
|
|
71
71
|
)
|
|
72
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 "
|
|
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
|
|
57
|
-
|
|
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:
|
|
60
|
-
|
|
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
|