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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require_relative "incremental"
|
|
5
|
+
require_relative "plugin_fact_fingerprint"
|
|
6
|
+
require_relative "../cache/file_digest"
|
|
5
7
|
require_relative "../cache/incremental_snapshot"
|
|
6
8
|
require_relative "../inference/scope_indexer"
|
|
7
9
|
|
|
@@ -21,23 +23,58 @@ module Rigor
|
|
|
21
23
|
# `--verify-incremental` acceptance gate, here without disk persistence or CLI wiring (the cache is
|
|
22
24
|
# in-process). It models the body tier only: an edit that adds / removes / moves a *file* is outside
|
|
23
25
|
# the analyzed set it maintains and falls to a fresh {#baseline} (the structural tier is a later slice).
|
|
24
|
-
class
|
|
26
|
+
# The class-length budget is relaxed: this is one cohesive orchestrator of the incremental state
|
|
27
|
+
# (per-file diagnostics cache, the file-level / symbol-level / negative dependency graphs, and the ADR-85
|
|
28
|
+
# seed bundles), clearer read together than split across micro-classes that would all share the same ivars.
|
|
29
|
+
class IncrementalSession # rubocop:disable Metrics/ClassLength
|
|
25
30
|
# The outcome of a {#recheck}: the merged diagnostics plus the file sets, so a caller (or the verify
|
|
26
|
-
# gate) can report what was re-analyzed versus served from cache.
|
|
27
|
-
|
|
31
|
+
# gate) can report what was re-analyzed versus served from cache. `added` / `removed` carry the
|
|
32
|
+
# structural delta so {#run_incremental} (ADR-87 WD3) can recognise a zero-change recheck and skip the
|
|
33
|
+
# unconditional snapshot rewrite.
|
|
34
|
+
Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused) do
|
|
35
|
+
# A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
|
|
36
|
+
# snapshot that was restored, so persisting it again would only rewrite identical bytes.
|
|
37
|
+
def no_change?
|
|
38
|
+
changed.empty? && added.empty? && removed.empty?
|
|
39
|
+
end
|
|
40
|
+
end
|
|
28
41
|
|
|
29
42
|
# @param paths [Array<String>, nil] explicit analysis roots; nil (the default) uses the configuration's
|
|
30
43
|
# `paths:`.
|
|
31
44
|
# @param environment [Rigor::Environment, nil] optional shared environment to thread into each internal
|
|
32
45
|
# Runner. Long-lived callers and specs can use this to avoid rebuilding the same RBS universe for
|
|
33
46
|
# every baseline / recheck / oracle run.
|
|
34
|
-
|
|
47
|
+
# @param cache_store [Rigor::Cache::Store, nil] ADR-85 WD1 — the persistent cache each internal Runner
|
|
48
|
+
# exposes to the RBS-env and plugin-producer tiers. A cross-process `--incremental` recheck otherwise
|
|
49
|
+
# rebuilt a fresh runner with no store, so every plugin `#prepare` producer (the ADR-9/#74/ADR-60 WD3
|
|
50
|
+
# record-and-validate caches) recomputed per invocation — 86% of a Rails warm incremental. Threading
|
|
51
|
+
# the store lets those producers serve from disk. `nil` (the default) preserves the pre-#85 behaviour
|
|
52
|
+
# the specs assert; the whole-run ADR-45 result cache stays disabled on these runs
|
|
53
|
+
# (`Runner#run_result_cacheable?` excludes `record_dependencies` / `analyze_only`).
|
|
54
|
+
# @param plugin_requirer [#call, nil] optional gem-require hook threaded into each internal Runner
|
|
55
|
+
# (mirrors {Runner}'s parameter). nil (the default, and what the CLI passes) uses `Kernel.require`;
|
|
56
|
+
# embedders and specs inject a fake so a test plugin registers without touching the real load path.
|
|
57
|
+
# @param workers [Integer] ADR-46 — the resolved fork-pool worker count threaded into every internal
|
|
58
|
+
# analyzer, so a `--incremental` recheck's closure re-analysis parallelises exactly like the standard
|
|
59
|
+
# `check` path (the recon's audit: `--workers` / `RIGOR_RACTOR_WORKERS` / `parallel.workers:` were
|
|
60
|
+
# silently ignored because `build_runner` passed no `workers:`). 0 (the default, and what the specs and
|
|
61
|
+
# `--verify-incremental` gate use) keeps the sequential recording path bit-for-bit unchanged. The fork
|
|
62
|
+
# pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
|
|
63
|
+
# dependency graph a pooled recheck rebuilds equals the sequential one.
|
|
64
|
+
def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
|
|
65
|
+
workers: 0)
|
|
35
66
|
@configuration = configuration
|
|
36
67
|
@paths = paths
|
|
37
68
|
@environment = environment
|
|
69
|
+
@cache_store = cache_store
|
|
70
|
+
@plugin_requirer = plugin_requirer
|
|
71
|
+
@workers = workers
|
|
72
|
+
# ADR-85 WD2 — per-file discovery seed bundles keyed by logical path. A cold baseline builds them; a
|
|
73
|
+
# warm recheck folds them (re-walking only changed files) and refreshes the set. Ride the snapshot.
|
|
74
|
+
@seed_bundles = {}
|
|
38
75
|
@cache = {} # analyzed path => [Diagnostic]
|
|
39
76
|
@sources = {} # analyzed path => Set<source path it read from>
|
|
40
|
-
@digests = {} # analyzed path =>
|
|
77
|
+
@digests = {} # analyzed path => ADR-87 packed stat-digest entry at last analysis
|
|
41
78
|
@analyzed = [] # the project files analyzed last round
|
|
42
79
|
@dependents = {} # inverted @sources (file-level)
|
|
43
80
|
# ADR-46 slice 4 — symbol-granularity tracking.
|
|
@@ -53,6 +90,18 @@ module Rigor
|
|
|
53
90
|
@missing = {} # consumer => Set<"kind:name"> it looked up and missed
|
|
54
91
|
@negative_dependents = {} # "kind:name" => Set<consumer> (inverted @missing)
|
|
55
92
|
@class_decls = {} # path => Set<qualified class name declared in the file>
|
|
93
|
+
# ADR-89 WD2 — per-def observed-key return summaries: [path, symbol] => { keys:, returns:, effects: }.
|
|
94
|
+
# Harvested from the ADR-84 return memo after each run; drives the behavioural-stability gate.
|
|
95
|
+
@return_summaries = {}
|
|
96
|
+
# ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
|
|
97
|
+
# `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
|
|
98
|
+
# banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
|
|
99
|
+
# post-hoc fingerprint reads from; `@plugin_fact_reusable` is the computed decision object.
|
|
100
|
+
@plugin_fact_digest = nil
|
|
101
|
+
@opaque_plugin_ids = [].freeze
|
|
102
|
+
@fact_surface_invalidated = false
|
|
103
|
+
@last_runner = nil
|
|
104
|
+
@plugin_fact_reusable = nil
|
|
56
105
|
end
|
|
57
106
|
|
|
58
107
|
# The project files analyzed at the last baseline / recheck — the set a verify pass partitions and the
|
|
@@ -66,10 +115,13 @@ module Rigor
|
|
|
66
115
|
def baseline
|
|
67
116
|
runner = build_runner(record_dependencies: true)
|
|
68
117
|
diagnostics = run_runner(runner).diagnostics
|
|
118
|
+
@last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
|
|
69
119
|
@analyzed = runner.analyzed_files
|
|
120
|
+
@seed_bundles = runner.seed_bundles # ADR-85 WD2 — the freshly built bundle set for the next run.
|
|
70
121
|
absorb_dependency_graph(runner)
|
|
122
|
+
@return_summaries = runner.return_summaries # ADR-89 WD2 — the full-run behavioural surface.
|
|
71
123
|
@cache = per_file(diagnostics)
|
|
72
|
-
@digests = @analyzed.to_h { |path| [path,
|
|
124
|
+
@digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
|
|
73
125
|
diagnostics
|
|
74
126
|
end
|
|
75
127
|
|
|
@@ -81,15 +133,17 @@ module Rigor
|
|
|
81
133
|
current = current_files
|
|
82
134
|
added = current - previous
|
|
83
135
|
removed = previous - current
|
|
84
|
-
changed = (current & previous)
|
|
136
|
+
changed = changed_paths(current & previous)
|
|
85
137
|
affected = affected_closure(changed, added, removed)
|
|
86
138
|
analyze_set = affected & current
|
|
87
139
|
runner = build_runner(analyze_only: analyze_set, record_dependencies: true)
|
|
88
140
|
fresh = run_runner(runner).diagnostics
|
|
141
|
+
@last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
|
|
89
142
|
reused = (current & previous) - affected.to_a
|
|
90
143
|
merged = fresh + reused.flat_map { |path| @cache[path] || [] }
|
|
91
144
|
absorb(runner, fresh, current, analyze_set, removed)
|
|
92
|
-
Recheck.new(diagnostics: merged, changed: changed.to_set,
|
|
145
|
+
Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
|
|
146
|
+
removed: removed.to_set, affected: affected, reused: reused.to_set)
|
|
93
147
|
end
|
|
94
148
|
|
|
95
149
|
# The frozen set of files a #recheck must re-analyse: the symbol/ancestry-granularity closure of the
|
|
@@ -99,19 +153,105 @@ module Rigor
|
|
|
99
153
|
# provided). An added file has no before-state, so all its symbols / classes appear.
|
|
100
154
|
def affected_closure(changed, added, removed)
|
|
101
155
|
scan = changed + added
|
|
102
|
-
|
|
103
|
-
|
|
156
|
+
# Parse the changed / added set ONCE for the per-symbol fingerprints, the class declarations, AND the
|
|
157
|
+
# ADR-89 WD1 declaration signatures. They were separate `discovered_def_index_for_paths` passes over
|
|
158
|
+
# the same `scan` set — a duplicate re-parse of every changed file each recheck (recon §2 / the P6
|
|
159
|
+
# recheck-floor audit).
|
|
160
|
+
summary = scan.empty? ? nil : Inference::ScopeIndexer.scan_summary_for_paths(scan)
|
|
161
|
+
scan_index = summary && summary[:def_index]
|
|
162
|
+
declaration_signatures = (summary && summary[:declaration_signatures]) || {}
|
|
163
|
+
new_fps = symbol_fingerprints_from_index(scan_index)
|
|
164
|
+
new_class_decls = class_declarations_from_index(scan_index)
|
|
104
165
|
changed_pairs = Incremental.changed_symbol_pairs(changed, @symbol_fingerprints, new_fps)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
166
|
+
# ADR-89 WD1 — a changed file whose DECLARATION signature (per-def parameter shape / visibility /
|
|
167
|
+
# ancestry / member layout / def line, bodies excluded) is byte-identical to the snapshot is
|
|
168
|
+
# declaration-stable: every cross-file fact its ancestry / file-level dependents consume is
|
|
169
|
+
# declaration-derived and therefore unchanged (a body edit is consumed only by SYMBOL dependents,
|
|
170
|
+
# governed below by `changed_pairs`), so those dependents are skipped. Only the declaration-UNSTABLE
|
|
171
|
+
# changed files contribute their ancestry / file-level dependents; the changed files themselves are
|
|
172
|
+
# always re-analysed (`changed.to_set`) to learn their own diagnostics. This generalises the B1
|
|
173
|
+
# comment-only gate (code-stable ⟹ declaration-stable) to same-line body edits.
|
|
174
|
+
unstable = declaration_unstable(changed, declaration_signatures)
|
|
175
|
+
# ADR-89 WD2 — a declaration-stable changed def whose behavioural surface (return type at every
|
|
176
|
+
# previously-observed call key + content-mutation effects) is unchanged is behaviourally stable: its
|
|
177
|
+
# symbol dependents' cached diagnostics stay valid, so drop them. `symbol_pairs` is `changed_pairs`
|
|
178
|
+
# minus those stable pairs, and only it (not `changed_pairs`) drives the symbol-dependent fan-out.
|
|
179
|
+
symbol_pairs = behaviourally_unstable_pairs(changed_pairs, unstable, scan_index)
|
|
180
|
+
base = dependents_base(unstable, symbol_pairs)
|
|
181
|
+
closure = base | changed.to_set | added.to_set | negative_affected(scan, new_fps, new_class_decls)
|
|
111
182
|
removed.each { |path| closure |= @dependents[path] || Set.new }
|
|
112
183
|
closure.freeze
|
|
113
184
|
end
|
|
114
185
|
|
|
186
|
+
# The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
|
|
187
|
+
# symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the
|
|
188
|
+
# unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out.
|
|
189
|
+
def dependents_base(unstable, symbol_pairs)
|
|
190
|
+
if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
|
|
191
|
+
Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
|
|
192
|
+
else
|
|
193
|
+
Incremental.affected(unstable, @dependents)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ADR-89 WD2 — `changed_pairs` minus the behaviourally-STABLE pairs whose symbol dependents may be
|
|
198
|
+
# skipped. A pair `[path, "Class#method"]` is a candidate when its file is declaration-stable (WD1), it
|
|
199
|
+
# carries a persisted return summary, and the (edited) def is GATE-ELIGIBLE — its only cross-file body
|
|
200
|
+
# surfaces are its return type and its content-mutation effects (no instance/class variable write, no
|
|
201
|
+
# `yield`, no implicit-self call whose transitive effect a caller could observe). For a candidate the
|
|
202
|
+
# session compares its content-mutation effects (pure AST) then re-evaluates its return at every
|
|
203
|
+
# persisted key (a runner probe); a pair passes only when BOTH are unchanged. Any missing summary,
|
|
204
|
+
# ineligible def, changed effect, changed return, or uncomputable key keeps the pair — the conservative
|
|
205
|
+
# direction the `--verify-incremental` and byte-identical recheck specs backstop.
|
|
206
|
+
def behaviourally_unstable_pairs(changed_pairs, unstable, scan_index)
|
|
207
|
+
return changed_pairs if @return_summaries.empty? || scan_index.nil?
|
|
208
|
+
|
|
209
|
+
candidates = changed_pairs.select do |pair|
|
|
210
|
+
!unstable.include?(pair.first) && @return_summaries.key?(pair) &&
|
|
211
|
+
gate_eligible_def?(scan_def_node(pair.last, scan_index))
|
|
212
|
+
end
|
|
213
|
+
return changed_pairs if candidates.empty?
|
|
214
|
+
|
|
215
|
+
effect_stable = candidates.select { |pair| effects_unchanged?(pair, scan_index) }
|
|
216
|
+
return changed_pairs if effect_stable.empty?
|
|
217
|
+
|
|
218
|
+
stable = return_stable_pairs(effect_stable, scan_index)
|
|
219
|
+
stable.empty? ? changed_pairs : (changed_pairs - stable)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# ADR-89 WD1 — the changed files whose ancestry / file-level dependents must STILL be re-checked: those
|
|
223
|
+
# not provably declaration-stable. A changed file is declaration-STABLE (its ancestry / file-level
|
|
224
|
+
# dependents skippable) when its current {ScopeIndexer.declaration_signature} matches the one stored in
|
|
225
|
+
# the snapshot's seed bundle — i.e. its edit changed no method signature, visibility, ancestry, member
|
|
226
|
+
# layout, def line, or method existence, so every cross-file DECLARATION fact its dependents consume is
|
|
227
|
+
# unchanged. Falls back to treating EVERY changed file as unstable (today's full closure) when a
|
|
228
|
+
# comment-ingesting plugin is loaded — such a plugin reads the very comments the signature ignores, so a
|
|
229
|
+
# comment edit it treats as a no-op could change a cross-file type. Sorbet sigs / dry-types includes are
|
|
230
|
+
# CODE, captured by ADR-88's plugin-fact fingerprint (WD3), so only comment-as-input plugins escape here.
|
|
231
|
+
def declaration_unstable(changed, declaration_signatures)
|
|
232
|
+
return changed if comment_ingesting_plugin_loaded?
|
|
233
|
+
|
|
234
|
+
changed.reject { |path| declaration_stable?(path, declaration_signatures[path]) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def declaration_stable?(path, current_declaration_signature)
|
|
238
|
+
return false if current_declaration_signature.nil?
|
|
239
|
+
|
|
240
|
+
bundle = @seed_bundles[path]
|
|
241
|
+
return false if bundle.nil?
|
|
242
|
+
|
|
243
|
+
bundle[:declaration_signature] == current_declaration_signature
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def comment_ingesting_plugin_loaded?
|
|
247
|
+
# Mirrors the plugin loader's gem-name resolution (`ProjectPrePasses#trusted_gem_name`): a String
|
|
248
|
+
# entry IS the gem name; a Hash entry names it under `"gem"` (or the manifest `"id"`).
|
|
249
|
+
@configuration.plugins.any? do |entry|
|
|
250
|
+
name = entry.is_a?(Hash) ? (entry["gem"] || entry["id"]) : entry
|
|
251
|
+
COMMENT_INGESTING_PLUGIN_IDS.include?(name.to_s)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
115
255
|
# The current project file set (cheap directory expansion, no analysis), used to detect files added /
|
|
116
256
|
# removed since the last run.
|
|
117
257
|
def current_files
|
|
@@ -139,27 +279,100 @@ module Rigor
|
|
|
139
279
|
# when a snapshot was restored. A nil `fingerprint` (uncomputable inputs) disables persistence: a
|
|
140
280
|
# plain full run.
|
|
141
281
|
def run_incremental(snapshot:, fingerprint:)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
282
|
+
# ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
|
|
283
|
+
# invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
|
|
284
|
+
# `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
|
|
285
|
+
# shares one digest memo across change detection and the baseline/absorb re-pack. The inner
|
|
286
|
+
# `Runner#run` nests its own `with_run` for the analysis descriptors; nesting is safe (each restores).
|
|
287
|
+
Cache::FileDigest.with_run(strict: @configuration.cache_validation_strict?) do
|
|
288
|
+
restored = fingerprint && snapshot.load(fingerprint: fingerprint)
|
|
289
|
+
# ADR-88 WD1 — the plugin fact-surface fingerprint gates snapshot reuse the same way the global
|
|
290
|
+
# fingerprint gates the load: a plugin sig/catalog edit outside `signature_paths:` (a Sorbet `.rbi`)
|
|
291
|
+
# changes the types unchanged call sites resolve, without moving any analyzed file, so the global
|
|
292
|
+
# fingerprint stays fresh and the recheck would serve stale diagnostics. The fingerprint is computed
|
|
293
|
+
# POST-HOC from the analysis runner (which already ran `#prepare` and validated its producers) so the
|
|
294
|
+
# warm path pays no second `#prepare`; the decision is applied after the recheck. Opaque plugins
|
|
295
|
+
# (types with no fingerprint surface) make the snapshot un-reusable.
|
|
296
|
+
if restored
|
|
297
|
+
restore(restored)
|
|
298
|
+
result = recheck
|
|
299
|
+
adopt_plugin_fact_fingerprint
|
|
300
|
+
reuse = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
|
|
301
|
+
if reuse
|
|
302
|
+
diagnostics = result.diagnostics
|
|
303
|
+
warm = true
|
|
304
|
+
# ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
|
|
305
|
+
# snapshot it restored, so skip the unconditional rewrite (209ms + 2 MB on gitlab per null recheck).
|
|
306
|
+
# A cold baseline always persists — there was no valid snapshot to reuse.
|
|
307
|
+
skip_save = result.no_change?
|
|
308
|
+
else
|
|
309
|
+
# The fact surface moved (a plugin sig/catalog edit) or a plugin is opaque: the cached-served
|
|
310
|
+
# files the recheck merged may be stale, so re-analyze the whole tree. The current fact-surface
|
|
311
|
+
# digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
|
|
312
|
+
@fact_surface_invalidated = true
|
|
313
|
+
diagnostics = baseline
|
|
314
|
+
warm = false
|
|
315
|
+
skip_save = false
|
|
316
|
+
end
|
|
317
|
+
else
|
|
318
|
+
diagnostics = baseline
|
|
319
|
+
adopt_plugin_fact_fingerprint
|
|
320
|
+
warm = false
|
|
321
|
+
skip_save = false
|
|
322
|
+
end
|
|
323
|
+
snapshot.save(fingerprint: fingerprint, payload: to_payload) if fingerprint && !skip_save
|
|
324
|
+
[diagnostics, warm]
|
|
150
325
|
end
|
|
151
|
-
|
|
152
|
-
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# ADR-88 WD1 — reporting hooks the CLI reads after {#run_incremental}. `fact_surface_invalidated?` is true
|
|
329
|
+
# when a valid snapshot was dropped for a fact-surface reason (a plugin sig/catalog edit, or an opaque
|
|
330
|
+
# plugin) rather than a cold miss. `opaque_plugin_ids` names the contributing-but-surfaceless plugins that
|
|
331
|
+
# force a full run every invocation.
|
|
332
|
+
attr_reader :opaque_plugin_ids
|
|
333
|
+
|
|
334
|
+
def fact_surface_invalidated?
|
|
335
|
+
@fact_surface_invalidated
|
|
153
336
|
end
|
|
154
337
|
|
|
155
338
|
private
|
|
156
339
|
|
|
340
|
+
# ADR-88 WD1 — capture this invocation's fact-surface fingerprint (from the last analysis runner) onto the
|
|
341
|
+
# reporting ivars + the `@plugin_fact_reusable` decision object.
|
|
342
|
+
def adopt_plugin_fact_fingerprint
|
|
343
|
+
fact = compute_plugin_fact_fingerprint
|
|
344
|
+
@plugin_fact_digest = fact.digest
|
|
345
|
+
@opaque_plugin_ids = fact.opaque_plugin_ids
|
|
346
|
+
@plugin_fact_reusable = fact
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
|
|
350
|
+
# post-hoc from the analysis runner's already-prepared registry (no second `#prepare`, producers already
|
|
351
|
+
# validated during analysis). A POOLED run's main process skips `#prepare`, so its registry carries no
|
|
352
|
+
# published facts — there it falls back to the always-sequential probe. Both paths compute the identical
|
|
353
|
+
# digest for a given fact surface, so the reuse decision is worker-count-independent (the parity spec
|
|
354
|
+
# asserts this).
|
|
355
|
+
def compute_plugin_fact_fingerprint
|
|
356
|
+
registry = @last_runner&.plugin_registry
|
|
357
|
+
if @workers.zero? && registry && !registry.empty?
|
|
358
|
+
PluginFactFingerprint.from_registry(registry)
|
|
359
|
+
else
|
|
360
|
+
PluginFactFingerprint.compute(
|
|
361
|
+
configuration: @configuration, cache_store: @cache_store, plugin_requirer: @plugin_requirer
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
157
366
|
# Adopt a persisted snapshot's per-file state as this session's baseline (the warm-start path).
|
|
158
367
|
def restore(payload)
|
|
159
368
|
@analyzed = payload.analyzed
|
|
160
369
|
@cache = payload.cache
|
|
161
370
|
@sources = payload.sources
|
|
162
371
|
@digests = payload.digests
|
|
372
|
+
# ADR-85 WD2 — restore the per-file discovery bundles if present (absent in a pre-#85 snapshot → empty,
|
|
373
|
+
# so the recheck's discovery re-walks every file: a cold-quality index, always sound). The SCHEMA bump
|
|
374
|
+
# makes a genuinely stale-shaped bundle unreadable rather than mis-folded.
|
|
375
|
+
@seed_bundles = payload.seed_bundles || {}
|
|
163
376
|
@dependents = Incremental.invert(@sources)
|
|
164
377
|
# ADR-46 slice 4 — restore symbol-granularity state if present in the payload (absent in snapshots
|
|
165
378
|
# written before slice 4 → fall back to file-level dependents, which is always sound).
|
|
@@ -172,6 +385,7 @@ module Rigor
|
|
|
172
385
|
# `--verify-incremental` backstops any residual under-capture, so it is never unsound).
|
|
173
386
|
@missing = payload.missing || {}
|
|
174
387
|
@class_decls = payload.class_decls || {}
|
|
388
|
+
@return_summaries = payload.return_summaries || {}
|
|
175
389
|
@symbol_dependents = Incremental.invert_symbols(@symbol_sources)
|
|
176
390
|
@ancestry_dependents = Incremental.invert(@ancestry_sources)
|
|
177
391
|
@negative_dependents = Incremental.invert(@missing)
|
|
@@ -182,22 +396,59 @@ module Rigor
|
|
|
182
396
|
cache: @cache, sources: @sources, digests: @digests, analyzed: @analyzed,
|
|
183
397
|
symbol_sources: @symbol_sources, ancestry_sources: @ancestry_sources,
|
|
184
398
|
symbol_fingerprints: @symbol_fingerprints, missing: @missing,
|
|
185
|
-
class_decls: @class_decls
|
|
399
|
+
class_decls: @class_decls, seed_bundles: @seed_bundles,
|
|
400
|
+
plugin_fact_digest: @plugin_fact_digest,
|
|
401
|
+
return_summaries: marshal_safe_return_summaries
|
|
186
402
|
)
|
|
187
403
|
end
|
|
188
404
|
|
|
405
|
+
# ADR-89 WD2 — the return summaries filtered to Marshal-clean entries. A summary's `keys` hold live
|
|
406
|
+
# `Type` objects; the common carriers (Nominal, Constant, Union, shapes, Dynamic) Marshal, but a type
|
|
407
|
+
# holding a live AST node would raise and abort the WHOLE snapshot save (a cache must never break a
|
|
408
|
+
# run). Drop any entry that does not round-trip, so the snapshot always writes; a dropped entry just
|
|
409
|
+
# means that callee's dependents always re-check (the conservative direction).
|
|
410
|
+
def marshal_safe_return_summaries
|
|
411
|
+
@return_summaries.each_with_object({}) do |(key, summary), safe|
|
|
412
|
+
Marshal.dump(summary)
|
|
413
|
+
safe[key] = summary
|
|
414
|
+
rescue StandardError
|
|
415
|
+
next
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
189
419
|
# Fold a #recheck's fresh results back into the cache + graph so the session is correct across
|
|
190
420
|
# multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
|
|
191
421
|
# are evicted from every map, and the analyzed-file list advances to `current`.
|
|
192
422
|
def absorb(runner, fresh, current, analyze_set, removed)
|
|
193
423
|
removed.each { |path| forget(path) }
|
|
194
424
|
@analyzed = current
|
|
425
|
+
# ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
|
|
426
|
+
# re-walked, removed files dropped, added files built), so adopt the runner's current set wholesale.
|
|
427
|
+
@seed_bundles = runner.seed_bundles
|
|
195
428
|
fresh_by_file = per_file(fresh)
|
|
196
429
|
analyze_set.each do |path|
|
|
197
430
|
@cache[path] = fresh_by_file[path] || []
|
|
198
|
-
@digests[path] =
|
|
431
|
+
@digests[path] = pack_digest(path)
|
|
199
432
|
end
|
|
200
433
|
absorb_dependency_graph(runner)
|
|
434
|
+
refresh_return_summaries(runner, analyze_set)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
|
|
438
|
+
# the analyzed files' old summaries first, then fold the fresh ones. Dropping first is the soundness
|
|
439
|
+
# invariant — a summary for a file is valid only if it came from a run that analyzed that file. On the
|
|
440
|
+
# sequential path (the default + `--verify-incremental`) the harvest is complete, so a re-analyzed def's
|
|
441
|
+
# summary is simply refreshed. On a pooled recheck the callee bodies are evaluated in worker processes,
|
|
442
|
+
# so the main-process memo harvest is sparse; dropping first means a re-analyzed file with no fresh
|
|
443
|
+
# summary loses its stale one (WD2 then conservatively re-checks its dependents) rather than comparing a
|
|
444
|
+
# later edit against a pre-edit summary. Files NOT re-analyzed keep their (unchanged, valid) summaries.
|
|
445
|
+
# A def whose symbol dependents WD2 skipped is observed under fewer keys, so its refreshed summary may
|
|
446
|
+
# shrink — sound (fewer keys = more conservative) and self-correcting (an empty summary stops the gate
|
|
447
|
+
# firing, so the callers re-analyse and repopulate).
|
|
448
|
+
def refresh_return_summaries(runner, analyze_set)
|
|
449
|
+
analyzed = analyze_set.to_set
|
|
450
|
+
@return_summaries.reject! { |(path, _symbol), _| analyzed.include?(path) }
|
|
451
|
+
@return_summaries.merge!(runner.return_summaries)
|
|
201
452
|
end
|
|
202
453
|
|
|
203
454
|
# Evict a removed file from every per-file map so its stale diagnostics are never served and it drops
|
|
@@ -210,6 +461,8 @@ module Rigor
|
|
|
210
461
|
@ancestry_sources.delete(path)
|
|
211
462
|
@missing.delete(path)
|
|
212
463
|
@symbol_fingerprints.delete(path)
|
|
464
|
+
# ADR-89 WD2 — drop every behavioural summary a removed file provided (keys are `[path, symbol]`).
|
|
465
|
+
@return_summaries.reject! { |(summary_path, _symbol), _| summary_path == path }
|
|
213
466
|
# @class_decls is wholesale-replaced from the (removed-excluding)
|
|
214
467
|
# pre-pass in absorb_dependency_graph, and is frozen, so no delete.
|
|
215
468
|
end
|
|
@@ -233,28 +486,36 @@ module Rigor
|
|
|
233
486
|
@class_decls = runner.class_declarations
|
|
234
487
|
end
|
|
235
488
|
|
|
236
|
-
#
|
|
237
|
-
# extraction, no type inference). Returns
|
|
238
|
-
#
|
|
239
|
-
# only their callers are added to the affected closure.
|
|
240
|
-
def
|
|
241
|
-
return {} if
|
|
489
|
+
# Per-symbol body fingerprints from the pre-parsed `index` (the shared scan-summary def-index — Prism
|
|
490
|
+
# parse + def extraction, no type inference). Returns `{ path => { "ClassName#method" => sha256_hex } }`
|
|
491
|
+
# with singleton methods under `"Class.method"` keys. Used by {#recheck} to detect which symbols in a
|
|
492
|
+
# changed file actually changed, so only their callers are added to the affected closure.
|
|
493
|
+
def symbol_fingerprints_from_index(index)
|
|
494
|
+
return {} if index.nil?
|
|
242
495
|
|
|
243
|
-
index = Inference::ScopeIndexer.discovered_def_index_for_paths(paths)
|
|
244
|
-
def_nodes = index[:def_nodes]
|
|
245
|
-
def_sources = index[:def_sources]
|
|
246
496
|
result = Hash.new { |h, k| h[k] = {} }
|
|
247
|
-
def_sources
|
|
497
|
+
fold_symbol_fingerprints(result, index[:def_sources], index[:def_nodes], "#")
|
|
498
|
+
# ADR-46 slice 4 (singleton) — mirror the instance fold over the singleton tables so a `def self.x`
|
|
499
|
+
# body edit yields a changed `"Class.method"` pair, matching the `Runner#symbol_fingerprints` key that
|
|
500
|
+
# the recorded singleton symbol edges (`singleton_def_for`) invert against.
|
|
501
|
+
fold_symbol_fingerprints(result, index[:singleton_def_sources], index[:singleton_def_nodes], ".")
|
|
502
|
+
result.transform_values(&:freeze).freeze
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Folds one `(sources, nodes)` table pair from the re-parse index into `result` under `separator`
|
|
506
|
+
# (`#` instance / `.` singleton). Nodes here are always LIVE (a fresh parse of the changed paths), so
|
|
507
|
+
# the fingerprint is the def's source slice; `sources` supplies the `"path:line"` a `Prism::Location`
|
|
508
|
+
# hides.
|
|
509
|
+
def fold_symbol_fingerprints(result, sources, nodes, separator)
|
|
510
|
+
sources.each do |class_name, methods|
|
|
248
511
|
methods.each do |method_sym, path_line|
|
|
249
512
|
path = path_line.split(":", 2).first
|
|
250
|
-
node =
|
|
513
|
+
node = nodes.dig(class_name, method_sym)
|
|
251
514
|
next unless node
|
|
252
515
|
|
|
253
|
-
result[path]["#{class_name}
|
|
254
|
-
Digest::SHA256.hexdigest(node.location.slice)
|
|
516
|
+
result[path]["#{class_name}#{separator}#{method_sym}"] = Digest::SHA256.hexdigest(node.location.slice)
|
|
255
517
|
end
|
|
256
518
|
end
|
|
257
|
-
result.transform_values(&:freeze).freeze
|
|
258
519
|
end
|
|
259
520
|
|
|
260
521
|
# ADR-46 slice 3 — the consumers to re-check because a symbol that appeared in a changed file resolves
|
|
@@ -269,13 +530,11 @@ module Rigor
|
|
|
269
530
|
Incremental.negative_closure(keys, @negative_dependents)
|
|
270
531
|
end
|
|
271
532
|
|
|
272
|
-
# The qualified class/module names declared in
|
|
273
|
-
# {#
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return {} if paths.empty?
|
|
533
|
+
# The qualified class/module names declared in the pre-parsed `index` (shared with
|
|
534
|
+
# {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
|
|
535
|
+
def class_declarations_from_index(index)
|
|
536
|
+
return {} if index.nil?
|
|
277
537
|
|
|
278
|
-
index = Inference::ScopeIndexer.discovered_def_index_for_paths(paths)
|
|
279
538
|
result = Hash.new { |hash, key| hash[key] = Set.new }
|
|
280
539
|
index[:class_sources].each do |class_name, files|
|
|
281
540
|
files.each { |file| result[file] << class_name }
|
|
@@ -283,16 +542,135 @@ module Rigor
|
|
|
283
542
|
result.transform_values(&:freeze).freeze
|
|
284
543
|
end
|
|
285
544
|
|
|
545
|
+
# ADR-89 WD2 — the (live, freshly parsed) def node for a `"Class#method"` / `"Class.method"` symbol in
|
|
546
|
+
# the changed-set scan index, or nil. Instance methods live under `def_nodes`, singleton under
|
|
547
|
+
# `singleton_def_nodes`, both keyed by class name then method Symbol.
|
|
548
|
+
def scan_def_node(symbol, scan_index)
|
|
549
|
+
singleton = !symbol.include?("#")
|
|
550
|
+
class_name, method_name = symbol.split(singleton ? "." : "#", 2)
|
|
551
|
+
return nil if method_name.nil?
|
|
552
|
+
|
|
553
|
+
table = singleton ? scan_index[:singleton_def_nodes] : scan_index[:def_nodes]
|
|
554
|
+
table.dig(class_name, method_name.to_sym)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# ADR-89 WD2 — true when the ONLY cross-file body surfaces of `node` a symbol dependent can consume are
|
|
558
|
+
# its return type and its content-mutation effects — so comparing exactly those two is a COMPLETE
|
|
559
|
+
# behavioural check. A def qualifies when its body writes no instance / class variable (an ivar
|
|
560
|
+
# definite-assignment a same-class caller consumes), does not `yield` (a block-parameter surface a
|
|
561
|
+
# caller passing a block consumes), and makes no implicit-self call (which could carry a transitive
|
|
562
|
+
# shared-state write). Deliberately conservative + purely syntactic: a def with a self-call or a
|
|
563
|
+
# shared-state write keeps its dependents (the general all-surfaces gate is deferred). Excludes nested
|
|
564
|
+
# `def`s (a different self) but walks blocks / lambdas (same self).
|
|
565
|
+
def gate_eligible_def?(node)
|
|
566
|
+
return false if node.nil? || !node.respond_to?(:body) || node.body.nil?
|
|
567
|
+
|
|
568
|
+
!body_has_ineligible_node?(node.body)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def body_has_ineligible_node?(node)
|
|
572
|
+
return false if node.nil? || node.is_a?(Prism::DefNode)
|
|
573
|
+
|
|
574
|
+
return true if INELIGIBLE_GATE_NODES.any? { |kind| node.is_a?(kind) }
|
|
575
|
+
return true if self_dispatch_call?(node)
|
|
576
|
+
|
|
577
|
+
node.rigor_each_child { |child| return true if body_has_ineligible_node?(child) }
|
|
578
|
+
false
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def self_dispatch_call?(node)
|
|
582
|
+
node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# ADR-89 WD2 — node kinds whose presence in a def body means a caller may consume a body surface beyond
|
|
586
|
+
# return + content-mutation: any instance/class-variable write (ivar definite-assignment) and `yield`
|
|
587
|
+
# (block-parameter typing). Class variables are per-file (never seeded cross-file) but are included for
|
|
588
|
+
# a conservative margin; global writes are likewise per-file so are not listed.
|
|
589
|
+
INELIGIBLE_GATE_NODES = [
|
|
590
|
+
Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
|
|
591
|
+
Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
|
|
592
|
+
Prism::ClassVariableWriteNode, Prism::ClassVariableOrWriteNode,
|
|
593
|
+
Prism::ClassVariableAndWriteNode, Prism::ClassVariableOperatorWriteNode,
|
|
594
|
+
Prism::YieldNode
|
|
595
|
+
].freeze
|
|
596
|
+
private_constant :INELIGIBLE_GATE_NODES
|
|
597
|
+
|
|
598
|
+
# ADR-89 WD2 — true when the current content-mutation effect set of `pair`'s (edited) def equals the one
|
|
599
|
+
# persisted in its return summary. Computed session-side from the fresh node (pure AST — no scope), so a
|
|
600
|
+
# caller's arg flooring is proven unchanged before the return re-evaluation. A nil / raised computation
|
|
601
|
+
# reads as CHANGED (keeps the pair).
|
|
602
|
+
def effects_unchanged?(pair, scan_index)
|
|
603
|
+
node = scan_def_node(pair.last, scan_index)
|
|
604
|
+
return false if node.nil?
|
|
605
|
+
|
|
606
|
+
current = content_effects_for(node)
|
|
607
|
+
!current.nil? && current == (@return_summaries[pair][:effects] || [])
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def content_effects_for(node)
|
|
611
|
+
(@effects_evaluator ||= Inference::StatementEvaluator.new(scope: Scope.empty))
|
|
612
|
+
.content_mutated_parameter_positions(node)
|
|
613
|
+
rescue StandardError
|
|
614
|
+
nil
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# ADR-89 WD2 — the subset of `pairs` whose (edited) def re-evaluates to the SAME return descriptor at
|
|
618
|
+
# every persisted key. Runs one runner probe (`evaluate_return_types` — builds the discovery + env once,
|
|
619
|
+
# no file analysis) over all candidate defs, then keeps a pair only when every key's re-evaluated
|
|
620
|
+
# descriptor is present (not a memo refusal) and equal to the persisted one. A probe failure yields an
|
|
621
|
+
# empty set (keep everything).
|
|
622
|
+
def return_stable_pairs(pairs, scan_index)
|
|
623
|
+
specs = pairs.filter_map { |pair| return_spec_for(pair, scan_index) }
|
|
624
|
+
return Set.new if specs.empty?
|
|
625
|
+
|
|
626
|
+
results = probe_return_types(specs)
|
|
627
|
+
specs.each_with_object(Set.new) do |spec, stable|
|
|
628
|
+
descriptors = results[[spec[:class_name], spec[:method_name], spec[:singleton]]]
|
|
629
|
+
expected = @return_summaries[spec[:pair]][:returns]
|
|
630
|
+
next if descriptors.nil? || descriptors.length != expected.length
|
|
631
|
+
next unless descriptors.each_with_index.all? { |d, i| !d.nil? && d == expected[i] }
|
|
632
|
+
|
|
633
|
+
stable << spec[:pair]
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def return_spec_for(pair, scan_index)
|
|
638
|
+
path, symbol = pair
|
|
639
|
+
node = scan_def_node(symbol, scan_index)
|
|
640
|
+
return nil if node.nil?
|
|
641
|
+
|
|
642
|
+
singleton = !symbol.include?("#")
|
|
643
|
+
class_name, method_name = symbol.split(singleton ? "." : "#", 2)
|
|
644
|
+
{ pair: pair, path: path, class_name: class_name, method_name: method_name.to_sym,
|
|
645
|
+
singleton: singleton, keys: @return_summaries[pair][:keys] }
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def probe_return_types(specs)
|
|
649
|
+
build_runner.evaluate_return_types(@paths, specs)
|
|
650
|
+
rescue StandardError
|
|
651
|
+
{}
|
|
652
|
+
end
|
|
653
|
+
|
|
286
654
|
TOP_LEVEL_KEY = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
|
|
287
655
|
private_constant :TOP_LEVEL_KEY
|
|
288
656
|
|
|
657
|
+
# B1 — plugin require-names that ingest COMMENT content as semantic input (inline-RBS reads `# @rbs` /
|
|
658
|
+
# `#:` annotations). B1's code fingerprint ignores comments, so a project configuring one of these opts
|
|
659
|
+
# OUT of the bundle-equality skip (a comment edit could change a cross-file type it contributes).
|
|
660
|
+
COMMENT_INGESTING_PLUGIN_IDS = %w[rigor-rbs-inline].freeze
|
|
661
|
+
private_constant :COMMENT_INGESTING_PLUGIN_IDS
|
|
662
|
+
|
|
289
663
|
def negative_key_for(symbol)
|
|
290
664
|
class_name, method = symbol.split("#", 2)
|
|
291
665
|
class_name == TOP_LEVEL_KEY ? "toplevel:#{method}" : "method:#{symbol}"
|
|
292
666
|
end
|
|
293
667
|
|
|
294
668
|
def build_runner(**)
|
|
295
|
-
Runner.new(
|
|
669
|
+
Runner.new(
|
|
670
|
+
configuration: @configuration, cache_store: @cache_store, environment: @environment,
|
|
671
|
+
plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
|
|
672
|
+
workers: @workers, **
|
|
673
|
+
)
|
|
296
674
|
end
|
|
297
675
|
|
|
298
676
|
# Run the runner over the session's explicit paths (or, when none were given, the configuration's
|
|
@@ -308,8 +686,35 @@ module Rigor
|
|
|
308
686
|
diagnostics.group_by(&:path).slice(*@analyzed)
|
|
309
687
|
end
|
|
310
688
|
|
|
311
|
-
|
|
312
|
-
|
|
689
|
+
# ADR-87 WD1 — change detection over the candidate paths (files present in both the prior and current
|
|
690
|
+
# analyzed set). A candidate is UNCHANGED when its recorded stat entry ({#pack_digest}) validates via
|
|
691
|
+
# {Cache::FileDigest.stat_fresh?}: the file is stat-ed first and re-hashed ONLY when its
|
|
692
|
+
# `(size, mtime, ctime, inode)` tuple moved or the entry is racy — so an unchanged file hashes zero
|
|
693
|
+
# content bytes (the recon anomaly this closes: the old path SHA-256'd every file every recheck). A
|
|
694
|
+
# touch (moved stat, identical content) re-hashes once and validates fresh. Returns the changed subset.
|
|
695
|
+
def changed_paths(candidates)
|
|
696
|
+
candidates.reject { |path| stat_fresh?(path) }
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# True when `path`'s recorded stat entry proves it unchanged since the last analysis. Any stat / parse
|
|
700
|
+
# failure (missing entry, unreadable / vanished file) reads as NOT fresh (→ re-analyse), preserving the
|
|
701
|
+
# prior `digest(path) != recorded` "changed" semantics for a file that cannot be validated.
|
|
702
|
+
def stat_fresh?(path)
|
|
703
|
+
entry = @digests[path]
|
|
704
|
+
return false if entry.nil?
|
|
705
|
+
|
|
706
|
+
Cache::FileDigest.stat_fresh?(path, entry)
|
|
707
|
+
rescue StandardError
|
|
708
|
+
false
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# ADR-87 WD1 — the recorded freshness entry for `path`: its SHA-256 content digest packed with the stat
|
|
712
|
+
# tuple `(size, mtime_ns, ctime_ns, inode)` and the run's recording instant, so the NEXT recheck can
|
|
713
|
+
# validate it by a single `File::Stat` when the tuple has not moved. The SHA-256 digest stays the sole
|
|
714
|
+
# change authority — the stat tuple only decides whether it must be recomputed. "missing" when the file
|
|
715
|
+
# cannot be read / stat-ed (its prior sentinel, so a vanished file still compares unequal and re-analyses).
|
|
716
|
+
def pack_digest(path)
|
|
717
|
+
Cache::FileDigest.pack_stat(path, Cache::FileDigest.hexdigest(path)) || "missing"
|
|
313
718
|
rescue StandardError
|
|
314
719
|
"missing"
|
|
315
720
|
end
|