rigortype 0.3.5 → 0.3.7
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 +5 -5
- data/data/builtins/ruby_core/date.yml +6 -3
- data/data/builtins/ruby_core/enumerable.yml +1 -0
- data/data/builtins/ruby_core/exception.yml +2 -1
- data/data/builtins/ruby_core/file.yml +28 -14
- data/data/builtins/ruby_core/hash.yml +5 -2
- data/data/builtins/ruby_core/io.yml +33 -16
- data/data/builtins/ruby_core/random.yml +2 -1
- data/data/builtins/ruby_core/re.yml +6 -3
- data/data/builtins/ruby_core/struct.yml +2 -0
- data/data/builtins/ruby_core/time.yml +2 -1
- data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +29 -16
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +12 -0
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +1 -1
- data/docs/manual/12-caching.md +1 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +19 -12
- data/docs/manual/README.md +2 -2
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +78 -5
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/docs/manual/plugins/rigor-sidekiq.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
- data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
- data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
- data/lib/rigor/analysis/check_rules.rb +383 -48
- data/lib/rigor/analysis/crash_signature.rb +116 -0
- data/lib/rigor/analysis/dependency_recorder.rb +18 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/incremental.rb +28 -0
- data/lib/rigor/analysis/incremental_session.rb +44 -9
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
- data/lib/rigor/analysis/reachability/graph.rb +44 -16
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +27 -7
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +53 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
- data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
- data/lib/rigor/analysis/runner.rb +298 -38
- data/lib/rigor/analysis/worker_session.rb +10 -1
- data/lib/rigor/bleeding_edge.rb +7 -6
- data/lib/rigor/builtins/hkt_builtins.rb +45 -6
- data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
- data/lib/rigor/builtins/static_return_refinements.rb +20 -1
- data/lib/rigor/cache/descriptor.rb +42 -2
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cache/incremental_snapshot.rb +40 -3
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
- data/lib/rigor/cache/store.rb +10 -1
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +28 -24
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +27 -2
- data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
- data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
- data/lib/rigor/cli/fused_protection_report.rb +23 -2
- data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
- data/lib/rigor/cli/mutation_protection_report.rb +31 -2
- data/lib/rigor/cli/sig_gen_command.rb +18 -0
- data/lib/rigor/cli/type_of_command.rb +188 -41
- data/lib/rigor/cli/type_of_renderer.rb +72 -3
- data/lib/rigor/cli/type_scan_command.rb +10 -1
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +22 -9
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -3
- data/lib/rigor/effects/signature_sources.rb +13 -2
- data/lib/rigor/effects/snapshot.rb +53 -21
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
- data/lib/rigor/environment/lockfile_resolver.rb +15 -5
- data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
- data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
- data/lib/rigor/environment/rbs_loader.rb +525 -46
- data/lib/rigor/environment.rb +38 -15
- data/lib/rigor/inference/acceptance.rb +172 -20
- data/lib/rigor/inference/captured_locals.rb +62 -0
- data/lib/rigor/inference/content_join.rb +347 -0
- data/lib/rigor/inference/def_handle.rb +15 -3
- data/lib/rigor/inference/def_node_resolver.rb +62 -3
- data/lib/rigor/inference/expression_typer.rb +1020 -141
- data/lib/rigor/inference/fork_map.rb +6 -1
- data/lib/rigor/inference/hkt_reducer.rb +12 -1
- data/lib/rigor/inference/hkt_registry.rb +46 -0
- data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
- data/lib/rigor/inference/index_write_widening.rb +48 -0
- data/lib/rigor/inference/indexed_narrowing.rb +14 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
- data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
- data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
- data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
- data/lib/rigor/inference/method_dispatcher.rb +155 -14
- data/lib/rigor/inference/method_parameter_binder.rb +3 -3
- data/lib/rigor/inference/mutation_widening.rb +244 -174
- data/lib/rigor/inference/narrowing.rb +331 -27
- data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
- data/lib/rigor/inference/pre_eval_constants.rb +15 -0
- data/lib/rigor/inference/precision_scanner.rb +5 -2
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +89 -39
- data/lib/rigor/inference/scope_indexer.rb +1493 -140
- data/lib/rigor/inference/statement_evaluator.rb +276 -91
- data/lib/rigor/inference/struct_fold_safety.rb +186 -27
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/inference/version_guard.rb +229 -0
- data/lib/rigor/language_server/selection_range_provider.rb +1 -1
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/inflector.rb +14 -5
- data/lib/rigor/plugin/io_boundary.rb +107 -4
- data/lib/rigor/plugin/loader.rb +14 -0
- data/lib/rigor/protection/analysis_guard.rb +60 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
- data/lib/rigor/protection/discovery_seed.rb +1 -1
- data/lib/rigor/protection/measurement_integrity.rb +36 -0
- data/lib/rigor/protection/mutation_scanner.rb +61 -6
- data/lib/rigor/protection/mutator.rb +121 -18
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/reflection.rb +182 -30
- data/lib/rigor/scope/discovery_index.rb +39 -1
- data/lib/rigor/scope.rb +385 -4
- data/lib/rigor/sig_gen/classification.rb +12 -1
- data/lib/rigor/sig_gen/generator.rb +254 -5
- data/lib/rigor/sig_gen/renderer.rb +1 -6
- data/lib/rigor/sig_gen/writer.rb +3 -0
- data/lib/rigor/source/constant_path.rb +79 -2
- data/lib/rigor/triage/catalogue.rb +1 -1
- data/lib/rigor/type/combinator.rb +10 -0
- data/lib/rigor/type/maybe.rb +47 -0
- data/lib/rigor/type/refined.rb +1 -2
- data/lib/rigor/type/result.rb +53 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
- data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
- data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
- data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
- data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
- data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
- data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
- data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
- data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
- data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
- data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
- data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
- data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
- data/sig/rigor/analysis/baseline.rbs +1 -1
- data/sig/rigor/environment.rbs +3 -0
- data/sig/rigor/inference.rbs +4 -1
- data/sig/rigor/plugin/io_boundary.rbs +2 -0
- data/sig/rigor/reflection.rbs +3 -1
- data/sig/rigor/scope.rbs +25 -1
- data/sig/rigor/type.rbs +95 -1
- data/sig/rigor.rbs +16 -0
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +73 -3
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
require "optionparser"
|
|
4
4
|
|
|
5
5
|
require_relative "../configuration"
|
|
6
|
-
|
|
6
|
+
# ADR-104 — `analysis/runner` is required lazily (in `#analyze`), not here: a warm report served by
|
|
7
|
+
# {Analysis::EffectsCacheProbe} must reach its answer without `rigor/inference` in `$LOADED_FEATURES`,
|
|
8
|
+
# which is the whole of the boot saving. The probe pulls only the cache and the effects value layer.
|
|
9
|
+
require_relative "../analysis/effects_cache_probe"
|
|
7
10
|
require_relative "../effects/plugin_facts"
|
|
8
11
|
require_relative "../effects/registry"
|
|
9
12
|
require_relative "../plugin/loader"
|
|
@@ -94,7 +97,7 @@ module Rigor
|
|
|
94
97
|
return list_labels(configuration) if options.fetch(:list_labels)
|
|
95
98
|
|
|
96
99
|
scope = @argv.dup
|
|
97
|
-
table, sources =
|
|
100
|
+
table, sources = resolve_table(configuration, scope)
|
|
98
101
|
report = EffectsReport.build(
|
|
99
102
|
table, full: options.fetch(:full), sources: sources, scope: scope,
|
|
100
103
|
label: options.fetch(:label), pure: options.fetch(:pure), limit: options.fetch(:limit)
|
|
@@ -231,6 +234,7 @@ module Rigor
|
|
|
231
234
|
# @return [Array(Rigor::Effects::EffectTable, Hash{String=>Array<String>})] the table, and which
|
|
232
235
|
# file each unit was defined in — the map {EffectsReport} needs to answer a path argument.
|
|
233
236
|
def analyze(configuration, scope)
|
|
237
|
+
require_relative "../analysis/runner"
|
|
234
238
|
runner = Analysis::Runner.new(
|
|
235
239
|
configuration: configuration,
|
|
236
240
|
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
@@ -240,6 +244,27 @@ module Rigor
|
|
|
240
244
|
runner.run((configuration.paths + scope).uniq)
|
|
241
245
|
[runner.effect_table, runner.effect_sources]
|
|
242
246
|
end
|
|
247
|
+
|
|
248
|
+
# ADR-104 — the report before the engine. A warm run's whole answer is the #482 summary entry, so
|
|
249
|
+
# {Analysis::EffectsCacheProbe} tries to serve it without loading `rigor/inference`; any decline
|
|
250
|
+
# falls through to the full runner, which behaves exactly as it did.
|
|
251
|
+
#
|
|
252
|
+
# The report is a pure function of the table, the sources and the configuration: the envelope
|
|
253
|
+
# diagnostics the analysing path also computes are discarded here (this method reads the table
|
|
254
|
+
# and the sources and nothing else), which is what makes the served answer identical rather than
|
|
255
|
+
# merely similar.
|
|
256
|
+
def resolve_table(configuration, scope)
|
|
257
|
+
served = probe(configuration, scope)
|
|
258
|
+
return [served.table, served.sources] if served
|
|
259
|
+
|
|
260
|
+
analyze(configuration, scope)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def probe(configuration, scope)
|
|
264
|
+
Analysis::EffectsCacheProbe.new(
|
|
265
|
+
configuration: configuration, cache_root: configuration.cache_path
|
|
266
|
+
).serve((configuration.paths + scope).uniq)
|
|
267
|
+
end
|
|
243
268
|
end
|
|
244
269
|
end
|
|
245
270
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
+
require_relative "../effects/definition_lines"
|
|
6
|
+
require_relative "../effects/snapshot"
|
|
5
7
|
require_relative "../effects/snapshot_diff"
|
|
6
8
|
require_relative "renderable"
|
|
7
9
|
|
|
@@ -16,8 +18,9 @@ module Rigor
|
|
|
16
18
|
# the one line the whole workflow turns on: intent is expressed by regenerating and committing the
|
|
17
19
|
# file, not by annotating the code.
|
|
18
20
|
#
|
|
19
|
-
# It is deliberately not a diagnostic format: no severities
|
|
20
|
-
#
|
|
21
|
+
# It is deliberately not a diagnostic format: no severities and no exit-code weight of its own — the
|
|
22
|
+
# exit code comes from the gate. A row carries a position for a reader to jump to, never one a
|
|
23
|
+
# `# rigor:disable` or a baseline could act on.
|
|
21
24
|
class EffectsDiffRenderer
|
|
22
25
|
include Renderable
|
|
23
26
|
|
|
@@ -41,9 +44,23 @@ module Rigor
|
|
|
41
44
|
CLOSING_LINE = "Run `rigor effects explain` to see what caused this, and `rigor effects update` to " \
|
|
42
45
|
"accept it."
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
# A regeneration event routes to `update` alone: `explain` answers "what caused this label to
|
|
48
|
+
# appear", and the answer here is "the record was written under different rules", which explain
|
|
49
|
+
# cannot expand and the line above already said.
|
|
50
|
+
REGENERATION_CLOSING_LINE = "Run `rigor effects update` to regenerate the record under the current " \
|
|
51
|
+
"rules."
|
|
52
|
+
|
|
53
|
+
# @param sources [Hash{String=>Array<String>}] `Runner#effect_sources` — where each unit is defined.
|
|
54
|
+
# A drift row names `file:line` so a reviewer does not have to search for the method (#435). The
|
|
55
|
+
# file rides the cached summary entry and is free; the line is resolved by parsing the row's own
|
|
56
|
+
# file ({Effects::DefinitionLines}), which is why a fresh report — no rows — still parses nothing
|
|
57
|
+
# and the whole-project parse ADR-104 removed from this command stays removed.
|
|
58
|
+
# @param lines [Effects::DefinitionLines] seam for the specs; the default parses on demand.
|
|
59
|
+
def initialize(out:, path:, sources: nil, lines: Effects::DefinitionLines.new)
|
|
45
60
|
@out = out
|
|
46
61
|
@path = path
|
|
62
|
+
@sources = sources || {}
|
|
63
|
+
@lines = lines
|
|
47
64
|
end
|
|
48
65
|
|
|
49
66
|
private
|
|
@@ -67,10 +84,7 @@ module Rigor
|
|
|
67
84
|
|
|
68
85
|
@out.puts("")
|
|
69
86
|
@out.puts("#{section_name(table)}:")
|
|
70
|
-
events.each
|
|
71
|
-
suffix = qualified ? " (#{event.table})" : ""
|
|
72
|
-
@out.puts(" #{render_event(event)}#{suffix}")
|
|
73
|
-
end
|
|
87
|
+
events.each { |event| @out.puts(" #{render_event(event, qualified: qualified)}") }
|
|
74
88
|
end
|
|
75
89
|
|
|
76
90
|
def section_name(table)
|
|
@@ -81,9 +95,38 @@ module Rigor
|
|
|
81
95
|
end
|
|
82
96
|
end
|
|
83
97
|
|
|
84
|
-
def render_event(event)
|
|
98
|
+
def render_event(event, qualified: false)
|
|
85
99
|
marker = marker_for(event)
|
|
86
|
-
|
|
100
|
+
return marker if event.symbol.nil?
|
|
101
|
+
|
|
102
|
+
"#{event.symbol} #{marker}#{annotation(event, qualified)}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# One parenthetical, never two: where the unit is defined, and — under `tolerated:`, which pools
|
|
106
|
+
# both tables — which table the event came from. A row with no known source under `tolerated:`
|
|
107
|
+
# renders exactly as it did before this suffix existed.
|
|
108
|
+
#
|
|
109
|
+
# A unit defined by a reopening spans several files and the row names them all rather than picking
|
|
110
|
+
# one; which of them a reviewer wants is exactly what the row cannot know.
|
|
111
|
+
def annotation(event, qualified)
|
|
112
|
+
parts = sources_of(event.symbol).map { |path, line| line ? "#{path}:#{line}" : path }
|
|
113
|
+
parts << event.table if qualified
|
|
114
|
+
parts.empty? ? "" : " (#{parts.join(', ')})"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Where a symbol is defined, as the two renderings share it — one implementation, so the text form
|
|
118
|
+
# and the JSON one cannot disagree about a position (WD3's rule).
|
|
119
|
+
#
|
|
120
|
+
# **A removed symbol has no entry here at all**, and cannot: `sources` is the CURRENT run's, and a
|
|
121
|
+
# method that no longer exists was not defined by it. Such a row renders exactly as it did before
|
|
122
|
+
# this suffix existed.
|
|
123
|
+
#
|
|
124
|
+
# @return [Array<Array(String, Integer)>] `[relative path, line]` per file, the line nil when that
|
|
125
|
+
# file spells the key with no `def` — an accessor, or a definition the nesting cannot name.
|
|
126
|
+
def sources_of(symbol)
|
|
127
|
+
Array(@sources[symbol]).map do |path|
|
|
128
|
+
[Effects::Snapshot.relativize(path, Dir.pwd), @lines.for(key: symbol, path: path)]
|
|
129
|
+
end
|
|
87
130
|
end
|
|
88
131
|
|
|
89
132
|
def marker_for(event)
|
|
@@ -99,21 +142,48 @@ module Rigor
|
|
|
99
142
|
def render_footer(diff)
|
|
100
143
|
footer = diff.footer
|
|
101
144
|
@out.puts("")
|
|
145
|
+
@out.puts(withheld_line(footer)) if diff.regeneration?
|
|
102
146
|
unless footer[:added_symbols].zero? && footer[:removed_symbols].zero?
|
|
103
147
|
@out.puts("symbols: +#{footer[:added_symbols]} / -#{footer[:removed_symbols]} " \
|
|
104
148
|
"(a rename is one of each)")
|
|
105
149
|
end
|
|
106
|
-
@out.puts(CLOSING_LINE)
|
|
150
|
+
@out.puts(diff.regeneration? ? REGENERATION_CLOSING_LINE : CLOSING_LINE)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# What a regeneration withheld, said as a count. The counts below it are still printed, so the
|
|
154
|
+
# reader sees the scale of the difference without reading it row by row.
|
|
155
|
+
def withheld_line(footer)
|
|
156
|
+
suppressed = footer[:suppressed]
|
|
157
|
+
return "The two records are not comparable, so no per-method difference is shown." if
|
|
158
|
+
suppressed.zero?
|
|
159
|
+
|
|
160
|
+
noun = suppressed == 1 ? "1 per-method difference is" : "#{suppressed} per-method differences are"
|
|
161
|
+
"The two records were computed under different rules and are not comparable, so #{noun} not shown."
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The machine form carries the same position the text one prints (#435): a bot that annotates a pull
|
|
165
|
+
# request is the consumer with the most use for a line and the least ability to find one itself.
|
|
166
|
+
# `sources` is omitted rather than emitted empty when the row names nothing — a removed symbol.
|
|
167
|
+
def event_json(event)
|
|
168
|
+
row = event.to_h
|
|
169
|
+
sources = sources_of(event.symbol)
|
|
170
|
+
return row if sources.empty?
|
|
171
|
+
|
|
172
|
+
row.merge("sources" => sources.map do |path, line|
|
|
173
|
+
line ? { "path" => path, "line" => line } : { "path" => path }
|
|
174
|
+
end)
|
|
107
175
|
end
|
|
108
176
|
|
|
109
177
|
def render_json(diff)
|
|
110
178
|
@out.puts(JSON.pretty_generate(
|
|
111
179
|
"fresh" => diff.fresh?,
|
|
112
|
-
"events" => diff.events.map(
|
|
180
|
+
"events" => diff.events.map { |event| event_json(event) },
|
|
113
181
|
"footer" => {
|
|
114
182
|
"added_symbols" => diff.footer[:added_symbols],
|
|
115
|
-
"removed_symbols" => diff.footer[:removed_symbols]
|
|
183
|
+
"removed_symbols" => diff.footer[:removed_symbols],
|
|
184
|
+
"suppressed" => diff.footer[:suppressed]
|
|
116
185
|
},
|
|
186
|
+
"regeneration" => diff.regeneration?,
|
|
117
187
|
"header" => {
|
|
118
188
|
"snapshot" => @path,
|
|
119
189
|
"gate" => diff.gate.to_s,
|
|
@@ -22,8 +22,9 @@ module Rigor
|
|
|
22
22
|
class EffectsExplainRenderer
|
|
23
23
|
include Renderable
|
|
24
24
|
|
|
25
|
-
# One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin
|
|
26
|
-
|
|
25
|
+
# One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin;
|
|
26
|
+
# `causes` is non-empty only for an exhaustiveness row, which has neither (#435).
|
|
27
|
+
Row = Data.define(:table, :symbol, :label, :path, :origin, :causes)
|
|
27
28
|
|
|
28
29
|
def initialize(out:)
|
|
29
30
|
@out = out
|
|
@@ -44,6 +45,7 @@ module Rigor
|
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def render_row(row)
|
|
48
|
+
return "#{row.symbol} stopped being exhaustive ← #{row.causes.join(', ')}" unless row.causes.empty?
|
|
47
49
|
return "#{row.path.join(' → ')} [#{row.label}]" unless row.path.empty?
|
|
48
50
|
|
|
49
51
|
origin = row.origin ? " ← #{row.origin}" : ""
|
|
@@ -58,7 +60,8 @@ module Rigor
|
|
|
58
60
|
"symbol" => row.symbol,
|
|
59
61
|
"label" => row.label,
|
|
60
62
|
"path" => row.path,
|
|
61
|
-
"origin" => row.origin
|
|
63
|
+
"origin" => row.origin,
|
|
64
|
+
"causes" => row.causes
|
|
62
65
|
}
|
|
63
66
|
end
|
|
64
67
|
))
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require "did_you_mean"
|
|
4
4
|
require "optionparser"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# ADR-104 — `analysis/runner` is required lazily (in `#analyze`): a warm snapshot verb served by the
|
|
7
|
+
# probe must reach its answer without `rigor/inference` in `$LOADED_FEATURES`.
|
|
8
|
+
require_relative "../analysis/effects_cache_probe"
|
|
7
9
|
require_relative "../cache/store"
|
|
8
10
|
require_relative "../configuration"
|
|
9
11
|
require_relative "../effects/discharge"
|
|
@@ -111,7 +113,8 @@ module Rigor
|
|
|
111
113
|
)
|
|
112
114
|
return explain(diff, options) if @verb == "explain"
|
|
113
115
|
|
|
114
|
-
EffectsDiffRenderer.new(out: @out, path: path
|
|
116
|
+
EffectsDiffRenderer.new(out: @out, path: path, sources: @sources)
|
|
117
|
+
.render(diff, format: options.fetch(:format))
|
|
115
118
|
@verb == "check" && diff.drift? ? 1 : 0
|
|
116
119
|
end
|
|
117
120
|
|
|
@@ -163,6 +166,9 @@ module Rigor
|
|
|
163
166
|
# `methods:` change gets the origin in the method's own body.
|
|
164
167
|
def rows_for_changes(diff)
|
|
165
168
|
diff.events.flat_map do |event|
|
|
169
|
+
next exhaustiveness_rows(event) if
|
|
170
|
+
event.category == Effects::SnapshotDiff::EXHAUSTIVE_LOST
|
|
171
|
+
|
|
166
172
|
labels = labels_of(event)
|
|
167
173
|
next [] if labels.empty?
|
|
168
174
|
|
|
@@ -178,13 +184,29 @@ module Rigor
|
|
|
178
184
|
entry ? entry.proven.to_a : []
|
|
179
185
|
end
|
|
180
186
|
|
|
187
|
+
# `exhaustive → not` is the drift row a reader is least equipped to interpret, and it was the one
|
|
188
|
+
# row `explain` could not expand (#435): it carries no label, so {#labels_of} answered with nothing
|
|
189
|
+
# and the event produced no explanation at all. What explains it is the taint causes — the calls
|
|
190
|
+
# the analyzer could not follow — which is the detail the snapshot stopped recording per row
|
|
191
|
+
# (#434). The two halves meet here: the record keeps the stable count, and this names them.
|
|
192
|
+
def exhaustiveness_rows(event)
|
|
193
|
+
entry = @table[event.symbol]
|
|
194
|
+
return [] if entry.nil?
|
|
195
|
+
|
|
196
|
+
causes = Effects::Snapshot.render_causes(entry.causes)
|
|
197
|
+
return [] if causes.empty?
|
|
198
|
+
|
|
199
|
+
[EffectsExplainRenderer::Row.new(table: event.table, symbol: event.symbol, label: nil,
|
|
200
|
+
path: [].freeze, origin: nil, causes: causes)]
|
|
201
|
+
end
|
|
202
|
+
|
|
181
203
|
def reach_rows(symbol, labels)
|
|
182
204
|
labels.filter_map do |label|
|
|
183
205
|
path = Effects::PathFinder.shortest(@table, symbol: symbol, label: label)
|
|
184
206
|
next if path.nil?
|
|
185
207
|
|
|
186
208
|
EffectsExplainRenderer::Row.new(table: "reach", symbol: symbol, label: label,
|
|
187
|
-
path: path.to_a, origin: path.origin)
|
|
209
|
+
path: path.to_a, origin: path.origin, causes: [].freeze)
|
|
188
210
|
end
|
|
189
211
|
end
|
|
190
212
|
|
|
@@ -197,7 +219,7 @@ module Rigor
|
|
|
197
219
|
next if origin.nil?
|
|
198
220
|
|
|
199
221
|
EffectsExplainRenderer::Row.new(table: "methods", symbol: symbol, label: label,
|
|
200
|
-
path: [].freeze, origin: origin.to_s)
|
|
222
|
+
path: [].freeze, origin: origin.to_s, causes: [].freeze)
|
|
201
223
|
end
|
|
202
224
|
end
|
|
203
225
|
|
|
@@ -207,7 +229,20 @@ module Rigor
|
|
|
207
229
|
# the ADR-45 whole-run result cache like `rigor check` — the diagnostics entry plus the #382 effects
|
|
208
230
|
# sidecar keyed beside it, so `rigor effects check` after `rigor check` under a configured `effects:`
|
|
209
231
|
# block is a warm hit plus the fixpoint.
|
|
232
|
+
# ADR-104 — a snapshot is built from the table, the sources and the vocabulary, all three of which
|
|
233
|
+
# the #482 summary entry (plus the plugins' own declarations) carries, so a warm run takes the
|
|
234
|
+
# probe and never loads the engine. A decline is the runner path below, unchanged.
|
|
210
235
|
def build_snapshot(configuration, full:)
|
|
236
|
+
served = Analysis::EffectsCacheProbe.new(
|
|
237
|
+
configuration: configuration, cache_root: configuration.cache_path
|
|
238
|
+
).serve(configuration.paths)
|
|
239
|
+
return snapshot_from(configuration, full, served) if served
|
|
240
|
+
|
|
241
|
+
snapshot_from(configuration, full, analyze(configuration))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def analyze(configuration)
|
|
245
|
+
require_relative "../analysis/runner"
|
|
211
246
|
runner = Analysis::Runner.new(
|
|
212
247
|
configuration: configuration,
|
|
213
248
|
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
@@ -215,14 +250,24 @@ module Rigor
|
|
|
215
250
|
workers: 0
|
|
216
251
|
)
|
|
217
252
|
runner.run(configuration.paths)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
table: @table, configuration: configuration, sources: runner.effect_sources, full: full,
|
|
253
|
+
Analysis::EffectsCacheProbe::Served.new(
|
|
254
|
+
table: runner.effect_table, sources: runner.effect_sources, plugin_facts: nil,
|
|
221
255
|
registry: Effects::Registry.for_configuration(configuration,
|
|
222
256
|
plugin_facts: runner.effect_plugin_facts)
|
|
223
257
|
)
|
|
224
258
|
end
|
|
225
259
|
|
|
260
|
+
# WD3 — one build, whichever lane produced the inputs, so a served snapshot and an analysed one
|
|
261
|
+
# cannot drift in how they are assembled.
|
|
262
|
+
def snapshot_from(configuration, full, served)
|
|
263
|
+
@table = served.table
|
|
264
|
+
@sources = served.sources
|
|
265
|
+
Effects::Snapshot.build(
|
|
266
|
+
table: @table, configuration: configuration, sources: served.sources, full: full,
|
|
267
|
+
registry: served.registry
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
226
271
|
# ---- options ------------------------------------------------
|
|
227
272
|
|
|
228
273
|
def parse_options
|
|
@@ -27,17 +27,34 @@ module Rigor
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def render_text(report)
|
|
30
|
-
pct = (report.ratio * 100).round(1)
|
|
30
|
+
pct = report.ratio ? (report.ratio * 100).round(1) : nil
|
|
31
31
|
@out.puts "Fused protection (static type ∪ dynamic test)"
|
|
32
|
-
@out.puts " protected: #{report.protected_total} / #{report.grand_total}
|
|
32
|
+
@out.puts " protected: #{report.protected_total} / #{report.grand_total}#{ratio_suffix(pct)}"
|
|
33
33
|
@out.puts " by type: #{report.total_type_killed}"
|
|
34
34
|
@out.puts " by test: #{report.total_test_killed} (type-survivors a test caught)"
|
|
35
35
|
@out.puts " unprotected: #{report.total_unprotected} (neither — add a type or a test)"
|
|
36
|
+
render_unmeasured_files(report)
|
|
36
37
|
render_harness_errors(report)
|
|
37
38
|
render_unprotected(report)
|
|
38
39
|
render_files(report)
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Issue #686 — see {MutationProtectionRenderer#ratio_suffix}.
|
|
43
|
+
def ratio_suffix(pct)
|
|
44
|
+
return " (not measured)" if pct.nil?
|
|
45
|
+
|
|
46
|
+
" (#{pct}%)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Issue #686 — see {MutationProtectionRenderer#render_unmeasured_files}.
|
|
50
|
+
def render_unmeasured_files(report)
|
|
51
|
+
count = report.unmeasured_files
|
|
52
|
+
return if count.zero?
|
|
53
|
+
|
|
54
|
+
@out.puts " unmeasured files: #{count} — every mutation of them failed inside the measurement " \
|
|
55
|
+
"harness, so they are not in the ratio above"
|
|
56
|
+
end
|
|
57
|
+
|
|
41
58
|
# #264 — see {MutationProtectionRenderer#render_harness_errors}; surfaced only when non-zero.
|
|
42
59
|
def render_harness_errors(report)
|
|
43
60
|
count = report.total_harness_errors
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../protection/measurement_integrity"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
class CLI
|
|
5
7
|
# ADR-70 — aggregates per-file {Protection::MutationScanner::FusedFileResult} into a project-level **fused**
|
|
@@ -14,6 +16,13 @@ module Rigor
|
|
|
14
16
|
def initialize(path:, type_killed:, test_killed:, unprotected:, ratio:, harness_errors: 0)
|
|
15
17
|
super
|
|
16
18
|
end
|
|
19
|
+
|
|
20
|
+
# Issue #686 — see {Rigor::CLI::FileEffectiveness#unmeasured?}.
|
|
21
|
+
def unmeasured?
|
|
22
|
+
!Protection::MeasurementIntegrity.measured?(
|
|
23
|
+
total: type_killed + test_killed + unprotected, harness_errors: harness_errors
|
|
24
|
+
)
|
|
25
|
+
end
|
|
17
26
|
end
|
|
18
27
|
UnprotectedBreakage = Data.define(:method_name, :count, :examples)
|
|
19
28
|
|
|
@@ -23,19 +32,31 @@ module Rigor
|
|
|
23
32
|
def total_unprotected = files.sum(&:unprotected)
|
|
24
33
|
def grand_total = total_type_killed + total_test_killed + total_unprotected
|
|
25
34
|
def protected_total = total_type_killed + total_test_killed
|
|
26
|
-
|
|
35
|
+
|
|
36
|
+
# Issue #686 review (second pass) — see {MutationProtectionReport#ratio}.
|
|
37
|
+
def ratio
|
|
38
|
+
return nil if Protection::MeasurementIntegrity.ratio_unmeasurable?(
|
|
39
|
+
grand_total: grand_total, unmeasured_files: unmeasured_files
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
|
|
43
|
+
end
|
|
27
44
|
|
|
28
45
|
# #264 — stays OUT of `grand_total`/`ratio`, exactly like the plain mutation report.
|
|
29
46
|
def total_harness_errors = files.sum(&:harness_errors)
|
|
30
47
|
|
|
48
|
+
# Issue #686 — see {Rigor::CLI::MutationProtectionReport#unmeasured_files}.
|
|
49
|
+
def unmeasured_files = files.count(&:unmeasured?)
|
|
50
|
+
|
|
31
51
|
def to_h
|
|
32
52
|
{
|
|
33
53
|
"mode" => "protection-fused",
|
|
34
54
|
"type_killed" => total_type_killed,
|
|
35
55
|
"test_killed" => total_test_killed,
|
|
36
56
|
"unprotected" => total_unprotected,
|
|
37
|
-
"protected_ratio" => ratio
|
|
57
|
+
"protected_ratio" => ratio&.round(4),
|
|
38
58
|
"harness_errors" => total_harness_errors,
|
|
59
|
+
"unmeasured_files" => unmeasured_files,
|
|
39
60
|
"files" => files.map do |f|
|
|
40
61
|
{ "path" => f.path, "type_killed" => f.type_killed, "test_killed" => f.test_killed,
|
|
41
62
|
"unprotected" => f.unprotected, "ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class CLI
|
|
5
|
+
# The stderr half of "did this measurement actually happen" for `rigor coverage --protection --mutation`.
|
|
6
|
+
#
|
|
7
|
+
# Two counts, escalating, and the difference between them is the whole point:
|
|
8
|
+
#
|
|
9
|
+
# - `harness_errors` (#264) counts MUTANTS the harness rescued. Advisory and floored — a few are the
|
|
10
|
+
# transient the bucket exists to make visible, and `determine_protection_exit` deliberately ignores
|
|
11
|
+
# them so a `--threshold` build does not start failing for a reason unrelated to the ratio it pins.
|
|
12
|
+
# - `unmeasured_files` (#686) counts FILES where nothing could be measured at all, because the clean
|
|
13
|
+
# baseline itself came back from a crashed analysis. No floor and not advisory: the ratio then answers
|
|
14
|
+
# a smaller question than the user asked, `determine_protection_exit` fails the run, and this says so.
|
|
15
|
+
# Without it, `killed + survived == 0` borrowed the vacuous-file convention's 1.0 and a run that
|
|
16
|
+
# measured nothing printed 100% and passed every threshold.
|
|
17
|
+
#
|
|
18
|
+
# Extracted from {CoverageMutation} rather than inlined there: that module sits at its length budget, and
|
|
19
|
+
# a warning pair with its own escalation rule is a thing worth naming anyway.
|
|
20
|
+
module MeasurementIntegrityWarning
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @param report [MutationProtectionReport, FusedProtectionReport] both expose the two counts.
|
|
24
|
+
# @param err [IO] the stream to warn on.
|
|
25
|
+
# @param floor [Integer] {CoverageMutation::HARNESS_ERROR_WARN_FLOOR}.
|
|
26
|
+
# @return [void]
|
|
27
|
+
def emit(report, err:, floor:)
|
|
28
|
+
lines_for(report, floor: floor).each { |line| err.puts(line) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<String>] the warnings this report earns, widest consequence first.
|
|
32
|
+
def lines_for(report, floor:)
|
|
33
|
+
lines = []
|
|
34
|
+
unmeasured = report.respond_to?(:unmeasured_files) ? report.unmeasured_files : 0 # see CoverageCommand
|
|
35
|
+
lines << unmeasured_line(unmeasured) if unmeasured.positive?
|
|
36
|
+
harness_errors = report.total_harness_errors
|
|
37
|
+
lines << harness_error_line(harness_errors, floor) if harness_errors >= floor
|
|
38
|
+
lines
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unmeasured_line(count)
|
|
42
|
+
"coverage: #{count} file(s) could not be measured at all — every mutation of them failed inside " \
|
|
43
|
+
"the measurement harness, typically because Rigor's own analysis crashed while re-analysing " \
|
|
44
|
+
"them. They contribute nothing to the ratio, so the effectiveness above is measured over the " \
|
|
45
|
+
"REST of the project. Exiting non-zero: a ratio over part of what you asked about must not read " \
|
|
46
|
+
"as a pass."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def harness_error_line(count, floor)
|
|
50
|
+
"coverage: #{count} mutants failed inside the measurement harness (\"harness_errors\", at/above " \
|
|
51
|
+
"the #{floor}-mutant floor) — excluded from the ratio like a parse-invalid mutant, but this many " \
|
|
52
|
+
"suggests a harness defect rather than one-off noise. Investigate before trusting --threshold " \
|
|
53
|
+
"on this run."
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -26,15 +26,35 @@ module Rigor
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def render_text(report)
|
|
29
|
-
pct = (report.ratio * 100).round(1)
|
|
29
|
+
pct = report.ratio ? (report.ratio * 100).round(1) : nil
|
|
30
30
|
@out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
|
|
31
|
-
@out.puts " caught breakages: #{report.total_killed} / #{report.grand_total}
|
|
31
|
+
@out.puts " caught breakages: #{report.total_killed} / #{report.grand_total}#{ratio_suffix(pct)}"
|
|
32
32
|
@out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
|
|
33
|
+
render_unmeasured_files(report)
|
|
33
34
|
render_harness_errors(report)
|
|
34
35
|
render_missed(report)
|
|
35
36
|
render_files(report)
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
# Issue #686 — `0 / 0` is `ratio` 1.0 by the vacuous-file convention, and printing "100.0%" over a run
|
|
40
|
+
# that measured nothing is the exact shape this issue exists to end. When nothing was measured AND a
|
|
41
|
+
# file went unmeasured, the percentage is withheld rather than invented.
|
|
42
|
+
def ratio_suffix(pct)
|
|
43
|
+
return " (not measured)" if pct.nil?
|
|
44
|
+
|
|
45
|
+
" (#{pct}%)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Issue #686 — the count that makes the command exit non-zero, on stdout beside the ratio it qualifies
|
|
49
|
+
# (the stderr twin in `CoverageMutation#warn_unmeasured_files` carries the explanation).
|
|
50
|
+
def render_unmeasured_files(report)
|
|
51
|
+
count = report.unmeasured_files
|
|
52
|
+
return if count.zero?
|
|
53
|
+
|
|
54
|
+
@out.puts " unmeasured files: #{count} — every mutation of them failed inside the measurement " \
|
|
55
|
+
"harness, so they are not in the ratio above"
|
|
56
|
+
end
|
|
57
|
+
|
|
38
58
|
# #264 — surfaced only when non-zero: a harness-level failure is a defect in the measurement itself, not
|
|
39
59
|
# in the code being measured, and a clean run should not carry a permanent line about a bucket that is
|
|
40
60
|
# always empty.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../protection/measurement_integrity"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
class CLI
|
|
5
7
|
# ADR-63 Tier 2 — aggregates per-file {Protection::MutationScanner} results into a project-level *effectiveness*
|
|
@@ -16,6 +18,13 @@ module Rigor
|
|
|
16
18
|
def initialize(path:, killed:, survived:, ratio:, harness_errors: 0)
|
|
17
19
|
super
|
|
18
20
|
end
|
|
21
|
+
|
|
22
|
+
# Issue #686 — the file measured the HARNESS, not the code: every mutant it had landed in
|
|
23
|
+
# `harness_errors`, so `killed + survived` is zero for a reason that is not "there was nothing to
|
|
24
|
+
# measure". `ratio` is 0.0 here rather than the vacuous 1.0, and this is what the exit gate reads.
|
|
25
|
+
def unmeasured?
|
|
26
|
+
!Protection::MeasurementIntegrity.measured?(total: killed + survived, harness_errors: harness_errors)
|
|
27
|
+
end
|
|
19
28
|
end
|
|
20
29
|
MissedBreakage = Data.define(:method_name, :count, :examples)
|
|
21
30
|
|
|
@@ -23,21 +32,41 @@ module Rigor
|
|
|
23
32
|
def total_killed = files.sum(&:killed)
|
|
24
33
|
def total_survived = files.sum(&:survived)
|
|
25
34
|
def grand_total = total_killed + total_survived
|
|
26
|
-
|
|
35
|
+
|
|
36
|
+
# Issue #686 review (second pass) — `nil`, not 1.0, when nothing was measured and something failed
|
|
37
|
+
# trying. The text renderer already withheld the percentage; JSON did not, so a CI gate reading
|
|
38
|
+
# `effectiveness_ratio` still saw 100% on a run that measured nothing — the same defect one channel
|
|
39
|
+
# over. An empty project (nothing to measure, nothing failed) stays vacuously 1.0.
|
|
40
|
+
def ratio
|
|
41
|
+
return nil if Protection::MeasurementIntegrity.ratio_unmeasurable?(
|
|
42
|
+
grand_total: grand_total, unmeasured_files: unmeasured_files
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
grand_total.zero? ? 1.0 : total_killed.to_f / grand_total
|
|
46
|
+
end
|
|
27
47
|
|
|
28
48
|
# #264 — a harness-level failure count, summed across files. Stays OUT of `grand_total`/`ratio` exactly
|
|
29
49
|
# like a parse error: it is not a measurement of the code.
|
|
30
50
|
def total_harness_errors = files.sum(&:harness_errors)
|
|
31
51
|
|
|
52
|
+
# Issue #686 — files where NOTHING could be measured. Distinct from `total_harness_errors`, which
|
|
53
|
+
# counts mutants and rightly tolerates a few (#264's floor): one wholly unmeasured file already means
|
|
54
|
+
# the ratio is computed over a smaller project than the user asked about, so this gates the exit and
|
|
55
|
+
# the mutant count does not.
|
|
56
|
+
def unmeasured_files = files.count(&:unmeasured?)
|
|
57
|
+
|
|
32
58
|
def to_h
|
|
33
59
|
{
|
|
34
60
|
"mode" => "mutation",
|
|
35
61
|
"killed" => total_killed,
|
|
36
62
|
"survived" => total_survived,
|
|
37
|
-
"effectiveness_ratio" => ratio
|
|
63
|
+
"effectiveness_ratio" => ratio&.round(4),
|
|
38
64
|
# #264 — unconditional: a JSON consumer (e.g. a CI gate) must be able to check this every run, not
|
|
39
65
|
# only when a text renderer decided it was worth a line.
|
|
40
66
|
"harness_errors" => total_harness_errors,
|
|
67
|
+
# Issue #686 — unconditional for the same reason `harness_errors` is: a CI gate reading JSON must
|
|
68
|
+
# be able to see that part of the project was never measured, on every run.
|
|
69
|
+
"unmeasured_files" => unmeasured_files,
|
|
41
70
|
"files" => files.map do |f|
|
|
42
71
|
{ "path" => f.path, "killed" => f.killed, "survived" => f.survived,
|
|
43
72
|
"ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
|
|
@@ -55,6 +55,7 @@ module Rigor
|
|
|
55
55
|
0
|
|
56
56
|
end
|
|
57
57
|
report_unrenderable(generator.unrenderable)
|
|
58
|
+
report_unresolvable_superclasses(generator.unresolvable_superclasses)
|
|
58
59
|
status
|
|
59
60
|
end
|
|
60
61
|
|
|
@@ -79,6 +80,23 @@ module Rigor
|
|
|
79
80
|
end
|
|
80
81
|
end
|
|
81
82
|
|
|
83
|
+
# Issue #735 — unlike {#report_unrenderable} this is a fact about the PROJECT's type universe, not a
|
|
84
|
+
# Rigor defect: the class's superclass is not declared by any RBS the environment loads, so a sidecar
|
|
85
|
+
# declaring it would fail to build and take the class's type coverage with it. Naming the unresolved
|
|
86
|
+
# superclass is the actionable part — it is usually one gem's missing RBS standing between the user
|
|
87
|
+
# and signatures for a whole layer of their app.
|
|
88
|
+
def report_unresolvable_superclasses(unresolvable)
|
|
89
|
+
return if unresolvable.empty?
|
|
90
|
+
|
|
91
|
+
@err.puts(
|
|
92
|
+
"rigor sig-gen: skipped #{unresolvable.size} class(es) whose superclass no loaded RBS declares. " \
|
|
93
|
+
"A signature for them would fail to build (`RBS::NoSuperclassFoundError`) and would leave the " \
|
|
94
|
+
"class LESS typed than it is now. Provide RBS for the superclass — `rbs collection install`, the " \
|
|
95
|
+
"gem's own `sig/`, or a `signature_paths:` entry — and re-run."
|
|
96
|
+
)
|
|
97
|
+
unresolvable.sort.each { |class_name, superclass| @err.puts(" #{class_name} < #{superclass}") }
|
|
98
|
+
end
|
|
99
|
+
|
|
82
100
|
def dispatch_print_or_diff(candidates, mode, options)
|
|
83
101
|
SigGen::Renderer.new(out: @out).render(
|
|
84
102
|
candidates: candidates,
|