rigortype 0.1.18 → 0.2.0
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 +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +756 -132
- data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
- data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/fact_store.rb +5 -4
- data/lib/rigor/analysis/rule_catalog.rb +153 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
- data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
- data/lib/rigor/analysis/runner.rb +75 -27
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
- data/lib/rigor/analysis/worker_session.rb +31 -25
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +5 -3
- data/lib/rigor/cli/annotate_command.rb +122 -16
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/check_command.rb +118 -16
- data/lib/rigor/cli/coverage_command.rb +148 -16
- data/lib/rigor/cli/coverage_scan.rb +57 -0
- data/lib/rigor/cli/explain_command.rb +2 -0
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
- data/lib/rigor/cli/mutation_protection_report.rb +73 -0
- data/lib/rigor/cli/options.rb +9 -0
- data/lib/rigor/cli/plugins_command.rb +4 -5
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/protection_renderer.rb +63 -0
- data/lib/rigor/cli/protection_report.rb +68 -0
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -1
- data/lib/rigor/cli/trace_command.rb +2 -1
- data/lib/rigor/cli/triage_command.rb +8 -4
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_command.rb +2 -1
- data/lib/rigor/cli.rb +12 -3
- data/lib/rigor/configuration/dependencies.rb +2 -4
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +100 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
- data/lib/rigor/environment/class_registry.rb +4 -3
- data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
- data/lib/rigor/environment/lockfile_resolver.rb +1 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
- data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
- data/lib/rigor/environment/rbs_loader.rb +74 -5
- data/lib/rigor/environment.rb +17 -7
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution.rb +3 -5
- data/lib/rigor/inference/acceptance.rb +17 -9
- data/lib/rigor/inference/block_parameter_binder.rb +2 -3
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +1072 -71
- data/lib/rigor/inference/hkt_body.rb +8 -11
- data/lib/rigor/inference/hkt_body_parser.rb +10 -12
- data/lib/rigor/inference/hkt_registry.rb +10 -11
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
- data/lib/rigor/inference/method_dispatcher.rb +112 -49
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +147 -11
- data/lib/rigor/inference/narrowing.rb +284 -53
- data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
- data/lib/rigor/inference/project_patched_methods.rb +4 -7
- data/lib/rigor/inference/project_patched_scanner.rb +2 -13
- data/lib/rigor/inference/protection_scanner.rb +86 -0
- data/lib/rigor/inference/scope_indexer.rb +821 -76
- data/lib/rigor/inference/statement_evaluator.rb +1179 -102
- data/lib/rigor/inference/struct_fold_safety.rb +181 -0
- data/lib/rigor/inference/synthetic_method.rb +7 -7
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/language_server/completion_provider.rb +6 -12
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
- data/lib/rigor/language_server/hover_provider.rb +2 -3
- data/lib/rigor/language_server/hover_renderer.rb +2 -11
- data/lib/rigor/language_server/server.rb +9 -17
- data/lib/rigor/language_server.rb +4 -5
- data/lib/rigor/plugin/base.rb +245 -87
- data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
- data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
- data/lib/rigor/plugin/macro.rb +6 -8
- data/lib/rigor/plugin/manifest.rb +49 -90
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +18 -18
- data/lib/rigor/plugin/type_node_resolver.rb +6 -8
- data/lib/rigor/protection/mutation_scanner.rb +120 -0
- data/lib/rigor/protection/mutator.rb +246 -0
- data/lib/rigor/rbs_extended.rb +24 -36
- data/lib/rigor/reflection.rb +4 -7
- data/lib/rigor/scope/discovery_index.rb +16 -2
- data/lib/rigor/scope.rb +185 -16
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observed_call.rb +3 -3
- data/lib/rigor/sig_gen/writer.rb +40 -2
- data/lib/rigor/source/constant_path.rb +62 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/bound_method.rb +2 -11
- data/lib/rigor/type/combinator.rb +45 -3
- data/lib/rigor/type/constant.rb +2 -11
- data/lib/rigor/type/data_class.rb +2 -11
- data/lib/rigor/type/data_instance.rb +2 -11
- data/lib/rigor/type/hash_shape.rb +2 -11
- data/lib/rigor/type/integer_range.rb +2 -11
- data/lib/rigor/type/intersection.rb +2 -11
- data/lib/rigor/type/nominal.rb +2 -11
- data/lib/rigor/type/plain_lattice.rb +37 -0
- data/lib/rigor/type/refined.rb +72 -13
- data/lib/rigor/type/singleton.rb +2 -11
- data/lib/rigor/type/struct_class.rb +75 -0
- data/lib/rigor/type/struct_instance.rb +93 -0
- data/lib/rigor/type/tuple.rb +5 -15
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +18 -1
- data/sig/rigor/type.rbs +37 -1
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +25 -2
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -6,6 +6,8 @@ require "optionparser"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../configuration"
|
|
8
8
|
require_relative "../analysis/result"
|
|
9
|
+
require_relative "../analysis/rule_catalog"
|
|
10
|
+
require_relative "coverage_scan"
|
|
9
11
|
require_relative "command"
|
|
10
12
|
require_relative "options"
|
|
11
13
|
require_relative "diagnostic_formats"
|
|
@@ -35,6 +37,7 @@ module Rigor
|
|
|
35
37
|
return CLI::EXIT_USAGE if buffer == :usage_error
|
|
36
38
|
|
|
37
39
|
configuration = load_check_configuration(options)
|
|
40
|
+
configuration = apply_bleeding_edge_override(configuration, options)
|
|
38
41
|
cache_root = configuration.cache_path
|
|
39
42
|
handle_clear_cache(cache_root) if options.fetch(:clear_cache)
|
|
40
43
|
|
|
@@ -48,7 +51,8 @@ module Rigor
|
|
|
48
51
|
raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
|
|
49
52
|
result = apply_baseline_filter(raw_result, configuration, options)
|
|
50
53
|
|
|
51
|
-
|
|
54
|
+
coverage = compute_coverage(runner, configuration, options)
|
|
55
|
+
write_result(result, options.fetch(:format), coverage: coverage)
|
|
52
56
|
emit_ci_detected_output(result, options)
|
|
53
57
|
write_run_stats(result.stats) if result.stats
|
|
54
58
|
write_trace_appendices
|
|
@@ -276,29 +280,18 @@ module Rigor
|
|
|
276
280
|
|
|
277
281
|
def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
278
282
|
options = {
|
|
279
|
-
# `nil` triggers `Configuration.discover` (`.rigor.yml` then
|
|
280
|
-
# `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
|
|
281
283
|
config: nil,
|
|
282
284
|
format: "text",
|
|
283
285
|
explain: false,
|
|
284
286
|
cache_stats: false,
|
|
285
287
|
clear_cache: false,
|
|
286
288
|
no_cache: false,
|
|
287
|
-
# Run-stats summary (target files, RBS class universe
|
|
288
|
-
# breakdown, wall time, peak RSS) is on by default
|
|
289
|
-
# because collection is ~free (single syscall for RSS,
|
|
290
|
-
# one walk of `class_decl_paths` for the breakdown).
|
|
291
|
-
# `--no-stats` suppresses it for callers that want a
|
|
292
|
-
# diagnostic-only output stream.
|
|
293
289
|
stats: true,
|
|
294
290
|
# ADR-15 Phase 4c — when nil, falls back to
|
|
295
291
|
# `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
|
|
296
292
|
# `parallel.workers:` then 0 (sequential). See
|
|
297
293
|
# `resolve_workers` for the precedence chain.
|
|
298
294
|
workers: nil,
|
|
299
|
-
# Editor mode (`docs/design/20260516-editor-mode.md`).
|
|
300
|
-
# Both must appear together; the runner uses the pair
|
|
301
|
-
# to bind an in-flight buffer file to its logical path.
|
|
302
295
|
tmp_file: nil,
|
|
303
296
|
instead_of: nil,
|
|
304
297
|
# ADR-22 — baseline filter. `:unset` means "fall through
|
|
@@ -335,17 +328,34 @@ module Rigor
|
|
|
335
328
|
# the human output; for GitLab / reviewdog-routed CIs, print a
|
|
336
329
|
# one-line hint. On by default; `--no-ci-detect` (or
|
|
337
330
|
# `RIGOR_CI_DETECT=0`) disables it.
|
|
338
|
-
ci_detect: true
|
|
331
|
+
ci_detect: true,
|
|
332
|
+
# ADR-50 § WD2 — the `--bleeding-edge[=ids]` / `--no-bleeding-edge`
|
|
333
|
+
# CLI mirror of the `bleeding_edge:` config key. `:unset` means "no
|
|
334
|
+
# flag — use the configured selection"; `true` adopts the whole
|
|
335
|
+
# overlay, `false` adopts none, and an Array of ids adopts only
|
|
336
|
+
# those (see `apply_bleeding_edge_override`).
|
|
337
|
+
bleeding_edge: :unset,
|
|
338
|
+
# Type-precision coverage block. Off by default — it is a
|
|
339
|
+
# second precision pass over the analyzed files (the same scan
|
|
340
|
+
# `rigor coverage` runs), so it is opt-in to keep the default
|
|
341
|
+
# check path's cost unchanged. When set, `--format json` gains
|
|
342
|
+
# a `coverage` object (scan_files + precision tiers) and the
|
|
343
|
+
# text output prints a one-line coverage summary.
|
|
344
|
+
coverage: false
|
|
339
345
|
}
|
|
340
346
|
parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
341
347
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
342
|
-
|
|
348
|
+
Options.add_config(opts, options)
|
|
343
349
|
opts.on("--format=FORMAT",
|
|
344
350
|
"Output format: text, json, sarif, github, gitlab, checkstyle, junit, teamcity") do |value|
|
|
345
351
|
options[:format] = value
|
|
346
352
|
end
|
|
347
353
|
opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
|
|
348
354
|
opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
|
|
355
|
+
opts.on("--coverage",
|
|
356
|
+
"Add a type-precision coverage block (an extra precision pass over the analyzed files)") do
|
|
357
|
+
options[:coverage] = true
|
|
358
|
+
end
|
|
349
359
|
opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
|
|
350
360
|
opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
|
|
351
361
|
opts.on("--[no-]stats",
|
|
@@ -385,6 +395,18 @@ module Rigor
|
|
|
385
395
|
"ADR-51: do not auto-emit CI-native output when a CI environment is detected") do
|
|
386
396
|
options[:ci_detect] = false
|
|
387
397
|
end
|
|
398
|
+
# ADR-50 § WD2 — `=[LIST]` (not ` [LIST]`) so a bare `--bleeding-edge`
|
|
399
|
+
# never swallows a following positional path: `rigor check
|
|
400
|
+
# --bleeding-edge lib` adopts the whole overlay and checks `lib`.
|
|
401
|
+
opts.on("--bleeding-edge=[LIST]",
|
|
402
|
+
"ADR-50: adopt the bleeding-edge overlay for this run " \
|
|
403
|
+
"(all features, or a comma-separated feature-id list)") do |value|
|
|
404
|
+
options[:bleeding_edge] = value.nil? || value.split(",").map(&:strip).reject(&:empty?)
|
|
405
|
+
end
|
|
406
|
+
opts.on("--no-bleeding-edge",
|
|
407
|
+
"ADR-50: ignore any configured bleeding_edge: selection for this run") do
|
|
408
|
+
options[:bleeding_edge] = false
|
|
409
|
+
end
|
|
388
410
|
end
|
|
389
411
|
parser.parse!(@argv)
|
|
390
412
|
options
|
|
@@ -410,6 +432,20 @@ module Rigor
|
|
|
410
432
|
Configuration.new(Configuration::DEFAULTS.merge(data))
|
|
411
433
|
end
|
|
412
434
|
|
|
435
|
+
# ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge`
|
|
436
|
+
# CLI selection over the configured `bleeding_edge:` value, mirroring the
|
|
437
|
+
# CLI-over-config precedence `--workers` and `--no-cache` follow. `:unset`
|
|
438
|
+
# (no flag) leaves the loaded configuration untouched; any other value is
|
|
439
|
+
# normalised by {Configuration#with_bleeding_edge}, so the two
|
|
440
|
+
# `SeverityProfile.resolve` sites (and the worker path, which receives the
|
|
441
|
+
# whole frozen Configuration) see the run's selection.
|
|
442
|
+
def apply_bleeding_edge_override(configuration, options)
|
|
443
|
+
selection = options.fetch(:bleeding_edge)
|
|
444
|
+
return configuration if selection == :unset
|
|
445
|
+
|
|
446
|
+
configuration.with_bleeding_edge(selection)
|
|
447
|
+
end
|
|
448
|
+
|
|
413
449
|
def inject_treat_all_as_inline_rbs(entries)
|
|
414
450
|
filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
|
|
415
451
|
filtered + [{
|
|
@@ -473,6 +509,9 @@ module Rigor
|
|
|
473
509
|
@err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
|
|
474
510
|
@err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
|
|
475
511
|
@err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
|
|
512
|
+
@err.puts(" recursion-unroll-fuel hits: #{counts[Inference::BudgetTrace::RECURSION_UNROLL_FUEL]}")
|
|
513
|
+
@err.puts(" recursion-fixpoint-cap hits: #{counts[Inference::BudgetTrace::RECURSION_FIXPOINT_CAP]}")
|
|
514
|
+
@err.puts(" block-writeback-cap hits: #{counts[Inference::BudgetTrace::BLOCK_WRITEBACK_CAP]}")
|
|
476
515
|
write_budget_distributions
|
|
477
516
|
end
|
|
478
517
|
|
|
@@ -632,12 +671,15 @@ module Rigor
|
|
|
632
671
|
format("%.1f MiB", bytes / (1024.0 * 1024.0))
|
|
633
672
|
end
|
|
634
673
|
|
|
635
|
-
def write_result(result, format)
|
|
674
|
+
def write_result(result, format, coverage: nil)
|
|
636
675
|
case format
|
|
637
676
|
when "json"
|
|
638
|
-
|
|
677
|
+
payload = enrich_json(result.to_h)
|
|
678
|
+
payload["coverage"] = coverage_payload(coverage) if coverage
|
|
679
|
+
@out.puts(JSON.pretty_generate(payload))
|
|
639
680
|
when "text"
|
|
640
681
|
write_text_result(result)
|
|
682
|
+
write_coverage_summary(coverage) if coverage
|
|
641
683
|
when ->(fmt) { CLI::DiagnosticFormats.supports?(fmt) }
|
|
642
684
|
# ADR-51 — CI-native renderings (SARIF / GitHub Actions commands /
|
|
643
685
|
# GitLab Code Quality). The `github` form is empty when there are no
|
|
@@ -649,6 +691,66 @@ module Rigor
|
|
|
649
691
|
end
|
|
650
692
|
end
|
|
651
693
|
|
|
694
|
+
# Runs the type-precision scan (`--coverage`) over the same file set
|
|
695
|
+
# the check analyzed and returns a `CoverageReport`, or nil when the
|
|
696
|
+
# flag is off. It is a second pass — the same scan `rigor coverage`
|
|
697
|
+
# runs, reused via {CoverageScan} — so it is opt-in to keep the
|
|
698
|
+
# default check path's cost unchanged.
|
|
699
|
+
def compute_coverage(runner, configuration, options)
|
|
700
|
+
return nil unless options.fetch(:coverage)
|
|
701
|
+
|
|
702
|
+
files = @argv.empty? ? runner.analysis_file_set : runner.analysis_file_set(@argv)
|
|
703
|
+
CoverageScan.precision_report(files: files, configuration: configuration)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# The `coverage` block embedded in `--format json`. Mirrors the
|
|
707
|
+
# `summary` of `rigor coverage --format json` (the same vocabulary —
|
|
708
|
+
# `precise_ratio`, not a separate `typed_ratio`) plus `scan_files`,
|
|
709
|
+
# so a consumer reads one stream to learn both what fired and how
|
|
710
|
+
# much of the analyzed surface Rigor could type.
|
|
711
|
+
def coverage_payload(report)
|
|
712
|
+
{
|
|
713
|
+
"scan_files" => report.files.size - report.parse_errors.size,
|
|
714
|
+
"parse_errors" => report.parse_errors.size,
|
|
715
|
+
"expressions_typed" => report.grand_total,
|
|
716
|
+
"precise_count" => report.precise_count,
|
|
717
|
+
"precise_ratio" => report.precision_ratio.round(4),
|
|
718
|
+
"dynamic_opaque_count" => report.opaque_count,
|
|
719
|
+
"dynamic_opaque_ratio" => report.opaque_ratio.round(4)
|
|
720
|
+
}
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def write_coverage_summary(report)
|
|
724
|
+
files = report.files.size - report.parse_errors.size
|
|
725
|
+
pct = (report.precision_ratio * 100).round(1)
|
|
726
|
+
@out.puts("Type coverage: #{files} file(s), #{pct}% precise " \
|
|
727
|
+
"(#{report.precise_count}/#{report.grand_total} expressions). " \
|
|
728
|
+
"Run `rigor coverage` for the full per-file / per-tier breakdown.")
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# Adds the per-rule `evidence_tier` and `documentation_url` fields
|
|
732
|
+
# to each diagnostic in the `--format json` payload. Both are pure
|
|
733
|
+
# functions of the rule id (the rule catalogue, ADR-61 / the
|
|
734
|
+
# 2026-06-15 feedback §4 + §5.1), so they enrich the presentation
|
|
735
|
+
# layer here rather than threading through every diagnostic
|
|
736
|
+
# construction site. Only built-in rules carry catalogue metadata;
|
|
737
|
+
# plugin / `rbs_extended` / parse-error diagnostics are left
|
|
738
|
+
# untouched (they host their own documentation and confidence).
|
|
739
|
+
def enrich_json(payload)
|
|
740
|
+
Array(payload["diagnostics"]).each do |diag|
|
|
741
|
+
next unless diag["source_family"] == Analysis::Diagnostic::DEFAULT_SOURCE_FAMILY.to_s
|
|
742
|
+
|
|
743
|
+
rule = diag["rule"]
|
|
744
|
+
next unless rule
|
|
745
|
+
|
|
746
|
+
tier = Analysis::RuleCatalog.evidence_tier(rule)
|
|
747
|
+
diag["evidence_tier"] = tier.to_s if tier
|
|
748
|
+
url = Analysis::RuleCatalog.documentation_url(rule)
|
|
749
|
+
diag["documentation_url"] = url if url
|
|
750
|
+
end
|
|
751
|
+
payload
|
|
752
|
+
end
|
|
753
|
+
|
|
652
754
|
# ADR-51 WD7 — CI auto-detection. Only augments the default human
|
|
653
755
|
# (`text`) output: an explicit `--format` means the caller is in control
|
|
654
756
|
# and is left untouched. For a first-class stdout-native CI (GitHub
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "English"
|
|
3
4
|
require "optionparser"
|
|
4
5
|
require "prism"
|
|
5
6
|
|
|
6
7
|
require_relative "../configuration"
|
|
8
|
+
require_relative "options"
|
|
7
9
|
require_relative "../environment"
|
|
8
10
|
require_relative "../inference/precision_scanner"
|
|
11
|
+
require_relative "../inference/protection_scanner"
|
|
12
|
+
require_relative "../inference/parameter_inference_collector"
|
|
13
|
+
require_relative "../protection/mutation_scanner"
|
|
14
|
+
require_relative "../language_server/project_context"
|
|
9
15
|
require_relative "../scope"
|
|
10
16
|
require_relative "coverage_report"
|
|
11
17
|
require_relative "coverage_renderer"
|
|
18
|
+
require_relative "coverage_scan"
|
|
19
|
+
require_relative "protection_report"
|
|
20
|
+
require_relative "protection_renderer"
|
|
21
|
+
require_relative "mutation_protection_report"
|
|
22
|
+
require_relative "mutation_protection_renderer"
|
|
12
23
|
require_relative "command"
|
|
13
24
|
|
|
14
25
|
module Rigor
|
|
@@ -32,10 +43,15 @@ module Rigor
|
|
|
32
43
|
# @return [Integer] CLI exit status.
|
|
33
44
|
def run
|
|
34
45
|
options = parse_options
|
|
46
|
+
return mutation_misuse_error if options[:mutation] && !options[:protection]
|
|
47
|
+
return run_mutation_protection(options) if options[:mutation]
|
|
48
|
+
|
|
35
49
|
paths = collect_paths(@argv, command_name: "coverage")
|
|
36
50
|
return CLI::EXIT_USAGE if paths.nil?
|
|
37
51
|
return usage_error if paths.empty?
|
|
38
52
|
|
|
53
|
+
return run_protection(paths, options) if options[:protection]
|
|
54
|
+
|
|
39
55
|
report = scan_paths(paths, options)
|
|
40
56
|
CoverageRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
41
57
|
determine_exit(report, options)
|
|
@@ -44,45 +60,112 @@ module Rigor
|
|
|
44
60
|
private
|
|
45
61
|
|
|
46
62
|
def parse_options
|
|
47
|
-
options = { format: "text", threshold: nil, config: nil }
|
|
63
|
+
options = { format: "text", threshold: nil, config: nil, protection: false, mutation: false }
|
|
48
64
|
|
|
49
65
|
OptionParser.new do |opts|
|
|
50
66
|
opts.banner = USAGE
|
|
51
67
|
opts.on("--format=FORMAT", "Output format: text or json") { |v| options[:format] = v }
|
|
52
|
-
|
|
68
|
+
Options.add_config(opts, options)
|
|
69
|
+
opts.on(
|
|
70
|
+
"--protection",
|
|
71
|
+
"Report type-protection coverage (ADR-63 Tier 1) instead of type precision"
|
|
72
|
+
) { options[:protection] = true }
|
|
73
|
+
opts.on(
|
|
74
|
+
"--mutation",
|
|
75
|
+
"With --protection: measure actual mutation effectiveness (ADR-63 Tier 2). " \
|
|
76
|
+
"Scopes to git-changed files when no paths are given; explicit paths override."
|
|
77
|
+
) { options[:mutation] = true }
|
|
53
78
|
opts.on(
|
|
54
79
|
"--threshold=RATIO", Float,
|
|
55
|
-
"Exit 1 when precision ratio is below RATIO (0.0–1.0)"
|
|
80
|
+
"Exit 1 when the precision (or, with --protection, protection/effectiveness) ratio is below RATIO (0.0–1.0)"
|
|
56
81
|
) { |v| options[:threshold] = v }
|
|
57
82
|
end.parse!(@argv)
|
|
58
83
|
|
|
59
84
|
options
|
|
60
85
|
end
|
|
61
86
|
|
|
62
|
-
def
|
|
63
|
-
@err.puts("coverage:
|
|
87
|
+
def mutation_misuse_error
|
|
88
|
+
@err.puts("coverage: --mutation requires --protection")
|
|
64
89
|
@err.puts(USAGE)
|
|
65
90
|
CLI::EXIT_USAGE
|
|
66
91
|
end
|
|
67
92
|
|
|
68
|
-
def
|
|
93
|
+
def run_protection(paths, options)
|
|
94
|
+
report = scan_protection(paths, options)
|
|
95
|
+
ProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
96
|
+
determine_protection_exit(report, options)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def scan_protection(paths, options)
|
|
69
100
|
configuration = Configuration.load(options.fetch(:config))
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
environment = project_environment(configuration)
|
|
102
|
+
scope = scope_with_inferred_params(paths, configuration, environment)
|
|
103
|
+
scanner = Inference::ProtectionScanner.new(scope: scope)
|
|
104
|
+
accumulator = ProtectionAccumulator.new
|
|
73
105
|
|
|
74
106
|
paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
|
|
75
|
-
accumulator.to_report
|
|
107
|
+
accumulator.to_report
|
|
76
108
|
end
|
|
77
109
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
110
|
+
# ADR-67 WD3 — seed the call-site parameter-inference table so the
|
|
111
|
+
# protection scan counts an inferred-parameter receiver (e.g. `node.loc`
|
|
112
|
+
# where `node` is a `def compile(node)` parameter) as protected when its
|
|
113
|
+
# call sites resolve to concrete argument types. ONLY the parameter table
|
|
114
|
+
# is seeded — no cross-file discovery — so every site that does not gain
|
|
115
|
+
# an inferred parameter type is classified byte-identically to the
|
|
116
|
+
# un-inferred baseline. Collection spans the scanned `paths`.
|
|
117
|
+
def scope_with_inferred_params(paths, configuration, environment)
|
|
118
|
+
base = Scope.empty(environment: environment)
|
|
119
|
+
table = Inference::ParameterInferenceCollector.collect(
|
|
120
|
+
files: paths, environment: environment, target_ruby: configuration.target_ruby
|
|
82
121
|
)
|
|
122
|
+
return base if table.empty?
|
|
123
|
+
|
|
124
|
+
base.with_discovery(base.discovery.with(param_inferred_types: table))
|
|
83
125
|
end
|
|
84
126
|
|
|
85
|
-
def
|
|
127
|
+
def determine_protection_exit(report, options)
|
|
128
|
+
return 1 unless report.parse_errors.empty?
|
|
129
|
+
|
|
130
|
+
threshold = options[:threshold]
|
|
131
|
+
return 0 if threshold.nil?
|
|
132
|
+
|
|
133
|
+
report.ratio < threshold ? 1 : 0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# ADR-63 Tier 2 — the mutation-effectiveness deep dive. Builds the RBS
|
|
137
|
+
# environment + project pre-pass once (the warm loop), then re-analyses
|
|
138
|
+
# each target file's mutants against its clean baseline. Defaults to the
|
|
139
|
+
# git-changed `.rb` files; explicit paths override (and enable the
|
|
140
|
+
# whole-project opt-in, which is minutes).
|
|
141
|
+
def run_mutation_protection(options)
|
|
142
|
+
explicit = collect_paths(@argv, command_name: "coverage")
|
|
143
|
+
return CLI::EXIT_USAGE if explicit.nil?
|
|
144
|
+
|
|
145
|
+
target_files = explicit.empty? ? changed_ruby_files : explicit
|
|
146
|
+
if target_files.empty?
|
|
147
|
+
@out.puts("No changed Ruby files to measure — pass paths to measure explicitly.")
|
|
148
|
+
return 0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
report = scan_mutation_protection(target_files, options)
|
|
152
|
+
MutationProtectionRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
153
|
+
determine_protection_exit(report, options)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def scan_mutation_protection(paths, options)
|
|
157
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
158
|
+
context = LanguageServer::ProjectContext.new(configuration: configuration)
|
|
159
|
+
scanner = Protection::MutationScanner.new(
|
|
160
|
+
configuration: configuration, environment: context.environment, project_scan: context.project_scan
|
|
161
|
+
)
|
|
162
|
+
accumulator = MutationProtectionAccumulator.new
|
|
163
|
+
|
|
164
|
+
paths.each { |path| scan_mutation_one(path, scanner, accumulator, configuration) }
|
|
165
|
+
accumulator.to_report
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def scan_mutation_one(path, scanner, accumulator, configuration)
|
|
86
169
|
source = File.read(path)
|
|
87
170
|
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
88
171
|
if parse_result.errors.any?
|
|
@@ -90,7 +173,56 @@ module Rigor
|
|
|
90
173
|
return
|
|
91
174
|
end
|
|
92
175
|
|
|
93
|
-
accumulator.absorb(path,
|
|
176
|
+
accumulator.absorb(scanner.scan_file(path, source: source))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The git-changed (modified / added / untracked) `.rb` files that exist on
|
|
180
|
+
# disk — the default Tier 2 scope. Returns [] outside a git work tree or
|
|
181
|
+
# when git is unavailable; the caller then reports "nothing to measure".
|
|
182
|
+
def changed_ruby_files
|
|
183
|
+
output = git_status_porcelain
|
|
184
|
+
return [] if output.nil?
|
|
185
|
+
|
|
186
|
+
output.each_line.filter_map { |line| changed_path(line) }.uniq.select { |p| File.file?(p) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Parse one `git status --porcelain` line (`XY <path>`, or `R old -> new`)
|
|
190
|
+
# into a candidate `.rb` path, or nil.
|
|
191
|
+
def changed_path(line)
|
|
192
|
+
path = line[3..]&.chomp
|
|
193
|
+
return nil if path.nil? || path.empty?
|
|
194
|
+
|
|
195
|
+
path = path.split(" -> ", 2).last if path.include?(" -> ")
|
|
196
|
+
path = path.delete_prefix('"').delete_suffix('"')
|
|
197
|
+
path.end_with?(".rb") ? path : nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def git_status_porcelain
|
|
201
|
+
output = IO.popen(%w[git status --porcelain --untracked-files=all], err: File::NULL, &:read)
|
|
202
|
+
$CHILD_STATUS&.success? ? output : nil
|
|
203
|
+
rescue SystemCallError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def usage_error
|
|
208
|
+
@err.puts("coverage: at least one path is required")
|
|
209
|
+
@err.puts(USAGE)
|
|
210
|
+
CLI::EXIT_USAGE
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def scan_paths(paths, options)
|
|
214
|
+
CoverageScan.precision_report(files: paths, configuration: Configuration.load(options.fetch(:config)))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Delegated to the shared scan module (see {CoverageScan}); the
|
|
218
|
+
# protection path below reuses both, and `rigor check --coverage`
|
|
219
|
+
# reuses `precision_report` over the same machinery.
|
|
220
|
+
def project_environment(configuration)
|
|
221
|
+
CoverageScan.project_environment(configuration)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def scan_one(path, scanner, accumulator, configuration)
|
|
225
|
+
CoverageScan.scan_into(path, scanner, accumulator, configuration)
|
|
94
226
|
end
|
|
95
227
|
|
|
96
228
|
def determine_exit(report, options)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../environment"
|
|
7
|
+
require_relative "../inference/precision_scanner"
|
|
8
|
+
require_relative "../scope"
|
|
9
|
+
require_relative "coverage_report"
|
|
10
|
+
|
|
11
|
+
module Rigor
|
|
12
|
+
class CLI
|
|
13
|
+
# Shared type-precision scan behind both `rigor coverage` (the
|
|
14
|
+
# dedicated command) and `rigor check --coverage` (the in-run
|
|
15
|
+
# coverage block). Walks each file's AST, types every expression via
|
|
16
|
+
# `Scope#type_of`, and accumulates the precision-tier breakdown into a
|
|
17
|
+
# `CoverageReport`. Extracted so the two surfaces stay byte-identical
|
|
18
|
+
# on the same file set.
|
|
19
|
+
module CoverageScan
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param files [Array<String>] explicit `.rb` file paths to scan.
|
|
23
|
+
# @param configuration [Rigor::Configuration]
|
|
24
|
+
# @return [Rigor::CLI::CoverageReport]
|
|
25
|
+
def precision_report(files:, configuration:)
|
|
26
|
+
scope = Scope.empty(environment: project_environment(configuration))
|
|
27
|
+
scanner = Inference::PrecisionScanner.new(scope: scope)
|
|
28
|
+
accumulator = CoverageAccumulator.new
|
|
29
|
+
files.each { |path| scan_into(path, scanner, accumulator, configuration) }
|
|
30
|
+
accumulator.to_report(files, {})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def project_environment(configuration)
|
|
34
|
+
Environment.for_project(
|
|
35
|
+
libraries: configuration.libraries,
|
|
36
|
+
signature_paths: configuration.signature_paths
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Parses one file and feeds the scan result (or a parse-error
|
|
41
|
+
# record) into `accumulator`. `scanner` / `accumulator` are a
|
|
42
|
+
# matched pair — a `PrecisionScanner` + `CoverageAccumulator`, or a
|
|
43
|
+
# `ProtectionScanner` + `ProtectionAccumulator` — both of which
|
|
44
|
+
# respond to `scan(node)` and `absorb(path, result)`.
|
|
45
|
+
def scan_into(path, scanner, accumulator, configuration)
|
|
46
|
+
source = File.read(path)
|
|
47
|
+
parse_result = Prism.parse(source, filepath: path, version: configuration.target_ruby)
|
|
48
|
+
if parse_result.errors.any?
|
|
49
|
+
accumulator.record_parse_error(path, parse_result.errors)
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
accumulator.absorb(path, scanner.scan(parse_result.value))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -95,6 +95,7 @@ module Rigor
|
|
|
95
95
|
render_section("Fires when:", entry.fires_when)
|
|
96
96
|
render_section("Does not fire when:", entry.does_not_fire_when)
|
|
97
97
|
@out.puts("Suppression: #{entry.suppression}")
|
|
98
|
+
@out.puts("Documentation: #{entry.documentation_url}")
|
|
98
99
|
@out.puts("Since: rigor #{entry.since}")
|
|
99
100
|
end
|
|
100
101
|
|
|
@@ -109,6 +110,7 @@ module Rigor
|
|
|
109
110
|
@out.puts("Authored severity: :#{entry.severity_authored}")
|
|
110
111
|
profile_table = entry.severity_by_profile.map { |profile, sev| "#{profile} → :#{sev}" }.join(", ")
|
|
111
112
|
@out.puts("Severity by profile: #{profile_table}")
|
|
113
|
+
@out.puts("Evidence tier: #{entry.evidence_tier || 'n/a (informational)'}")
|
|
112
114
|
@out.puts("")
|
|
113
115
|
end
|
|
114
116
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "command"
|
|
4
|
+
require_relative "options"
|
|
4
5
|
|
|
5
6
|
require "optionparser"
|
|
6
7
|
|
|
@@ -8,11 +9,8 @@ module Rigor
|
|
|
8
9
|
class CLI
|
|
9
10
|
# Executes the `rigor lsp` command.
|
|
10
11
|
#
|
|
12
|
+
# Starts a long-running LSP server over stdio (JSON-RPC).
|
|
11
13
|
# See `docs/design/20260517-language-server.md` for the design.
|
|
12
|
-
# Slice 1 (this commit) ships the CLI subcommand entry point.
|
|
13
|
-
# The actual stdio JSON-RPC reader / writer is queued for slice 2;
|
|
14
|
-
# invoking `rigor lsp` at slice 1 returns immediately after
|
|
15
|
-
# validating the transport flag.
|
|
16
14
|
class LspCommand < Command
|
|
17
15
|
USAGE = "Usage: rigor lsp [options]"
|
|
18
16
|
|
|
@@ -109,9 +107,7 @@ module Rigor
|
|
|
109
107
|
opts.on("--log=PATH", "Write LSP wire log + server debug to PATH (default: stderr)") do |value|
|
|
110
108
|
options[:log] = value
|
|
111
109
|
end
|
|
112
|
-
|
|
113
|
-
options[:config] = value
|
|
114
|
-
end
|
|
110
|
+
Options.add_config(opts, options)
|
|
115
111
|
end
|
|
116
112
|
parser.parse!(@argv)
|
|
117
113
|
options
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Renders a {MutationProtectionReport} (ADR-63 Tier 2) as text or JSON. The
|
|
8
|
+
# text form leads with the effectiveness ratio (caught breakages), then the
|
|
9
|
+
# breakages Rigor missed ("add a type here"), then the least-effective files.
|
|
10
|
+
# The framing is always *where to add a type*, never "your code is broken".
|
|
11
|
+
class MutationProtectionRenderer
|
|
12
|
+
TOP_CALLS = 15
|
|
13
|
+
TOP_FILES = 10
|
|
14
|
+
|
|
15
|
+
def initialize(out:)
|
|
16
|
+
@out = out
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(report, format:)
|
|
20
|
+
format == "json" ? render_json(report) : render_text(report)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render_json(report)
|
|
26
|
+
@out.puts(JSON.pretty_generate(report.to_h))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_text(report)
|
|
30
|
+
pct = (report.ratio * 100).round(1)
|
|
31
|
+
@out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
|
|
32
|
+
@out.puts " caught breakages: #{report.total_killed} / #{report.grand_total} (#{pct}%)"
|
|
33
|
+
@out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
|
|
34
|
+
render_missed(report)
|
|
35
|
+
render_files(report)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_missed(report)
|
|
39
|
+
missed = report.missed
|
|
40
|
+
return if missed.empty?
|
|
41
|
+
|
|
42
|
+
@out.puts "\nAdd a type here — breakages Rigor missed (a wrong call that stayed silent):"
|
|
43
|
+
missed.first(TOP_CALLS).each do |call|
|
|
44
|
+
@out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
|
|
45
|
+
count: call.count, method: call.method_name, sites: call.examples.join(" "))
|
|
46
|
+
end
|
|
47
|
+
@out.puts " (#{missed.size - TOP_CALLS} more)" if missed.size > TOP_CALLS
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_files(report)
|
|
51
|
+
worst = report.files.reject { |f| f.survived.zero? }.sort_by(&:ratio).first(TOP_FILES)
|
|
52
|
+
return if worst.empty?
|
|
53
|
+
|
|
54
|
+
@out.puts "\nLeast-effective files:"
|
|
55
|
+
worst.each do |file|
|
|
56
|
+
total = file.killed + file.survived
|
|
57
|
+
@out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d breakages caught)",
|
|
58
|
+
pct: file.ratio * 100, path: file.path, n: file.killed, total: total)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|