rigortype 0.1.9 → 0.1.10
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/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +73 -3
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3425a6183f3c9312517a9a82de77b561907c0afd99f8f77844d08741a766fae8
|
|
4
|
+
data.tar.gz: cef2e0e925fb844e4112552626966319c942ff0f31ff6d2af05b04ec8c2ee93f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 749cd171f08f03311d70da91e742b07980974876889d152827ae8e3aa4518d2c2f933475efa889e3c63a7f715eddb96112584445967c3e15fe7c58c38d0ed3cd
|
|
7
|
+
data.tar.gz: bb5f032e6663cb137b774bfd18d39ad1498b0913d552a92b86c0256fd8a2fb76c1426d9914f01caf3f78359c96079d8dfc7c84f09e2fb473681cf0889ee46e09
|
data/README.md
CHANGED
|
@@ -63,7 +63,7 @@ the executable it installs is `rigor`.
|
|
|
63
63
|
|
|
64
64
|
Full options — `asdf`, dev containers, CI workflow template — are in the
|
|
65
65
|
[installation guide](https://rigor.typedduck.fail/reference/manual/01-installation/)
|
|
66
|
-
and [CI guide](https://rigor.typedduck.fail/reference/manual/
|
|
66
|
+
and [CI guide](https://rigor.typedduck.fail/reference/manual/11-ci/).
|
|
67
67
|
|
|
68
68
|
## Getting started with AI Skills
|
|
69
69
|
|
|
@@ -7,6 +7,7 @@ require_relative "../environment"
|
|
|
7
7
|
require_relative "../scope"
|
|
8
8
|
require_relative "../cache/store"
|
|
9
9
|
require_relative "../plugin"
|
|
10
|
+
require_relative "../plugin/source_rbs_synthesis_reporter"
|
|
10
11
|
require_relative "../rbs_extended/reporter"
|
|
11
12
|
require_relative "../reflection"
|
|
12
13
|
require_relative "../type/combinator"
|
|
@@ -97,6 +98,7 @@ module Rigor
|
|
|
97
98
|
@dependency_source_index = DependencySourceInference::Index::EMPTY
|
|
98
99
|
@rbs_extended_reporter = RbsExtended::Reporter.new
|
|
99
100
|
@boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
|
|
101
|
+
@source_rbs_synthesis_reporter = Plugin::SourceRbsSynthesisReporter.new
|
|
100
102
|
# `#run` resets these for each invocation; pre-seed them to
|
|
101
103
|
# empty containers so `build_run_stats` / `pre_file_diagnostics`
|
|
102
104
|
# (private, called only from `#run`) can read them without
|
|
@@ -147,6 +149,7 @@ module Rigor
|
|
|
147
149
|
diagnostics += analyze_files(target_files(expansion))
|
|
148
150
|
diagnostics += rbs_extended_reporter_diagnostics
|
|
149
151
|
diagnostics += boundary_cross_diagnostics
|
|
152
|
+
diagnostics += source_rbs_synthesis_diagnostics
|
|
150
153
|
|
|
151
154
|
Result.new(
|
|
152
155
|
diagnostics: apply_severity_profile(diagnostics),
|
|
@@ -294,7 +297,7 @@ module Rigor
|
|
|
294
297
|
if pool_mode?
|
|
295
298
|
dispatch_pool(files)
|
|
296
299
|
else
|
|
297
|
-
environment = resolve_sequential_environment
|
|
300
|
+
environment = resolve_sequential_environment(source_files: files)
|
|
298
301
|
result = files.flat_map { |path| analyze_file(path, environment) }
|
|
299
302
|
if @collect_stats
|
|
300
303
|
loader = environment.rbs_loader
|
|
@@ -311,8 +314,8 @@ module Rigor
|
|
|
311
314
|
# runner's diagnostics) when present; otherwise builds a
|
|
312
315
|
# fresh Environment per-call via {#build_runner_environment}
|
|
313
316
|
# — preserving the pre-override behaviour bit-for-bit.
|
|
314
|
-
def resolve_sequential_environment
|
|
315
|
-
return build_runner_environment unless @environment_override
|
|
317
|
+
def resolve_sequential_environment(source_files: [])
|
|
318
|
+
return build_runner_environment(source_files: source_files) unless @environment_override
|
|
316
319
|
|
|
317
320
|
@environment_override.attach_reporters!(
|
|
318
321
|
rbs_extended_reporter: @rbs_extended_reporter,
|
|
@@ -505,7 +508,14 @@ module Rigor
|
|
|
505
508
|
# Coordinator-side Environment used by the sequential code
|
|
506
509
|
# path. Pool mode builds one Environment per worker inside
|
|
507
510
|
# the worker Ractor's body instead.
|
|
508
|
-
|
|
511
|
+
#
|
|
512
|
+
# ADR-32 WD4 — `source_files:` is threaded down so that
|
|
513
|
+
# `Environment.for_project` can invoke each loaded plugin's
|
|
514
|
+
# `source_rbs_synthesizer` callable per project source file
|
|
515
|
+
# at env-build time. Defaults to `[]` for callers that don't
|
|
516
|
+
# have a file list yet (e.g. pre-pass-only build paths); in
|
|
517
|
+
# that case no synthesised RBS is contributed.
|
|
518
|
+
def build_runner_environment(source_files: [])
|
|
509
519
|
Environment.for_project(
|
|
510
520
|
libraries: @configuration.libraries,
|
|
511
521
|
signature_paths: @configuration.signature_paths,
|
|
@@ -514,13 +524,15 @@ module Rigor
|
|
|
514
524
|
dependency_source_index: @dependency_source_index,
|
|
515
525
|
rbs_extended_reporter: @rbs_extended_reporter,
|
|
516
526
|
boundary_cross_reporter: @boundary_cross_reporter,
|
|
527
|
+
source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
|
|
517
528
|
bundler_bundle_path: @configuration.bundler_bundle_path,
|
|
518
529
|
bundler_auto_detect: @configuration.bundler_auto_detect,
|
|
519
530
|
bundler_lockfile: @configuration.bundler_lockfile,
|
|
520
531
|
rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
|
|
521
532
|
rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
|
|
522
533
|
synthetic_method_index: @synthetic_method_index,
|
|
523
|
-
project_patched_methods: @project_patched_methods
|
|
534
|
+
project_patched_methods: @project_patched_methods,
|
|
535
|
+
source_files: source_files
|
|
524
536
|
)
|
|
525
537
|
end
|
|
526
538
|
|
|
@@ -559,7 +571,7 @@ module Rigor
|
|
|
559
571
|
# which dedupe on the same keys as a single-session run
|
|
560
572
|
# would. Net result: reporter state is identical to the
|
|
561
573
|
# sequential path.
|
|
562
|
-
def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
574
|
+
def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
563
575
|
# Pre-warm class-level lazy memos on the MAIN Ractor.
|
|
564
576
|
# `Environment::ClassRegistry.default` is the
|
|
565
577
|
# default kwarg threaded through `Environment.new`
|
|
@@ -591,15 +603,22 @@ module Rigor
|
|
|
591
603
|
cache_root = @cache_store&.root
|
|
592
604
|
blueprints = @plugin_registry.blueprints
|
|
593
605
|
explain = @explain
|
|
606
|
+
# ADR-32 WD4 — the full project file list travels into
|
|
607
|
+
# every Ractor worker so each worker's WorkerSession
|
|
608
|
+
# can invoke loaded plugins' source_rbs_synthesizers at
|
|
609
|
+
# env-build time. The list is a frozen Array<String>;
|
|
610
|
+
# cheaply shareable.
|
|
611
|
+
shareable_source_files = files.map { |path| path.to_s.dup.freeze }.freeze
|
|
594
612
|
|
|
595
613
|
pool = Array.new(@workers) do
|
|
596
|
-
Ractor.new(configuration, cache_root, blueprints, explain) do |configuration, cache_root, blueprints, explain|
|
|
614
|
+
Ractor.new(configuration, cache_root, blueprints, explain, shareable_source_files) do |configuration, cache_root, blueprints, explain, shareable_source_files| # rubocop:disable Layout/LineLength
|
|
597
615
|
cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
|
|
598
616
|
session = Rigor::Analysis::WorkerSession.new(
|
|
599
617
|
configuration: configuration,
|
|
600
618
|
cache_store: cache_store,
|
|
601
619
|
plugin_blueprints: blueprints,
|
|
602
|
-
explain: explain
|
|
620
|
+
explain: explain,
|
|
621
|
+
source_files: shareable_source_files
|
|
603
622
|
)
|
|
604
623
|
main = Ractor.main
|
|
605
624
|
main.send([:prepare, session.prepare_diagnostics])
|
|
@@ -665,7 +684,8 @@ module Rigor
|
|
|
665
684
|
plugin_blueprints: @plugin_registry.blueprints,
|
|
666
685
|
explain: @explain,
|
|
667
686
|
synthetic_method_index: @synthetic_method_index,
|
|
668
|
-
project_patched_methods: @project_patched_methods
|
|
687
|
+
project_patched_methods: @project_patched_methods,
|
|
688
|
+
source_files: files
|
|
669
689
|
)
|
|
670
690
|
# Force the full RBS load on the parent so children
|
|
671
691
|
# copy-on-write inherit a warm Environment rather than each
|
|
@@ -851,6 +871,15 @@ module Rigor
|
|
|
851
871
|
rbs_display: entry.rbs_display
|
|
852
872
|
)
|
|
853
873
|
end
|
|
874
|
+
# ADR-32 WD6 — merge per-worker synthesizer failures
|
|
875
|
+
# back into the coordinator's reporter. Fetched with a
|
|
876
|
+
# default empty array so older drains (pre-slice-2)
|
|
877
|
+
# remain compatible.
|
|
878
|
+
Array(drained[:source_rbs_synthesis]).each do |entry|
|
|
879
|
+
@source_rbs_synthesis_reporter.record(
|
|
880
|
+
plugin_id: entry.plugin_id, path: entry.path, message: entry.message
|
|
881
|
+
)
|
|
882
|
+
end
|
|
854
883
|
end
|
|
855
884
|
|
|
856
885
|
# Loads project-configured plugins through {Rigor::Plugin::Loader}
|
|
@@ -1160,6 +1189,35 @@ module Rigor
|
|
|
1160
1189
|
# per-call-site — the diagnostic anchors at `.rigor.yml`
|
|
1161
1190
|
# like the other `dependency-source.*` diagnostics that
|
|
1162
1191
|
# report on opt-in configuration.
|
|
1192
|
+
# ADR-32 WD6 — drains the per-run
|
|
1193
|
+
# {Plugin::SourceRbsSynthesisReporter} into
|
|
1194
|
+
# `source-rbs-synthesis-failed` `:info` diagnostics. Each
|
|
1195
|
+
# entry names the plugin that owns the synthesizer, the
|
|
1196
|
+
# source file the rbs-inline parser couldn't process, and
|
|
1197
|
+
# the upstream error message. The synthesizer-emitting
|
|
1198
|
+
# plugin (currently only `rigor-rbs-inline`) treats a
|
|
1199
|
+
# parse failure as a no-contribution event so analysis
|
|
1200
|
+
# continues; this stream surfaces the failure so the user
|
|
1201
|
+
# can see which files contributed nothing and why.
|
|
1202
|
+
#
|
|
1203
|
+
# Severity profile re-stamps the rule per project taste.
|
|
1204
|
+
def source_rbs_synthesis_diagnostics
|
|
1205
|
+
return [] if @source_rbs_synthesis_reporter.empty?
|
|
1206
|
+
|
|
1207
|
+
@source_rbs_synthesis_reporter.entries.map do |entry|
|
|
1208
|
+
Diagnostic.new(
|
|
1209
|
+
path: entry.path, line: 1, column: 1,
|
|
1210
|
+
message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
|
|
1211
|
+
"#{entry.message}. The file's analysis falls back to no inline-RBS " \
|
|
1212
|
+
"contribution. Fix the inline-RBS comment grammar or remove the " \
|
|
1213
|
+
"annotation to silence this diagnostic.",
|
|
1214
|
+
severity: :info,
|
|
1215
|
+
rule: "source-rbs-synthesis-failed",
|
|
1216
|
+
source_family: :builtin
|
|
1217
|
+
)
|
|
1218
|
+
end
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1163
1221
|
def boundary_cross_diagnostics
|
|
1164
1222
|
return [] if @boundary_cross_reporter.empty?
|
|
1165
1223
|
|
|
@@ -93,15 +93,20 @@ module Rigor
|
|
|
93
93
|
# emits one `:info` `fallback` diagnostic per
|
|
94
94
|
# directly-unrecognised node, mirroring
|
|
95
95
|
# {Rigor::Analysis::Runner#explain_diagnostics}.
|
|
96
|
-
def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
|
|
96
|
+
def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
|
|
97
97
|
plugin_blueprints: [], explain: false, buffer: nil,
|
|
98
|
-
synthetic_method_index: nil, project_patched_methods: nil
|
|
98
|
+
synthetic_method_index: nil, project_patched_methods: nil,
|
|
99
|
+
source_files: [])
|
|
99
100
|
@configuration = configuration
|
|
100
101
|
@cache_store = cache_store
|
|
101
102
|
@explain = explain
|
|
102
103
|
@buffer = buffer
|
|
103
104
|
@synthetic_method_index = synthetic_method_index
|
|
104
105
|
@project_patched_methods = project_patched_methods
|
|
106
|
+
# ADR-32 WD4 — full project file list (frozen
|
|
107
|
+
# Array<String>) for env-build-time invocation of any
|
|
108
|
+
# loaded plugin's `source_rbs_synthesizer` callable.
|
|
109
|
+
@source_files = source_files
|
|
105
110
|
|
|
106
111
|
# NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
|
|
107
112
|
# is process-global state. Writing it from a non-main
|
|
@@ -112,6 +117,7 @@ module Rigor
|
|
|
112
117
|
# pool. The substrate stays Ractor-safe by construction.
|
|
113
118
|
@rbs_extended_reporter = RbsExtended::Reporter.new
|
|
114
119
|
@boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
|
|
120
|
+
@source_rbs_synthesis_reporter = Plugin::SourceRbsSynthesisReporter.new
|
|
115
121
|
@dependency_source_index = DependencySourceInference::Builder.build(configuration.dependencies)
|
|
116
122
|
|
|
117
123
|
@services = Plugin::Services.new(
|
|
@@ -132,13 +138,15 @@ module Rigor
|
|
|
132
138
|
dependency_source_index: @dependency_source_index,
|
|
133
139
|
rbs_extended_reporter: @rbs_extended_reporter,
|
|
134
140
|
boundary_cross_reporter: @boundary_cross_reporter,
|
|
141
|
+
source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
|
|
135
142
|
bundler_bundle_path: configuration.bundler_bundle_path,
|
|
136
143
|
bundler_auto_detect: configuration.bundler_auto_detect,
|
|
137
144
|
bundler_lockfile: configuration.bundler_lockfile,
|
|
138
145
|
rbs_collection_lockfile: configuration.rbs_collection_lockfile,
|
|
139
146
|
rbs_collection_auto_detect: configuration.rbs_collection_auto_detect,
|
|
140
147
|
synthetic_method_index: @synthetic_method_index,
|
|
141
|
-
project_patched_methods: @project_patched_methods
|
|
148
|
+
project_patched_methods: @project_patched_methods,
|
|
149
|
+
source_files: @source_files
|
|
142
150
|
)
|
|
143
151
|
@prepare_diagnostics = run_plugin_prepare.freeze
|
|
144
152
|
end
|
|
@@ -180,7 +188,8 @@ module Rigor
|
|
|
180
188
|
unresolved_payloads: @rbs_extended_reporter.unresolved_payloads,
|
|
181
189
|
lossy_projections: @rbs_extended_reporter.lossy_projections
|
|
182
190
|
},
|
|
183
|
-
boundary_cross: @boundary_cross_reporter.entries
|
|
191
|
+
boundary_cross: @boundary_cross_reporter.entries,
|
|
192
|
+
source_rbs_synthesis: @source_rbs_synthesis_reporter.entries
|
|
184
193
|
}
|
|
185
194
|
end
|
|
186
195
|
|
|
@@ -19,7 +19,7 @@ module Rigor
|
|
|
19
19
|
Descriptor.new(
|
|
20
20
|
gems: [rbs_gem_entry],
|
|
21
21
|
files: file_entries(loader),
|
|
22
|
-
configs: [libraries_entry(loader)]
|
|
22
|
+
configs: [libraries_entry(loader), virtual_rbs_entry(loader)].compact
|
|
23
23
|
)
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -51,7 +51,26 @@ module Rigor
|
|
|
51
51
|
)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
# ADR-32 WD5 — encode the loader's virtual_rbs set into a
|
|
55
|
+
# `ConfigEntry` so the env cache invalidates when a
|
|
56
|
+
# plugin-contributed synthesised RBS string changes (or
|
|
57
|
+
# appears for the first time). Returns nil when the
|
|
58
|
+
# loader has no virtual_rbs entries, so callers without
|
|
59
|
+
# any synthesizer-emitting plugin pay zero descriptor
|
|
60
|
+
# cost.
|
|
61
|
+
def self.virtual_rbs_entry(loader)
|
|
62
|
+
return nil unless loader.respond_to?(:virtual_rbs)
|
|
63
|
+
return nil if loader.virtual_rbs.nil? || loader.virtual_rbs.empty?
|
|
64
|
+
|
|
65
|
+
sorted_pairs = loader.virtual_rbs.sort_by { |name, _content| name }
|
|
66
|
+
joined = sorted_pairs.map { |name, content| "#{name}\0#{content}" }.join("\n\0\n")
|
|
67
|
+
Descriptor::ConfigEntry.new(
|
|
68
|
+
key: "rbs.virtual_rbs",
|
|
69
|
+
value_hash: Digest::SHA256.hexdigest(joined)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method :rbs_gem_entry, :file_entries, :libraries_entry, :virtual_rbs_entry
|
|
55
74
|
end
|
|
56
75
|
end
|
|
57
76
|
end
|
|
@@ -42,7 +42,8 @@ module Rigor
|
|
|
42
42
|
def self.compute(loader)
|
|
43
43
|
Rigor::Environment::RbsLoader.build_env_for(
|
|
44
44
|
libraries: loader.libraries,
|
|
45
|
-
signature_paths: loader.signature_paths
|
|
45
|
+
signature_paths: loader.signature_paths,
|
|
46
|
+
virtual_rbs: loader.respond_to?(:virtual_rbs) ? loader.virtual_rbs : []
|
|
46
47
|
)
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -6,6 +6,7 @@ require "prism"
|
|
|
6
6
|
require_relative "../configuration"
|
|
7
7
|
require_relative "../environment"
|
|
8
8
|
require_relative "../scope"
|
|
9
|
+
require_relative "../inference/def_return_typer"
|
|
9
10
|
require_relative "../inference/scope_indexer"
|
|
10
11
|
require_relative "prism_colorizer"
|
|
11
12
|
|
|
@@ -83,7 +84,15 @@ module Rigor
|
|
|
83
84
|
|
|
84
85
|
def execute(file, options)
|
|
85
86
|
configuration = Configuration.load(options.fetch(:config))
|
|
86
|
-
|
|
87
|
+
# Force UTF-8 (with BOM tolerance) regardless of
|
|
88
|
+
# `Encoding.default_external`. Under a minimal locale
|
|
89
|
+
# the default is US-ASCII; reading multi-byte source
|
|
90
|
+
# under that tag makes the post-parse `String#sub` /
|
|
91
|
+
# `String#lines` calls in `#annotate` raise
|
|
92
|
+
# `invalid byte sequence in US-ASCII`. Ruby source is
|
|
93
|
+
# UTF-8 by convention (the parser's own assumption
|
|
94
|
+
# absent a magic comment).
|
|
95
|
+
source = File.read(file, mode: "r:bom|utf-8")
|
|
87
96
|
parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
|
|
88
97
|
return 1 if parse_errors?(parse_result, file)
|
|
89
98
|
|
|
@@ -164,20 +173,33 @@ module Rigor
|
|
|
164
173
|
by_line[statement.location.end_line] = type unless type.nil?
|
|
165
174
|
end
|
|
166
175
|
fill_uncovered_lines(program, by_line)
|
|
176
|
+
override_def_header_lines(program, by_line)
|
|
167
177
|
by_line
|
|
168
178
|
end
|
|
169
179
|
|
|
170
180
|
private
|
|
171
181
|
|
|
172
182
|
# Yields each statement node (a child of any `StatementsNode`
|
|
173
|
-
# anywhere in the tree) in
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
|
|
183
|
+
# anywhere in the tree) in post-order: nested statements are
|
|
184
|
+
# yielded before the enclosing statement that contains them.
|
|
185
|
+
# `by_line[end_line] = type` overwrites earlier entries, so
|
|
186
|
+
# post-order means the *outermost* statement closing a line
|
|
187
|
+
# wins — for `b = if cond then :then else :else end` the
|
|
188
|
+
# line resolves to the assignment's type (the if-expression's
|
|
189
|
+
# union), not the else-branch's inner `:else`. Direct siblings
|
|
190
|
+
# (`1; 2; 3`) are still yielded in source order so the last
|
|
191
|
+
# sibling wins.
|
|
192
|
+
def each_statement(node, &block)
|
|
177
193
|
return if node.nil?
|
|
178
194
|
|
|
179
|
-
|
|
180
|
-
|
|
195
|
+
if node.is_a?(Prism::StatementsNode)
|
|
196
|
+
node.body.each do |stmt|
|
|
197
|
+
each_statement(stmt, &block)
|
|
198
|
+
block.call(stmt)
|
|
199
|
+
end
|
|
200
|
+
else
|
|
201
|
+
node.compact_child_nodes.each { |child| each_statement(child, &block) }
|
|
202
|
+
end
|
|
181
203
|
end
|
|
182
204
|
|
|
183
205
|
# For a line no statement closes (the `if` / block header
|
|
@@ -219,6 +241,34 @@ module Rigor
|
|
|
219
241
|
rescue StandardError
|
|
220
242
|
nil
|
|
221
243
|
end
|
|
244
|
+
|
|
245
|
+
# For every `def`, replace the annotation on the header line
|
|
246
|
+
# (where the `def` keyword sits) with the method's inferred
|
|
247
|
+
# return type. The default annotation there comes from the
|
|
248
|
+
# parameter list (`name` typing as `Dynamic[top]`), which is
|
|
249
|
+
# noise; the return type is what readers actually want next
|
|
250
|
+
# to the method signature. When the return type cannot be
|
|
251
|
+
# inferred (empty body, scope-lookup miss, or any error
|
|
252
|
+
# under `DefReturnTyper.call`), the entry is deleted so no
|
|
253
|
+
# annotation is shown on that line.
|
|
254
|
+
def override_def_header_lines(program, by_line)
|
|
255
|
+
each_def_node(program) do |def_node|
|
|
256
|
+
line = def_node.location.start_line
|
|
257
|
+
return_type = Inference::DefReturnTyper.call(def_node, @scope_index)
|
|
258
|
+
if return_type.nil?
|
|
259
|
+
by_line.delete(line)
|
|
260
|
+
else
|
|
261
|
+
by_line[line] = return_type
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def each_def_node(node, &block)
|
|
267
|
+
return if node.nil?
|
|
268
|
+
|
|
269
|
+
block.call(node) if node.is_a?(Prism::DefNode)
|
|
270
|
+
node.compact_child_nodes.each { |child| each_def_node(child, &block) }
|
|
271
|
+
end
|
|
222
272
|
end
|
|
223
273
|
end
|
|
224
274
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
require_relative "../environment"
|
|
8
|
+
require_relative "../inference/precision_scanner"
|
|
9
|
+
require_relative "../scope"
|
|
10
|
+
require_relative "coverage_report"
|
|
11
|
+
require_relative "coverage_renderer"
|
|
12
|
+
|
|
13
|
+
module Rigor
|
|
14
|
+
class CLI
|
|
15
|
+
# Executes the `rigor coverage` command.
|
|
16
|
+
#
|
|
17
|
+
# Walks every Prism node in one or more files, infers its type via
|
|
18
|
+
# `Rigor::Scope#type_of`, and classifies the result into precision tiers
|
|
19
|
+
# (constant / nominal / shaped / refined / bot / dynamic_specific /
|
|
20
|
+
# dynamic_top / top). Reports aggregate and per-file statistics so
|
|
21
|
+
# maintainers can track type-precision trends and SKILL pipelines can
|
|
22
|
+
# measure the impact of adding new constant-fold or shape-dispatch rules.
|
|
23
|
+
#
|
|
24
|
+
# Exit codes:
|
|
25
|
+
# 0 — scan complete, precision ratio ≥ threshold (or no threshold given)
|
|
26
|
+
# 1 — precision ratio < threshold, or parse errors encountered
|
|
27
|
+
# 64 — usage error
|
|
28
|
+
class CoverageCommand
|
|
29
|
+
USAGE = "Usage: rigor coverage [options] PATH..."
|
|
30
|
+
|
|
31
|
+
def initialize(argv:, out:, err:)
|
|
32
|
+
@argv = argv
|
|
33
|
+
@out = out
|
|
34
|
+
@err = err
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Integer] CLI exit status.
|
|
38
|
+
def run
|
|
39
|
+
options = parse_options
|
|
40
|
+
paths = collect_paths(@argv)
|
|
41
|
+
return CLI::EXIT_USAGE if paths.nil?
|
|
42
|
+
return usage_error if paths.empty?
|
|
43
|
+
|
|
44
|
+
report = scan_paths(paths, options)
|
|
45
|
+
CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
46
|
+
determine_exit(report, options)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_options
|
|
52
|
+
options = { format: "text", threshold: nil, config: nil }
|
|
53
|
+
|
|
54
|
+
OptionParser.new do |opts|
|
|
55
|
+
opts.banner = USAGE
|
|
56
|
+
opts.on("--format=FORMAT", "Output format: text or json") { |v| options[:format] = v }
|
|
57
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
|
|
58
|
+
opts.on(
|
|
59
|
+
"--threshold=RATIO", Float,
|
|
60
|
+
"Exit 1 when precision ratio is below RATIO (0.0–1.0)"
|
|
61
|
+
) { |v| options[:threshold] = v }
|
|
62
|
+
end.parse!(@argv)
|
|
63
|
+
|
|
64
|
+
options
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect_paths(args)
|
|
68
|
+
paths = []
|
|
69
|
+
args.each do |arg|
|
|
70
|
+
if File.directory?(arg)
|
|
71
|
+
paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
|
|
72
|
+
elsif File.file?(arg)
|
|
73
|
+
paths << arg
|
|
74
|
+
else
|
|
75
|
+
@err.puts("coverage: not a file or directory: #{arg}")
|
|
76
|
+
return nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
paths.uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def usage_error
|
|
83
|
+
@err.puts("coverage: at least one path is required")
|
|
84
|
+
@err.puts(USAGE)
|
|
85
|
+
CLI::EXIT_USAGE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def scan_paths(paths, options)
|
|
89
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
90
|
+
scope = Scope.empty(environment: project_environment(configuration))
|
|
91
|
+
scanner = Inference::PrecisionScanner.new(scope: scope)
|
|
92
|
+
accumulator = CoverageAccumulator.new
|
|
93
|
+
|
|
94
|
+
paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
|
|
95
|
+
accumulator.to_report(paths, options)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def project_environment(configuration)
|
|
99
|
+
Environment.for_project(
|
|
100
|
+
libraries: configuration.libraries,
|
|
101
|
+
signature_paths: configuration.signature_paths
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def scan_one(path, scanner, accumulator, configuration)
|
|
106
|
+
source = File.read(path)
|
|
107
|
+
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
108
|
+
if parse_result.errors.any?
|
|
109
|
+
accumulator.record_parse_error(path, parse_result.errors)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
accumulator.absorb(path, scanner.scan(parse_result.value))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def determine_exit(report, options)
|
|
117
|
+
return 1 unless report.parse_errors.empty?
|
|
118
|
+
|
|
119
|
+
threshold = options[:threshold]
|
|
120
|
+
return 0 if threshold.nil?
|
|
121
|
+
|
|
122
|
+
report.precision_ratio < threshold ? 1 : 0
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|