Module: Rigor::Inference::PreEvalConstants

Defined in:
lib/rigor/inference/pre_eval_constants.rb

Overview

Issue #352 / ADR-17 — the constant half of the pre_eval: publication surface.

ADR-17 slice 2 gave pre_eval: a project-wide method registry (ProjectPatchedMethods). A constant declared in the same file stayed invisible across the file boundary: Scope#in_source_constants is per-file and is deliberately not part of Runner#project_scope_seed_tables, so only class-shaped constants (a class / module declaration, or one of the four meta-new forms ScopeIndexer's record_class_new_constant_decl promotes) ever crossed. TIMEOUT = 30 in a listed file read as Dynamic[top] everywhere else.

This collector closes that half. It walks the pre_eval: files with the SAME constant pre-pass the per-file path uses (ScopeIndexer.build_in_source_constants) and publishes the result — widened — into the project seed. Files not listed under pre_eval: are untouched, so the feature is opt-in by construction and its cost stays proportional to the listed file count (ADR-17 WD1's cost-bounded argument, which is also why slice 5's full-project two-pass stayed rejectable).

The widening rule — why the published type is not the same-file type

Same-file constant propagation is value-pinned: 42, "hello", a Tuple, a HashShape. Carrying that verbatim across a file boundary would land diagnostics in files whose author never opened the constant's definition — CONFIG = { a: 1 } arriving as a closed HashShape makes CONFIG.fetch(:b) fire, ARR = [1, 2] arriving as a Tuple routes through ShapeDispatch instead of the RBS overload a call site relied on. Today's cross-file Dynamic[top] is false-positive-free by construction, so precision here can only spend that budget; AGENTS.md § "Implementation Guidelines" ranks false positives above worst-case static reading, so this slice spends as little of it as possible.

PreEvalConstants.widen therefore publishes the erased class, never the value:

Declared Same file Published cross-file
INT_LIT = 42 42 Integer
STR_LIT = "hello" "hello" String
ARR_LIT = [1, 2] [1, 2] (Tuple) Array (raw, elements dropped)
HSH_LIT = { a: 1 } { a: 1 } (HashShape) Hash (raw, keys dropped)
ALIAS_CLS = String singleton(String) singleton(String)
Nest::NESTED_INT = 7 7 Integer

Anything the rule does not recognise declines — the name is simply not published and reads exactly as it does today. Declining is always the safe answer here, which is why the else arm is nil rather than a fallback. Constant[nil] declines on purpose: a X = nil at declaration position is a placeholder for a value assigned at runtime far more often than it is a genuine NilClass, and publishing NilClass project-wide would make every use of it a diagnostic.

Value-pinning a provably frozen single-write literal cross-file is a strictly later question; it needs its own FP measurement and is deliberately not attempted here.

The multi-file write rule — widen on conflict, all the way to Dynamic[top]

Within one file, ScopeIndexer#record_constant_write unions repeated writes; that union is widened as a whole (X = 1; X = "a" in one file publishes Integer | String). ACROSS files the same union would be a type neither author can see, so the rule is widen on conflict: when two listed files publish the same qualified name with different widened types, the name is dropped from the table entirely and reads as Dynamic[top] — the widest type there is, and the one the name already had. Agreeing writes (X = 1 here, X = 2 there — both Integer) are not a conflict at all, which is the point of widening first: 1 | 2 is never produced.

Ordering

The published table seeds Scope#in_source_constants, which Reflection.constant_type_at consults AFTER the class registry and discovered_classes. A published entry can therefore never mask a class-shaped constant that already crossed, and the per-file table always wins over the seed (see ScopeIndexer.index's merge) — same-file remains the most specific authority.

Constant Summary collapse

EMPTY =
{}.freeze

Class Method Summary collapse

Class Method Details

.collect(paths:, scope_builder:, target_ruby: nil, buffer: nil) ⇒ Hash{String => Rigor::Type}

Collects and widens the constants every pre_eval: file declares.

Parameters:

  • paths (Array<String>)

    absolute paths to the pre_eval: files that exist on disk.

  • scope_builder (#call)

    path -> Rigor::Scope; the caller supplies a project-seeded, environment- bound scope so the rvalue typer resolves cross-file classes exactly as per-file analysis would.

  • target_ruby (String, nil) (defaults to: nil)

    the Prism parse version (Configuration#target_ruby).

  • buffer (Rigor::Analysis::BufferBinding, nil) (defaults to: nil)

    editor-mode binding; when set, a listed file that matches the in-flight buffer is read from its physical bytes.

Returns:

  • (Hash{String => Rigor::Type})

    frozen qualified-name -> published type table.



87
88
89
90
91
92
93
94
95
# File 'lib/rigor/inference/pre_eval_constants.rb', line 87

def collect(paths:, scope_builder:, target_ruby: nil, buffer: nil)
  published = {}
  conflicted = {}
  paths.each do |path|
    file_constants(path, scope_builder: scope_builder, target_ruby: target_ruby, buffer: buffer)
      .each { |name, type| merge_publication(published, conflicted, name, type) }
  end
  published.freeze
end

.widen(type) ⇒ Object

The publication widening. Returns the type to publish, or nil to decline (the name keeps today's Dynamic[top] cross-file reading). See the module doc for why declining is the safe default.



148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/rigor/inference/pre_eval_constants.rb', line 148

def widen(type)
  case type
  when Type::Constant then widen_constant(type)
  when Type::Refined then widen(type.base)
  when Type::IntegerRange then Type::Combinator.nominal_of("Integer")
  when Type::Tuple then Type::Combinator.nominal_of("Array")
  when Type::HashShape then Type::Combinator.nominal_of("Hash")
  when Type::Nominal then type.type_args.empty? ? type : Type::Combinator.nominal_of(type.class_name)
  when Type::Singleton then type
  when Type::DataInstance, Type::StructInstance then nominal_for_class_name(type.class_name)
  when Type::Union then widen_union(type)
  end
end