rigortype 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/string_scanner.rbs +6 -5
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/03-narrowing.md +2 -1
- data/docs/handbook/04-tuples-and-shapes.md +7 -3
- data/docs/handbook/06-classes.md +19 -10
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -246
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/11-sig-gen.md +11 -1
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-mypy.md +4 -3
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/install.md +14 -6
- data/docs/manual/01-installation.md +98 -4
- data/docs/manual/02-cli-reference.md +81 -12
- data/docs/manual/03-configuration.md +34 -2
- data/docs/manual/04-diagnostics.md +59 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/07-plugins.md +4 -4
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +7 -6
- data/docs/manual/11-ci.md +51 -0
- data/docs/manual/12-caching.md +65 -0
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
- data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
- data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
- data/lib/rigor/analysis/check_rules.rb +581 -98
- data/lib/rigor/analysis/dependency_recorder.rb +93 -9
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental_session.rb +456 -51
- data/lib/rigor/analysis/path_expansion.rb +42 -0
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
- data/lib/rigor/analysis/rule_catalog.rb +226 -1
- data/lib/rigor/analysis/run_cache_key.rb +78 -0
- data/lib/rigor/analysis/run_cache_probe.rb +72 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
- data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
- data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
- data/lib/rigor/analysis/runner.rb +344 -68
- data/lib/rigor/analysis/severity_stamp.rb +43 -0
- data/lib/rigor/analysis/worker_session.rb +30 -2
- data/lib/rigor/bleeding_edge.rb +37 -8
- data/lib/rigor/builtins/regex_refinement.rb +36 -1
- data/lib/rigor/cache/descriptor.rb +88 -28
- data/lib/rigor/cache/file_digest.rb +158 -0
- data/lib/rigor/cache/incremental_snapshot.rb +41 -5
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_descriptor.rb +56 -10
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +132 -27
- data/lib/rigor/ci_detector.rb +90 -0
- data/lib/rigor/cli/annotate_command.rb +15 -9
- data/lib/rigor/cli/baseline_command.rb +4 -1
- data/lib/rigor/cli/check_command.rb +223 -32
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +6 -1
- data/lib/rigor/cli/docs_command.rb +0 -30
- data/lib/rigor/cli/doctor_command.rb +169 -31
- data/lib/rigor/cli/lsp_command.rb +5 -0
- data/lib/rigor/cli/mcp_command.rb +5 -0
- data/lib/rigor/cli/plugins_command.rb +36 -6
- data/lib/rigor/cli/plugins_renderer.rb +44 -12
- data/lib/rigor/cli/probe_environment.rb +85 -0
- data/lib/rigor/cli/sig_gen_command.rb +36 -9
- data/lib/rigor/cli/skill_command.rb +22 -33
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/cli/trace_command.rb +5 -6
- data/lib/rigor/cli/type_of_command.rb +9 -8
- data/lib/rigor/cli/type_scan_command.rb +8 -8
- data/lib/rigor/cli.rb +15 -4
- data/lib/rigor/config_audit.rb +48 -2
- data/lib/rigor/configuration/severity_profile.rb +31 -0
- data/lib/rigor/configuration.rb +156 -8
- data/lib/rigor/environment/default_libraries.rb +36 -0
- data/lib/rigor/environment/rbs_loader.rb +164 -5
- data/lib/rigor/environment.rb +29 -28
- data/lib/rigor/inference/budget_trace.rb +77 -2
- data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
- data/lib/rigor/inference/def_handle.rb +23 -0
- data/lib/rigor/inference/def_node_resolver.rb +90 -0
- data/lib/rigor/inference/def_return_typer.rb +2 -1
- data/lib/rigor/inference/expression_typer.rb +361 -113
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
- data/lib/rigor/inference/method_dispatcher.rb +57 -1
- data/lib/rigor/inference/mutation_widening.rb +42 -8
- data/lib/rigor/inference/narrowing.rb +127 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +8 -1
- data/lib/rigor/inference/scope_indexer.rb +451 -36
- data/lib/rigor/inference/statement_evaluator.rb +64 -15
- data/lib/rigor/inference/struct_fold_safety.rb +48 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
- data/lib/rigor/inference/void_origin.rb +25 -0
- data/lib/rigor/inference/void_tail_summary.rb +220 -0
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +46 -6
- data/lib/rigor/language_server/completion_provider.rb +2 -1
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
- data/lib/rigor/language_server/folding_range_provider.rb +2 -1
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/selection_range_provider.rb +2 -1
- data/lib/rigor/language_server/server.rb +19 -9
- data/lib/rigor/language_server/signature_help_provider.rb +2 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/base.rb +38 -24
- data/lib/rigor/plugin/inflector.rb +12 -3
- data/lib/rigor/plugin/io_boundary.rb +5 -1
- data/lib/rigor/plugin/isolation.rb +81 -11
- data/lib/rigor/plugin/load_error.rb +10 -1
- data/lib/rigor/plugin/loader.rb +96 -14
- data/lib/rigor/plugin/node_rule_walk.rb +5 -3
- data/lib/rigor/plugin/registry.rb +32 -23
- data/lib/rigor/plugin.rb +26 -0
- data/lib/rigor/protection/mutator.rb +3 -2
- data/lib/rigor/reflection.rb +64 -0
- data/lib/rigor/runtime/jit.rb +128 -0
- data/lib/rigor/scope/discovery_index.rb +12 -2
- data/lib/rigor/scope.rb +122 -28
- data/lib/rigor/sig_gen/classification.rb +5 -1
- data/lib/rigor/sig_gen/generator.rb +38 -6
- data/lib/rigor/sig_gen/observation_collector.rb +4 -3
- data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
- data/lib/rigor/sig_gen/renderer.rb +10 -0
- data/lib/rigor/sig_gen/write_result.rb +9 -4
- data/lib/rigor/sig_gen/writer.rb +207 -49
- data/lib/rigor/source/node_children.rb +116 -0
- data/lib/rigor/source/node_locator.rb +3 -1
- data/lib/rigor/source/node_walker.rb +4 -2
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/difference.rb +28 -24
- data/lib/rigor/type/hash_shape.rb +34 -10
- data/lib/rigor/version.rb +1 -1
- data/lib/rigortype.rb +24 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
- data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
- data/sig/prism_node_children.rbs +9 -0
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +18 -0
- data/sig/rigor/inference.rbs +7 -0
- data/sig/rigor/plugin/base.rbs +6 -6
- data/sig/rigor/reflection.rbs +1 -0
- data/sig/rigor/scope.rbs +12 -1
- data/skills/rigor-ci-setup/SKILL.md +10 -0
- data/skills/rigor-editor-setup/SKILL.md +11 -0
- data/skills/rigor-next-steps/SKILL.md +23 -2
- data/skills/rigor-plugin-author/SKILL.md +3 -3
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
- data/skills/rigor-plugin-review/SKILL.md +2 -2
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
- data/skills/rigor-project-init/SKILL.md +29 -3
- data/skills/rigor-project-init/references/01-detect.md +1 -1
- data/skills/rigor-project-init/references/02-configure.md +39 -5
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
- data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
- metadata +48 -26
- data/lib/rigor/cli/ci_detector.rb +0 -89
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
# ADR-87 WD4 — project file-set expansion shared by the {Analysis::Runner} (miss path) and the
|
|
6
|
+
# boot-slimming {RunCacheProbe}, so both resolve the IDENTICAL analyzed-path SET that feeds the run cache
|
|
7
|
+
# key's `paths` slot. A directory argument globs `**/*.rb` and drops excluded paths; a `.rb` file argument
|
|
8
|
+
# is taken verbatim; anything else contributes no files here (the Runner layers its own path-error
|
|
9
|
+
# diagnostics and buffer / in-memory handling on top — none of which add or remove files for an ordinary
|
|
10
|
+
# on-disk check, so the two file sets agree).
|
|
11
|
+
module PathExpansion
|
|
12
|
+
RUBY_GLOB = "**/*.rb"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The `.rb` files an ordinary (non-buffer) check over `paths` would analyze.
|
|
17
|
+
def ruby_files(paths, exclude_patterns)
|
|
18
|
+
Array(paths).flat_map do |path|
|
|
19
|
+
if File.directory?(path)
|
|
20
|
+
directory_files(path, exclude_patterns)
|
|
21
|
+
elsif File.file?(path) && path.end_with?(".rb")
|
|
22
|
+
[path]
|
|
23
|
+
else
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def directory_files(dir, exclude_patterns)
|
|
30
|
+
reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
|
|
34
|
+
# (the patterns behave like substring globs) — the exact semantics the Runner's directory glob applies.
|
|
35
|
+
def reject_excluded(file_list, exclude_patterns)
|
|
36
|
+
return file_list if exclude_patterns.empty?
|
|
37
|
+
|
|
38
|
+
file_list.reject { |path| exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) } }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "runner"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Analysis
|
|
8
|
+
# ADR-88 WD1 — a stable fingerprint of the plugin FACT SURFACE for an incremental run: the plugin-computed
|
|
9
|
+
# values a cached per-file diagnostic can depend on but that the snapshot's global fingerprint (config /
|
|
10
|
+
# gems / RBS env / `signature_paths:`) does NOT capture.
|
|
11
|
+
#
|
|
12
|
+
# The gap: a plugin like `rigor-sorbet` reads `.rb` / `.rbi` sig files under its own `paths:` / `rbi_paths:`
|
|
13
|
+
# (outside `signature_paths:`), builds a catalog, and contributes `dynamic_return` types at call sites in
|
|
14
|
+
# OTHER files. An edit to such a sig file changes the types those call sites resolve WITHOUT moving any
|
|
15
|
+
# analyzed file's content — so `IncrementalSnapshot.fingerprint` stays fresh, the recheck sees an empty
|
|
16
|
+
# changed set, and it serves the stale cached diagnostics. Nothing in the ADR-46 dependency graph records
|
|
17
|
+
# the cross-file plugin read (the catalog is plugin-internal, not a `Scope` table).
|
|
18
|
+
#
|
|
19
|
+
# This class digests three fact-surface channels after plugin `#prepare`:
|
|
20
|
+
# (a) every ADR-9 fact-store publication `(plugin_id, name) -> value` (a producer/consumer fact such as
|
|
21
|
+
# `activerecord :model_index`, `dry-types :dry_type_aliases`),
|
|
22
|
+
# (b) every declared ADR-60 producer's computed value (`sorbet :catalog`, `actionpack :controller_index`),
|
|
23
|
+
# (c) each plugin's optional {Plugin::Base#incremental_state_fingerprint} — a hook for internal catalog
|
|
24
|
+
# state that lives in neither (a) nor (b).
|
|
25
|
+
#
|
|
26
|
+
# The digest rides the {Cache::IncrementalSnapshot} (schema 9); a warm recheck compares it and, on a
|
|
27
|
+
# mismatch, invalidates the snapshot and runs a full analysis (the conservative, sound direction).
|
|
28
|
+
#
|
|
29
|
+
# OPAQUE plugins: a plugin that CONTRIBUTES per-call types (`dynamic_return` / `narrowing_facts`) but
|
|
30
|
+
# declares NONE of (a)/(b)/(c) has stale-able state the fingerprint cannot see. Rather than silently risk a
|
|
31
|
+
# stale reuse, such a plugin makes the snapshot un-reusable (a full analysis every run) and is named in a
|
|
32
|
+
# one-line note. The bundled contributing plugins all declare a surface (producers, facts, or the hook), so
|
|
33
|
+
# the bundled set stays incremental-capable; a third-party plugin that contributes types must do the same.
|
|
34
|
+
#
|
|
35
|
+
# The probe is ALWAYS sequential and independent of the analysis's pool mode, so the invalidation decision
|
|
36
|
+
# is identical whether the recheck ran pooled or sequential (the pooled-vs-sequential parity requirement).
|
|
37
|
+
class PluginFactFingerprint
|
|
38
|
+
# `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses
|
|
39
|
+
# freely. `opaque_plugin_ids` names contributing-but-surfaceless plugins (see the class doc); when
|
|
40
|
+
# non-empty the snapshot is never reused.
|
|
41
|
+
Result = Data.define(:digest, :opaque_plugin_ids) do
|
|
42
|
+
def opaque?
|
|
43
|
+
!opaque_plugin_ids.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A warm recheck may reuse the snapshot only when this run is not opaque and its fact-surface digest
|
|
47
|
+
# matches the one the snapshot stored. A nil `stored` (a pre-schema-9 snapshot, or a snapshot written
|
|
48
|
+
# before any plugin existed) never matches a computed digest — the conservative full-run direction —
|
|
49
|
+
# while a nil==nil (plugin-free project both times) reuses.
|
|
50
|
+
def reusable_against?(stored)
|
|
51
|
+
!opaque? && digest == stored
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The SEQUENTIAL probe: load the plugins and run `#prepare` fresh, then fingerprint. Pool-independent
|
|
56
|
+
# (its prepare pass is always sequential), so it is the pooled-mode path and the reference the parity
|
|
57
|
+
# spec asserts against.
|
|
58
|
+
def self.compute(configuration:, cache_store:, plugin_requirer: nil)
|
|
59
|
+
new.digest_registry(
|
|
60
|
+
prepared_registry(configuration: configuration, cache_store: cache_store, plugin_requirer: plugin_requirer)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# ADR-88 WD1 — the CHEAP post-hoc path: fingerprint a registry the analysis runner ALREADY prepared
|
|
65
|
+
# (sequential runs run `#prepare` and consult the producers during analysis, so producer values are
|
|
66
|
+
# memoised and their cache entries current). Avoids a second `#prepare` pass and a second producer
|
|
67
|
+
# validation. Used only when the runner's main-process registry is prepared (sequential); the pooled path
|
|
68
|
+
# (whose main process skips `#prepare`) falls back to {.compute}. Both compute the identical digest for a
|
|
69
|
+
# given fact surface, so the reuse decision is pool-independent.
|
|
70
|
+
def self.from_registry(registry)
|
|
71
|
+
new.digest_registry(registry)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Loads the plugins and runs every `#prepare` hook sequentially, returning the prepared registry (nil on
|
|
75
|
+
# any failure → the caller treats it as "no fact surface").
|
|
76
|
+
def self.prepared_registry(configuration:, cache_store:, plugin_requirer:)
|
|
77
|
+
Runner::ProjectPrePasses.new(
|
|
78
|
+
configuration: configuration, cache_store: cache_store, buffer: nil,
|
|
79
|
+
plugin_requirer: plugin_requirer, pool_mode: -> { false }
|
|
80
|
+
).prepared_registry
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def digest_registry(registry)
|
|
86
|
+
return Result.new(digest: nil, opaque_plugin_ids: [].freeze) if registry.nil? || registry.empty?
|
|
87
|
+
|
|
88
|
+
facts_by_plugin = facts_by_plugin(registry.plugins.first&.services&.fact_store)
|
|
89
|
+
parts = fact_parts(facts_by_plugin)
|
|
90
|
+
opaque = []
|
|
91
|
+
registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts_by_plugin, parts, opaque) }
|
|
92
|
+
# Sort the parts so the digest is independent of plugin registration / iteration order.
|
|
93
|
+
Result.new(
|
|
94
|
+
digest: Digest::SHA256.hexdigest(parts.sort.join("\x00")),
|
|
95
|
+
opaque_plugin_ids: opaque.uniq.freeze
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Channel (a) parts: one `fact\x1f<plugin>\x1f<name=digest,...>` string per publishing plugin.
|
|
102
|
+
def fact_parts(facts_by_plugin)
|
|
103
|
+
facts_by_plugin.map { |plugin_id, entries| "fact\x1f#{plugin_id}\x1f#{entries.sort.join("\x1e")}" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Appends one plugin's producer / hook parts to `parts` and marks it opaque when it contributes a type
|
|
107
|
+
# (`dynamic_return` / `narrowing_facts`) yet declares NONE of (a)/(b)/(c).
|
|
108
|
+
def collect_plugin_parts(plugin, facts_by_plugin, parts, opaque)
|
|
109
|
+
id = safe_id(plugin)
|
|
110
|
+
has_facts = facts_by_plugin.key?(id)
|
|
111
|
+
has_producers = collect_producer_parts(plugin, id, parts, opaque)
|
|
112
|
+
has_hook = collect_hook_part(plugin, id, parts, opaque)
|
|
113
|
+
surface = has_facts || has_producers || has_hook
|
|
114
|
+
opaque << id if !surface && contributes_types?(plugin)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `{ plugin_id => ["name=digest", ...] }` for every ADR-9 published fact (channel a). A plugin that
|
|
118
|
+
# published nothing does not appear.
|
|
119
|
+
def facts_by_plugin(fact_store)
|
|
120
|
+
result = Hash.new { |hash, key| hash[key] = [] }
|
|
121
|
+
return result if fact_store.nil?
|
|
122
|
+
|
|
123
|
+
fact_store.each_fact do |fact|
|
|
124
|
+
result[fact.plugin_id.to_s] << "#{fact.name}=#{digest_value(fact.value)}"
|
|
125
|
+
end
|
|
126
|
+
result
|
|
127
|
+
rescue StandardError
|
|
128
|
+
# An unreadable fact store contributes nothing; opacity for contributing plugins is decided below.
|
|
129
|
+
Hash.new { |hash, key| hash[key] = [] }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Channel (b): a stable signature of every declared producer's VALUE — a digest of the producer's own
|
|
133
|
+
# value, NOT its cache entry blob. The distinction is load-bearing: the blob also carries the dependency
|
|
134
|
+
# descriptor (the input files' digests), so it changes on ANY input edit — including a value-PRESERVING
|
|
135
|
+
# one (editing a controller's body without adding an action leaves `controller_index` identical but
|
|
136
|
+
# rewrites its blob). Digesting the value invalidates the snapshot only when a producer's contributed
|
|
137
|
+
# value actually moves (a new action, a changed sig), which is what a cached diagnostic can depend on;
|
|
138
|
+
# a value-preserving edit keeps the recheck incremental. Returns true when the plugin declared at least
|
|
139
|
+
# one producer (a surface); a producer whose value cannot be digested marks the plugin opaque.
|
|
140
|
+
# rubocop:disable Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
|
|
141
|
+
def collect_producer_parts(plugin, id, parts, opaque)
|
|
142
|
+
producer_ids = plugin.class.producers.keys
|
|
143
|
+
return false if producer_ids.empty?
|
|
144
|
+
|
|
145
|
+
producer_ids.sort.each do |producer_id|
|
|
146
|
+
parts << "prod\x1f#{id}\x1f#{producer_id}\x1f#{digest_value(plugin.producer_value(producer_id))}"
|
|
147
|
+
rescue StandardError
|
|
148
|
+
opaque << id
|
|
149
|
+
end
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
# rubocop:enable Naming/PredicateMethod
|
|
153
|
+
|
|
154
|
+
# Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the
|
|
155
|
+
# plugin defines it (it has a surface).
|
|
156
|
+
def collect_hook_part(plugin, id, parts, opaque)
|
|
157
|
+
return false unless plugin.respond_to?(:incremental_state_fingerprint)
|
|
158
|
+
|
|
159
|
+
parts << "hook\x1f#{id}\x1f#{digest_value(plugin.incremental_state_fingerprint)}"
|
|
160
|
+
true
|
|
161
|
+
rescue StandardError
|
|
162
|
+
opaque << id
|
|
163
|
+
true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A stable content digest of an arbitrary fact / producer value. Producer values are Marshal-clean by
|
|
167
|
+
# contract (the ADR-45 disk cache serialises them the same way), so this is the same round-trip the cache
|
|
168
|
+
# already relies on. A Marshal failure raises to the caller, which marks the plugin opaque (never a
|
|
169
|
+
# silent wrong-value digest).
|
|
170
|
+
def digest_value(value)
|
|
171
|
+
Digest::SHA256.hexdigest(Marshal.dump(value))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def contributes_types?(plugin)
|
|
175
|
+
klass = plugin.class
|
|
176
|
+
klass.dynamic_returns.any? || klass.narrowing_facts_rules.any?
|
|
177
|
+
rescue StandardError
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def safe_id(plugin)
|
|
182
|
+
plugin.manifest.id.to_s
|
|
183
|
+
rescue StandardError
|
|
184
|
+
plugin.class.name.to_s
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "check_rules"
|
|
3
|
+
require_relative "check_rules/rule_ids"
|
|
4
4
|
|
|
5
5
|
module Rigor
|
|
6
6
|
module Analysis
|
|
@@ -215,6 +215,40 @@ module Rigor
|
|
|
215
215
|
since: "0.0.2"
|
|
216
216
|
),
|
|
217
217
|
|
|
218
|
+
CheckRules::RULE_RAISE_NON_EXCEPTION => Entry.new(
|
|
219
|
+
id: CheckRules::RULE_RAISE_NON_EXCEPTION,
|
|
220
|
+
summary: "`raise` / `fail` operand provably cannot be raised (TypeError at runtime).",
|
|
221
|
+
fires_when: [
|
|
222
|
+
"The call is an implicit-self `raise x` / `fail x` (Kernel#raise) with a plain first " \
|
|
223
|
+
"positional argument.",
|
|
224
|
+
"The argument's inferred type is concrete and provably NOT a legal raise operand: not an " \
|
|
225
|
+
"Exception class, not an Exception instance, not a String, and its class defines no " \
|
|
226
|
+
"`#exception` (e.g. `raise 42`, `raise :sym`, `raise [1, 2]`, `raise nil` — an explicit " \
|
|
227
|
+
"nil argument is a TypeError, unlike bare `raise`).",
|
|
228
|
+
"A Class-object operand fires only when the class is RBS-known and its ancestry provably " \
|
|
229
|
+
"excludes Exception (including a concretely-resolved bare Module).",
|
|
230
|
+
"A union argument fires only when EVERY arm is independently illegal."
|
|
231
|
+
],
|
|
232
|
+
does_not_fire_when: [
|
|
233
|
+
"The argument is `Dynamic[T]` / untyped / unresolved, or a union with any legal or unknown arm.",
|
|
234
|
+
"The call has an explicit receiver (`obj.raise(...)` is a user method, not Kernel#raise).",
|
|
235
|
+
"The project redefines `raise` / `fail` anywhere the call could resolve (toplevel def, " \
|
|
236
|
+
"Object/Kernel monkey-patch, or a def on the enclosing class — in source or `pre_eval:`).",
|
|
237
|
+
"The argument's class defines `#exception` (RBS-declared or project-defined) — the duck " \
|
|
238
|
+
"protocol `raise` consults at runtime.",
|
|
239
|
+
"The instance operand's nominal class subsumes exceptions (`Object`, `Class`, `Module`, a " \
|
|
240
|
+
"module type) — the runtime value may still be a legal operand.",
|
|
241
|
+
"The operand's class is declared in the analyzed source — a project `sig/` that omits the " \
|
|
242
|
+
"superclass would misread the ancestry as non-Exception, so source-declared classes stay silent.",
|
|
243
|
+
"Bare `raise` (re-raises `$!`), or a splat / keyword / forwarded first argument."
|
|
244
|
+
],
|
|
245
|
+
suppression: "`# rigor:disable call.raise-non-exception`.",
|
|
246
|
+
severity_authored: :error,
|
|
247
|
+
severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
|
|
248
|
+
evidence_tier: :high,
|
|
249
|
+
since: "0.3.0"
|
|
250
|
+
),
|
|
251
|
+
|
|
218
252
|
CheckRules::RULE_DUMP_TYPE => Entry.new(
|
|
219
253
|
id: CheckRules::RULE_DUMP_TYPE,
|
|
220
254
|
summary: "`dump_type(expr)` from Rigor::Testing — informational type print.",
|
|
@@ -346,6 +380,63 @@ module Rigor
|
|
|
346
380
|
since: "0.1.17"
|
|
347
381
|
),
|
|
348
382
|
|
|
383
|
+
CheckRules::RULE_RETURN_IN_ENSURE => Entry.new(
|
|
384
|
+
id: CheckRules::RULE_RETURN_IN_ENSURE,
|
|
385
|
+
summary: "Explicit `return` inside an `ensure` clause swallows in-flight exceptions.",
|
|
386
|
+
fires_when: [
|
|
387
|
+
"An explicit `return` sits lexically inside the `ensure` clause of a `begin` / `def` / class " \
|
|
388
|
+
"body (`ensure` always runs, so its `return` overrides the method's in-flight return value " \
|
|
389
|
+
"AND silently discards any exception being raised).",
|
|
390
|
+
"The `return` is inside a plain block (`each do ... return ... end`) within the ensure body — " \
|
|
391
|
+
"a `return` there still exits the enclosing method."
|
|
392
|
+
],
|
|
393
|
+
does_not_fire_when: [
|
|
394
|
+
"The `return` is inside a nested `def`, a lambda (`->` / `lambda`), or a `define_method` " \
|
|
395
|
+
"block within the ensure body — it exits that inner frame, not the one the `ensure` guards.",
|
|
396
|
+
"The `ensure` body contains no explicit `return` (implicit last-expression values in `ensure` " \
|
|
397
|
+
"are discarded harmlessly and do not swallow exceptions)."
|
|
398
|
+
],
|
|
399
|
+
suppression: "`# rigor:disable flow.return-in-ensure` on the `return` line.",
|
|
400
|
+
severity_authored: :warning,
|
|
401
|
+
severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
|
|
402
|
+
# Purely syntactic proof with a frame-aware envelope — Ruby's `ensure` semantics make every
|
|
403
|
+
# firing a real control-flow hazard (RuboCop `Lint/EnsureReturn` precedent, low-FP).
|
|
404
|
+
evidence_tier: :high,
|
|
405
|
+
since: "0.3.0"
|
|
406
|
+
),
|
|
407
|
+
|
|
408
|
+
CheckRules::RULE_SHADOWED_RESCUE_CLAUSE => Entry.new(
|
|
409
|
+
id: CheckRules::RULE_SHADOWED_RESCUE_CLAUSE,
|
|
410
|
+
summary: "A `rescue` clause an earlier clause of the same chain already fully catches.",
|
|
411
|
+
fires_when: [
|
|
412
|
+
"An earlier `rescue` clause in the same `begin` / `def` rescue chain names a superclass (or " \
|
|
413
|
+
"the same class) of EVERY exception class the later clause names — `rescue StandardError` " \
|
|
414
|
+
"followed by `rescue ArgumentError` leaves the ArgumentError arm dead.",
|
|
415
|
+
"A bare `rescue` / `rescue => e` counts as the implicit `StandardError` on either side.",
|
|
416
|
+
"Every exception reference in both clauses is a constant path resolving (against its lexical " \
|
|
417
|
+
"namespace) to a CLASS with known ancestry — RBS / registry core classes, or a project class " \
|
|
418
|
+
"whose `class Foo < Bar` superclass chain is discovered.",
|
|
419
|
+
"A multi-class arm (`rescue A, B`) fires only when every class it names is covered by earlier " \
|
|
420
|
+
"clauses."
|
|
421
|
+
],
|
|
422
|
+
does_not_fire_when: [
|
|
423
|
+
"Any exception reference in either clause is an unresolved constant, a dynamic expression " \
|
|
424
|
+
"(`rescue klass_var`), or a splat (`rescue *ERRORS`) — the comparisons involving that clause " \
|
|
425
|
+
"stay silent.",
|
|
426
|
+
"A resolved constant is a MODULE (the `rescue MyGem::Error` module-tag mixin pattern — module " \
|
|
427
|
+
"`===` semantics are custom), or a project constant the discovery cannot certify as a class.",
|
|
428
|
+
"The later clause names a SUPERCLASS of an earlier one — the normal narrow-to-wide rescue order.",
|
|
429
|
+
"The clauses belong to different (nested) `begin` nodes — only clauses of the same chain compare."
|
|
430
|
+
],
|
|
431
|
+
suppression: "`# rigor:disable shadowed-rescue-clause` on the shadowed `rescue` line.",
|
|
432
|
+
severity_authored: :warning,
|
|
433
|
+
severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
|
|
434
|
+
# Purely syntactic + class-ancestry proof: both sides must resolve to concrete classes with known
|
|
435
|
+
# ancestry before a comparison happens, so a firing has no flow / mutation FP envelope.
|
|
436
|
+
evidence_tier: :high,
|
|
437
|
+
since: "0.3.0"
|
|
438
|
+
),
|
|
439
|
+
|
|
349
440
|
CheckRules::RULE_DEAD_ASSIGNMENT => Entry.new(
|
|
350
441
|
id: CheckRules::RULE_DEAD_ASSIGNMENT,
|
|
351
442
|
summary: "Local variable assigned in a method body but never read.",
|
|
@@ -370,6 +461,33 @@ module Rigor
|
|
|
370
461
|
since: "0.1.2"
|
|
371
462
|
),
|
|
372
463
|
|
|
464
|
+
CheckRules::RULE_DUPLICATE_HASH_KEY => Entry.new(
|
|
465
|
+
id: CheckRules::RULE_DUPLICATE_HASH_KEY,
|
|
466
|
+
summary: "Duplicate literal key within a single Hash literal (the last entry wins silently at runtime).",
|
|
467
|
+
fires_when: [
|
|
468
|
+
"Two entries of one Hash literal — braced (`{ a: 1, a: 2 }`) or bare keyword arguments " \
|
|
469
|
+
"(`m(a: 1, a: 2)`) — carry the same value-pinned literal key: a symbol (the `key:` shorthand " \
|
|
470
|
+
"and `:key =>` spell the same symbol), a plain non-interpolated string, an integer, a float, " \
|
|
471
|
+
"or `true` / `false` / `nil`.",
|
|
472
|
+
"A `**splat` between two identical literal keys does not rescue the pair — the later literal " \
|
|
473
|
+
"entry still overwrites the earlier one regardless of what the splat contributes."
|
|
474
|
+
],
|
|
475
|
+
does_not_fire_when: [
|
|
476
|
+
"Either key is not value-pinned at parse time: interpolated strings / symbols, constants, " \
|
|
477
|
+
"method calls, locals, and `**splat` entries are never compared.",
|
|
478
|
+
"The keys live in different literal kinds — `:a` vs `\"a\"`, and `1` vs `1.0` (`1.eql?(1.0)` " \
|
|
479
|
+
"is false, so Hash treats them as distinct keys) never collide.",
|
|
480
|
+
"The repeated keys sit in different Hash literals (nested literals are each their own scope)."
|
|
481
|
+
],
|
|
482
|
+
suppression: "`# rigor:disable duplicate-hash-key` on the later occurrence's line.",
|
|
483
|
+
severity_authored: :warning,
|
|
484
|
+
severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
|
|
485
|
+
# Purely syntactic value-pinned comparison with no metaprogramming escape: when it fires, the
|
|
486
|
+
# runtime overwrite is certain (Ruby itself warns under `-w`).
|
|
487
|
+
evidence_tier: :high,
|
|
488
|
+
since: "0.3.0"
|
|
489
|
+
),
|
|
490
|
+
|
|
373
491
|
CheckRules::RULE_RETURN_TYPE => Entry.new(
|
|
374
492
|
id: CheckRules::RULE_RETURN_TYPE,
|
|
375
493
|
summary: "Method body's last-expression type is incompatible with the declared return type.",
|
|
@@ -526,6 +644,113 @@ module Rigor
|
|
|
526
644
|
# escapes are excluded.
|
|
527
645
|
evidence_tier: :high,
|
|
528
646
|
since: "0.1.2"
|
|
647
|
+
),
|
|
648
|
+
|
|
649
|
+
CheckRules::RULE_SUPPRESSION_UNKNOWN_RULE => Entry.new(
|
|
650
|
+
id: CheckRules::RULE_SUPPRESSION_UNKNOWN_RULE,
|
|
651
|
+
summary: "A `# rigor:disable[-file]` comment names a rule that does not exist.",
|
|
652
|
+
fires_when: [
|
|
653
|
+
"A `# rigor:disable` / `# rigor:disable-file` marker carries a token that is not a canonical " \
|
|
654
|
+
"rule id, a legacy alias, `all`, or a family wildcard (`call` / `flow` / ...).",
|
|
655
|
+
"The token is also not a known non-catalogue engine diagnostic (`rbs_extended.*`, `dynamic.*`, " \
|
|
656
|
+
"`rbs.*`, `pre-eval.*`, or a bare engine id such as `load-error`).",
|
|
657
|
+
"Typically a typo — `call.undefined-metod` — leaving the suppression silently ineffective."
|
|
658
|
+
],
|
|
659
|
+
does_not_fire_when: [
|
|
660
|
+
"The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
|
|
661
|
+
"The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
|
|
662
|
+
"be enumerated statically and under-warning is the FP-safe direction.",
|
|
663
|
+
"The comment merely mentions the marker followed by non-token text (documentation prose " \
|
|
664
|
+
"like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
|
|
665
|
+
],
|
|
666
|
+
suppression: "Fix or remove the dead token; `# rigor:disable suppression.unknown-rule` on the " \
|
|
667
|
+
"same line, or `disable: [\"suppression.unknown-rule\"]` in `.rigor.yml`.",
|
|
668
|
+
severity_authored: :warning,
|
|
669
|
+
severity_by_profile: { lenient: :warning, balanced: :warning, strict: :warning },
|
|
670
|
+
# Pure token-table membership over the same tables the suppression matcher uses — no inference
|
|
671
|
+
# uncertainty; the plugin/prose escapes above are excluded before firing.
|
|
672
|
+
evidence_tier: :high,
|
|
673
|
+
since: "0.3.0"
|
|
674
|
+
),
|
|
675
|
+
|
|
676
|
+
CheckRules::RULE_SUPPRESSION_EMPTY => Entry.new(
|
|
677
|
+
id: CheckRules::RULE_SUPPRESSION_EMPTY,
|
|
678
|
+
summary: "A `# rigor:disable[-file]` comment lists no rules.",
|
|
679
|
+
fires_when: [
|
|
680
|
+
"A comment is exactly the bare marker (`# rigor:disable` / `# rigor:disable-file`) with " \
|
|
681
|
+
"nothing but whitespace or commas after it.",
|
|
682
|
+
"Such a marker suppresses nothing — the author almost certainly meant to name rules or `all`."
|
|
683
|
+
],
|
|
684
|
+
does_not_fire_when: [
|
|
685
|
+
"At least one token follows the marker (each token is then checked by " \
|
|
686
|
+
"`suppression.unknown-rule` instead).",
|
|
687
|
+
"Non-token text follows the marker (documentation prose mentioning the syntax)."
|
|
688
|
+
],
|
|
689
|
+
suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
|
|
690
|
+
"`disable: [\"suppression.empty\"]` in `.rigor.yml`.",
|
|
691
|
+
severity_authored: :warning,
|
|
692
|
+
severity_by_profile: { lenient: :warning, balanced: :warning, strict: :warning },
|
|
693
|
+
# Syntactic: the marker word is present and the token list is provably empty.
|
|
694
|
+
evidence_tier: :high,
|
|
695
|
+
since: "0.3.0"
|
|
696
|
+
),
|
|
697
|
+
|
|
698
|
+
CheckRules::RULE_VALUE_USE_VOID => Entry.new(
|
|
699
|
+
id: CheckRules::RULE_VALUE_USE_VOID,
|
|
700
|
+
summary: "A value recovered from an author-declared `-> void` return is used in value context.",
|
|
701
|
+
fires_when: [
|
|
702
|
+
"A call resolves through direct RBS dispatch (the receiver's own resolvable class) to a method " \
|
|
703
|
+
"whose selected overload declares a `-> void` return.",
|
|
704
|
+
"That call node sits in a value position: an assignment right-hand side (`x = obj.log(...)`), a " \
|
|
705
|
+
"call's explicit receiver (`obj.log(...).inspect`), or a call's positional argument.",
|
|
706
|
+
"The `use-of-void-value` bleeding-edge feature is enabled (the rule resolves `:off` otherwise)."
|
|
707
|
+
],
|
|
708
|
+
does_not_fire_when: [
|
|
709
|
+
"The void call is a bare statement (statement context) — a `void` result is accepted there.",
|
|
710
|
+
"The value is a legitimate `top` (an author-declared `-> top` return) or any non-void type — " \
|
|
711
|
+
"only an author-declared `-> void` is recorded, so a real `top` never fires.",
|
|
712
|
+
"The `-> void` return was resolved through the Object / user-class ancestor fallback rather than " \
|
|
713
|
+
"direct dispatch (the transitive / ancestor case is deferred, ADR-100 WD4).",
|
|
714
|
+
"The `use-of-void-value` bleeding-edge feature is not enabled (the default)."
|
|
715
|
+
],
|
|
716
|
+
suppression: "`# rigor:disable static.value-use.void` on the call line, or " \
|
|
717
|
+
"`disable: [\"static.value-use.void\"]` in `.rigor.yml`.",
|
|
718
|
+
severity_authored: :warning,
|
|
719
|
+
severity_by_profile: { lenient: :off, balanced: :off, strict: :off },
|
|
720
|
+
# Recorded only for an author-written `-> void` on the direct-dispatch path — the strongest
|
|
721
|
+
# possible "do not rely on this return" signal — so a firing is a concrete misuse, not an
|
|
722
|
+
# inference guess. It ships `:off` behind `bleeding_edge:` per ADR-50 WD1 (a new required
|
|
723
|
+
# diagnostic is a compatibility change), not for lack of confidence.
|
|
724
|
+
evidence_tier: :high,
|
|
725
|
+
since: "0.3.0"
|
|
726
|
+
),
|
|
727
|
+
|
|
728
|
+
CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER => Entry.new(
|
|
729
|
+
id: CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER,
|
|
730
|
+
summary: "A comment uses a suppression marker Rigor does not recognise " \
|
|
731
|
+
"(`rigor:disable-next-line`, `rigor:enable`, ...).",
|
|
732
|
+
fires_when: [
|
|
733
|
+
"A comment carries `rigor:disable-<suffix>` with a suffix other than `file`, or " \
|
|
734
|
+
"`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
|
|
735
|
+
"<rule>` — followed by nothing or a rule-list-shaped remainder.",
|
|
736
|
+
"Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
|
|
737
|
+
"nothing; Rigor's only markers are `# rigor:disable <rules>` (same line) and " \
|
|
738
|
+
"`# rigor:disable-file <rules>`."
|
|
739
|
+
],
|
|
740
|
+
does_not_fire_when: [
|
|
741
|
+
"The marker is one of the two recognised forms (their tokens are then checked by " \
|
|
742
|
+
"`suppression.unknown-rule` / `suppression.empty` instead).",
|
|
743
|
+
"Non-token text follows the marker (documentation prose mentioning the spelling)."
|
|
744
|
+
],
|
|
745
|
+
suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
|
|
746
|
+
"next-line or enable form) or delete the comment; " \
|
|
747
|
+
"`disable: [\"suppression.unknown-marker\"]` in `.rigor.yml`.",
|
|
748
|
+
severity_authored: :warning,
|
|
749
|
+
severity_by_profile: { lenient: :warning, balanced: :warning, strict: :warning },
|
|
750
|
+
# Syntactic: the marker word is present and provably outside the suppression grammar; the
|
|
751
|
+
# prose escape is excluded before firing.
|
|
752
|
+
evidence_tier: :high,
|
|
753
|
+
since: "0.3.0"
|
|
529
754
|
)
|
|
530
755
|
}.freeze
|
|
531
756
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
# ADR-87 WD4 — the `rbs` gem's version feeds the cache key's `gems` slot (`RbsDescriptor.rbs_gem_entry`).
|
|
5
|
+
# Load only its version constant (not the full RBS parser / env) so the boot-slimming probe can build the key
|
|
6
|
+
# without paying for — or `$LOADED_FEATURES`-touching — the RBS machinery. Falls back to the full gem only if
|
|
7
|
+
# this build of `rbs` has no standalone version file.
|
|
8
|
+
begin
|
|
9
|
+
require "rbs/version"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
require "rbs"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "../version"
|
|
15
|
+
require_relative "../cache/descriptor"
|
|
16
|
+
require_relative "../cache/rbs_descriptor"
|
|
17
|
+
require_relative "../environment/default_libraries"
|
|
18
|
+
|
|
19
|
+
module Rigor
|
|
20
|
+
module Analysis
|
|
21
|
+
# ADR-45 / ADR-87 WD4 — the stable run-result cache KEY, built in ONE place so the miss path (the
|
|
22
|
+
# {Analysis::Runner}) and the boot-slimming hit path (the {RunCacheProbe}) can never drift out of key
|
|
23
|
+
# agreement. The key reads the stable inputs known before analysis: the `rbs` gem version, the resolved
|
|
24
|
+
# RBS library list, a digest of the whole resolved configuration, the engine + schema + `--explain`
|
|
25
|
+
# triple, and the analyzed-path SET.
|
|
26
|
+
#
|
|
27
|
+
# The ONLY difference between the two callers is the `rbs_config_entries` slot: the Runner passes the
|
|
28
|
+
# loader's `RbsDescriptor.config_entries` (which include a `rbs.virtual_rbs` entry when a plugin's
|
|
29
|
+
# `source_rbs_synthesizer` contributed one), while the probe passes {#libraries_config_entries} —
|
|
30
|
+
# reconstructed from config alone, WITHOUT building the RBS environment or loading any plugin. A project
|
|
31
|
+
# whose plugins DO synthesise virtual RBS therefore produces a probe key that omits that entry, so the
|
|
32
|
+
# probe simply misses and the full path takes over (sound: never a wrong hit, only a forgone fast lane).
|
|
33
|
+
# Slot order is irrelevant — {Cache::Descriptor#to_canonical_hash} sorts configs by key.
|
|
34
|
+
module RunCacheKey
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
RUN_DIAGNOSTICS_PRODUCER_ID = "analysis.run-diagnostics"
|
|
38
|
+
|
|
39
|
+
# The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
|
|
40
|
+
# but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
|
|
41
|
+
# one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
|
|
42
|
+
# single file are three separate live generations. 16 is a judgement call sized for that churn; it is
|
|
43
|
+
# a cap on GENERATIONS, not on correctness — over-evicting here costs a recompute, never a wrong
|
|
44
|
+
# answer. Measured 2026-07-25 against a real project's `.rigor/cache`: the cap does bind (60 distinct
|
|
45
|
+
# path-set generations observed, differing in the `paths` slot alone), with no evidence yet on how
|
|
46
|
+
# often an evicted generation is asked for again — see issue #151.
|
|
47
|
+
GENERATION_CAP = 16
|
|
48
|
+
|
|
49
|
+
# @param rbs_config_entries [Array<Cache::Descriptor::ConfigEntry>] the RBS-derived config slots
|
|
50
|
+
# (`rbs.libraries` [+ `rbs.virtual_rbs`]). nil on any failure so a malformed key disables the cache.
|
|
51
|
+
def descriptor(configuration:, files:, explain:, rbs_config_entries:)
|
|
52
|
+
Cache::Descriptor.new(
|
|
53
|
+
gems: [Cache::RbsDescriptor.rbs_gem_entry],
|
|
54
|
+
configs: rbs_config_entries + [
|
|
55
|
+
config_entry("configuration", Marshal.dump(configuration.to_h)),
|
|
56
|
+
config_entry("engine",
|
|
57
|
+
"#{Rigor::VERSION}:#{Cache::Descriptor::SCHEMA_VERSION}:#{explain}"),
|
|
58
|
+
config_entry("paths", files.sort.join("\n"))
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def config_entry(key, payload)
|
|
66
|
+
Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The `rbs.libraries` config slot reconstructed from configuration alone — byte-identical to the
|
|
70
|
+
# loader's `RbsDescriptor.libraries_entry(loader.libraries)` because `Environment.for_project` merges
|
|
71
|
+
# exactly `DEFAULT_LIBRARIES + config.libraries` (uniq) into `loader.libraries`.
|
|
72
|
+
def libraries_config_entries(configuration)
|
|
73
|
+
merged = (Environment::DEFAULT_LIBRARIES + configuration.libraries.map(&:to_s)).uniq
|
|
74
|
+
[Cache::RbsDescriptor.libraries_entry(merged)]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "result"
|
|
4
|
+
require_relative "run_cache_key"
|
|
5
|
+
require_relative "path_expansion"
|
|
6
|
+
require_relative "severity_stamp"
|
|
7
|
+
require_relative "../cache/store"
|
|
8
|
+
require_relative "../cache/file_digest"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module Analysis
|
|
12
|
+
# ADR-87 WD4 — the boot-slimming run-cache hit probe. Serves an ordinary `rigor check`'s diagnostics
|
|
13
|
+
# straight from the ADR-45 `analysis.run-diagnostics` cache WITHOUT loading the inference engine, its
|
|
14
|
+
# plugin gems, or building the RBS environment: a warm HIT boots only CLI + config + cache + digest code
|
|
15
|
+
# (the recon's `$LOADED_FEATURES` never gains a `rigor/inference` entry). The full plugin/prepass +
|
|
16
|
+
# env-build tax (~0.8s on a gitlab null) is paid ONLY on a miss.
|
|
17
|
+
#
|
|
18
|
+
# Soundness is inherited wholesale from ADR-45: the stored dependency descriptor records every file the
|
|
19
|
+
# prior run read — analyzed sources, the RBS signature tree, AND every file each plugin read mid-analysis
|
|
20
|
+
# (the Pundit-policy case) — and {Cache::Store#peek_validated} re-checks all of it against the live tree
|
|
21
|
+
# (ADR-87 `:stat`-validated). A hit therefore means every input the prior run observed is unchanged, so
|
|
22
|
+
# its cached diagnostics are exactly what a fresh full run would produce; skipping the plugin prepasses is
|
|
23
|
+
# sound because their inputs are in that same validated set. The key is built through the shared
|
|
24
|
+
# {RunCacheKey} — a project whose plugins synthesise virtual RBS produces a probe key that omits that
|
|
25
|
+
# entry, so it simply misses and the full path takes over (never a wrong hit).
|
|
26
|
+
class RunCacheProbe
|
|
27
|
+
# @param configuration [Rigor::Configuration]
|
|
28
|
+
# @param cache_root [String]
|
|
29
|
+
# @param explain [Boolean] the `--explain` flag (folded into the key, as the runner does).
|
|
30
|
+
def initialize(configuration:, cache_root:, explain:)
|
|
31
|
+
@configuration = configuration
|
|
32
|
+
@cache_root = cache_root
|
|
33
|
+
@explain = explain
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param paths [Array<String>] the analysis roots (`@argv` or `configuration.paths`).
|
|
37
|
+
# @return [Analysis::Result, nil] the cached run result with the severity profile applied and no stats
|
|
38
|
+
# (matching a cache-served `Runner#run`), or nil to DECLINE — a miss / stale / unavailable cache — so
|
|
39
|
+
# the caller loads the engine and runs the full path. Any failure declines rather than raising: the
|
|
40
|
+
# probe must never turn a servable run into a crash.
|
|
41
|
+
def serve(paths)
|
|
42
|
+
files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns)
|
|
43
|
+
key = RunCacheKey.descriptor(
|
|
44
|
+
configuration: @configuration, files: files, explain: @explain,
|
|
45
|
+
rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
|
|
46
|
+
)
|
|
47
|
+
return nil if key.nil?
|
|
48
|
+
|
|
49
|
+
diagnostics = validated_diagnostics(key)
|
|
50
|
+
return nil if diagnostics.nil?
|
|
51
|
+
|
|
52
|
+
Result.new(diagnostics: SeverityStamp.apply(diagnostics, @configuration), stats: nil)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def validated_diagnostics(key)
|
|
60
|
+
store = Cache::Store.new(root: @cache_root, max_bytes: @configuration.cache_max_bytes)
|
|
61
|
+
# The digest fallback + `cache.validation` / RIGOR_STRICT_VALIDATION escape hatch route through the
|
|
62
|
+
# same per-run FileDigest scope the full run uses.
|
|
63
|
+
strict = @configuration.cache_validation_strict?
|
|
64
|
+
Cache::FileDigest.with_run(strict: strict) do
|
|
65
|
+
store.peek_validated(
|
|
66
|
+
producer_id: RunCacheKey::RUN_DIAGNOSTICS_PRODUCER_ID, key_descriptor: key
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|