rigortype 0.1.7 → 0.1.8
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/lib/rigor/analysis/check_rules.rb +3 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -3
- data/lib/rigor/analysis/runner.rb +178 -3
- data/lib/rigor/analysis/worker_session.rb +14 -3
- data/lib/rigor/cli/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +135 -12
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
- data/lib/rigor/inference/method_dispatcher.rb +31 -3
- data/lib/rigor/inference/scope_indexer.rb +156 -6
- data/lib/rigor/inference/statement_evaluator.rb +40 -20
- data/lib/rigor/scope.rb +46 -0
- data/lib/rigor/triage/catalogue.rb +296 -0
- data/lib/rigor/triage/hint.rb +27 -0
- data/lib/rigor/triage.rb +89 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9880f534e47f36db5fbbaa5e6d169350f90ecf1ed12081e4ccf5e5e01e9d4518
|
|
4
|
+
data.tar.gz: 1abb8764932adadaafc6cf43152d2306002aa04419ec403e81e63eed3cedb08e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9135e0d12d588614b7fc1c4af793aab3b798f42bae19e0a72d15ba875e979f24927abcce8976431d7694b856bdc89fc94c588874414fdeee5f437c915af956d
|
|
7
|
+
data.tar.gz: d77dbf70aa13cb3418636e31ff498384ebefd21e0c6887324cc316975e862d1a09c86dc06f313fa081b17fd5f0f7c1592ee786f1958be66ab02e697c5630b80d
|
|
@@ -1215,7 +1215,9 @@ module Rigor
|
|
|
1215
1215
|
line: location.start_line,
|
|
1216
1216
|
column: location.start_column + 1,
|
|
1217
1217
|
message: "undefined method `#{call_node.name}' for #{rendered_receiver}",
|
|
1218
|
-
severity: :error
|
|
1218
|
+
severity: :error,
|
|
1219
|
+
receiver_type: rendered_receiver,
|
|
1220
|
+
method_name: call_node.name.to_s
|
|
1219
1221
|
)
|
|
1220
1222
|
end
|
|
1221
1223
|
|
|
@@ -8,7 +8,8 @@ module Rigor
|
|
|
8
8
|
# baseline against which non-default families are recognised.
|
|
9
9
|
DEFAULT_SOURCE_FAMILY = :builtin
|
|
10
10
|
|
|
11
|
-
attr_reader :path, :line, :column, :message, :severity, :rule, :source_family
|
|
11
|
+
attr_reader :path, :line, :column, :message, :severity, :rule, :source_family,
|
|
12
|
+
:receiver_type, :method_name
|
|
12
13
|
|
|
13
14
|
# `rule:` is the stable identifier (a kebab-case string)
|
|
14
15
|
# of the diagnostic's source rule. It is used by the
|
|
@@ -24,8 +25,19 @@ module Rigor
|
|
|
24
25
|
# ADR-2 § "Plugin Diagnostic Provenance") let consumers
|
|
25
26
|
# distinguish where a diagnostic originated without committing
|
|
26
27
|
# to the plugin API itself.
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
#
|
|
29
|
+
# `receiver_type:` / `method_name:` are optional structured
|
|
30
|
+
# fields populated by the call-related rules (`call.undefined-
|
|
31
|
+
# method`) — the rendered receiver type and the called method
|
|
32
|
+
# name as plain strings. ADR-23 WD3 / slice 4: `rigor triage`'s
|
|
33
|
+
# heuristic recognisers read these directly instead of parsing
|
|
34
|
+
# the diagnostic message, so the catalogue no longer couples to
|
|
35
|
+
# message wording. Both stay nil for rules that have no such
|
|
36
|
+
# pair; a consumer that finds them nil falls back to message
|
|
37
|
+
# parsing.
|
|
38
|
+
def initialize(path:, line:, column:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
|
|
39
|
+
source_family: DEFAULT_SOURCE_FAMILY,
|
|
40
|
+
receiver_type: nil, method_name: nil)
|
|
29
41
|
@path = path
|
|
30
42
|
@line = line
|
|
31
43
|
@column = column
|
|
@@ -33,6 +45,8 @@ module Rigor
|
|
|
33
45
|
@severity = severity
|
|
34
46
|
@rule = rule
|
|
35
47
|
@source_family = source_family
|
|
48
|
+
@receiver_type = receiver_type
|
|
49
|
+
@method_name = method_name
|
|
36
50
|
end
|
|
37
51
|
|
|
38
52
|
def error?
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require "tmpdir"
|
|
4
5
|
|
|
5
6
|
require_relative "../environment"
|
|
6
7
|
require_relative "../scope"
|
|
@@ -104,6 +105,9 @@ module Rigor
|
|
|
104
105
|
@signature_paths_snapshot = [].freeze
|
|
105
106
|
@cached_plugin_prepare_diagnostics = [].freeze
|
|
106
107
|
@project_discovered_classes = {}.freeze
|
|
108
|
+
@project_discovered_def_nodes = {}.freeze
|
|
109
|
+
@project_discovered_superclasses = {}.freeze
|
|
110
|
+
@project_discovered_includes = {}.freeze
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
# ADR-pending editor mode — present when the runner is wired
|
|
@@ -241,6 +245,16 @@ module Rigor
|
|
|
241
245
|
# can share parses with the existing scanner passes.
|
|
242
246
|
@project_discovered_classes =
|
|
243
247
|
Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
248
|
+
# ADR-24 slice 2 — cross-file def-node + class->superclass
|
|
249
|
+
# index so an implicit-self call inside a subclass
|
|
250
|
+
# resolves a superclass `def` declared in a sibling
|
|
251
|
+
# file. One extra parse pass over the project; shares
|
|
252
|
+
# the cost profile of the class-discovery pass above.
|
|
253
|
+
def_index =
|
|
254
|
+
Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
|
|
255
|
+
@project_discovered_def_nodes = def_index.fetch(:def_nodes)
|
|
256
|
+
@project_discovered_superclasses = def_index.fetch(:superclasses)
|
|
257
|
+
@project_discovered_includes = def_index.fetch(:includes)
|
|
244
258
|
end
|
|
245
259
|
|
|
246
260
|
# Internal: adopts a frozen {ProjectScan} snapshot supplied
|
|
@@ -278,7 +292,7 @@ module Rigor
|
|
|
278
292
|
return [] if files.empty?
|
|
279
293
|
|
|
280
294
|
if pool_mode?
|
|
281
|
-
|
|
295
|
+
dispatch_pool(files)
|
|
282
296
|
else
|
|
283
297
|
environment = resolve_sequential_environment
|
|
284
298
|
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
@@ -460,6 +474,34 @@ module Rigor
|
|
|
460
474
|
@buffer.nil?
|
|
461
475
|
end
|
|
462
476
|
|
|
477
|
+
# ADR-15 Amendment (2026-05-20) — worker-pool backend selector.
|
|
478
|
+
# `fork` is the active backend: separate processes sidestep both
|
|
479
|
+
# the Ruby Bug #22075 use-after-free and the worker-side
|
|
480
|
+
# `Ractor::IsolationError` that make the Ractor pool unusable
|
|
481
|
+
# (see the ADR-15 Amendment +
|
|
482
|
+
# docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
|
|
483
|
+
# is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
|
|
484
|
+
# opts back in so it stays testable. Platforms without `fork`
|
|
485
|
+
# (Windows) fall back to sequential.
|
|
486
|
+
def pool_backend
|
|
487
|
+
return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
|
|
488
|
+
return :fork if Process.respond_to?(:fork)
|
|
489
|
+
|
|
490
|
+
:sequential
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Routes pool-mode analysis to the selected backend.
|
|
494
|
+
def dispatch_pool(files)
|
|
495
|
+
case pool_backend
|
|
496
|
+
when :ractor then analyze_files_in_pool(files)
|
|
497
|
+
when :fork then analyze_files_in_fork_pool(files)
|
|
498
|
+
else
|
|
499
|
+
analyze_files_sequentially_fallback(
|
|
500
|
+
files, reason: "fork-based parallelism is unavailable on this platform"
|
|
501
|
+
)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
463
505
|
# Coordinator-side Environment used by the sequential code
|
|
464
506
|
# path. Pool mode builds one Environment per worker inside
|
|
465
507
|
# the worker Ractor's body instead.
|
|
@@ -598,6 +640,122 @@ module Rigor
|
|
|
598
640
|
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
599
641
|
end
|
|
600
642
|
|
|
643
|
+
# ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
|
|
644
|
+
# active backend for `workers > 0`. Builds ONE {WorkerSession}
|
|
645
|
+
# on the parent, then `fork`s N children that copy-on-write
|
|
646
|
+
# inherit it. Each child analyses a contiguous slice of `files`
|
|
647
|
+
# and writes a Marshal'd `{results:, reporters:}` payload to a
|
|
648
|
+
# temp file; the parent `Process.wait`s every child, merges the
|
|
649
|
+
# payloads, and re-orders diagnostics by original path order.
|
|
650
|
+
#
|
|
651
|
+
# Separate processes have separate GC heaps and `vm->ci_table`
|
|
652
|
+
# (immune to Ruby Bug #22075) and copy-on-write-inherit every
|
|
653
|
+
# constant (no `Ractor.shareable?` constraint). See the ADR-15
|
|
654
|
+
# Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
|
|
655
|
+
#
|
|
656
|
+
# A child that exits non-zero (crash / unmarshalable payload) is
|
|
657
|
+
# degraded: the parent re-analyses that slice in-process and
|
|
658
|
+
# prepends a `pool-degraded` warning.
|
|
659
|
+
def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize
|
|
660
|
+
Environment::ClassRegistry.default
|
|
661
|
+
|
|
662
|
+
session = WorkerSession.new(
|
|
663
|
+
configuration: @configuration,
|
|
664
|
+
cache_store: @cache_store,
|
|
665
|
+
plugin_blueprints: @plugin_registry.blueprints,
|
|
666
|
+
explain: @explain,
|
|
667
|
+
synthetic_method_index: @synthetic_method_index,
|
|
668
|
+
project_patched_methods: @project_patched_methods
|
|
669
|
+
)
|
|
670
|
+
# Force the full RBS load on the parent so children
|
|
671
|
+
# copy-on-write inherit a warm Environment rather than each
|
|
672
|
+
# rebuilding it after the fork.
|
|
673
|
+
session.environment.rbs_loader&.prewarm
|
|
674
|
+
snapshot_fork_pool_stats(session) if @collect_stats
|
|
675
|
+
|
|
676
|
+
worker_count = [@workers, files.size].min
|
|
677
|
+
slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
|
|
678
|
+
results_by_path = {}
|
|
679
|
+
|
|
680
|
+
degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
|
|
681
|
+
children = slices.each_with_index.map do |slice, index|
|
|
682
|
+
out_path = File.join(tmpdir, "worker-#{index}")
|
|
683
|
+
{ pid: fork { run_fork_worker(session, slice, out_path) },
|
|
684
|
+
slice: slice, out_path: out_path }
|
|
685
|
+
end
|
|
686
|
+
collect_fork_results(children, results_by_path)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
unless degraded.empty?
|
|
690
|
+
degraded.each { |path| results_by_path[path] = session.analyze(path) }
|
|
691
|
+
merge_worker_reporters(session.drain_reporters)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
diagnostics = Array(session.prepare_diagnostics) +
|
|
695
|
+
files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
696
|
+
degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Child-process body for {#analyze_files_in_fork_pool}. Analyses
|
|
700
|
+
# the slice with the copy-on-write-inherited session and writes
|
|
701
|
+
# the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
|
|
702
|
+
# stdio flush — the payload is already durable on disk by then.
|
|
703
|
+
def run_fork_worker(session, slice, out_path)
|
|
704
|
+
results = slice.to_h { |path| [path, session.analyze(path)] }
|
|
705
|
+
payload = { results: results, reporters: session.drain_reporters }
|
|
706
|
+
File.binwrite(out_path, Marshal.dump(payload))
|
|
707
|
+
exit!(0)
|
|
708
|
+
rescue StandardError
|
|
709
|
+
exit!(1)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Snapshots `class_decl_paths` from the parent session's loader
|
|
713
|
+
# so end-of-run {RunStats} can attribute the RBS class universe.
|
|
714
|
+
def snapshot_fork_pool_stats(session)
|
|
715
|
+
loader = session.environment.rbs_loader
|
|
716
|
+
@class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
|
|
717
|
+
@signature_paths_snapshot = loader&.signature_paths || [].freeze
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Waits for every forked child, merges each successful payload
|
|
721
|
+
# into `results_by_path`, and returns the file paths whose
|
|
722
|
+
# worker exited abnormally (for in-process degrade).
|
|
723
|
+
def collect_fork_results(children, results_by_path)
|
|
724
|
+
degraded = []
|
|
725
|
+
children.each do |child|
|
|
726
|
+
_, status = Process.waitpid2(child[:pid])
|
|
727
|
+
payload = fork_worker_payload(status, child[:out_path])
|
|
728
|
+
if payload
|
|
729
|
+
results_by_path.merge!(payload.fetch(:results))
|
|
730
|
+
merge_worker_reporters(payload.fetch(:reporters))
|
|
731
|
+
else
|
|
732
|
+
degraded.concat(child[:slice])
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
degraded
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# @return [Hash, nil] the child's `{results:, reporters:}`
|
|
739
|
+
# payload, or nil when the child exited abnormally or wrote no
|
|
740
|
+
# readable payload. `Marshal.load` is safe here: the blob was
|
|
741
|
+
# written by our own forked child to a temp file we created.
|
|
742
|
+
def fork_worker_payload(status, out_path)
|
|
743
|
+
return nil unless status.success? && File.exist?(out_path)
|
|
744
|
+
|
|
745
|
+
Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
|
|
746
|
+
rescue StandardError
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def fork_degraded_diagnostic(count)
|
|
751
|
+
Diagnostic.new(
|
|
752
|
+
path: ".rigor.yml", line: 1, column: 1,
|
|
753
|
+
message: "fork pool degraded: #{count} file(s) re-analysed in-process " \
|
|
754
|
+
"after a worker exited abnormally",
|
|
755
|
+
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
756
|
+
)
|
|
757
|
+
end
|
|
758
|
+
|
|
601
759
|
# End-of-run telemetry. Walks the cached
|
|
602
760
|
# `class_decl_paths` snapshot (sequential mode: from
|
|
603
761
|
# the coordinator's environment; pool mode: from the
|
|
@@ -1221,12 +1379,29 @@ module Rigor
|
|
|
1221
1379
|
Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
|
|
1222
1380
|
end
|
|
1223
1381
|
|
|
1382
|
+
# Seeds the cross-file project pre-pass indexes onto a
|
|
1383
|
+
# fresh per-file scope: discovered classes, and the ADR-24
|
|
1384
|
+
# def-node / superclass / included-module maps. Each is
|
|
1385
|
+
# applied only when non-empty so a runner constructed
|
|
1386
|
+
# without the project pre-pass (e.g. a single-file probe)
|
|
1387
|
+
# keeps an empty seed.
|
|
1388
|
+
def seed_project_scope(scope)
|
|
1389
|
+
scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
|
|
1390
|
+
unless @project_discovered_def_nodes.empty?
|
|
1391
|
+
scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
|
|
1392
|
+
end
|
|
1393
|
+
unless @project_discovered_superclasses.empty?
|
|
1394
|
+
scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
|
|
1395
|
+
end
|
|
1396
|
+
scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
|
|
1397
|
+
scope
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1224
1400
|
def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
|
|
1225
1401
|
parse_result = parse_source(path)
|
|
1226
1402
|
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
1227
1403
|
|
|
1228
|
-
scope = Scope.empty(environment: environment, source_path: path)
|
|
1229
|
-
scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
|
|
1404
|
+
scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
|
|
1230
1405
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
1231
1406
|
diagnostics = CheckRules.diagnose(
|
|
1232
1407
|
path: path,
|
|
@@ -38,6 +38,12 @@ module Rigor
|
|
|
38
38
|
# - `plugin_blueprints` — Phase 3a
|
|
39
39
|
# (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
|
|
40
40
|
# - `explain` — Boolean.
|
|
41
|
+
# - `synthetic_method_index` / `project_patched_methods` —
|
|
42
|
+
# optional (default `nil`). NOT `Ractor.shareable?`, so the
|
|
43
|
+
# Ractor pool path leaves them unset; the fork backend
|
|
44
|
+
# (ADR-15 Amendment), which builds the session pre-fork on the
|
|
45
|
+
# parent, threads the runner's project-scan results through so
|
|
46
|
+
# per-file inference matches the sequential path exactly.
|
|
41
47
|
#
|
|
42
48
|
# Internally the session OWNS (and never shares):
|
|
43
49
|
#
|
|
@@ -70,7 +76,7 @@ module Rigor
|
|
|
70
76
|
# output (modulo severity-profile re-stamping, which the
|
|
71
77
|
# session leaves to the caller because it is a per-run
|
|
72
78
|
# aggregate concern).
|
|
73
|
-
class WorkerSession
|
|
79
|
+
class WorkerSession # rubocop:disable Metrics/ClassLength
|
|
74
80
|
attr_reader :configuration, :cache_store, :services, :plugin_registry,
|
|
75
81
|
:dependency_source_index, :environment,
|
|
76
82
|
:rbs_extended_reporter, :boundary_cross_reporter,
|
|
@@ -88,11 +94,14 @@ module Rigor
|
|
|
88
94
|
# directly-unrecognised node, mirroring
|
|
89
95
|
# {Rigor::Analysis::Runner#explain_diagnostics}.
|
|
90
96
|
def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
|
|
91
|
-
plugin_blueprints: [], explain: false, buffer: nil
|
|
97
|
+
plugin_blueprints: [], explain: false, buffer: nil,
|
|
98
|
+
synthetic_method_index: nil, project_patched_methods: nil)
|
|
92
99
|
@configuration = configuration
|
|
93
100
|
@cache_store = cache_store
|
|
94
101
|
@explain = explain
|
|
95
102
|
@buffer = buffer
|
|
103
|
+
@synthetic_method_index = synthetic_method_index
|
|
104
|
+
@project_patched_methods = project_patched_methods
|
|
96
105
|
|
|
97
106
|
# NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
|
|
98
107
|
# is process-global state. Writing it from a non-main
|
|
@@ -127,7 +136,9 @@ module Rigor
|
|
|
127
136
|
bundler_auto_detect: configuration.bundler_auto_detect,
|
|
128
137
|
bundler_lockfile: configuration.bundler_lockfile,
|
|
129
138
|
rbs_collection_lockfile: configuration.rbs_collection_lockfile,
|
|
130
|
-
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect
|
|
139
|
+
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect,
|
|
140
|
+
synthetic_method_index: @synthetic_method_index,
|
|
141
|
+
project_patched_methods: @project_patched_methods
|
|
131
142
|
)
|
|
132
143
|
@prepare_diagnostics = run_plugin_prepare.freeze
|
|
133
144
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../triage"
|
|
9
|
+
require_relative "triage_renderer"
|
|
10
|
+
|
|
11
|
+
module Rigor
|
|
12
|
+
class CLI
|
|
13
|
+
# ADR-23 — executes `rigor triage`.
|
|
14
|
+
#
|
|
15
|
+
# Runs the same analysis as `rigor check`, then summarises the
|
|
16
|
+
# diagnostic stream (rule distribution, per-file hotspots,
|
|
17
|
+
# heuristic hints) instead of printing the raw per-line list.
|
|
18
|
+
# Read-only and advisory (WD4): never edits config, never
|
|
19
|
+
# writes a baseline. Always exits 0 — it is an inspection
|
|
20
|
+
# command, not a gate (`rigor check` remains the gate).
|
|
21
|
+
class TriageCommand
|
|
22
|
+
USAGE = "Usage: rigor triage [options] [paths]"
|
|
23
|
+
DEFAULT_SECTIONS = %i[distribution hotspots hints].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(argv:, out:, err:)
|
|
26
|
+
@argv = argv
|
|
27
|
+
@out = out
|
|
28
|
+
@err = err
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Integer] CLI exit status (always 0).
|
|
32
|
+
def run
|
|
33
|
+
options = parse_options
|
|
34
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
35
|
+
diagnostics = analyze(configuration)
|
|
36
|
+
|
|
37
|
+
report = Triage.analyze(diagnostics, top: options.fetch(:top),
|
|
38
|
+
hints: options.fetch(:sections).include?(:hints))
|
|
39
|
+
renderer = TriageRenderer.new(report, sections: options.fetch(:sections))
|
|
40
|
+
@out.puts(options.fetch(:format) == "json" ? renderer.json : renderer.text)
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_options
|
|
47
|
+
options = { config: nil, format: "text", top: 10, sections: DEFAULT_SECTIONS }
|
|
48
|
+
OptionParser.new do |opts|
|
|
49
|
+
opts.banner = USAGE
|
|
50
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
51
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
52
|
+
opts.on("--top=N", Integer, "Hotspot-file count (default 10)") { |v| options[:top] = v }
|
|
53
|
+
opts.on("--hints-only", "Print only the heuristic-hints section") { options[:sections] = %i[hints] }
|
|
54
|
+
opts.on("--no-hints", "Print distribution + hotspots only") do
|
|
55
|
+
options[:sections] = %i[distribution hotspots]
|
|
56
|
+
end
|
|
57
|
+
end.parse!(@argv)
|
|
58
|
+
validate!(options)
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate!(options)
|
|
63
|
+
return if %w[text json].include?(options.fetch(:format))
|
|
64
|
+
|
|
65
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Sequential, cache-backed, no run-stats: triage only needs
|
|
69
|
+
# the diagnostic stream, and sequential keeps the rule
|
|
70
|
+
# distribution deterministic (the fork pool's cross-file
|
|
71
|
+
# divergence would skew the histogram).
|
|
72
|
+
def analyze(configuration)
|
|
73
|
+
runner = Analysis::Runner.new(
|
|
74
|
+
configuration: configuration,
|
|
75
|
+
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
76
|
+
collect_stats: false,
|
|
77
|
+
workers: 0
|
|
78
|
+
)
|
|
79
|
+
runner.run(@argv.empty? ? configuration.paths : @argv).diagnostics
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../triage"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
class CLI
|
|
9
|
+
# ADR-23 — renders a {Rigor::Triage::Report} as the `rigor
|
|
10
|
+
# triage` text report or as `--format json`.
|
|
11
|
+
class TriageRenderer
|
|
12
|
+
BAR_WIDTH = 24
|
|
13
|
+
|
|
14
|
+
def initialize(report, sections:)
|
|
15
|
+
@report = report
|
|
16
|
+
@sections = sections # subset of %i[distribution hotspots hints]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def json
|
|
20
|
+
JSON.pretty_generate(Triage.report_to_h(@report))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def text
|
|
24
|
+
blocks = []
|
|
25
|
+
blocks << distribution_block if @sections.include?(:distribution)
|
|
26
|
+
blocks << hotspots_block if @sections.include?(:hotspots)
|
|
27
|
+
blocks << hints_block if @sections.include?(:hints)
|
|
28
|
+
"#{blocks.join("\n\n")}\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def distribution_block
|
|
34
|
+
s = @report.summary
|
|
35
|
+
max = @report.distribution.map(&:count).max || 1
|
|
36
|
+
lines = ["Diagnostic distribution — #{s.total} total " \
|
|
37
|
+
"(#{s.error} error / #{s.warning} warning#{" / #{s.info} info" if s.info.positive?})"]
|
|
38
|
+
@report.distribution.each do |row|
|
|
39
|
+
lines << format(" %<rule>-32s %<count>5d %<bar>s",
|
|
40
|
+
rule: row.rule, count: row.count, bar: bar(row.count, max))
|
|
41
|
+
end
|
|
42
|
+
lines.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def hotspots_block
|
|
46
|
+
return "Hotspot files\n (none)" if @report.hotspots.empty?
|
|
47
|
+
|
|
48
|
+
lines = ["Hotspot files"]
|
|
49
|
+
@report.hotspots.each do |spot|
|
|
50
|
+
by_rule = spot.by_rule.map { |rule, count| "#{rule}×#{count}" }.join(" ")
|
|
51
|
+
lines << format(" %<file>-40s %<count>4d %<rules>s",
|
|
52
|
+
file: spot.file, count: spot.count, rules: by_rule)
|
|
53
|
+
end
|
|
54
|
+
lines.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hints_block
|
|
58
|
+
return "Hints\n (no heuristic hints)" if @report.hints.empty?
|
|
59
|
+
|
|
60
|
+
lines = ["Hints — heuristics, verify before acting"]
|
|
61
|
+
@report.hints.each do |hint|
|
|
62
|
+
lines << ""
|
|
63
|
+
lines << " [#{hint.confidence} #{hint.id}] #{hint.diagnostic_count} diagnostic(s)"
|
|
64
|
+
lines << " #{hint.summary}"
|
|
65
|
+
lines << " → #{hint.action}"
|
|
66
|
+
end
|
|
67
|
+
lines.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def bar(count, max)
|
|
71
|
+
filled = max.zero? ? 0 : (count * BAR_WIDTH / max)
|
|
72
|
+
filled = 1 if filled.zero? && count.positive?
|
|
73
|
+
"█" * filled
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -27,7 +27,8 @@ module Rigor
|
|
|
27
27
|
"diff" => :run_diff,
|
|
28
28
|
"sig-gen" => :run_sig_gen,
|
|
29
29
|
"lsp" => :run_lsp,
|
|
30
|
-
"baseline" => :run_baseline
|
|
30
|
+
"baseline" => :run_baseline,
|
|
31
|
+
"triage" => :run_triage
|
|
31
32
|
}.freeze
|
|
32
33
|
|
|
33
34
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -464,6 +465,12 @@ module Rigor
|
|
|
464
465
|
BaselineCommand.new(argv: @argv, out: @out, err: @err).run
|
|
465
466
|
end
|
|
466
467
|
|
|
468
|
+
def run_triage
|
|
469
|
+
require_relative "cli/triage_command"
|
|
470
|
+
|
|
471
|
+
CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
472
|
+
end
|
|
473
|
+
|
|
467
474
|
def write_result(result, format)
|
|
468
475
|
case format
|
|
469
476
|
when "json"
|
|
@@ -505,6 +512,7 @@ module Rigor
|
|
|
505
512
|
diff Compare current diagnostics to a saved baseline JSON
|
|
506
513
|
sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
|
|
507
514
|
lsp Run the Rigor Language Server (LSP) over stdio
|
|
515
|
+
triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
|
|
508
516
|
version Print the Rigor version
|
|
509
517
|
help Print this help
|
|
510
518
|
HELP
|