rigortype 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/data/core_overlay/csv.rbs +28 -0
- data/data/core_overlay/psych.rbs +22 -0
- data/docs/handbook/01-getting-started.md +9 -1
- data/docs/handbook/02-everyday-types.md +4 -1
- data/docs/handbook/03-narrowing.md +2 -1
- data/docs/handbook/04-tuples-and-shapes.md +7 -3
- data/docs/handbook/06-classes.md +18 -9
- data/docs/handbook/08-understanding-errors.md +4 -3
- data/docs/handbook/11-sig-gen.md +11 -1
- data/docs/handbook/appendix-mypy.md +4 -3
- data/docs/install.md +14 -6
- data/docs/manual/01-installation.md +99 -4
- data/docs/manual/02-cli-reference.md +65 -19
- data/docs/manual/03-configuration.md +34 -2
- data/docs/manual/04-diagnostics.md +23 -0
- data/docs/manual/07-plugins.md +5 -5
- data/docs/manual/09-editor-integration.md +4 -4
- data/docs/manual/11-ci.md +51 -0
- data/docs/manual/12-caching.md +65 -0
- data/docs/manual/14-rails-quickstart.md +4 -2
- data/docs/manual/15-type-protection-coverage.md +21 -0
- data/docs/manual/plugins/rigor-actionpack.md +1 -1
- data/docs/manual/plugins/rigor-activerecord.md +12 -5
- data/docs/manual/plugins/rigor-rails-routes.md +11 -0
- 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 +93 -0
- data/lib/rigor/analysis/check_rules.rb +557 -95
- 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 +68 -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 +343 -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_descriptor.rb +56 -10
- data/lib/rigor/cache/store.rb +207 -60
- 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 +212 -27
- data/lib/rigor/cli/coverage_command.rb +56 -30
- data/lib/rigor/cli/diagnostic_formats.rb +4 -1
- data/lib/rigor/cli/docs_command.rb +0 -30
- data/lib/rigor/cli/doctor_command.rb +163 -23
- 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/protection_fork_scan.rb +55 -0
- data/lib/rigor/cli/protection_report.rb +7 -1
- data/lib/rigor/cli/sig_gen_command.rb +36 -9
- data/lib/rigor/cli/skill_command.rb +1 -32
- 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 +35 -0
- data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
- data/lib/rigor/environment/rbs_loader.rb +164 -5
- data/lib/rigor/environment.rb +71 -43
- 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 +379 -113
- data/lib/rigor/inference/fork_map.rb +87 -0
- 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 +11 -0
- 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 +245 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
- 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 +460 -49
- data/lib/rigor/inference/statement_evaluator.rb +111 -17
- 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/completion_provider.rb +2 -1
- 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/selection_range_provider.rb +2 -1
- data/lib/rigor/language_server/signature_help_provider.rb +2 -1
- data/lib/rigor/plugin/base.rb +9 -22
- 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 +24 -2
- 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-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
- 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-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
- 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 +191 -0
- 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/helper_table.rb +19 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
- 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/environment.rbs +1 -0
- data/sig/rigor/inference.rbs +7 -0
- data/sig/rigor/plugin/base.rbs +2 -3
- 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 +49 -24
- data/lib/rigor/cli/ci_detector.rb +0 -89
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../type"
|
|
6
|
+
require_relative "../reflection"
|
|
6
7
|
require_relative "../ast"
|
|
7
8
|
require_relative "../source/constant_path"
|
|
9
|
+
require_relative "../source/node_children"
|
|
8
10
|
require_relative "../analysis/self_call_resolution_recorder"
|
|
9
11
|
require_relative "block_parameter_binder"
|
|
10
12
|
require_relative "body_fixpoint"
|
|
@@ -209,6 +211,11 @@ module Rigor
|
|
|
209
211
|
}.freeze
|
|
210
212
|
private_constant :PRISM_DISPATCH
|
|
211
213
|
|
|
214
|
+
# Sentinel distinguishing "the key is not a value-pinned literal" from the legitimate literal hash
|
|
215
|
+
# keys `nil` and `false` (see {#static_hash_key}).
|
|
216
|
+
NO_STATIC_HASH_KEY = Object.new.freeze
|
|
217
|
+
private_constant :NO_STATIC_HASH_KEY
|
|
218
|
+
|
|
212
219
|
def initialize(scope:, tracer: nil)
|
|
213
220
|
@scope = scope
|
|
214
221
|
@tracer = tracer
|
|
@@ -244,6 +251,29 @@ module Rigor
|
|
|
244
251
|
fallback_for(node, family: :prism)
|
|
245
252
|
end
|
|
246
253
|
|
|
254
|
+
# ADR-89 WD2 — the return type of `def_node` called with `receiver` / `arg_types`, computed exactly as a
|
|
255
|
+
# resolved in-body dispatch does (through the ADR-84 memo, so it is cheap and yields FINAL values only).
|
|
256
|
+
# Public entry point for the incremental session's return-summary re-evaluation: it re-drives a
|
|
257
|
+
# declaration-stable changed callee at each previously-observed call key to prove its return is
|
|
258
|
+
# unchanged before skipping the callee's symbol dependents. nil for an abstract / bodyless def, or when
|
|
259
|
+
# the memo refuses a transient result — the caller treats either as "not provably stable" (keeps the
|
|
260
|
+
# dependents), the conservative direction.
|
|
261
|
+
def return_type_for(def_node, receiver, arg_types)
|
|
262
|
+
infer_user_method_return(def_node, receiver, arg_types)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
|
|
266
|
+
# that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
|
|
267
|
+
# recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
|
|
268
|
+
# empty hash when no bucket exists (a run that memoised nothing). Class method: the bucket lives on
|
|
269
|
+
# `Thread.current`, independent of any one `ExpressionTyper` instance.
|
|
270
|
+
def self.harvest_return_memo
|
|
271
|
+
slot = Thread.current[RETURN_MEMO_KEY]
|
|
272
|
+
return {} if slot.nil?
|
|
273
|
+
|
|
274
|
+
slot[1].transform_values(&:values)
|
|
275
|
+
end
|
|
276
|
+
|
|
247
277
|
private
|
|
248
278
|
|
|
249
279
|
attr_reader :scope, :tracer
|
|
@@ -385,75 +415,49 @@ module Rigor
|
|
|
385
415
|
# `Inference::FallbackTracer` from inside `Rigor::CLI::Foo` resolves to
|
|
386
416
|
# `Rigor::Inference::FallbackTracer`.
|
|
387
417
|
def type_of_constant_read(node)
|
|
388
|
-
resolve_constant_name(node.name.to_s) ||
|
|
418
|
+
resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
|
|
389
419
|
end
|
|
390
420
|
|
|
391
421
|
def type_of_constant_path(node)
|
|
392
422
|
full_name = Source::ConstantPath.qualified_name_or_nil(node)
|
|
393
423
|
return fallback_for(node, family: :prism) if full_name.nil?
|
|
394
424
|
|
|
395
|
-
resolve_constant_name(full_name) ||
|
|
425
|
+
resolve_constant_name(full_name) || unresolved_constant_fallback(node, full_name)
|
|
396
426
|
end
|
|
397
427
|
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
402
|
-
# the
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return singleton if singleton
|
|
410
|
-
|
|
411
|
-
in_source_class = discovered[candidate]
|
|
412
|
-
return in_source_class if in_source_class
|
|
413
|
-
|
|
414
|
-
# In-source value-bearing constants take precedence over RBS constant decls because user code is
|
|
415
|
-
# the authoritative source for its own constants.
|
|
416
|
-
in_source_value = in_source[candidate]
|
|
417
|
-
return in_source_value if in_source_value
|
|
418
|
-
|
|
419
|
-
value = env.constant_for_name(candidate)
|
|
420
|
-
return value if value
|
|
421
|
-
end
|
|
422
|
-
nil
|
|
423
|
-
end
|
|
428
|
+
# ADR-82 WD9 — an unresolved constant whose root name a locked, RBS-less gem declares carries the
|
|
429
|
+
# `external_gem_without_rbs` cause instead of the generic `unsupported_syntax`. The constant read is
|
|
430
|
+
# where the class name is last visible (a no-RBS gem's receiver never types Nominal, so the dispatch
|
|
431
|
+
# tiers that record this cause under ADR-10 / `pre_eval:` opt-ins can't see it); WD6 chain inheritance
|
|
432
|
+
# then carries the cause through `Faraday.new.get(...)`. Side-channel only — the type stays the same
|
|
433
|
+
# `Dynamic[top]`, and an unindexed constant (project typo, unanalyzed project path) keeps the generic
|
|
434
|
+
# cause: the fail-open direction is a missing label, never a wrong one.
|
|
435
|
+
def unresolved_constant_fallback(node, full_name)
|
|
436
|
+
root = full_name.delete_prefix("::").split("::").first
|
|
437
|
+
owner = root && scope.environment.missing_rbs_gem_owner(root)
|
|
438
|
+
return fallback_for(node, family: :prism) unless owner
|
|
424
439
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
prefix = enclosing_class_path
|
|
430
|
-
candidates = []
|
|
431
|
-
while prefix && !prefix.empty?
|
|
432
|
-
candidates << "#{prefix}::#{name}"
|
|
433
|
-
# Strip the last `::` segment without `rpartition`'s throwaway 3-element array + extra substrings
|
|
434
|
-
# (this loop is the sole caller of the `String#rpartition` allocation seen in the profile): `rindex`
|
|
435
|
-
# + slice gives the same prefix, or nil.
|
|
436
|
-
idx = prefix.rindex("::")
|
|
437
|
-
prefix = idx ? prefix[0, idx] : nil
|
|
438
|
-
end
|
|
439
|
-
candidates << name
|
|
440
|
-
candidates
|
|
440
|
+
inner = dynamic_top
|
|
441
|
+
record_fallback(node, family: :prism, inner_type: inner, origin: DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
|
|
442
|
+
scope.record_dynamic_origin(node, DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS)
|
|
443
|
+
inner
|
|
441
444
|
end
|
|
442
445
|
|
|
443
|
-
#
|
|
444
|
-
# `
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
446
|
+
# Resolves a constant reference through Ruby's lexical constant lookup. Delegates to the shared
|
|
447
|
+
# `Reflection.resolve_constant_type` owner so the same walk (registry singleton, discovered class,
|
|
448
|
+
# in-source value, RBS constant, across the peeled `::` prefix candidates) is reused by
|
|
449
|
+
# `Inference::Narrowing`'s `Constant[Regexp]` match-operand recognition. Returns the matched
|
|
450
|
+
# `Rigor::Type` or nil; the caller decides whether to fall back.
|
|
451
|
+
def resolve_constant_name(name)
|
|
452
|
+
Reflection.resolve_constant_type(name, scope: scope)
|
|
450
453
|
end
|
|
451
454
|
|
|
452
455
|
# Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
|
|
453
|
-
# whose key is a
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
456
|
+
# whose key is a value-pinned scalar literal — Symbol, plain String, Integer, Float, `true`, `false`,
|
|
457
|
+
# or `nil` (covering `{ a: 1, "b" => 2 }` and `{ 1 => 2, 1.0 => 4 }` alike) — falling back to the
|
|
458
|
+
# generic `Hash[K, V]` form otherwise. Splatted entries (`{ **other }`) and dynamic keys widen to the
|
|
459
|
+
# underlying `Hash[K, V]` form by unioning the types each entry exposes; when no concrete pair
|
|
460
|
+
# survives we fall back to the raw `Hash` so callers stay backward compatible.
|
|
457
461
|
def type_of_hash(node)
|
|
458
462
|
elements = node.respond_to?(:elements) ? node.elements : []
|
|
459
463
|
# v0.0.7 — `{}` resolves to the empty `HashShape{}` carrier rather than `Nominal[Hash]`, mirroring the
|
|
@@ -473,16 +477,22 @@ module Rigor
|
|
|
473
477
|
)
|
|
474
478
|
end
|
|
475
479
|
|
|
476
|
-
# Builds `HashShape{...}` when every entry is an `AssocNode` whose key is a
|
|
477
|
-
# literal
|
|
480
|
+
# Builds `HashShape{...}` when every entry is an `AssocNode` whose key is a value-pinned scalar
|
|
481
|
+
# literal (Symbol, plain String, Integer, Float, true, false, nil). Returns nil otherwise so the
|
|
482
|
+
# caller falls back to the generic shape.
|
|
483
|
+
#
|
|
484
|
+
# Duplicate keys are LAST-WINS, matching the runtime (`{ a: 1, a: 2 }` keeps `a: 2`; Ruby itself
|
|
485
|
+
# only warns under `-w`). Key identity is Ruby `Hash` `eql?` identity because `pairs` is a native
|
|
486
|
+
# Hash — `1` and `1.0` stay distinct entries, `1.0` and `1.00` collide. The plain re-assignment
|
|
487
|
+
# also reproduces the runtime's ordering: the key keeps its FIRST insertion position while the
|
|
488
|
+
# value comes from the LAST occurrence.
|
|
478
489
|
def static_hash_shape_for(elements)
|
|
479
490
|
pairs = {}
|
|
480
491
|
elements.each do |entry|
|
|
481
492
|
return nil unless entry.is_a?(Prism::AssocNode)
|
|
482
493
|
|
|
483
494
|
key = static_hash_key(entry.key)
|
|
484
|
-
return nil if key.
|
|
485
|
-
return nil if pairs.key?(key)
|
|
495
|
+
return nil if key.equal?(NO_STATIC_HASH_KEY)
|
|
486
496
|
|
|
487
497
|
pairs[key] = type_of(entry.value)
|
|
488
498
|
end
|
|
@@ -491,16 +501,22 @@ module Rigor
|
|
|
491
501
|
Type::Combinator.hash_shape_of(pairs)
|
|
492
502
|
end
|
|
493
503
|
|
|
494
|
-
# Returns the
|
|
495
|
-
#
|
|
496
|
-
#
|
|
504
|
+
# Returns the value-pinned scalar literal carried by a hash key node, or {NO_STATIC_HASH_KEY} when
|
|
505
|
+
# the key is dynamic (a computed expression, an interpolated string — SymbolNode#value /
|
|
506
|
+
# StringNode#unescaped are nil under interpolation — a constant, a local, …). `nil` / `false` are
|
|
507
|
+
# real key values here, hence the sentinel rather than a nil return.
|
|
497
508
|
def static_hash_key(node)
|
|
498
509
|
case node
|
|
499
510
|
when Prism::SymbolNode
|
|
500
511
|
raw = node.value
|
|
501
|
-
raw
|
|
512
|
+
raw.nil? ? NO_STATIC_HASH_KEY : raw.to_sym
|
|
502
513
|
when Prism::StringNode
|
|
503
|
-
node.unescaped
|
|
514
|
+
node.unescaped || NO_STATIC_HASH_KEY
|
|
515
|
+
when Prism::IntegerNode, Prism::FloatNode then node.value
|
|
516
|
+
when Prism::TrueNode then true
|
|
517
|
+
when Prism::FalseNode then false
|
|
518
|
+
when Prism::NilNode then nil
|
|
519
|
+
else NO_STATIC_HASH_KEY
|
|
504
520
|
end
|
|
505
521
|
end
|
|
506
522
|
|
|
@@ -1591,18 +1607,106 @@ module Rigor
|
|
|
1591
1607
|
# args change folds (`factorial(5)` vs `factorial(6)`), so a coarser key would serve a stale fold. The
|
|
1592
1608
|
# third unsafe dimension — the ADR-55 recursion machinery (unroll fuel / fixpoint Kleene
|
|
1593
1609
|
# assumption / WD1 clamp) producing a TRANSIENT result rather than a final return — is excluded
|
|
1594
|
-
#
|
|
1595
|
-
#
|
|
1596
|
-
#
|
|
1610
|
+
# post-hoc (ADR-84 WD3): candidacy only requires that the plain signature not already be on the
|
|
1611
|
+
# recursion guard stack (a frame inside its own cycle returns a Kleene iterate by construction), and
|
|
1612
|
+
# the store gate brackets the compute over the transient-event depth log
|
|
1613
|
+
# (TRANSIENT_EVENT_DEPTHS_KEY), refusing only when an event referenced a frame BELOW the bracket's
|
|
1614
|
+
# entry depth — proof the ancestor context (an in-flight ancestor summary, the shared unroll fuel, a
|
|
1615
|
+
# possibly-ancestor-caused clamp) influenced the result. Events at-or-above the entry depth are the
|
|
1616
|
+
# compute's own deterministic machinery — a recursive method's own converged fixpoint stores from a
|
|
1617
|
+
# top-of-stack entry (standalone by construction: fuel reseeds, summary tables cleared at the
|
|
1618
|
+
# previous drain) — and a nested compute whose bracket saw no below-entry event ran as if standalone
|
|
1619
|
+
# (fuel consumed without exhausting is invisible; every summary read fires a logged guard event). A
|
|
1620
|
+
# memo HIT while an unroll is merely in flight elsewhere on the stack is legal (stored values are
|
|
1621
|
+
# final and context-free). This replaced the blanket unroll-in-flight candidacy exclusion, which
|
|
1622
|
+
# refused 82% of mail's body evaluations for results that were overwhelmingly final (PR #79
|
|
1623
|
+
# counters; ADR-84).
|
|
1597
1624
|
#
|
|
1598
|
-
#
|
|
1599
|
-
#
|
|
1600
|
-
#
|
|
1601
|
-
#
|
|
1602
|
-
#
|
|
1625
|
+
# Bucket scope (ADR-84 WD2): ONE bucket per analysis run, keyed by the identity of the run-generation
|
|
1626
|
+
# token `Analysis::Runner#run_analysis` mints and seeds through `scope.discovery` — hits cross
|
|
1627
|
+
# consumer-file boundaries within a run, and a re-run in the same process (LSP re-check, ADR-62 warm
|
|
1628
|
+
# loop) rolls the bucket over. Scopes without a runner seed (single-file probes) fall back to the
|
|
1629
|
+
# per-file merged `discovered_def_nodes` identity — exactly the pre-WD2 per-file scope. Inside the
|
|
1630
|
+
# bucket, entries key on the `def_node`'s object identity (the identity Hash holds the node strongly,
|
|
1631
|
+
# so a collected parse can never recycle an object id into a stale hit) and then on the
|
|
1632
|
+
# `[receiver, *args]` descriptor tuple; a callee may appear under at most two node identities per run
|
|
1633
|
+
# (the project-index parse every OTHER file resolves through, plus the defining file's own analysis
|
|
1634
|
+
# parse). Only the current generation's bucket is retained (single slot) — a rolled-over run's entries
|
|
1635
|
+
# become garbage instead of accumulating across LSP runs. `ExpressionTyper` is rebuilt per
|
|
1636
|
+
# `Scope#type_of`, so the store lives on `Thread.current`; fork-pool workers are separate processes,
|
|
1637
|
+
# so it never crosses a project boundary (ADR-84 WD5).
|
|
1603
1638
|
RETURN_MEMO_KEY = :__rigor_user_method_return_memo__
|
|
1604
1639
|
private_constant :RETURN_MEMO_KEY
|
|
1605
1640
|
|
|
1641
|
+
# ADR-84 WD2 — one memo entry: the final inferred return plus, when the entry was computed under
|
|
1642
|
+
# ADR-46 dependency recording, the frozen `Analysis::DependencyRecorder::ReadSet` its body walk
|
|
1643
|
+
# produced (nil on non-recording runs — recording is decided per run and the bucket never outlives a
|
|
1644
|
+
# run, so a recording run only ever hits entries that carry a read-set; pinned by
|
|
1645
|
+
# return_memo_recording_spec's rollover example). ADR-89 WD2 additionally carries the CALL DESCRIPTOR
|
|
1646
|
+
# (`receiver` + `arg_types`) so the incremental session can harvest each observed call key and persist
|
|
1647
|
+
# it as a return-summary — a recheck re-evaluates the (declaration-stable) callee at those keys and,
|
|
1648
|
+
# when every return is unchanged, skips its symbol dependents. Carrying two frozen type refs is
|
|
1649
|
+
# negligible on the memo-store path (already in scope) and never enters memo equality (entries are
|
|
1650
|
+
# keyed by `memo_key`, retrieved by `.result` / `.read_set`).
|
|
1651
|
+
MemoEntry = Data.define(:result, :read_set, :receiver, :arg_types)
|
|
1652
|
+
private_constant :MemoEntry
|
|
1653
|
+
|
|
1654
|
+
# ADR-84 WD3 — thread-local transient-machinery event log, appended by `note_transient_fallback` at
|
|
1655
|
+
# EVERY site where the ADR-55 recursion machinery substitutes transient state for a plain body
|
|
1656
|
+
# evaluation. Each entry is the STACK POSITION (guard-stack index) of the frame whose in-flight state
|
|
1657
|
+
# the event referenced — the consulted owner for a guard hit, 0 for the whole-stack resources (shared
|
|
1658
|
+
# unroll fuel; conservatively the clamp), the owner's own position for its cap collapse.
|
|
1659
|
+
# `consult_and_store_return_memo` brackets a candidate's compute over the log and refuses to store
|
|
1660
|
+
# only when an event referenced a frame BELOW the bracket's entry depth: such an event proves the
|
|
1661
|
+
# ancestor context influenced the result, while events at-or-above the entry depth are the compute's
|
|
1662
|
+
# OWN deterministic machinery (a recursive method's own converged fixpoint, a sub-cycle that opened
|
|
1663
|
+
# and closed inside the bracket) which a standalone recompute reproduces event-for-event. A
|
|
1664
|
+
# top-of-stack compute (entry depth 0) can never see a below-entry event — it is standalone by
|
|
1665
|
+
# construction (fuel reseeds on an empty stack, the summary tables were cleared at the previous
|
|
1666
|
+
# drain). Cleared with the other per-outermost-entry tables when the guard stack drains. LOAD-BEARING:
|
|
1667
|
+
# unlike `BudgetTrace` counters the log must be maintained on every run, not only under
|
|
1668
|
+
# RIGOR_BUDGET_TRACE.
|
|
1669
|
+
#
|
|
1670
|
+
# Audit table (ADR-84 WD3) — every early-return / fallback in the recursion machinery, each either
|
|
1671
|
+
# LOGGED (routes through `note_transient_fallback` with the referenced frame's position) or PROVABLY
|
|
1672
|
+
# FINAL (a deterministic pure function of the frame's own inputs, so a standalone recompute
|
|
1673
|
+
# reproduces it):
|
|
1674
|
+
#
|
|
1675
|
+
# compute_user_method_return
|
|
1676
|
+
# - in-cycle re-entry (`stack.include?(signature)` → `consult_summary`) LOGGED at the consulted
|
|
1677
|
+
# owner's stack position (the outermost frame carrying the plain signature — the seeder whose
|
|
1678
|
+
# in-flight Kleene iterate the re-entry returns)
|
|
1679
|
+
# evaluate_guarded_user_method_body
|
|
1680
|
+
# - non-outermost plain evaluation, clamp pass-through PROVABLY FINAL (plain
|
|
1681
|
+
# body eval; nested transient events log at their own sites)
|
|
1682
|
+
# - `clamp_unroll_result` clamp branch (ADR-55 WD1 → `untyped`) LOGGED at 0 (the
|
|
1683
|
+
# would-have-been-guarded match may be an ancestor frame; position not threaded — conservative)
|
|
1684
|
+
# fixpoint_user_method_return
|
|
1685
|
+
# - `degrade_entangled_fixpoint` (foreign in-flight consult) LOGGED at the degrading
|
|
1686
|
+
# owner's own position (the foreign consult that caused it was already logged at the ancestor's
|
|
1687
|
+
# position by the guard site, which is what taints the enclosing brackets)
|
|
1688
|
+
# - "summary never consulted" early return (body did not recurse) PROVABLY FINAL
|
|
1689
|
+
# - `resolve_bot_collapse` (widened re-run / explicit-return floor) PROVABLY FINAL in itself
|
|
1690
|
+
# (deterministic); transient events inside the re-run log at their own sites
|
|
1691
|
+
# - `fixpoint_step` convergence (`joined == assumption`) converged value; any
|
|
1692
|
+
# consults the iterations performed were already LOGGED at the guard site
|
|
1693
|
+
# - `fixpoint_step` cap collapse (`RECURSION_FIXPOINT_CAP` → `untyped`) LOGGED at the owner's own
|
|
1694
|
+
# position (per-owner constant cap — deterministic for every enclosing bracket; the iterates'
|
|
1695
|
+
# context-dependence, if any, logs separately at its own sites)
|
|
1696
|
+
# unroll helpers
|
|
1697
|
+
# - `unroll_fuel_remaining` exhaustion (extended key denied → plain) LOGGED at 0 (fuel is ONE
|
|
1698
|
+
# shared resource seeded at stack bottom, so a nested frame's unroll budget depends on what ran
|
|
1699
|
+
# before it — the hazard the blanket exclusion used to over-approximate)
|
|
1700
|
+
# - `constant_argument_value_key` / `pinned_value_descriptor` nil PROVABLY FINAL (pure
|
|
1701
|
+
# functions of `arg_types`)
|
|
1702
|
+
#
|
|
1703
|
+
# Adding a fallback? Route it through `note_transient_fallback` unless it is provably final in the
|
|
1704
|
+
# sense above — position 0 is always a sound (conservative) choice; spec/rigor/inference/
|
|
1705
|
+
# return_memo_taint_spec.rb pins this (no raw `BudgetTrace.hit` on the recursion categories outside
|
|
1706
|
+
# the helper).
|
|
1707
|
+
TRANSIENT_EVENT_DEPTHS_KEY = :__rigor_user_method_transient_event_depths__
|
|
1708
|
+
private_constant :TRANSIENT_EVENT_DEPTHS_KEY
|
|
1709
|
+
|
|
1606
1710
|
# Per-inference recursion context threaded through the guard / fixpoint helpers (ADR-55 slice 2).
|
|
1607
1711
|
# Bundles the call descriptor (`receiver`, `arg_types`, `plain_signature`), the thread-local summary
|
|
1608
1712
|
# table, and the WD1 clamp flag so the helpers stay within the parameter-list budget. `def_node` is
|
|
@@ -1629,7 +1733,7 @@ module Rigor
|
|
|
1629
1733
|
RECURSION_VALUE_SIZE_CAP = 64
|
|
1630
1734
|
private_constant :RECURSION_VALUE_SIZE_CAP
|
|
1631
1735
|
|
|
1632
|
-
def infer_user_method_return(def_node, receiver, arg_types)
|
|
1736
|
+
def infer_user_method_return(def_node, receiver, arg_types)
|
|
1633
1737
|
return nil if def_node.body.nil?
|
|
1634
1738
|
|
|
1635
1739
|
body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
|
|
@@ -1646,6 +1750,9 @@ module Rigor
|
|
|
1646
1750
|
stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
|
|
1647
1751
|
summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
|
|
1648
1752
|
|
|
1753
|
+
# WD0 (RIGOR_BUDGET_TRACE) — one entry into user-method return inference. No-op when disabled.
|
|
1754
|
+
BudgetTrace.hit(BudgetTrace::MEMO_ENTRIES)
|
|
1755
|
+
|
|
1649
1756
|
# ADR-57 follow-up — return memo. The inferred return is a pure function of `(def_node, receiver,
|
|
1650
1757
|
# arg_types)` and the frozen discovery index whenever the computation does NOT depend on a transient
|
|
1651
1758
|
# ADR-55 Kleene assumption (an in-flight fixpoint summary). Two structural preconditions decide
|
|
@@ -1661,25 +1768,104 @@ module Rigor
|
|
|
1661
1768
|
# call, re-walking the shared sub-readers combinatorially (~932k body evaluations for ~20 tiny
|
|
1662
1769
|
# methods). The computation itself lives in `compute_user_method_return`.
|
|
1663
1770
|
unless memo_candidate?(stack, plain_signature)
|
|
1771
|
+
trace_memo_refusal(stack, plain_signature)
|
|
1664
1772
|
return compute_user_method_return(def_node, body_scope, stack, summaries,
|
|
1665
1773
|
receiver, arg_types, plain_signature)
|
|
1666
1774
|
end
|
|
1667
1775
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1776
|
+
# INVARIANT (ADR-46 recording soundness, ADR-84 WD2) — the deep cross-file dependency edges (the
|
|
1777
|
+
# reads a callee body's dispatches perform through the instrumented `Scope` accessors) are recorded,
|
|
1778
|
+
# per consumer, only as a side effect of evaluating that body, and a memo hit serves the return
|
|
1779
|
+
# WITHOUT re-evaluating it. Since the bucket is run-scoped, hits CROSS consumer-file boundaries, so
|
|
1780
|
+
# every cross-file hit is PAIRED WITH CACHE-AND-REPLAY of the callee's read-set: under recording,
|
|
1781
|
+
# the first evaluation of a key captures the recorder events of its body walk
|
|
1782
|
+
# (`DependencyRecorder.capture` — observe-and-forward, so the first consumer's own record is
|
|
1783
|
+
# untouched) onto the entry, and a hit replays that set into the current consumer's accumulator
|
|
1784
|
+
# (`DependencyRecorder.replay`, which re-applies the per-consumer self-read filter). A memo hit is
|
|
1785
|
+
# thereby edge-equivalent to a fresh body evaluation for EVERY consumer. The naive alternative —
|
|
1786
|
+
# bypassing the memo while the recorder is active — measured >200x wall on analyzer-shaped files
|
|
1787
|
+
# (ActiveStorage video_analyzer.rb's subtree, 0.43s -> >90s; PR #79) and stays rejected. Pinned by
|
|
1788
|
+
# spec/rigor/inference/return_memo_recording_spec.rb (cross-file replay completeness) and
|
|
1789
|
+
# dependency_recorder_spec.rb's transitive deep-edge example.
|
|
1790
|
+
consult_and_store_return_memo(def_node, body_scope, stack, summaries,
|
|
1791
|
+
receiver, arg_types, plain_signature)
|
|
1792
|
+
end
|
|
1793
|
+
|
|
1794
|
+
# The candidate-frame memo path: consult the current run generation's bucket, and on a miss compute
|
|
1795
|
+
# and store a FINAL result. Reached only when `memo_candidate?` held (see `infer_user_method_return`).
|
|
1796
|
+
# The store gate (ADR-84 WD3): a result is stored when the ADR-55 fixpoint consult counter did not
|
|
1797
|
+
# move across the compute (the WD0 `MEMO_REFUSE_CONSULT_TAINTED` non-store) AND no transient-machinery
|
|
1798
|
+
# event during the bracket referenced a stack frame BELOW the bracket's entry depth (see
|
|
1799
|
+
# TRANSIENT_EVENT_DEPTHS_KEY). Below-entry events — an ancestor's in-flight Kleene iterate read by a
|
|
1800
|
+
# guard hit, a shared-fuel exhaustion, a possibly-ancestor-caused WD1 clamp — mean the ancestor
|
|
1801
|
+
# context influenced `result`, which a standalone recompute would not reproduce; at-or-above-entry
|
|
1802
|
+
# events are the compute's own deterministic machinery (its own converged fixpoint, sub-cycles that
|
|
1803
|
+
# opened and closed inside the bracket) and do not block the store. A top-of-stack compute (entry
|
|
1804
|
+
# depth 0) is standalone by construction. A hit under ADR-46 recording replays the entry's captured
|
|
1805
|
+
# read-set into the current consumer (see the INVARIANT comment at the call site).
|
|
1806
|
+
def consult_and_store_return_memo(def_node, body_scope, stack, summaries,
|
|
1807
|
+
receiver, arg_types, plain_signature)
|
|
1808
|
+
per_def = (return_memo_bucket[def_node] ||= {})
|
|
1809
|
+
memo_key = [receiver.describe(:short),
|
|
1670
1810
|
arg_types.map { |type| type.describe(:short) }]
|
|
1671
|
-
|
|
1811
|
+
if (entry = per_def[memo_key])
|
|
1812
|
+
BudgetTrace.hit(BudgetTrace::MEMO_HITS)
|
|
1813
|
+
Analysis::DependencyRecorder.replay(entry.read_set) if Analysis::DependencyRecorder.active?
|
|
1814
|
+
return entry.result
|
|
1815
|
+
end
|
|
1816
|
+
BudgetTrace.hit(BudgetTrace::MEMO_MISSES)
|
|
1817
|
+
trace_distinct_memo_key(plain_signature, def_node, memo_key)
|
|
1818
|
+
|
|
1819
|
+
entry_depth = stack.size
|
|
1820
|
+
event_mark = transient_event_mark
|
|
1821
|
+
consults_before = summary_consult_count
|
|
1822
|
+
result, read_set = compute_with_read_capture(def_node, body_scope, stack, summaries,
|
|
1823
|
+
receiver, arg_types, plain_signature)
|
|
1824
|
+
|
|
1825
|
+
if summary_consult_count != consults_before
|
|
1826
|
+
BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_CONSULT_TAINTED)
|
|
1827
|
+
elsif context_tainted?(event_mark, entry_depth)
|
|
1828
|
+
BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_TRANSIENT)
|
|
1829
|
+
else
|
|
1830
|
+
per_def[memo_key] = MemoEntry.new(result: result, read_set: read_set,
|
|
1831
|
+
receiver: receiver, arg_types: arg_types)
|
|
1832
|
+
end
|
|
1833
|
+
result
|
|
1834
|
+
end
|
|
1672
1835
|
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
consults_after = Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
|
|
1836
|
+
def transient_event_mark
|
|
1837
|
+
Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]&.size || 0
|
|
1838
|
+
end
|
|
1677
1839
|
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1840
|
+
def summary_consult_count
|
|
1841
|
+
Thread.current[SUMMARY_CONSULT_COUNTER_KEY] || 0
|
|
1842
|
+
end
|
|
1843
|
+
|
|
1844
|
+
# ADR-84 WD3 — true when a transient-machinery event logged during the bracket (entries past
|
|
1845
|
+
# `event_mark`) referenced a frame below `entry_depth`. The log is cleared when the guard stack drains
|
|
1846
|
+
# (only possible mid-bracket for a top-of-stack compute, whose events are deterministic anyway), so a
|
|
1847
|
+
# missing / shorter log reads as untainted.
|
|
1848
|
+
def context_tainted?(event_mark, entry_depth)
|
|
1849
|
+
log = Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]
|
|
1850
|
+
return false if log.nil? || log.size <= event_mark
|
|
1851
|
+
|
|
1852
|
+
log[event_mark..].any? { |depth| depth < entry_depth }
|
|
1853
|
+
end
|
|
1854
|
+
|
|
1855
|
+
# ADR-84 WD2 — the memo-miss compute, wrapped in a `DependencyRecorder.capture` window when ADR-46
|
|
1856
|
+
# recording is active so the entry can carry its replayable read-set. Returns `[result, read_set]`
|
|
1857
|
+
# (`read_set` nil when not recording).
|
|
1858
|
+
def compute_with_read_capture(def_node, body_scope, stack, summaries,
|
|
1859
|
+
receiver, arg_types, plain_signature)
|
|
1860
|
+
unless Analysis::DependencyRecorder.active?
|
|
1861
|
+
return [compute_user_method_return(def_node, body_scope, stack, summaries,
|
|
1862
|
+
receiver, arg_types, plain_signature), nil]
|
|
1863
|
+
end
|
|
1864
|
+
|
|
1865
|
+
Analysis::DependencyRecorder.capture do
|
|
1866
|
+
compute_user_method_return(def_node, body_scope, stack, summaries,
|
|
1867
|
+
receiver, arg_types, plain_signature)
|
|
1868
|
+
end
|
|
1683
1869
|
end
|
|
1684
1870
|
|
|
1685
1871
|
# The ADR-55 recursion-guard + value-unroll + fixpoint body of user-method return inference, factored
|
|
@@ -1687,6 +1873,7 @@ module Rigor
|
|
|
1687
1873
|
# unchanged from pre-memo behaviour).
|
|
1688
1874
|
def compute_user_method_return(def_node, body_scope, stack, summaries,
|
|
1689
1875
|
receiver, arg_types, plain_signature)
|
|
1876
|
+
trace_body_eval(plain_signature)
|
|
1690
1877
|
# ADR-55 slice 1: when every bound argument is value-pinned, extend the guard key with a stable
|
|
1691
1878
|
# descriptor of the argument *values* so distinct constant frames may recurse (e.g. `factorial(5)`
|
|
1692
1879
|
# folds to `Constant[120]`). Distinct constant frames are bounded by `RECURSION_UNROLL_FUEL` per
|
|
@@ -1698,7 +1885,10 @@ module Rigor
|
|
|
1698
1885
|
signature = [plain_signature, value_key] if extended
|
|
1699
1886
|
|
|
1700
1887
|
if stack.include?(signature)
|
|
1701
|
-
|
|
1888
|
+
# ADR-84 WD3 — the referenced frame is the consulted owner: the OUTERMOST frame carrying this
|
|
1889
|
+
# plain signature (the seeder whose in-flight iterate the re-entry returns).
|
|
1890
|
+
note_transient_fallback(BudgetTrace::RECURSION_GUARD,
|
|
1891
|
+
stack.index { |frame| plain_part(frame) == plain_signature } || 0)
|
|
1702
1892
|
# ADR-55 slice 2: in-cycle re-entries return the current assumed summary (Kleene iterate, seeded
|
|
1703
1893
|
# `bot`) instead of bare `untyped`. The fixpoint loop below seeds the entry on the outermost frame;
|
|
1704
1894
|
# if a re-entry beats it here the entry already exists. The WD4 composition: slice 1's clamp/fuel
|
|
@@ -1724,30 +1914,85 @@ module Rigor
|
|
|
1724
1914
|
evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
|
|
1725
1915
|
end
|
|
1726
1916
|
|
|
1727
|
-
# True when this frame's result is a candidate for the return memo: the structural
|
|
1728
|
-
# stable across the body walk, that
|
|
1729
|
-
#
|
|
1730
|
-
#
|
|
1731
|
-
#
|
|
1732
|
-
#
|
|
1733
|
-
#
|
|
1734
|
-
#
|
|
1735
|
-
#
|
|
1917
|
+
# True when this frame's result is a candidate for the return memo: the one structural precondition,
|
|
1918
|
+
# stable across the body walk, that is necessary (but not sufficient) for a FINAL result — this plain
|
|
1919
|
+
# signature must not itself be on the recursion guard stack (else we are inside its own cycle,
|
|
1920
|
+
# returning a Kleene iterate by construction). Sufficiency is decided post-hoc in
|
|
1921
|
+
# `consult_and_store_return_memo` by the two bracket counters (fixpoint consults + ADR-84 WD3
|
|
1922
|
+
# transient-machinery events) — so unlike the prior form this deliberately does NOT refuse while a
|
|
1923
|
+
# constant-arg unroll is in flight: a nested frame whose compute finishes without a single transient
|
|
1924
|
+
# event ran exactly as it would standalone (fuel consumption without exhaustion is invisible), and
|
|
1925
|
+
# the blanket exclusion refused 82% of mail's body evaluations for such final results (ADR-84).
|
|
1736
1926
|
def memo_candidate?(stack, plain_signature)
|
|
1737
|
-
|
|
1738
|
-
# unroll has begun iff the thread-local is set and the stack is non-empty (the `ensure` in
|
|
1739
|
-
# `evaluate_guarded_user_method_body` clears it at stack-empty).
|
|
1740
|
-
unroll_idle = stack.empty? || Thread.current[INFERENCE_UNROLL_FUEL_KEY].nil?
|
|
1741
|
-
unroll_idle &&
|
|
1742
|
-
stack.none? { |frame| plain_part(frame) == plain_signature }
|
|
1927
|
+
stack.none? { |frame| plain_part(frame) == plain_signature }
|
|
1743
1928
|
end
|
|
1744
1929
|
|
|
1745
|
-
# Run-scoped return-memo bucket
|
|
1746
|
-
#
|
|
1747
|
-
#
|
|
1930
|
+
# Run-scoped return-memo bucket (ADR-84 WD2): a single retained slot keyed by the identity of the
|
|
1931
|
+
# run-generation token the runner seeds (`Scope#run_generation`), falling back to the per-file merged
|
|
1932
|
+
# `def_nodes` table identity for runner-less scopes (single-file probes) — the pre-WD2 per-file
|
|
1933
|
+
# scope. A generation mismatch drops the previous bucket whole, so a re-run in one process (LSP,
|
|
1934
|
+
# ADR-62 warm loop) can neither hit stale entries nor accumulate them. The bucket maps `def_node`
|
|
1935
|
+
# (object identity, held strongly — see RETURN_MEMO_KEY) to its per-descriptor entries.
|
|
1748
1936
|
def return_memo_bucket
|
|
1749
|
-
|
|
1750
|
-
|
|
1937
|
+
generation = scope.run_generation || scope.discovered_def_nodes
|
|
1938
|
+
slot = Thread.current[RETURN_MEMO_KEY]
|
|
1939
|
+
unless slot && slot[0].equal?(generation)
|
|
1940
|
+
slot = [generation, {}.compare_by_identity]
|
|
1941
|
+
Thread.current[RETURN_MEMO_KEY] = slot
|
|
1942
|
+
end
|
|
1943
|
+
slot[1]
|
|
1944
|
+
end
|
|
1945
|
+
|
|
1946
|
+
# WD0 (RIGOR_BUDGET_TRACE) trace helpers. Each guards on `BudgetTrace.enabled?` before doing any work
|
|
1947
|
+
# beyond a single boolean check, so a normal run pays nothing (no signature-label string built, no
|
|
1948
|
+
# stack scan). Attributes a non-candidate frame's non-store to on-stack vs unroll-in-flight; since
|
|
1949
|
+
# ADR-84 WD3 reduced candidacy to the on-stack check alone, the unroll-in-flight branch is
|
|
1950
|
+
# structurally unreachable and is kept only so the printed counter table still shows the category
|
|
1951
|
+
# (pinned ~0 — the ADR-84 WD3 gate).
|
|
1952
|
+
def trace_memo_refusal(stack, plain_signature)
|
|
1953
|
+
return unless BudgetTrace.enabled?
|
|
1954
|
+
|
|
1955
|
+
if stack.any? { |frame| plain_part(frame) == plain_signature }
|
|
1956
|
+
BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_ON_STACK)
|
|
1957
|
+
else
|
|
1958
|
+
BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_UNROLL)
|
|
1959
|
+
end
|
|
1960
|
+
end
|
|
1961
|
+
|
|
1962
|
+
# The observed member is the full `(def-node identity, receiver, args)` entry key, so the per-signature
|
|
1963
|
+
# count also surfaces the ADR-84 WD2 dual-parse duplication (project-index parse vs the defining
|
|
1964
|
+
# file's own parse — at most 2 node identities per def per run).
|
|
1965
|
+
def trace_distinct_memo_key(plain_signature, def_node, memo_key)
|
|
1966
|
+
return unless BudgetTrace.enabled?
|
|
1967
|
+
|
|
1968
|
+
BudgetTrace.observe_distinct(
|
|
1969
|
+
BudgetTrace::MEMO_DISTINCT_KEY_BY_SIGNATURE, signature_label(plain_signature),
|
|
1970
|
+
[def_node.object_id, memo_key]
|
|
1971
|
+
)
|
|
1972
|
+
end
|
|
1973
|
+
|
|
1974
|
+
def trace_body_eval(plain_signature)
|
|
1975
|
+
return unless BudgetTrace.enabled?
|
|
1976
|
+
|
|
1977
|
+
BudgetTrace.hit(BudgetTrace::MEMO_BODY_EVALS)
|
|
1978
|
+
BudgetTrace.observe(BudgetTrace::MEMO_BODY_EVAL_BY_SIGNATURE, signature_label(plain_signature))
|
|
1979
|
+
end
|
|
1980
|
+
|
|
1981
|
+
# `"Receiver#method"` label for a `[receiver_descriptor, method_name]` plain signature — the bucket key
|
|
1982
|
+
# for the WD0 per-signature distributions. Built only under `BudgetTrace.enabled?`.
|
|
1983
|
+
def signature_label(plain_signature)
|
|
1984
|
+
"#{plain_signature[0]}##{plain_signature[1]}"
|
|
1985
|
+
end
|
|
1986
|
+
|
|
1987
|
+
# ADR-84 WD3 — the single choke point every transient-machinery fallback routes through: appends the
|
|
1988
|
+
# referenced frame's stack position to the load-bearing thread-local event log (regardless of
|
|
1989
|
+
# RIGOR_BUDGET_TRACE) and forwards the category to `BudgetTrace`. The taint spec pins that no
|
|
1990
|
+
# recursion-machinery `BudgetTrace.hit` exists outside this helper, so a future fallback cannot
|
|
1991
|
+
# silently join unlogged (see the audit table at TRANSIENT_EVENT_DEPTHS_KEY; `context_depth` 0 is
|
|
1992
|
+
# always a sound conservative choice).
|
|
1993
|
+
def note_transient_fallback(category, context_depth)
|
|
1994
|
+
(Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] ||= []) << context_depth
|
|
1995
|
+
BudgetTrace.hit(category)
|
|
1751
1996
|
end
|
|
1752
1997
|
|
|
1753
1998
|
# Pushes the recursion-guard frame, evaluates the body (the outermost frame for a plain signature runs
|
|
@@ -1772,6 +2017,7 @@ module Rigor
|
|
|
1772
2017
|
Thread.current[INFERENCE_UNROLL_FUEL_KEY] = nil
|
|
1773
2018
|
Thread.current[INFERENCE_SUMMARY_KEY] = nil
|
|
1774
2019
|
Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] = nil
|
|
2020
|
+
Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] = nil
|
|
1775
2021
|
end
|
|
1776
2022
|
end
|
|
1777
2023
|
end
|
|
@@ -1861,12 +2107,21 @@ module Rigor
|
|
|
1861
2107
|
# soundness fix, 2026-06-12), parking `untyped` in the assumption so any consumer that still reads
|
|
1862
2108
|
# this signature's summary sees the floor, not the stale `bot` seed.
|
|
1863
2109
|
def degrade_entangled_fixpoint(summaries, plain_signature)
|
|
1864
|
-
|
|
2110
|
+
# ADR-84 WD3 — logged at the degrading owner's own position: the foreign consult that caused the
|
|
2111
|
+
# entanglement was already logged at the ANCESTOR's position by the guard site.
|
|
2112
|
+
note_transient_fallback(BudgetTrace::RECURSION_GUARD, own_guard_frame_position)
|
|
1865
2113
|
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
1866
2114
|
summaries[plain_signature][:assumption] = Type::Combinator.untyped
|
|
1867
2115
|
Type::Combinator.untyped
|
|
1868
2116
|
end
|
|
1869
2117
|
|
|
2118
|
+
# The stack position of the currently-evaluating owner frame (the guard stack's top) — the
|
|
2119
|
+
# self-referential `context_depth` for events that are deterministic per owner (ADR-84 WD3).
|
|
2120
|
+
def own_guard_frame_position
|
|
2121
|
+
size = Thread.current[INFERENCE_GUARD_KEY]&.size || 0
|
|
2122
|
+
size.positive? ? size - 1 : 0
|
|
2123
|
+
end
|
|
2124
|
+
|
|
1870
2125
|
# One Kleene-iteration step of the fixpoint loop. Joins `computed` into the running assumption
|
|
1871
2126
|
# (widening value-pinned constituents on the final permitted iteration to force convergence) and
|
|
1872
2127
|
# either returns a final type — convergence, or the capped `untyped` collapse — or `:continue` to
|
|
@@ -1882,9 +2137,9 @@ module Rigor
|
|
|
1882
2137
|
return candidate if joined == assumption
|
|
1883
2138
|
|
|
1884
2139
|
if last_iteration
|
|
1885
|
-
# Out of iterations and still unstable — collapse to today's
|
|
1886
|
-
#
|
|
1887
|
-
|
|
2140
|
+
# Out of iterations and still unstable — collapse to today's widening behaviour. ADR-84 WD3: the
|
|
2141
|
+
# cap is a per-owner constant, so the event references the owner's own frame.
|
|
2142
|
+
note_transient_fallback(BudgetTrace::RECURSION_FIXPOINT_CAP, own_guard_frame_position)
|
|
1888
2143
|
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
1889
2144
|
summaries[plain_signature][:assumption] = Type::Combinator.untyped
|
|
1890
2145
|
return Type::Combinator.untyped
|
|
@@ -1944,7 +2199,14 @@ module Rigor
|
|
|
1944
2199
|
return false if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
1945
2200
|
return true if node.is_a?(Prism::ReturnNode)
|
|
1946
2201
|
|
|
1947
|
-
|
|
2202
|
+
found = false
|
|
2203
|
+
node.rigor_each_child do |child|
|
|
2204
|
+
next unless body_has_explicit_return?(child)
|
|
2205
|
+
|
|
2206
|
+
found = true
|
|
2207
|
+
break
|
|
2208
|
+
end
|
|
2209
|
+
found
|
|
1948
2210
|
end
|
|
1949
2211
|
|
|
1950
2212
|
# Returns the current assumed summary for `plain_signature`, recording that it was consulted (so the
|
|
@@ -1967,7 +2229,9 @@ module Rigor
|
|
|
1967
2229
|
def clamp_unroll_result(type, would_have_been_guarded)
|
|
1968
2230
|
return type unless would_have_been_guarded && !fully_value_pinned?(type)
|
|
1969
2231
|
|
|
1970
|
-
|
|
2232
|
+
# ADR-84 WD3 — position 0 (conservative): the would-have-been-guarded match may be an ancestor
|
|
2233
|
+
# frame, and the matched position is not threaded through RecursionContext.
|
|
2234
|
+
note_transient_fallback(BudgetTrace::RECURSION_GUARD, 0)
|
|
1971
2235
|
scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
|
|
1972
2236
|
# ADR-55 WD1 clamp: a guarded extended frame whose body is non-pinned must be byte-identical to the
|
|
1973
2237
|
# plain guard's `untyped`. This path deliberately does NOT route to the in-progress fixpoint summary:
|
|
@@ -1998,7 +2262,9 @@ module Rigor
|
|
|
1998
2262
|
if remaining.positive?
|
|
1999
2263
|
Thread.current[INFERENCE_UNROLL_FUEL_KEY] = remaining - 1
|
|
2000
2264
|
else
|
|
2001
|
-
|
|
2265
|
+
# ADR-84 WD3 — position 0: fuel is one shared resource seeded at the stack bottom, so its
|
|
2266
|
+
# exhaustion is context-dependent for every nested bracket.
|
|
2267
|
+
note_transient_fallback(BudgetTrace::RECURSION_UNROLL_FUEL, 0)
|
|
2002
2268
|
end
|
|
2003
2269
|
remaining
|
|
2004
2270
|
end
|