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
-
.collect(paths:, scope_builder:, target_ruby: nil, buffer: nil) ⇒ Hash{String => Rigor::Type}
Collects and widens the constants every
pre_eval:file declares. -
.widen(type) ⇒ Object
The publication widening.
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.
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 |