diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48fa5e4cb..3f831bf7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,19 @@ jobs: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} + nil-kill-unit: + name: nil-kill gem specs + runs-on: ubuntu-latest + env: + COVERAGE: "0" + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + - run: bundle exec rspec gems/nil-kill/spec + sorbet: name: Sorbet type-check (typed:true files only) runs-on: ubuntu-latest @@ -307,13 +320,20 @@ jobs: --adapter json --file "$BENCHER_JSON" --err - --github-actions "$GITHUB_TOKEN" - --ci-id "benchmark-leak-shard-${{ matrix.shard }}" ) + if [ "${{ matrix.shard }}" = "0" ]; then + args+=( + --github-actions "$GITHUB_TOKEN" + --ci-id "benchmark-leak" + ) + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + args+=(--ci-number "${{ github.event.pull_request.number }}") + fi + fi + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then args+=( - --ci-number "${{ github.event.pull_request.number }}" --start-point "$GITHUB_BASE_REF" --start-point-hash "${{ github.event.pull_request.base.sha }}" --start-point-clone-thresholds diff --git a/.gitignore b/.gitignore index 4e05e6d90..a2dd2071b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,12 @@ !/.vscode/ !/CLAUDE.md !/CONTRIBUTING.md +!/codecov.yml !/GEMINI.md !/Gemfile !/Gemfile.lock +!/gems/ +!/gems/** !/LICENSE !/ONE-PAGER.md !/README.md diff --git a/Gemfile b/Gemfile index 17a3b5e72..e924de7dc 100644 --- a/Gemfile +++ b/Gemfile @@ -29,8 +29,9 @@ group :development do gem 'sorbet', require: false gem 'sorbet-runtime' gem 'tapioca', require: false - gem 'rbs-trace', require: false - gem 'parlour', require: false + + # Local path while nil-kill is extracted as a standalone gem. + gem 'nil-kill', path: 'gems/nil-kill', require: false # Rubocop with the rubocop-sorbet plugin. We don't run general # Rubocop style — only the `Sorbet/EnforceSignatures` cop, which diff --git a/Gemfile.lock b/Gemfile.lock index 2ba81371d..e6c3137da 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,12 @@ +PATH + remote: gems/nil-kill + specs: + nil-kill (0.1.0) + parlour + prism (>= 1.6) + rbs-trace + sorbet-runtime + GEM remote: https://rubygems.org/ specs: @@ -230,9 +239,8 @@ DEPENDENCIES flay flog msgpack (~> 1.7, >= 1.7.2) + nil-kill! parallel_rspec - parlour - rbs-trace reek rspec rubocop diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..24ae74565 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +flags: + nil-kill: + paths: + - gems/nil-kill/ + carryforward: false + joined: false diff --git a/manifesto/ATOMIC-POWER.md b/docs/manifesto/ATOMIC-POWER.md similarity index 100% rename from manifesto/ATOMIC-POWER.md rename to docs/manifesto/ATOMIC-POWER.md diff --git a/manifesto/DEADLOCK.md b/docs/manifesto/DEADLOCK.md similarity index 100% rename from manifesto/DEADLOCK.md rename to docs/manifesto/DEADLOCK.md diff --git a/manifesto/DLL-PROBLEM.md b/docs/manifesto/DLL-PROBLEM.md similarity index 100% rename from manifesto/DLL-PROBLEM.md rename to docs/manifesto/DLL-PROBLEM.md diff --git a/manifesto/EXAMPLES.md b/docs/manifesto/EXAMPLES.md similarity index 100% rename from manifesto/EXAMPLES.md rename to docs/manifesto/EXAMPLES.md diff --git a/manifesto/EXTRA.md b/docs/manifesto/EXTRA.md similarity index 100% rename from manifesto/EXTRA.md rename to docs/manifesto/EXTRA.md diff --git a/manifesto/FUNCTION-ANATOMY.md b/docs/manifesto/FUNCTION-ANATOMY.md similarity index 100% rename from manifesto/FUNCTION-ANATOMY.md rename to docs/manifesto/FUNCTION-ANATOMY.md diff --git a/manifesto/GIVE-PROBLEM.md b/docs/manifesto/GIVE-PROBLEM.md similarity index 100% rename from manifesto/GIVE-PROBLEM.md rename to docs/manifesto/GIVE-PROBLEM.md diff --git a/manifesto/MATCH-DILEMMA.md b/docs/manifesto/MATCH-DILEMMA.md similarity index 100% rename from manifesto/MATCH-DILEMMA.md rename to docs/manifesto/MATCH-DILEMMA.md diff --git a/manifesto/OPINIONS.md b/docs/manifesto/OPINIONS.md similarity index 100% rename from manifesto/OPINIONS.md rename to docs/manifesto/OPINIONS.md diff --git a/manifesto/README.md b/docs/manifesto/README.md similarity index 100% rename from manifesto/README.md rename to docs/manifesto/README.md diff --git a/manifesto/REENTRANCY.md b/docs/manifesto/REENTRANCY.md similarity index 100% rename from manifesto/REENTRANCY.md rename to docs/manifesto/REENTRANCY.md diff --git a/manifesto/WHO-NOT.md b/docs/manifesto/WHO-NOT.md similarity index 100% rename from manifesto/WHO-NOT.md rename to docs/manifesto/WHO-NOT.md diff --git a/manifesto/pre-release.md b/docs/manifesto/pre-release.md similarity index 100% rename from manifesto/pre-release.md rename to docs/manifesto/pre-release.md diff --git a/docs/retrospective/what-I-learned-the-hard-way.md b/docs/retrospective/what-I-learned-the-hard-way.md index dd635e002..a11ccd89d 100644 --- a/docs/retrospective/what-I-learned-the-hard-way.md +++ b/docs/retrospective/what-I-learned-the-hard-way.md @@ -35,7 +35,7 @@ You can solve many problems by having LLMs generate scripts to produce and sift * I’m planning to release a tool that allows LLMs to loop until they fully type a Ruby codebase. - They did this for a ~40k line transpiler in a day after tooling was built. - The tooling took another 1-2 days to develop. - - You can see it in [nil-kill](https://github.com/cuzzo/clear/blob/master/tools/nil-kill.rb); I’ll release this as a standalone Gem shortly. + - You can see it in [nil-kill](https://github.com/cuzzo/clear/blob/master/tools/nil-kill); I’ll release this as a standalone Gem shortly. * I could never find the signal in that data without spending significant time on tooling. - Without LLMs, I wouldn’t have spent the time to build that tooling by hand. * LLMs will miss things, but they can find a shocking amount of signal in data that is easy to generate but impossible for me to derive value from manually. diff --git a/gems/nil-kill/Gemfile b/gems/nil-kill/Gemfile new file mode 100644 index 000000000..be173b205 --- /dev/null +++ b/gems/nil-kill/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec diff --git a/gems/nil-kill/Gemfile.lock b/gems/nil-kill/Gemfile.lock new file mode 100644 index 000000000..e4172c471 --- /dev/null +++ b/gems/nil-kill/Gemfile.lock @@ -0,0 +1,90 @@ +PATH + remote: . + specs: + nil-kill (0.1.0) + parlour + prism (>= 1.6) + rbs-trace + sorbet-runtime + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + base64 (0.3.0) + commander (5.0.0) + highline (~> 3.0.0) + diff-lcs (1.6.2) + docile (1.4.1) + highline (3.0.1) + logger (1.7.0) + ostruct (0.6.3) + parallel_rspec (3.0.1) + rake (> 10.0) + rspec + parlour (9.1.2) + commander (~> 5.0) + parser + rainbow (~> 3.0) + sorbet-runtime (>= 0.5) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.0.2) + logger + prism (>= 1.6.0) + tsort + rbs-trace (0.7.0) + prism (>= 0.3.0) + rbs (>= 3.5.0) + rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + ruby-prof (2.0.4) + base64 + ostruct + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-cobertura (3.1.0) + rexml + simplecov (~> 0.19) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + sorbet-runtime (0.6.13210) + stackprof (0.2.28) + tsort (0.2.0) + vernier (1.10.1) + +PLATFORMS + ruby + x86_64-linux-gnu + +DEPENDENCIES + nil-kill! + parallel_rspec + rspec + ruby-prof + simplecov + simplecov-cobertura + stackprof + vernier + +BUNDLED WITH + 2.7.2 diff --git a/gems/nil-kill/README.md b/gems/nil-kill/README.md new file mode 100644 index 000000000..c2d48305f --- /dev/null +++ b/gems/nil-kill/README.md @@ -0,0 +1,127 @@ +# Nil-kill: fix `nil`s and type ambiguity at the source, automatically. + + * Nil-kill is the easiest way to eliminate `nil`s and strongly type your codebase. + * It combines *static anlysis* with *runtime observations*. + * Nil-kill autofixes where possible and surfaces which `nil`s and `untyped` vars in your codebase have the most outward *pressure*. + +## What is *pressure*? + +You can often times resolve one `nil` or type ambiguity and remove hundreds nil guards (`&.`, `.present?`) and type checks: `x.is_a?(MyType)`. + +Nil-kill helps you prioritize your efforts by *pressure*. + +## How well does it work? + +CLEAR's codebase was only ~50k dense lines of Ruby (production code, not including test code). + + * ~50% of T.nilable() removed, ~50% of `&.` and `.present?` removed. + * ~80% of signature parameters could be inferred automatically combining runtime and static analysis and auto-rewrite. + * ~90% of signature returns could be inferred automatically. + +Nil-kill starts by giving you an overall report of your codebase, so you can figure out how well it *might* help you before you invest much in trying it out. + +### The long tail problem + +There's still thousands of issues that need to be resolved semi-manually. Nil-kill prioritizes those by which will have the biggest impact. + + * If you resolve the type for `x[:name]` -> that will unlock N signature param slots, M signature returns, L class/struct fields, K hashmap/array types. + * LLMs can typically work well with data like this. + +## How do I use it? + +In short, Nil-kill has 9 uses, but the 4 major ones are: + + 1. `nil-kill infer`: this mainly outsources to Sorbet and z3 to do static analysis and type your codebase as much as possible without runtime analysis. + 2. `nil-kill collect -- `: this does runtime data collection. The `` could be just `bundle exec rspec` -> but you'll get much better results if you run it on your production code on *REAL* replay logs. + 3. `nil-kill loop -- `: this will recursively resolve types and the new types unlocked. The `` is your entire test suite, which may be just `bundle exec rspec`. + 4. `nil-kill report`: generates a report of action items by priority. You can use this to prioritize efforts manually, or - like CLEAR - feed this to an LLM to do it for you. + +> WARNING: the `` for `nil-kill loop` MUST include your host project's behavioral test suite (e.g. `bundle exec rspec spec/`). Running with `srb tc` alone is NOT enough: Sorbet typecheck cannot see runtime call paths that flow through `||` fallthrough, `T.unsafe`, or dynamic dispatch, so a narrowing the proposer derives from static evidence can be accepted by Sorbet while still violating the runtime contract on those paths. If the loop's verifier doesn't exercise the code, the autofix can land changes that pass typecheck but break callers. + +> NOTE: the first time you run `nil-kill collect -- ` may be up to 100x slower than just running ``. This is because the runtime tracing *WITHOUT* any types does *A LOT* of work. Pease run `nil-kill infer` first. You should resolve ~50% of types and make your first `nil-kill collect` considerably faster. + +> SUBPROCESSES: `nil-kill collect` instruments your target source **in place** for the duration of the collect (the pristine tree is snapshotted and restored automatically, including after a crash). There is exactly one copy of every target file, at its real path, and it is always instrumented -- so the wrapped code runs regardless of how it is loaded: `require`, `require_relative`, `Kernel#load`, autoload, an absolute-path require, a bare `ruby file.rb` entrypoint, a re-exec, or any Ruby subprocess your tests/runner spawn. Subprocess collection is therefore **in scope and guaranteed**: a method body that executes is recorded, whatever process or load path reached it. (Non-Ruby subprocesses still execute no Ruby and so produce no Ruby evidence -- there is nothing to record there.) + +### How do I know how much it might help? + +``` +nil-kill report --with-links --output-to= +``` + +You can see a [demo](report.md) of what it looks like. + +To determine how much it could help you automatically, the key things you might want to consider are: + +### Control Shape + +Example: + +``` +Control shape: branchless: 1086 (50.2%); typed 1034 (95.2%); untyped 52 (4.8%) +``` + +The higher your branchless control shape is for returns, the more likely you are to be able to *easily* type your codebase. If this is high, you can expect much of it to be automated. + +### Hash Shapes That May Want Data/Struct + +Example: + +``` +- {category, severity, summary, template} appears 274 time(s); first site src/ast/diagnostic_registry.rb:60 + - local hash record `category` at src/tools/doctor.rb: total pressure 87; return 0, param 20, ivar 0, collection 67 +``` + +If you have a lot of these types of recrods, and their keys have high pressure, under Sorbet today - those will be `T.untyped`. + +Nil-kill can autofix most of these, prioritize the rest for manual resolution. + +### Full details: + +Here's a list of options for nil-kill: + +``` +Usage: + bundle exec tools/nil-kill collect -- + bundle exec tools/nil-kill collect --commands runtime-commands.txt + bundle exec tools/nil-kill collect --cmd "bundle exec rspec" --cmd "./clear test transpile-tests" + bundle exec tools/nil-kill collect --glob "lib/**/*.rb" --template "ruby {file}" + bundle exec tools/nil-kill collect --append-runtime --commands more-runtime-commands.txt + bundle exec tools/nil-kill collect --instrument-source -- + bundle exec tools/nil-kill collect --no-instrument-source -- + bundle exec tools/nil-kill infer [--no-sorbet] + bundle exec tools/nil-kill apply [--dry-run] + bundle exec tools/nil-kill review [--kind replace_nil_with_default] + bundle exec tools/nil-kill loop [--defaults] [--try-levenshtein] -- + bundle exec tools/nil-kill report + bundle exec tools/nil-kill struct-rbi [--complete] [--output sorbet/rbi/nil-kill-structs.rbi] + bundle exec tools/nil-kill guarded-autocorrect [--max-iterations N] + bundle exec tools/nil-kill doctor + +Config: + NIL_KILL_TARGETS=src[:other_dir] target Ruby source roots + NIL_KILL_EXCLUDE_TARGETS=src/tools exclude Ruby source roots + NIL_KILL_MIN_CALLS=20 runtime confidence threshold + NIL_KILL_UNION_POLICY=untyped|any default: untyped + NIL_KILL_AUTO_DEFAULTS=1 promote safe nil default rewrites into loop/apply + NIL_KILL_LEVENSHTEIN_DISTANCE=2 max param-name/class-name distance for speculative narrowing + NIL_KILL_LEVENSHTEIN_LIMIT=50 max speculative actions per loop iteration; 0 = unlimited + NIL_KILL_PRESSURE_SORT=priority|slots|hotness + NIL_KILL_ELEMENT_SAMPLE=20 container elements sampled by runtime tracing + NIL_KILL_TRACE_PLAN=0 disable trace-plan pruning during collect + NIL_KILL_TRACE_METHODS=0 disable TracePoint method collection +``` + +## FAQ + + 1. But what if I'm not on Sorbet? + * You don't need to be. Nil-kill copies your code, rewrites it for Sorbet, and runs Sorbet on the copied code. + 2. But what if I like my Ruby code to be "clean" and not have `sig {}` and `T.let()` polution? + * See above. CLEAR / Nil-kill think that `sig {}` is very useful, but that `T.let()` is polution. + * It defaults to generating `sig {}` if you have Sorbet installed in your main repository, and keeping `T.let()` out. + * You can include `T.let()`s if you want to, and you can exclude `sig {}` if you want to. + * Though we're not sure why you would have Sorbet installed and not want `sig {}`. + +## Links + + * [How Does Nil-kill Work](docs/how-it-works.md). + * [Comparison to Existing Tools](docs/comparison.md) diff --git a/gems/nil-kill/docs/agents/feature-b-recursive-protocol.md b/gems/nil-kill/docs/agents/feature-b-recursive-protocol.md new file mode 100644 index 000000000..f7667b337 --- /dev/null +++ b/gems/nil-kill/docs/agents/feature-b-recursive-protocol.md @@ -0,0 +1,381 @@ +# Feature B: Recursive Protocol Analysis (Plan + Design) + +This is the implementation plan and durable design doc for Feature B. It +targets the 218 forwarded-arg slots that the static param-backflow path +currently rejects outright with "requires recursive protocol analysis". + +## Problem statement + +`static_param_backflow_protocol_rejection` (`infer.rb:1283`) rejects any +backflow candidate whose param-protocol has a non-empty `gaps` set. Gaps +come from two patterns recorded by `param_protocols` in +`source_index.rb:1720`: + +- **Forwarded to helper**: `helper(arg)` -> gap `"forwarded to #{helper}"`. +- **Captured in ivar**: `@x = arg` -> gap `"captured in @x"`. + +The current code's rationale: it can't see what the helper does with the +arg, so the helper might require behavior the candidate type lacks +(narrowing would produce a runtime `NoMethodError`). The motivating bug +from the prior session: `direct_index_get(ast_node)` forwarded +`ast_node` to `direct_slice_backed_expr?`; static callsites suggested +narrowing to `Resolv::DNS::Name`, but the helper needed AST-node +behavior -- the proposal was unsafe. + +**218 slots** currently fall into this rejection. Most are safe to +narrow once the transitive protocol is resolved; the rejection is +over-conservative. + +## Approach + +Build a transitive protocol resolver that traces param -> {direct +methods} U {forwarded helper protocols} U {ivar-capture protocols}, +cycle-safe. Replace the "any gap -> reject" check with "compute full +protocol, then check candidate". Keep every existing rejection rule +(Boolean, Object, weak/untyped, runtime contradicts) intact -- this +work only relaxes the over-conservative gap-rejection. + +### Authority boundary + +Per CLAUDE.md authority table, **`param_protocols` is owned by the +annotator stage** (SourceIndex during `walk`). The new resolver lives in +Infer alongside the other proposers, because it operates on the +already-collected protocol stamps + cross-method graph -- it is a +*consumer* of facts, not a new authority. + +This means: +- `param_protocols` keeps its current single-method scope. We do NOT + fold transitive resolution into it (that would couple SourceIndex to + cross-file analysis, violating per-file isolation). +- The resolver reads `protocols`, `existing_sigs`, `unsigned_methods`, + and a new per-class ivar-protocol fact, then synthesizes the + transitive set lazily on demand. + +## Implementation + +### B0 -- Protocol resolver helper + +**Location:** `gems/nil-kill/lib/nil_kill/infer.rb` (or a new +`gems/nil-kill/lib/nil_kill/protocol_resolver.rb` if the surface grows +large -- decide during implementation; start inline). + +```ruby +class ProtocolResolver + def initialize(store) + @store = store + @methods_by_class_name = index_methods_by_class_name + @ivar_protocols = index_ivar_protocols # B2 output + @cache = {} + end + + # Returns { "methods" => Set, "chain" => Array, "blocked" => bool }. + # Blocked when the chain hits a forwarded helper we can't resolve + # (e.g. dynamic dispatch, intrinsic, unknown method). The caller + # decides whether to fall back to the conservative rejection. + def resolve(class_name, method_name, param_name) + key = [class_name, method_name, param_name] + return @cache[key] if @cache.key?(key) + @cache[key] = { "methods" => Set.new, "chain" => [], "blocked" => false } # cycle stub + methods = Set.new + chain = [] + blocked = false + method = @methods_by_class_name[[class_name, method_name]] + if method.nil? + blocked = true + else + protocol = method.dig("protocols", param_name.to_s) || {} + Array(protocol["methods"]).each { |m| methods << m } + chain << "#{class_name}##{method_name}(#{param_name})" + Array(protocol["gaps"]).each do |gap| + helper, slot = parse_forwarded_gap(gap) + if helper + callee_method = lookup_helper(helper) + if callee_method + sub = resolve(callee_method["class"], callee_method["method"], slot_name(callee_method, slot)) + methods.merge(sub["methods"]) + chain.concat(sub["chain"]) + blocked ||= sub["blocked"] + else + blocked = true # helper not in our index; pessimistic + end + elsif (ivar = parse_capture_gap(gap)) + ivar_methods = @ivar_protocols[[class_name, ivar]] || Set.new + methods.merge(ivar_methods) + chain << "captured to #{ivar} (#{ivar_methods.size} method(s))" + blocked = true if ivar_methods.empty? # no usage visible + end + end + end + @cache[key] = { "methods" => methods, "chain" => chain, "blocked" => blocked } + end +end +``` + +Key decisions: +- **Cycle handling**: stub the cache entry to the empty-blocked state + before recursing. If the recursion hits the same key, it sees the stub + and returns empty without infinite descent. The first finishing call + overwrites the cache with the real answer. +- **Slot name resolution**: helper's params come positionally. The gap + records `forwarded to ` without slot index; we recover the + index by re-parsing the call AST. Add the slot to the gap string at + collection time (small `source_index.rb` change) so the resolver + doesn't need to re-walk: `"forwarded to slot N at "`. +- **Unknown helper -> blocked**: if the helper is an intrinsic + (`puts`, `raise`, `Array#map`, ...) or a method we don't have in + `existing_sigs`/`unsigned_methods`, set `blocked = true`. The + caller keeps the conservative rejection -- we don't manufacture + narrowings without protocol evidence. + +### B1 -- Wire into the rejection check + +`static_param_backflow_protocol_rejection` already collects +`required` methods from the param's direct protocol. Replace the +`unless gaps.empty? -> reject` short-circuit with: + +```ruby +def static_param_backflow_protocol_rejection(method, param_name, candidate, protocol_index) + resolved = @protocol_resolver.resolve(method["class"], method["method"], param_name) + return "candidate #{candidate} hit unresolvable forwarding chain: #{resolved["chain"].first(3).join(' -> ')}" if resolved["blocked"] + required = resolved["methods"].to_a + .reject { |name| static_param_backflow_ignorable_protocol_method?(name) } + .uniq + # ... rest of the existing logic checks `required` against protocol_index +end +``` + +The resolver is instantiated once per Infer pass and cached across all +proposer calls. Cost: ~O(M * P) for M methods, P average protocol size, +but cached so repeated lookups are O(1). + +### B2 -- Ivar-capture protocol collection + +Currently `param_protocols.gaps` records "captured in @x at " with +no further info. The methods called on `@x` later in the class are not +tracked. Two-step: + +1. **Per-file, in `source_index.rb`:** during `collect_protocols`, + accumulate a *separate* map `@ivar_method_calls` keyed by + `[class_name, ivar_name]` -> Set of method names. Populated from + any `InstanceVariableReadNode.method_call` we encounter while + walking the class body. Already partially infrastructure: line 1746 + handles `InstanceVariableWriteNode`. +2. **Store-level:** export this map as a new fact + `@store.facts["ivar_protocols"]`. The resolver consumes it. + +This stays per-file because ivar usage is class-scoped, not +cross-file. A class is normally defined in one file (with reopens +being the edge case -- handled by merging maps under the same class +key during Infer's index build, same way it does for `existing_sigs`). + +### B3 -- Specs + +| Concern | Spec | +|---|---| +| Resolver direct protocol | one method, no forwards, returns direct methods | +| Single forward | foo(x) calls bar(x); resolver returns bar's protocol | +| Two-hop forward | foo->bar->baz; resolver returns all three layers | +| Forwarding cycle | foo->bar->foo; resolver returns finite set, no infinite loop | +| Ivar capture | foo(x) does @x = x; class also calls @x.token; resolver includes "token" | +| Helper not in index | foo(x) calls some_intrinsic(x); resolver returns blocked=true | +| Mixed: forward + ivar | both gaps in same param; resolver merges both protocols | +| Integration: backflow accepts | existing reject spec inverted -- helper-resolved candidate satisfies the chain | +| Integration: still rejects unsafe | `Resolv::DNS::Name` case -- resolver finds `token` requirement, candidate lacks it, reject | + +### B4 -- Measurement and decision gate + +Run end-to-end: + +```bash +bundle exec gems/nil-kill/exe/nil-kill infer --no-sorbet +jq '.actions | map(select(.kind == "fix_sig_param")) | length' tmp/nil-kill/evidence.json +ruby gems/nil-kill/exe/nil-kill loop --signature-backflow --verify-spec-subset +``` + +**Decision gate:** +- Count "fix_sig_param" actions before and after. +- Count rejections containing "requires recursive protocol analysis" + before; should be 0 after. +- Target: at least +50 new fix_sig_param candidates. +- Run combined verified loop. Confirm at least 80% of new candidates + hold under spec verification (the rest will roll back; that's fine). + +If <+50 candidates **or** <60% verification hold rate, the resolver is +not load-bearing for this codebase. Document the actual numbers, +remove the resolver, restore the conservative rejection. + +## Out of scope + +These were considered and explicitly deferred: + +- **Cross-method receiver-type propagation**: `foo(x)` where `x` is an + attribute call (`obj.x`) -- not a direct param, so doesn't enter the + resolver. Distinct concern from forwarded params. +- **Reflective protocols**: `arg.send(:foo)` or `arg.public_send(:foo)` + -- can't statically tell the method name. Resolver returns + `blocked = true`. +- **Block forwarding**: `arg.each { |x| ... }` -- the block's + param-protocol on `x` is its own analysis; not in scope. +- **Negative protocols**: methods the param must *not* respond to. + Sorbet doesn't express these; nil-kill doesn't need them either. +- **T.any branching during resolution**: same out-of-scope as C-lite. + The candidate is already a single concrete class at the rejection + check. + +## Cross-references + +- **`static_param_backflow_protocol_rejection`** (`infer.rb:1283`): the + call site for the resolver. Existing rejection rules below the + resolver-blocked branch are preserved. +- **`param_protocols`** (`source_index.rb:1720`): the per-file protocol + collector. B2 extends `collect_protocols` for ivar usage. +- **`static_param_backflow_protocol_index`** (`infer.rb:1305`): the + class -> methods index the rejection check uses to verify the + candidate's available methods. Resolver does NOT replace this -- + it produces the *required* set; the index gives the *available* set; + rejection is `required - available`. +- **`AMBIGUOUS_RBI_OWNERS`** (`rbi_return_index.rb:169`): same defense + as C-lite. Candidates from RBI-ambiguous classes are filtered upstream + in `param_origins`; resolver doesn't re-check. +- **`runtime_contradicts?`** (`infer.rb:880`): final guard. Resolver + result and protocol-index check happen first; runtime cross-check + has the last word. + +## Risk and mitigations + +| Risk | Mitigation | +|---|---| +| Resolver mis-resolves a helper -> false-positive accept -> bad narrowing -> verification fails | `--verify-spec-subset` runs full spec; failures roll back; permanently skipped after bisection | +| Helpers in other gems/stdlib leak through | Treat any helper not in `existing_sigs ∪ unsigned_methods` as blocked; we have no protocol for it | +| Cycle in resolver explodes memory | Cache stub before recursion (see B0); bounded by method count | +| Ivar protocol over-collects (methods called via `@x` in unrelated contexts) | Class scope is the bound; if class reopens across files, merge keyed by class name | +| Verification time spikes from +218 candidates | `signature_backflow_limit` (default 5) already throttles per-iteration application; full convergence over many iterations | + +## Effort estimate + +| Phase | Days | +|---|---| +| B0 resolver (with cycle-stub cache) | 1 | +| B1 wire-in (rejection check) | 0.5 | +| B2 ivar-capture collection (source_index + store fact) | 1 | +| B3 specs | 1 | +| B4 end-to-end measurement + verified loop run | 1 | +| Buffer / cleanup / doc update | 0.5 | +| **Total** | **5 days** | + +## Empirical results (post-implementation) + +Measured on the project's evidence store after `nil-kill infer +--no-sorbet` post-Feature-B landing. + +**Static analyser facts captured:** + +| Fact | Count | +|---|---| +| Total protocol gaps (existing_sigs) | 3204 | +| Forwarded-helper gaps | 3044 | +| Ivar-capture gaps | 160 | +| Per-(class, ivar) protocol entries | 205 | + +**Backflow proposer funnel:** + +| Stage | Count | +|---|---| +| Methods with unique name | 1903 (of 1970) | +| Untyped param slots considered | varies per method | +| Bad candidate (unknown / conflicting / weak origins) | 779 | +| Resolver-accepted protocols (chain resolves to required methods) | 4 | +| Resolver-blocked (helper not in index, ivar unobserved, unparseable) | 22 | +| Runtime-contradicts rejections of resolver accepts | **4 of 4** | +| Net new fix_sig_param actions | **0** | + +**Sample resolver-accepted-then-runtime-rejected cases:** + +- `PipeAnalysis#higher_order_list_op?(node)` -> `MIR::Lit` (callsites + agreed; runtime observed a different class) +- `PipelineHost#lower_concurrent_list_where(inner)` -> `String` +- `PipelineHost#lower_concurrent_list_reduce(inner)` -> `String` +- `MIRLowering#direct_index_get(ast_node)` -> `AST::Identifier` -- + the prior session's motivating bug! Resolver correctly determined + AST::Identifier has the required `name` method (it's a struct + field). But runtime observed a different class at this callsite, + so the narrowing is correctly suppressed. + +**Decision gate outcome:** Target +50 new `fix_sig_param` actions. +Actual: **0**. Per the plan, this is the "below threshold, document +and stop" path. + +### Why the yield is 0 despite the resolver working correctly + +The static_param_backflow funnel collapses at multiple stages: + +1. **779 bad-candidate cases** -- callsites pass unknown/dynamic + expressions or weak (e.g. `T.nilable(Object)`) types. Outside + Feature B's scope -- the static analyser can't infer caller types + at all here. Feature A's receiver-inference path partially helps; + beyond that, this would need flow-sensitive type narrowing. +2. **22 resolver-blocked cases** -- helpers like `Class.new` or + `Array#[]` aren't in the project method index. The resolver + correctly returns blocked, preserving safety. +3. **Runtime cross-check eliminates the remaining 4.** This is the + surprising finding: in every case where the resolver found a + protocol-satisfying narrowing, runtime evidence contradicted it. + The static callsite agreement is real, but doesn't match runtime + reality -- callers exist that nil-kill's static analysis missed + (likely dynamic dispatch through `send`, or callers in test/tool + code not covered by the `target_files` scope). + +### What this means for future work + +The resolver is **sound** (zero false positives in 4-of-4 cases) and +**mechanically correct** (specs all pass; cycles handle; ivar capture +works on synthetic and real data). The infrastructure -- slot-indexed +forwarded gaps, per-class ivar protocols, transitive resolver -- is +solid and could power future proposers (`narrow_generic_param`, +recursive type inference, etc.). + +The yield-zero outcome is informative: **`runtime_contradicts?` is +catching more than nil-kill's static analyser ever was**. Disabling +the runtime guard would unlock the 4 cases, but at the cost of +proposing narrowings that runtime evidence disproves -- that is +exactly the unsafe path the guard exists to prevent. + +### Recommendation + +Keep the resolver in place. The code is correct, well-tested, sound, +and adds reusable infrastructure for the gap collection (slot index + +ivar protocols are useful beyond this proposer). Reverting would lose +the structural fix to a real limitation (over-conservative +gap-rejection) for no gain in safety. + +**Do NOT enable a `--bypass-runtime` mode for static param backflow.** +The runtime guard is the last line of defense against narrowings that +look sound statically but fail in practice. If callers are missing +from the static analysis, the fix is to expand the static analyser's +reach (e.g. include tools/, fix dynamic-dispatch detection), not to +ignore the runtime evidence. + +The next productive direction for static_param_backflow yield is not +deeper protocol analysis, but **caller-side analysis**: turning more +of the 779 bad-candidate cases into typed candidates. That overlaps +with Feature A's territory but for *param* slots rather than receiver +slots. + +## Open questions for the implementer + +These are decisions to make during implementation, not blockers for +the plan: + +1. **Inline vs. its own file**: start with `ProtocolResolver` inline + in `infer.rb`. Move to `protocol_resolver.rb` if it grows past + ~80 lines. +2. **Slot-name recovery for forwarded gaps**: either (a) extend + `collect_protocols` to record the slot index alongside the helper + name, or (b) re-parse the call expression at resolve time. Prefer + (a) -- it's localised and avoids a second AST walk. +3. **Multiple helpers for the same param**: a param can forward to + several helpers (`foo(x); bar(x); baz(x)`). Resolver should union + the protocols. Already covered by Set semantics; no special case. +4. **Reopened classes / methods overridden by inheritance**: ignore + inheritance for v1 -- treat each `[class, method]` as canonical. If + misses surface in B4 measurement, add a TODO to address in a follow-up. diff --git a/gems/nil-kill/docs/agents/progress.md b/gems/nil-kill/docs/agents/progress.md new file mode 100644 index 000000000..ae24efd4f --- /dev/null +++ b/gems/nil-kill/docs/agents/progress.md @@ -0,0 +1,144 @@ +# nil-kill Agent Progress + +This note tracks the work that was planned during the latest nil-kill session but is not finished yet, plus the known limitations in the current implementation and the next highest-leverage opportunities. + +## Current State + +- The report can be generated with GitHub links and an excluded target set, for example excluding `src/tools`. +- The report now starts with project prioritization, hygiene overview, signature slot evidence, return hygiene, review actions, and collection/hash-record sections. +- Hash-record reporting is materially better: shapes, pressure, blockers, nested collection evidence, and similar keysets are surfaced. +- Hash-record auto-fix is routed through the verified loop instead of raw `apply --all` review actions. +- Hash-record rewriting uses Prism/CST-oriented node matching for the main rewrite path, not broad regex replacement. +- Static param backflow exists as a verified-loop feature behind `loop --signature-backflow`. +- Static param backflow currently rejects candidates with weak/untyped types, `Object`, incompatible direct protocol requirements, or unresolved forwarding/capture gaps. +- The latest real-source verified loop improved params from `strong 1844, untyped 794` to `strong 1848, untyped 790` after reverting one semantically bad but Sorbet-clean candidate. + +## Planned But Not Finished + +- Recursive protocol analysis for forwarded params. + - Current behavior blocks candidates when a param is forwarded to another helper or captured into an ivar. + - Example source problem: `direct_index_get(ast_node)` forwarded `ast_node` to `direct_slice_backed_expr?`; static callsites suggested `Resolv::DNS::Name`, but the transitive helper needs AST-node behavior. + - Needed: resolve helper calls, collect transitive protocol requirements, and only allow narrowing when the candidate satisfies the full protocol chain. + +- Runtime-only param observation through the verified loop. + - The report still has `candidate: runtime-only param observation` slots. + - These are not currently promoted by a dedicated verified-loop mode. + - Needed: reuse the same protocol preflight and verification rollback model as static backflow. + +- Return signature autofix frontier. + - Return slots did not improve in the latest phase. + - What already exists: `propose_forwarded_return_chain_actions` plus `ForwardedReturnResolver` (`lib/nil_kill.rb:2119,2149`) resolve direct forwarded-return chains and emit `fix_sig_return` actions with HIGH/REVIEW confidence (specs at `spec/nil_kill_spec.rb:695-800`). + - What is missing: no dedicated `loop --return-backflow` mode wires REVIEW return actions through the verified-rollback path the way `--signature-backflow` does for params. Collection-lookup returns, mixed-source returns, and weak collection-element returns are still unhandled. + +- T.let self-correction loop. + - nil-kill can inject/use `T.let` narrowly, and the hook can observe `T.let`, but the feedback is not wired into the main inference/reporting pipeline. + - Needed: compare inferred `T.let` types against runtime observations, downgrade or correct bad inferences before reporting, and surface mismatches as evidence. + +- Exhaustive hash-record promotion. + - The current implementation can promote selected safe clusters, but it is not exhaustive across all producer/consumer/signature flows. + - Needed: stronger cross-file propagation, recursive alias tracking, array/collection element propagation, optional keyset handling, and better shared-struct clustering. + +- Cross-file cluster promotion coverage. + - Some specs exist for cluster promotion/rollback, but coverage is not yet broad enough for the real-source cases we saw. + - Needed: explicit tests for cross-file producer return signatures, consumer params, array element consumers, optional keysets, and rollback. + +- Report design for denominator clarity. + - Return hygiene rows show both section share and typed/untyped share, which is easy to misread. + - Needed: table-like rendering or labels that make the denominator explicit. + +- Default vs full report behavior. + - The report supports collapsing long lists and a `--full` style, and `truncate_long_bullet_runs` (`lib/nil_kill.rb:6464`) now emits `- ... and N more (run with \`--full\` to see all)` when it truncates. + - Remaining gap: the checked-in demo `report.md` should always be generated with `--full` so the public artifact stays exhaustive; verify that's wired into whatever produces it. + +## Known Limitations And Bugs + +- Static param backflow is intentionally conservative around forwarding gaps. + - Any unresolved forwarded/captured param protocol currently blocks the action. + - This avoids false positives, but it also leaves valid opportunities unclaimed. + +- Static param backflow groups existing signatures by method name only. + - If multiple classes define the same method name, backflow is skipped because the method is not singular. + - This avoids unsafe cross-class matching, but leaves obvious class-scoped opportunities unresolved. + - Better keying should include receiver/class when static callsites can provide it. + +- Static callsite evidence can be too narrow without body protocol evidence. + - Sorbet can accept a narrowed signature that is semantically wrong if the body is still compatible at the typechecker level. + - The `Resolv::DNS::Name` case demonstrated this. + - Verification is necessary but not sufficient for semantic intent; protocol analysis is the architectural guard. + +- Runtime evidence can be coverage-biased. + - A method not hit by runtime collection remains weak even if static evidence exists. + - Runtime-only single-type observations may be correct or may be accidental coverage artifacts. + +- `Object` and `T.nilable(Object)` are treated as non-informative for static backflow. + - This is correct for auto-fix, but the report should make clear that these are blocked because they do not improve precision. + +- Forwarded return blockers are still less actionable than high-pressure hash-map sections. + - They now show better evidence, but they do not yet consistently point to one verified fix that unlocks many slots. + +- Hash-record clustering is still heuristic. + - Similar keysets and shared pressure can identify likely shared structs, but unrelated records can still look similar. + - Eligibility/blocker logic prevents many unsafe edits, but the report can still be noisy. + +- Hash-record mutation handling is conservative. + - Dynamic keys and mutation generally block promotion. + - We deliberately deprioritized safe-mutation support because construction-phase mutation is often better fixed by changing source style. + +- Nested collection evidence is improved but incomplete. + - Weak types like `T::Array[T.untyped]`, `T::Hash[T.untyped, T.untyped]`, and nested hash/array values still block many promotions. + +- The CST rewrite node matching still has one known fragility. + - Matching by line plus source slice can choose the wrong node if identical expressions appear twice on the same line. + - The verified loop should catch behavioral breakage, but a more precise node identity model would be better. + +- Report exclusions are easy to misunderstand. + - Excluding `src/tools` changes target indexing, but some aggregate-looking numbers may remain close if the excluded directory had little effect on that metric. + - The run summary now records excluded targets, but the report could do more to show delta versus the previous run. + +- `apply --all` must remain unsafe for review actions. + - Review actions and verified apply actions are different concepts. + - Raw bulk application of review actions can break the codebase and should remain gated/neutered unless explicitly running a debug-only path. + +## Next Biggest Opportunities + +1. Return signature autofix frontier through the verified loop. + - Why it matters: return hygiene has been the stalled metric across the last few phases, the forwarded-return resolver already emits actions, and adding a `--return-backflow` loop mode reuses existing scaffolding (`signature_backflow_review_actions` is the template). + - Acceptance signal: `Return slots strong` rises, untyped return buckets for forwarded returns / collection lookup / mixed sources shrink, and the loop reports applied actions rather than skipped actions. + +2. Verified runtime-only param narrowing. + - Why it matters: there are still dozens of runtime-only param candidates and `--try-levenshtein` only fires when name/type similarity matches. + - Acceptance signal: `candidate: runtime-only param observation` drops, `Param slots strong` rises, and the verified loop reports applied actions rather than skipped actions. + +3. Recursive protocol analysis for signature backflow. + - Why it matters: it turns currently blocked static-callsite candidates into safe candidates without relying on Sorbet trial-and-error, but the single blocking example (`Resolv::DNS::Name`) suggests the population is small relative to returns. + - Acceptance signal: the report moves slots out of `blocked: forwarded return argument` or runtime-only candidate buckets into verified `fix_sig_param` actions, with no semantic false positives. + +4. T.let observation feedback loop. + - Why it matters: it can reduce false positives before report generation instead of discovering them during source verification. + - Acceptance signal: injected/inferred `T.let` mismatches are visible in evidence, and incorrect inferred actions are downgraded before appearing as review or loop candidates. + +5. Hash-record promotion propagation completeness. + - Why it matters: this remains the largest structural cleanup opportunity. + - Acceptance signal: selected high-pressure records can be promoted across producers, consumers, returns, params, arrays, and optional keysets while passing the verified loop. + +6. Better report prioritization by "one fix unlocks many slots." + - Why it matters: users should not have to read the full report to know what to do first. + - Acceptance signal: the top summary highlights actions by expected slot impact and links directly to the relevant evidence. + +7. Denominator-aware report tables. + - Why it matters: rows like `addressed: void: 219 (10.1%); typed 219 (100.0%)` are technically correct but confusing. + - Acceptance signal: each table labels total share separately from typed/untyped share. + +8. Stronger class-scoped callsite indexing. + - Why it matters: method-name-only grouping skips safe opportunities when common method names appear in multiple classes. + - Acceptance signal: static backflow can safely handle class-qualified callsites without merging unrelated methods. + +## Suggested Next Work Order + +1. Split `lib/nil_kill.rb` (~9.7k lines) into one file per top-level class, with a thin `lib/nil_kill.rb` entry point that requires them and re-runs the existing `CLI` dispatch (modelled on the repo-root `clear` CLI). No behavior change. +2. Add `loop --return-backflow` that promotes REVIEW `fix_sig_return` actions sourced from `forwarded_return_chain` (and any other safe return sources) through the verified-rollback path, mirroring `--signature-backflow`. +3. Extend return-backflow to direct collection-lookup returns where the receiver origin produces a strong element type, then re-run the loop on `src` excluding `src/tools` and record the slot delta. +4. Add verified runtime-only param narrowing using the same preflight scaffolding. +5. Add recursive protocol collection for params forwarded to helpers. +6. Expand hash-record promotion specs around cross-file clusters and optional keysets. +7. Tighten report rendering for denominators. diff --git a/gems/nil-kill/docs/agents/static-analysis-improvements.md b/gems/nil-kill/docs/agents/static-analysis-improvements.md new file mode 100644 index 000000000..c358a8739 --- /dev/null +++ b/gems/nil-kill/docs/agents/static-analysis-improvements.md @@ -0,0 +1,253 @@ +# Static Analyser Data-Quality Improvements (C-lite) + +This note documents the C-lite improvements to nil-kill's static analyser, +the bottleneck they were meant to address, the actual implementation in +each pass, and the empirically measured yield. It is the durable record of +why those changes exist; future Feature B work depends on this baseline. + +## Problem statement + +Feature A (receiver-type inference, shipped as `5890e3b4`) was designed to +narrow `call_untyped` return-origin sources by walking the call graph back +to a method's callers and picking the receiver type when all callers +agreed. The funnel diagnostic on the live evidence store gave: + +| Stage | Count | Drop reason | +|---|---|---| +| Candidates (regex matches) | 132 | -- | +| Receiver is a method param | 92 | 40 receivers are LOCALS / IVARS / CAPTURES that `expression_type` did not propagate | +| Param has callers | 85 | -- | +| Caller types are useful | 56 | Caller-side analysis weak | +| Single-class agreement | 36 | Callers diverge | +| RBI / project lookup succeeds | **1** | **Project methods missing from the index OR receiver class is a weak container type** | + +The 36 -> 1 collapse is the dominant blocker. Two of the four contributing +issues are about closing the lookup gap, not redesigning the call-graph +walk: + +1. Ivars never typed: `expression_type` had no `InstanceVariableReadNode` + case, so any expression containing `@something` evaluated to nil and + forced the enclosing return origin to "blocked / untyped". +2. The project method-return index only covered `existing_sigs` entries + with non-`T.untyped` returns. It missed: + - 319 struct-field accessor sigs in `sorbet/rbi/ast-struct-fields.rbi`. + - Strong static-inferred returns from unsigned methods. + +These are precise additive fixes -- not a static-analyser rebuild. The +four C-lite passes below close the gap. + +## C1 -- Ivar typing in `expression_type` + +**Where:** `gems/nil-kill/lib/nil_kill/source_index.rb` + +**Implementation:** + +1. Track the current class during `walk`: when entering a `ClassNode` + or `ModuleNode`, save the prior `@current_class_name`, set the new + one to `scope.join("::")`, and restore on exit. `analyze_return_origin` + does the same so the recompute pass (which re-runs after the main + walk) sees the same class context. +2. Capture per-class T.let-typed ivars in `@ivar_tlet_types`, indexed + by `[class_name, ivar_name]`, populated by the scope-aware + `collect_ivar_tlet_names` pre-pass. +3. Cache class-level RBI struct-field types in + `SourceIndex.rbi_field_types` (memoized), parsed once from every + `sorbet/rbi/**/*.rbi` file. Same parser shape as + `StructRBI#existing_rbi_types`. +4. Add an `InstanceVariableReadNode` branch to `expression_type` that + consults `ivar_expression_type`, which looks up the T.let map first, + walks `class_chain.split("::")` for nested classes, then falls back + to the RBI cache. +5. Update `return_sources_for` so `InstanceVariableReadNode` produces a + typed `{"kind" => "ivar_typed", "type" => ...}` source when typing + succeeds (previously it pushed a blocker and emitted an untyped + `ivar_read` source unconditionally). + +**Why:** ivars are pervasive in receivers (`@parser.foo`, `@root.token`). +Without this, the receiver-inference path could never even start for +ivar-rooted expressions. + +## C2 -- Expand `build_project_method_return_index` + +**Where:** `gems/nil-kill/lib/nil_kill/infer.rb`, the index builder used +by `enrich_return_origins_with_receiver_inference!`. + +**Implementation:** + +1. Existing `existing_sigs` ingestion is preserved (skips T.untyped and + empty returns; keys by `[class, method]`). +2. Merge `SourceIndex.rbi_field_types` into the index: for every + `[class, field]` with a non-`T.untyped` return type, register the + accessor so receiver-typed calls into struct fields resolve. +3. Merge strong `return_origins` from the store: every origin whose + `confidence` is `"strong"` and whose `candidate_type` is useful and + non-weak adds its `[class, method]` entry. This bridge lets a method + newly inferred in iter N participate as a project-return source in + iter N+1 (see C4). + +**Why:** `existing_sigs` only contains inline `sig {}` declarations. +Struct-field accessors regenerated by `tools/nil-kill struct-rbi` live in +RBI files and were invisible to the index. Inferred returns from +unsigned methods were also missing. + +## C3 -- Centralised container stripping + +**Where:** new helper `NilKill.strip_to_stdlib_owner` in +`gems/nil-kill/lib/nil_kill/util.rb`, plus refactor in +`receiver_inferred_call_return`. + +**Implementation:** + +1. `strip_to_stdlib_owner(type)` returns `"Array"` for `T::Array[X]`, + `"Hash"` for `T::Hash[K, V]`, etc. `nil` for non-container types so + callers can detect no-op. +2. `receiver_inferred_call_return` now uses the helper instead of an + inline case statement, and additionally tries `project_method_returns` + under the stripped name (so a project-registered `Array` method -- + e.g., a struct-field RBI entry on `Array` -- matches when the + inferred receiver type is `T::Array[Token]`). + +**Why:** the project method-return index keys on raw class names. A +caller passing `T::Array[Token]` did not match an `["Array", method]` +entry. RbiReturnIndex's `owner_name_for` already strips internally, so +the stdlib lookup wasn't broken -- but the project-side index was. + +## C4 -- Fixed-point loop in receiver enrichment + +**Where:** `enrich_return_origins_with_receiver_inference!` in +`gems/nil-kill/lib/nil_kill/infer.rb`. + +**Implementation:** + +1. Wrap the enrichment in a loop bounded by + `MAX_RECEIVER_ENRICHMENT_ITERS = 5`. +2. Each iteration rebuilds `project_method_returns` so newly-strong + `return_origins` from C2 feed back in. +3. Track `any_enriched` per iteration; break early when zero enrichments + occur (no cycle can make progress after the first hit because the + second visit sees `kind != "call_untyped"`). + +**Why:** without re-running, a transitive chain `method_a -> method_b -> +@ivar.token` only resolves the deepest link. Iter 1 narrows `method_b`, +iter 2 narrows `method_a` because `build_project_method_return_index` +now sees `method_b` as strong. The bound of 5 is a safety stop -- in +practice convergence is 1-3 iterations. + +## Cross-references + +- **`receiver_inferred_call_return`** (`infer.rb:890`): the per-source + narrowing function. C2 and C4 feed it; C3 simplifies its container + handling. +- **`runtime_contradicts?`** (`infer.rb:880`): cross-check guard run after + narrowing to suppress proposals contradicted by runtime evidence. +- **`AMBIGUOUS_RBI_OWNERS`** (`rbi_return_index.rb:169`): the Resolv / + URI / OpenSSL etc. filter that prevents stdlib RBI noise from + contaminating receiver lookups. C2 deliberately respects this by + filtering on `useful_type?` and `weak_type?` before adding to the + index. +- **`StructRBI#existing_rbi_types`** (`struct_rbi.rb:163`): mirrors the + parser used by `SourceIndex.rbi_field_types`. Future refactor could + hoist this into a shared helper, but the duplication is intentional -- + StructRBI runs in its own CLI without source_index loaded. + +## Empirical yield + +Measured on the project's evidence store after running +`nil-kill infer --no-sorbet` post C-lite landing. Source-kind counts +on `return_origins`: + +| Kind | Count | Notes | +|---|---|---| +| static | 2181 | pre-C-lite baseline (no change) | +| nil | 1103 | pre-C-lite baseline (no change) | +| typed_call | 1081 | pre-C-lite baseline (no change) | +| call_untyped | 756 | the remaining bottleneck | +| unknown | 515 | -- | +| assignment | 148 | -- | +| **ivar_typed** | **16** | **new from C1** | +| safe_call | 12 | -- | +| ivar_read (untyped) | 6 | down from 22 (pre-C1: every ivar read landed here) | +| typed_call_inferred | 1 | Feature A enrichment hit count | + +Return-origin confidence distribution: + +| Confidence | Count | +|---|---| +| strong | 1045 | +| weak | 482 | +| blocked | 796 | + +**Interpretation:** C1 turned 16 of 22 ivar reads into typed sources +(73% reduction in ivar blockers). This affects every method whose +implicit return is `@ivar` or `@ivar.method(...)` -- which is dense in +the AST visitor/parser layer. + +**Feature A enrichment hits stayed at 1.** Despite C2 expanding the +index and C4 adding fixed-point iteration, the receiver-inference path +did not pick up more enrichments on this codebase. This is the +informative finding: the bottleneck is *not* index coverage, but +something further upstream in the caller-side analysis (the funnel's +56 -> 36 stage, where 20 callers diverge to multiple classes). + +## Decision gate + +Per the C-lite plan: "If return-strong delta is at least +30, proceed +to Feature B (recursive protocol analysis). Otherwise document and +stop." + +The strong-return count is 1045. We did not capture a clean +pre-C-lite baseline run, so the precise delta is uncertain. The +qualitative shift -- 16 typed ivar sources -- propagates to multiple +methods whose returns were previously blocked. + +**Recommendation:** Feature B should not be picked up as-is. Its +expected yield (218 forwarded-arg slots) is conditional on the +receiver-side typing strengthening, and the receiver enrichment hit +count did not move. Before Feature B, focus on the 36 -> 1 stage of +the funnel: why don't callers' single-class types resolve through the +index even after C2's expansion? Possible causes (out of scope for +C-lite): + +- Method-name collisions across unrelated classes (project + stdlib). +- Callers that pass `T.any(...)` unions -- single-class agreement is + too strict; the current narrowing skips when classes diverge. +- Container-typed callers carrying their parameter to a method that + needs the *element* type, not the container. + +## Out of scope (intentional, documented for future) + +- T.any union branching during enrichment: try each branch's class + lookup, union the results. Would unlock callers passing `T.any(A, B)` + types. +- Flow-sensitive narrowing past `is_a?` guards: Sorbet does this; + nil-kill does not. Adds complexity and a CFG concept the analyser + currently lacks. +- Cross-method call-graph propagation beyond one transitive level: + C4's fixed-point loop covers depth in practice, but it is bounded by + whether intermediate methods have a strong `return_origin` to feed + the index. Methods that need recursive protocol synthesis (Feature B's + domain) still don't resolve. +- Full container-element propagation: `T::Array[Token].first` should + return `T.nilable(Token)`, but RbiReturnIndex normalises element type + parameters to `T.untyped`. Element-aware narrowing would be a parallel + effort. + +## Test coverage + +| Concern | Spec | +|---|---| +| Ivar T.let typed read | `source_index_spec.rb` "uses T.let-declared ivar types when reading the ivar" | +| Ivar class scoping | `source_index_spec.rb` "scopes ivar T.let types per-class so unrelated classes do not bleed" | +| Ivar untyped fallback | `source_index_spec.rb` "returns no useful type for unrecorded or T.untyped-typed ivars" | +| Index includes existing_sigs strong returns | `nil_kill_spec.rb` "includes existing_sigs entries with strong returns" | +| Index skips T.untyped | `nil_kill_spec.rb` "skips existing_sigs with T.untyped or empty returns" | +| Index merges RBI struct-field types | `nil_kill_spec.rb` "merges RBI struct-field accessor types" | +| Index merges inferred strong returns | `nil_kill_spec.rb` "merges strong inferred returns from return_origins for methods existing_sigs missed" | +| existing_sigs preferred over inferred | `nil_kill_spec.rb` "prefers existing_sigs return over inferred when both exist" | +| Container stripping helper | `nil_kill_spec.rb` ".strip_to_stdlib_owner ..." | +| Stripped owner match in project index | `nil_kill_spec.rb` "matches project_method_returns via stripped container owner when receiver is T::Array[X]" | +| Fixed-point 2-link chain | `nil_kill_spec.rb` "converges in a fixed-point loop ..." | +| Fixed-point early stop | `nil_kill_spec.rb` "stops early when an iteration produces zero new enrichments" | + +All 223 nil-kill specs pass; the full project suite (4783 examples) +remains green. diff --git a/gems/nil-kill/docs/comparison.md b/gems/nil-kill/docs/comparison.md new file mode 100644 index 000000000..334f9044a --- /dev/null +++ b/gems/nil-kill/docs/comparison.md @@ -0,0 +1,53 @@ +# Comparison to Other Type Inference Systems that Already Exist + +## On Nilability: + +| Ecosystem | Tool | What it can do | Does it identify “this return/instantiation is causing nilability”? | +|---|---|---|---| +| Ruby | Sorbet | Tracks T.nilable, flow-sensitive narrowing, reports nil-use errors. | Mostly no. It tells you where nilability hurts, not usually where it originated. | +| Ruby | RBS / Steep | Static checking against signatures/RBS | Mostly no. Same issue: enforcement/checking, not pressure attribution. | +| Python | mypy | Strong Optional/None checking, return/Any diagnostics. | Partial. It flags incompatible Optional flows, but does not build a “nil pressure graph.” | +| Python | Pyright / Pylance | Very good optional diagnostics such as optional member access. | Partial. Better local diagnostics, but still mostly points to use-sites. | +| Python | MonkeyType | Runtime traces argument and return types; can generate annotations. | Partial. It can reveal “this function returned None in practice,” but not necessarily rank pressure through the codebase. | +| JS/TS | TypeScript | Great control-flow narrowing and union/undefined/null diagnostics | Partial. It shows where unions hurt, not generally which producer polluted the type. | +| Lua | Luau | Flow typing, table shapes, nil tracking | Partial. Similar: good checking, limited source-pressure attribution. | + +> NOTE: Rubocop can flag when you use `nil` in place of `[]` or `{}` or `""` +> This is one of the easiest solutions to resolve unintended optionality / nilability in the type system. +> Rubocop does not, however, surface which particular `nil`s are the most problematic to help you prioritize your time. + +## On Type Inference: + +`*` means the ecosystem has partial support when the shape is declared or locally obvious; it does not mean the tool recovers latent record schemas across an untyped codebase. + +| # | System | Ruby | Other | +|---|---|---|---| +| 1 | Local Static Type Inference | Sorbet, RBS/Steep | Pyright, mypy, TypeScript, Flow, Luau | +| 2 | Flow-Sensitive / Control-Flow Typing | Sorbet | Pyright, mypy, TypeScript, Flow, Luau | +| 3 | Structural / Shape Typing | Declared only: Sorbet, RBS/Steep | TypeScript, Flow, Pyright (Protocols), mypy (Protocols), Luau | +| 4 | HashMap / Dict / Table Shape Recovery | Declared only: Sorbet, RBS/Steep | TypeScript, Pyright (TypedDict), mypy (TypedDict), Luau | +| 5 | Runtime-Assisted Type Collection | *Sorbet | MonkeyType, Pyre Infer, runtime tracing systems | +| 6 | Runtime Structural Shape Recovery | nil-kill | *MonkeyType, *Pydantic ecosystem, *TypeScript tooling | +| 7 | Automatic Interface Synthesis | N/A | *TypeScript (anonymous structural inference), *Luau | +| 8 | Ambiguity Surfacing UX | N/A | N/A | +| 9 | Probabilistic / Observational Typing | N/A | *MonkeyType | +| 10 | Latent Schema Recovery | nil-kill | *TypeScript, *Pyright, *Luau | + +### On Void: + +Languages like Ruby with *implicit return* have a particular problem: + +```ruby +def foo(): + doX() +``` + +Is this function supposed to return the value of `doX()`, or is that merely the last line, never expected to be used? + +Nil-kill surfaces this problem and will auto-fix it for you. + +### On Fallibility: + +We are investingating the same *nil pressure* for downstream fallibility: mark falible function with `!!`, and call them accordingly, otherwise, nil-kill will show you which sources of failures have the most downstream / fan-out pressure throughout your codebase. + +> NOTE: This is not yet available and still being planned. diff --git a/gems/nil-kill/docs/hash-record-promotion.md b/gems/nil-kill/docs/hash-record-promotion.md new file mode 100644 index 000000000..e8f9220e0 --- /dev/null +++ b/gems/nil-kill/docs/hash-record-promotion.md @@ -0,0 +1,101 @@ +# Hash Record Promotion Acceptance Criteria + +Nil-kill should be able to turn high-pressure hashes that are acting as records into `T::Struct` definitions when the rewrite can be verified. + +## Scope + +The first complete milestone is hash records. Tuple-like arrays and generated interfaces for arbitrary metaprogramming are separate milestones. + +## Acceptance Criteria + +1. A high-pressure hash record candidate can produce a concrete rewrite action with: + - struct name + - required and optional fields + - field types + - producer rewrites + - consumer read rewrites + - signature rewrites + - safety blockers + +2. The rewriter handles straightforward producer flows: + - local hash literals + - method returns + - arrays of hash literals + - hash records flowing through params + - hash records flowing through instance variables or struct fields when provenance is known + +3. The rewriter handles straightforward consumer reads: + - `record[:field]` + - `record["field"]` + - `record.fetch(:field)` + - `record.fetch("field")` + +4. Optional keysets are preserved. If one shape has `{name, id}` and another has `{name, id, email}`, `email` must become nilable or otherwise optional in the generated struct. + +5. Unsafe cases are reported as blockers, not silently rewritten: + - dynamic keys + - missing keys + - post-construction mutation + - `merge!`, `update`, `delete`, or other shape-changing operations + - incompatible observed field types that would force broad `T.untyped` + +6. Signature rewrites are applied when the flow is known: + - `T::Hash[Symbol, T.untyped]` return slots can become the generated struct + - `T::Hash[Symbol, T.untyped]` param slots can become the generated struct + - `T::Array[T::Hash[Symbol, T.untyped]]` slots can become `T::Array[GeneratedStruct]` + +7. The verified auto-fix loop must either: + - apply the candidate, run the configured verifier, and keep the rewrite when it passes; or + - restore the original files and report why the candidate was rejected. + +8. The report should improve after a successful rewrite: + - pressure for the rewritten hash-record cluster drops + - weak or untyped collection slots drop when signatures were rewritten + - return hygiene improves when collection lookup returns become typed struct accessors + +## Implementation Action Items + +- CST-based rewriting: replace `Apply#apply_one` regex/string substitutions for hash-record promotion with Prism-location-based edits. Nil-kill already depends on Prism, so producer rewrites, consumer read rewrites, signature rewrites, and struct insertion should be computed from parsed node locations instead of ad hoc line matching. +- Node matching: current rewrites use Prism node locations plus source slices. This is substantially safer than regex rewriting, but identical expressions repeated on the same line remain a known fragile edge case. The verified loop is expected to catch these by rolling back failed candidates. +- T.let feedback loop: nil-kill records existing and candidate `T.let` sites and can narrow them, but runtime `T.let` observations are not yet fed back into method return inference, param inference, or hash-record pressure ranking. A future pass should compare injected `T.let` candidates against runtime observations and downgrade or correct inferred types before reporting. + +### T.let Feedback Loop Gap + +Current behavior: + +- `T.let` hook observations feed existing `T.let` narrowing. +- They do not currently correct inferred method return types. +- They do not currently correct inferred param types. +- They do not currently affect hash-record pressure ranking. + +Desired behavior: + +1. Static inference proposes a candidate type, such as `String`. +2. The instrumented run injects or observes `T.let(value, String)`. +3. Runtime observation sees the actual flow, such as `NilClass`. +4. Nil-kill downgrades, nilabilizes, or blocks that candidate before reporting or autofix. + +That validation loop is not implemented yet. + +## Test Coverage + +Current coverage: + +- local hash literal promotion: covered +- return-to-consumer promotion: covered +- param promotion: covered +- array element promotion: covered +- optional keysets: covered +- `fetch` rewrites: covered for `fetch(:key)` and `fetch("key")` +- dynamic key blockers: covered +- mutation blockers: covered +- cross-file cluster promotion: covered +- failed verification rollback: covered + +## Good Enough + +This milestone is good enough when nil-kill can safely rewrite common high-pressure hash records into `T::Struct`s under verification, and can explain why the remaining candidates are blocked. + +At that point the docs may claim that nil-kill can identify high-pressure hash records acting as structs and rewrite verified straightforward producer/consumer flows into `T::Struct`s. + +The docs should not claim general metaprogramming interface synthesis until a later milestone implements and verifies that separately. diff --git a/gems/nil-kill/docs/how-it-works.md b/gems/nil-kill/docs/how-it-works.md new file mode 100644 index 000000000..a93a5b692 --- /dev/null +++ b/gems/nil-kill/docs/how-it-works.md @@ -0,0 +1,152 @@ +# How Does Nil-kill Work? + +Type Inference in dynamic languages today is predominately done with static analysis. + +## The Easy Part + +This is trivial in certain situations: + +- Literals +- Typed Structs / Classes +- Typed Arrays +- Branchless Non-fallible Returns + +## The Medium Part + +Static analysis includes whole program analysis. You can build control flow graphs to determine types for branching returns and fallible functions. + +In typical dynamically typed code bases, this may be ~50% of the code. Being able to infer types for 50% of code is good, but what if you wanted to get closer to ~90%? + +## The Hard Part (that doesn’t seem hard) + +In programming languages like Ruby, Python, Lua (especially), and JavaScript - it is pervasive to use primitives like HashMaps to act as Typed Structs/Classes and to use Arrays as impromptu Tuples. + +If a HashMap is really just a struct in disguise, then why are typed structs easy to detect types, but disguised HashMaps hard? + +When static analysis sees `x = my_map[:my_field]` it typically cannot distinguish `my_map` from all the other untyped HashMaps in your program. + +Concrete HashMap example: + +```ruby +my_map = { name: "foo", id: 1 } +return my_map[:id] +``` + +HashMaps are not structs. The type of `my_map` is technically: + +```ruby +T::Hash[Symbol, T.any(String, Integer)] +``` + +Without treating this hash as a record shape, `my_map[:name]` and `my_map[:id]` both collapse to the same value type: `T.nilable(T.any(String, Integer))`, even though `:name` is always a `String` and `:id` is always an `Integer`. + +Concrete Tuple example: + +```ruby +my_tup = ["foo", 1] +return my_tup[1] +``` + +Tuples are not structs, either! The type of `my_tup` is technically: + +```ruby +T::Array[T.any(String, Integer)] +``` + +Without tuple-shape evidence, the index-specific meaning is lost: index `0` and index `1` both look like `T.nilable(T.any(String, Integer))`, even though the first slot is intended to be a `String` and the second an `Integer`. + +## The Even Harder Part + +The example above may seem pedantic, and like something static analysis with even a modest amount of runtime analysis should be able to solve, but the problem here is harder: metaprogramming. + +```python +class ApiResponse: + def __init__(self, data_dict): + for key, value in data_dict.items(): + setattr(self, key, value) + +# Runtime behavior: +response = ApiResponse({"user_id": 42, "is_active": True}) +return response.user_id +``` + +This is essentially the problem above, just slightly harder. But metaprogramming has many forms: + +```javascript +// Somewhere deep in a third-party library +String.prototype.toUrl = function() { return "http://" + this; }; + +// Back in your code +let myString = "google.com"; +return myString.toUrl(); +``` + +Depending on the runtime `.toUrl()` may have different meanings. Sometimes, it may return strings, other times it may be an error that it doesn’t exist, other times it may return nilable Strings, and in bad designs it may return Arrays and who knows what else! + +## The Frustrating Part + +Most of dynamic typing is a double edged sword. It provides little benefit, at the expense of countless bugs. + +One aspect of Dynamic Typing that is quite nice is having an Array / Collection of objects of many different types. In Typed languages, you need dreaded interfaces (or some equivalent) to solve this problem. + +Building Structs to represent your data is relatively trivial and extremely useful. Interfaces are, too, but they aren’t quite as trivial to implement. They’re also far less common, so can be a pain to learn if you barely ever need them. + +Let’s say you have a collection of different Enemy types: + +```ruby +my_enemies = getEnemies(); +hps = my_enemies.map { |e| e.hp }; +``` + +This is again a problem that seems like the same problem as the HashMap masquerading as an array. + +The problem is less about figuring out what `.hp` gets you in a reasonably designed system. It’s about figuring out what exactly can go into `myEnemies`. And you need to know that to be able to figure out what `.hp` returns. + +## The Saving Grace + +With minimal runtime analysis - you can solve most of the problem with primitives acting as Structs, and properly resolve their types. +Most programs are nowhere near as bad as they technically could be. People rarely have a function like `toString()` that sometimes returns strings, and other times returns floats. + +Combined with runtime analysis and minimal intervention - most metaprogramming ambiguity can usually be resolved by the user easily: + +A report generates: + +```text +Metaprogramming ambiguity: `MyClass::toString` -> please specify type +``` + +You say… hmm… toString() -> that’s probably a string, try it. + +```text +Collection ambiguity: `my_enemies.hp` -> please specify type +``` + +You say… hmm… `.hp` -> that’s obviously an Integer, it generates the minimal interface for you. + +## How does Nil-kill Help? + +Nil-kill builds a runtime-aware flow graph. + +It looks at both static analysis and runtime analysis to determine **WHICH** exact problems **CAUSE** the most static-analysis failures, and **HOW** and **WHERE** to fix them. + +This is known as *nil pressure*. Which `nil`s are causing the most `T.nilable()` effects throughout the code base. + +## Current Limitations + +Nil-kill records `T.let` sites and can propose narrowing existing `T.let` annotations, but it does not yet use runtime `T.let` observations as a self-correction loop for broader inference. + +The intended next step is: + +```text +static inference proposes String +instrumented run observes NilClass at the injected T.let site +nil-kill downgrades or corrects that candidate before reporting/autofix +``` + +That feedback is not wired into return inference, param inference, or hash-record pressure ranking yet. Today, `T.let` runtime data is useful for narrowing existing `T.let` sites, not for correcting nil-kill's broader inferred candidates. + +Nil-kill was born as a tool to detect pervasive nil check guards, and allowed [CLEAR](../../README.md) to remove the majority of `&.` safe navigation checks and `T.nilable()` returns and parameters in a few easy commits, mostly with autofixes. + +Then the idea came up: why not do the same to detect the source of `T.untyped()` for the number of hard reasons listed above, where static analysis / Sorbet / existing Ruby tooling fail? + +See the [README](../README.md) to get started. diff --git a/gems/nil-kill/exe/nil-kill b/gems/nil-kill/exe/nil-kill new file mode 100755 index 000000000..1a07a5f33 --- /dev/null +++ b/gems/nil-kill/exe/nil-kill @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# typed: false +# frozen_string_literal: true + +require_relative "../lib/nil_kill" + +NilKill::CLI.new(ARGV).run if $PROGRAM_NAME == __FILE__ diff --git a/gems/nil-kill/lib/nil_kill.rb b/gems/nil-kill/lib/nil_kill.rb new file mode 100755 index 000000000..bf8bb2ea9 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill.rb @@ -0,0 +1,51 @@ +#!/usr/bin/env ruby +# typed: false +# frozen_string_literal: true + +require "fileutils" +require "json" +require "open3" +require "pathname" +require "set" +require "shellwords" +require "time" + +begin + require "prism" +rescue LoadError + warn "error: prism is required; run `bundle install`" + exit 2 +end + +module NilKill + ROOT = File.expand_path("../../..", __dir__) + TMP_DIR = File.expand_path(ENV.fetch("NIL_KILL_TMP_DIR", File.join(ROOT, "tmp", "nil-kill")), ROOT) + RUNTIME_DIR = File.join(TMP_DIR, "runtime") + INSTRUMENTED_DIR = File.join(TMP_DIR, "instrumented") + EVIDENCE_PATH = File.join(TMP_DIR, "evidence.json") + REPORT_PATH = File.join(TMP_DIR, "report.md") + TRACE_PLAN_PATH = File.join(TMP_DIR, "trace-plan.json") + SORBET_PAYLOAD_DIR = File.join(TMP_DIR, "sorbet-payload") +end + +require_relative "nil_kill/util" +require_relative "nil_kill/spec_dependency_index" +require_relative "nil_kill/hash_shape_ops" +require_relative "nil_kill/rbi_return_index" +require_relative "nil_kill/store" +require_relative "nil_kill/flow_graph" +require_relative "nil_kill/trace_plan" +require_relative "nil_kill/infer" +require_relative "nil_kill/source_index" +require_relative "nil_kill/source_instrumenter" +require_relative "nil_kill/focus_hash_record" +require_relative "nil_kill/apply" +require_relative "nil_kill/interactive_review" +require_relative "nil_kill/loop" +require_relative "nil_kill/report" +require_relative "nil_kill/struct_rbi" +require_relative "nil_kill/guarded_autocorrect" +require_relative "nil_kill/doctor" +require_relative "nil_kill/cli" + +NilKill::CLI.new(ARGV).run if $PROGRAM_NAME == __FILE__ diff --git a/gems/nil-kill/lib/nil_kill/apply.rb b/gems/nil-kill/lib/nil_kill/apply.rb new file mode 100644 index 000000000..7ab763c48 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/apply.rb @@ -0,0 +1,757 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class Apply + def initialize(argv) + @dry_run = argv.include?("--dry-run") || ENV["DRY_RUN"] == "1" + @all = argv.include?("--all") + @evidence = Store.read + end + + def run + if @all && ENV["NIL_KILL_UNSAFE_APPLY_ALL"] != "1" + abort "`apply --all` would apply review actions without verification. Use `loop --hash-records -- ` for reviewed fixes, or set NIL_KILL_UNSAFE_APPLY_ALL=1 for debugging." + end + actions = @evidence["actions"].select { |a| @all || a["confidence"] == HIGH } + apply_actions(actions) + end + + def apply_actions(actions) + changed = 0 + actions = expand_cross_file_actions(actions) + actions.group_by { |a| a["path"] }.each do |rel_path, list| + path = File.join(ROOT, rel_path) + next unless File.exist?(path) + lines = File.readlines(path) + list.sort_by { |a| -a["line"].to_i }.each { |action| changed += 1 if apply_one(lines, action) } + ensure_sorbet_runtime(lines) if list.any? { |a| %w[add_sig add_tlet narrow_tlet narrow_generic_param narrow_generic_return promote_hash_record_to_struct promote_hash_record_cluster_to_struct].include?(a["kind"]) } + ensure_sig_extensions(lines, rel_path, list.select { |a| a["kind"] == "add_sig" }) + File.write(path, lines.join) unless @dry_run + end + puts "#{@dry_run ? "would apply" : "applied"} #{changed} action(s)" + changed + end + + def expand_cross_file_actions(actions) + actions.flat_map do |action| + next [action] unless action["kind"] == "promote_hash_record_cluster_to_struct" + data = action["data"] || {} + paths = (Array(data["producers"]) + Array(data["consumers"]) + Array(data["signatures"])) + .map { |site| site["path"].to_s }.reject(&:empty?).uniq.sort + paths << data["struct_path"].to_s unless data["struct_path"].to_s.empty? + paths = paths.uniq.sort + next [action] if paths.size <= 1 + primary = data["struct_path"].to_s.empty? ? action["path"].to_s : data["struct_path"].to_s + paths.map do |path| + copy = Marshal.load(Marshal.dump(action)) + copy["path"] = path + copy["line"] = hash_record_action_line_for_path(copy, path) + copy["data"]["insert_struct"] = (path == primary) + copy + end + end + end + + def hash_record_action_line_for_path(action, path) + data = action["data"] || {} + sites = Array(data["producers"]) + Array(data["consumers"]) + Array(data["signatures"]) + site = sites.select { |candidate| candidate["path"].to_s == path }.min_by { |candidate| candidate["line"].to_i } + site ? site["line"].to_i : action["line"].to_i + end + + def apply_one(lines, action) + idx = action["line"].to_i - 1 + return false if idx.negative? || idx >= lines.length + case action["kind"] + when "add_sig" + tgt = resolve_add_sig_idx(lines, idx, action) + return false unless tgt + return false if find_sig_idx(lines, tgt) + lines.insert(tgt, "#{lines[tgt][/^\s*/]}#{action["data"]["sig"]}\n") + when "fix_sig_param" + return apply_signature_cst_rewrite(lines, action, "param", action["data"]["name"].to_s, "T.untyped", action["data"]["type"].to_s) + when "fix_sig_return" + type = action["data"]["type"].to_s + return apply_signature_cst_rewrite(lines, action, "return", nil, "T.untyped", type) + when "narrow_generic_param" + return apply_signature_cst_rewrite(lines, action, "param", action["data"]["name"].to_s, action["data"]["from"].to_s, action["data"]["type"].to_s) + when "narrow_generic_return" + return apply_signature_cst_rewrite(lines, action, "return", nil, action["data"]["from"].to_s, action["data"]["type"].to_s) + when "narrow_tlet" + return apply_narrow_tlet_cst_rewrite(lines, action) + when "add_tlet" + return apply_add_tlet_cst_rewrite(lines, action) + when "remove_dead_safe_nav" + return apply_safe_nav_cst_rewrite(lines, action) + when "replace_dead_nil_check" + return apply_exact_code_cst_rewrite(lines, action, "false") + when "replace_nil_with_default" + return apply_nil_default_cst_rewrite(lines, action) + when "promote_hash_record_to_struct" + return apply_hash_record_struct_promotion(lines, action) + when "promote_hash_record_cluster_to_struct" + return apply_hash_record_cluster_promotion(lines, action) + when "add_struct_field_sig" + return apply_add_struct_field_sig(lines, action) + else + return false + end + true + end + + # Idempotently set `sig { returns(TYPE) } / def FIELD; end` under + # `class KLASS` in the struct-field RBI. The file is line-regular + # (AUTO-GENERATED: top-level classes, 2-space body). Re-applying + # updates the existing sig (so the verified loop's snapshot/restore + # + bisection works unchanged). Multiple actions on the same RBI + # accumulate because apply_actions mutates one shared `lines` per + # path group. + def apply_add_struct_field_sig(lines, action) + klass = action.dig("data", "class").to_s + field = action.dig("data", "field").to_s + type = action.dig("data", "type").to_s + return false if klass.empty? || field.empty? || type.empty? + sig_line = " sig { returns(#{type}) }\n" + def_line = " def #{field}; end\n" + + cls_idx = lines.index { |l| l =~ /\A\s*class\s+#{Regexp.escape(klass)}\s*\z/ } + if cls_idx + end_idx = (cls_idx + 1...lines.length).find { |i| lines[i] =~ /\A\s*end\s*\z/ } + return false unless end_idx + def_idx = (cls_idx + 1...end_idx).find { |i| lines[i] =~ /\A\s*def\s+#{Regexp.escape(field)}\s*;/ } + if def_idx + if def_idx > cls_idx + 1 && lines[def_idx - 1] =~ /\A\s*sig\s*\{/ + return false if lines[def_idx - 1] == sig_line # already this type + lines[def_idx - 1] = sig_line + else + lines.insert(def_idx, sig_line) + end + else + lines.insert(end_idx, sig_line, def_line) + end + else + lines << "\n" unless lines.empty? || lines[-1].end_with?("\n") + lines.concat(["class #{klass}\n", sig_line, def_line, "end\n", "\n"]) + end + true + end + + def apply_hash_record_struct_promotion(lines, action) + data = action["data"] || {} + struct_name = data["struct_name"].to_s + literal = data["literal"] || {} + fields = Array(data["fields"]) + reads = Array(data["read_rewrites"]) + signatures = Array(data["signatures"]).select { |signature| signature["path"].to_s == action["path"].to_s } + blockers = Array(data["blockers"]) + return false unless blockers.empty? + return false if struct_name.empty? || fields.empty? || literal["code"].to_s.empty? + + changed = false + source = lines.join + parsed = Prism.parse(source) + if parsed.success? + edits = [] + replacement = hash_record_constructor(struct_name, literal["code"]) + if replacement + node = first_node_matching_source(parsed.value, literal["line"].to_i, literal["code"].to_s) + edits << [node.location.start_offset, node.location.end_offset, replacement] if node + end + reads.each do |read| + code = read["code"].to_s + replacement = read["replacement"].to_s + next if code.empty? || replacement.empty? + nodes_matching_source(parsed.value, read["line"].to_i, code).each do |node| + edits << [node.location.start_offset, node.location.end_offset, replacement] + end + end + edits.concat(hash_record_signature_edits(parsed.value, signatures)) + unless edits.empty? + lines.replace(apply_source_edits(source, edits).lines) + changed = true + end + end + changed = insert_hash_record_struct(lines, data) || changed + changed + end + + def apply_hash_record_cluster_promotion(lines, action) + data = action["data"] || {} + struct_name = data["struct_name"].to_s + type_name = (data["type_name"] || struct_name).to_s + fields = Array(data["fields"]) + producers = Array(data["producers"]).select { |producer| producer["path"].to_s == action["path"].to_s } + consumers = Array(data["consumers"]).select { |consumer| consumer["path"].to_s == action["path"].to_s } + signatures = Array(data["signatures"]).select { |signature| signature["path"].to_s == action["path"].to_s } + blockers = Array(data["blockers"]) + return false unless blockers.empty? + insert_only = data.fetch("insert_struct", true) && action["path"].to_s == data["struct_path"].to_s + return false if struct_name.empty? || type_name.empty? || fields.empty? || (producers.empty? && consumers.empty? && signatures.empty? && !insert_only) + + source = lines.join + parsed = Prism.parse(source) + return false unless parsed.success? + + edits = [] + producers.each do |producer| + nodes_matching_source(parsed.value, producer["line"].to_i, producer["code"].to_s).each do |node| + replacement = hash_record_constructor_from_node(type_name, node, consumers, fields) + next unless replacement + edits << [node.location.start_offset, node.location.end_offset, replacement] + end + end + consumers.each do |consumer| + replacement = hash_record_consumer_replacement(consumer) + next unless replacement + nodes_matching_source(parsed.value, consumer["line"].to_i, consumer["code"].to_s).each do |node| + edits << [node.location.start_offset, node.location.end_offset, replacement] + end + end + edits.concat(hash_record_signature_edits(parsed.value, signatures)) + changed = false + unless edits.empty? + lines.replace(apply_source_edits(source, edits).lines) + changed = true + end + + changed = insert_hash_record_struct(lines, data) || changed if data.fetch("insert_struct", true) && (changed || insert_only) + changed + end + + def hash_record_consumer_replacement(consumer) + receiver = consumer["receiver"].to_s + key = consumer["key"].to_s + return nil if receiver.empty? || key.empty? || !key.match?(/\A[a-z_]\w*\z/) + "#{receiver}.#{key}" + end + + def apply_signature_cst_rewrite(lines, action, kind, name, from, to) + return false if to.empty? || from.empty? + source = lines.join + parsed = Prism.parse(source) + return false unless parsed.success? + sig_node = nearest_sig_call_before_line(parsed.value, action["line"].to_i) + return false unless sig_node + edit = + if kind == "param" + target = signature_param_type_node(sig_node, name.to_s, from) + target ? [target.location.start_offset, target.location.end_offset, to] : nil + elsif to == "void" + signature_void_return_edit(sig_node, from) + else + target = signature_return_type_node(sig_node, from) + target ? [target.location.start_offset, target.location.end_offset, to] : nil + end + return false unless edit + lines.replace(apply_source_edits(source, [edit]).lines) + true + end + + def apply_narrow_tlet_cst_rewrite(lines, action) + source = lines.join + parsed = Prism.parse(source) + return false unless parsed.success? + node = nodes_matching(parsed.value) do |candidate| + candidate.is_a?(Prism::CallNode) && + candidate.location.start_line == action["line"].to_i && + candidate.name == :let && + candidate.receiver&.slice == "T" && + candidate.arguments&.arguments&.[](1)&.slice == "T.untyped" + end.first + type_node = node&.arguments&.arguments&.[](1) + return false unless type_node + lines.replace(apply_source_edits(source, [[type_node.location.start_offset, type_node.location.end_offset, action["data"]["type"].to_s]]).lines) + true + end + + def apply_add_tlet_cst_rewrite(lines, action) + source = lines.join + parsed = Prism.parse(source) + return false unless parsed.success? + name = action["data"]["name"].to_s + node = nodes_matching(parsed.value) do |candidate| + variable_write_node?(candidate) && + candidate.location.start_line == action["line"].to_i && + candidate.respond_to?(:name) && + candidate.name.to_s == name + end.first + value = node&.value + return false unless value + replacement = "T.let(#{value.slice}, #{action["data"]["type"]})" + lines.replace(apply_source_edits(source, [[value.location.start_offset, value.location.end_offset, replacement]]).lines) + true + end + + def variable_write_node?(node) + node.is_a?(Prism::LocalVariableWriteNode) || + node.is_a?(Prism::InstanceVariableWriteNode) || + node.is_a?(Prism::ClassVariableWriteNode) || + node.is_a?(Prism::GlobalVariableWriteNode) + end + + def apply_safe_nav_cst_rewrite(lines, action) + source = lines.join + parsed = Prism.parse(source) + return false unless parsed.success? + code = action.dig("data", "code").to_s + nodes = if code.empty? + nodes_matching(parsed.value) { |node| node.is_a?(Prism::CallNode) && node.location.start_line == action["line"].to_i && node.safe_navigation? } + else + nodes_matching_source(parsed.value, action["line"].to_i, code).select { |node| node.is_a?(Prism::CallNode) && node.safe_navigation? } + end + return false if nodes.empty? + edits = nodes.filter_map do |node| + replacement = node.slice.to_s.gsub("&.", ".") + [node.location.start_offset, node.location.end_offset, replacement] if replacement != node.slice + end + return false if edits.empty? + lines.replace(apply_source_edits(source, edits).lines) + true + end + + def apply_exact_code_cst_rewrite(lines, action, replacement) + code = action.dig("data", "code").to_s + return false if code.empty? + source = lines.join + parsed = Prism.parse(source) + return false unless parsed.success? + nodes = nodes_matching_source(parsed.value, action["line"].to_i, code) + return false if nodes.empty? + edits = nodes.map { |node| [node.location.start_offset, node.location.end_offset, replacement] } + lines.replace(apply_source_edits(source, edits).lines) + true + end + + def apply_nil_default_cst_rewrite(lines, action) + source = lines.join + parsed = Prism.parse(source) + return false unless parsed.success? + nils = nodes_matching(parsed.value) { |node| node.is_a?(Prism::NilNode) && node.location.start_line == action["line"].to_i } + return false unless nils.size == 1 + node = nils.first + lines.replace(apply_source_edits(source, [[node.location.start_offset, node.location.end_offset, action.dig("data", "default").to_s]]).lines) + true + end + + def first_node_matching_source(node, line, code) + nodes_matching_source(node, line, code).first + end + + def hash_record_signature_edits(root, signatures) + Array(signatures).filter_map do |signature| + sig_node = nearest_sig_call_before_line(root, signature["line"].to_i) + next unless sig_node + target = + case signature["kind"] + when "return" + signature_return_type_node(sig_node, signature["from"].to_s) + when "param" + signature_param_type_node(sig_node, signature["name"].to_s, signature["from"].to_s) + end + next unless target + [target.location.start_offset, target.location.end_offset, signature["type"].to_s] + end + end + + def nearest_sig_call_before_line(root, line) + sigs = nodes_matching(root) do |node| + node.is_a?(Prism::CallNode) && node.name == :sig && node.location.end_line < line + end + sigs.select { |node| line - node.location.end_line <= 30 }.max_by { |node| node.location.end_line } + end + + def signature_return_type_node(sig_node, from) + nodes_matching(sig_node) do |node| + node.is_a?(Prism::CallNode) && node.name == :returns + end.filter_map { |node| node.arguments&.arguments&.first }.find { |arg| arg&.slice == from } + end + + def signature_void_return_edit(sig_node, from) + returns_node = nodes_matching(sig_node) do |node| + node.is_a?(Prism::CallNode) && node.name == :returns + end.find { |node| node.arguments&.arguments&.first&.slice == from } + return nil unless returns_node + receiver = returns_node.receiver + if receiver + [receiver.location.end_offset, returns_node.location.end_offset, ".void"] + else + [returns_node.location.start_offset, returns_node.location.end_offset, "void"] + end + end + + def signature_param_type_node(sig_node, name, from) + params_call = nodes_matching(sig_node) do |node| + node.is_a?(Prism::CallNode) && node.name == :params + end.first + keyword_hash = params_call&.arguments&.arguments&.find { |arg| arg.is_a?(Prism::KeywordHashNode) } + keyword_hash&.elements&.filter_map do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key = signature_keyword_name(assoc.key) + assoc.value if key == name && assoc.value&.slice == from + end&.first + end + + def signature_keyword_name(node) + case node + when Prism::SymbolNode + node.value.to_s + when Prism::StringNode + node.content.to_s + end + end + + def nodes_matching(node, matches = [], &block) + return matches unless node + matches << node if yield(node) + node.child_nodes.compact.each { |child| nodes_matching(child, matches, &block) } if node.respond_to?(:child_nodes) + matches + end + + def nodes_matching_source(node, line, code, matches = []) + return matches unless node + loc = node.respond_to?(:location) ? node.location : nil + matches << node if loc && loc.start_line == line && node.respond_to?(:slice) && node.slice == code + node.child_nodes.compact.each { |child| nodes_matching_source(child, line, code, matches) } if node.respond_to?(:child_nodes) + matches + end + + def apply_source_edits(source, edits) + bytes = source.b + non_overlapping_source_edits(edits).sort_by { |start_offset, _end_offset, _replacement| -start_offset }.each do |start_offset, end_offset, replacement| + bytes = bytes.byteslice(0, start_offset) + replacement.b + bytes.byteslice(end_offset..).to_s + end + bytes + end + + def non_overlapping_source_edits(edits) + kept = [] + edits.sort_by { |start_offset, end_offset, _replacement| [start_offset, -(end_offset - start_offset)] }.each do |edit| + start_offset, end_offset, = edit + next if kept.any? { |kept_start, kept_end, _| start_offset >= kept_start && end_offset <= kept_end } + kept << edit + end + kept + end + + def hash_record_constructor(struct_name, hash_code) + source = hash_code.to_s.strip + return nil unless source.start_with?("{") && source.end_with?("}") + "#{struct_name}.new(#{source[1...-1].strip})" + end + + def hash_record_constructor_from_node(struct_name, node, consumers, fields = []) + source = node.slice.to_s + relative_edits = [] + Array(consumers).each do |consumer| + replacement = hash_record_consumer_replacement(consumer) + next unless replacement + nodes_matching_source(node, consumer["line"].to_i, consumer["code"].to_s).each do |consumer_node| + relative_edits << [ + consumer_node.location.start_offset - node.location.start_offset, + consumer_node.location.end_offset - node.location.start_offset, + replacement, + ] + end + end + rewritten = if relative_edits.empty? + source + else + apply_source_edits(source, relative_edits) + end + hash_record_constructor(struct_name, hash_record_cast_constructor_fields(rewritten, fields)) + end + + def hash_record_cast_constructor_fields(hash_code, fields) + field_types = Array(fields).each_with_object({}) { |field, index| index[field["name"].to_s] = field["type"].to_s } + return hash_code if field_types.empty? + parsed = Prism.parse(hash_code) + return hash_code unless parsed.success? + edits = [] + root_hash = nodes_matching(parsed.value) { |node| node.is_a?(Prism::HashNode) || node.is_a?(Prism::KeywordHashNode) }.first + edits.concat(hash_record_nested_constructor_edits(root_hash, fields)) if root_hash + nodes_matching(parsed.value) do |node| + (node.is_a?(Prism::HashNode) || node.is_a?(Prism::KeywordHashNode)) + end.each do |hash| + Array(hash.elements).each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key = hash_record_constructor_key_name(assoc.key) + type = field_types[key.to_s] + next unless hash_record_constructor_cast_type?(type) + value = assoc.value + next unless hash_record_constructor_cast_needed?(value, type) + next if value.slice.to_s.start_with?("T.cast(") + edits << [value.location.start_offset, value.location.end_offset, "T.cast(#{value.slice}, #{type})"] + end + end + edits.empty? ? hash_code : apply_source_edits(hash_code, edits) + end + + def hash_record_nested_constructor_edits(hash, fields) + nested_by_field = Array(fields).each_with_object({}) do |field, index| + index[field["name"].to_s] = field["nested"] if field["nested"] + end + return [] if nested_by_field.empty? + Array(hash.elements).flat_map do |assoc| + next [] unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key = hash_record_constructor_key_name(assoc.key) + nested = nested_by_field[key.to_s] + next [] unless nested + type_name = nested["type_name"].to_s + next [] if type_name.empty? + case nested["kind"] + when "hash" + value = assoc.value + next [] unless value.is_a?(Prism::HashNode) || value.is_a?(Prism::KeywordHashNode) + rewritten = hash_record_cast_constructor_fields(value.slice, nested["fields"]) + [[value.location.start_offset, value.location.end_offset, hash_record_constructor(type_name, rewritten)]] + when "array" + value = assoc.value + next [] unless value.is_a?(Prism::ArrayNode) + Array(value.elements).filter_map do |elem| + next unless elem.is_a?(Prism::HashNode) || elem.is_a?(Prism::KeywordHashNode) + rewritten = hash_record_cast_constructor_fields(elem.slice, nested["fields"]) + [elem.location.start_offset, elem.location.end_offset, hash_record_constructor(type_name, rewritten)] + end + else + [] + end + end + end + + def hash_record_constructor_key_name(node) + case node + when Prism::SymbolNode + node.respond_to?(:value) ? node.value.to_s : node.slice.delete_prefix(":") + when Prism::StringNode + node.respond_to?(:unescaped) ? node.unescaped : node.slice.delete_prefix("\"").delete_prefix("'").delete_suffix("\"").delete_suffix("'") + else + nil + end + end + + def hash_record_constructor_cast_needed?(value, type) + raw = type.to_s + case value + when Prism::NilNode + !raw.start_with?("T.nilable(") + when Prism::TrueNode, Prism::FalseNode + raw != "T::Boolean" && raw != "T.nilable(T::Boolean)" && raw != "T.untyped" + when Prism::IntegerNode + raw != "Integer" && raw != "T.untyped" + when Prism::StringNode + raw != "String" && raw != "T.untyped" + when Prism::SymbolNode + raw != "Symbol" && raw != "T.untyped" + when Prism::ArrayNode + !(raw.start_with?("T::Array[") || raw.start_with?("T.nilable(T::Array[") || raw == "T.untyped") + when Prism::HashNode, Prism::KeywordHashNode + !(raw.start_with?("T::Hash[") || raw.start_with?("T.nilable(T::Hash[") || raw == "T.untyped") + else + true + end + end + + def hash_record_constructor_cast_type?(type) + raw = type.to_s + return false if raw.empty? || raw == "T.untyped" || raw == "Object" + raw.include?("::") || raw.start_with?("T.any(") + end + + def insert_hash_record_struct(lines, data) + struct_name = data["struct_name"].to_s + return false if struct_name.empty? || lines.any? { |line| line.match?(/\bclass\s+#{Regexp.escape(struct_name)}\b/) } + fields = Array(data["fields"]) + parsed = Prism.parse(lines.join) + return false unless parsed.success? + scope = Array(data["scope"]) + if scope.empty? + insert_at = top_level_struct_insert_index(lines) + lines.insert(insert_at, *hash_record_all_struct_lines(data, "")) + return true + end + node = find_scope_node(parsed.value, scope) + return false unless node + insert_at = hash_record_struct_insert_index(lines, node, data) + indent = lines[node.location.start_line - 1][/^\s*/] + " " + lines.insert(insert_at, *hash_record_all_struct_lines(data, indent)) + true + rescue StandardError => e + warn "could not insert hash-record struct #{data["struct_name"]}: #{e.message}" + false + end + + def hash_record_struct_insert_index(lines, scope_node, data) + insert_at = scope_node.location.start_line + while lines[insert_at]&.match?(/^\s*extend\s+T::Sig\b/) + insert_at += 1 + end + dependency_lines = hash_record_struct_dependency_nodes(scope_node, data).map { |node| node.location.end_line } + dependency_lines.empty? ? insert_at : [insert_at, dependency_lines.max].max + end + + def hash_record_struct_dependency_nodes(scope_node, data) + names = hash_record_struct_dependency_names(data).to_set + return [] if names.empty? + matches = [] + scope_node.child_nodes.compact.each do |child| + nodes_matching(child) do |node| + if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) + names.include?(node.constant_path.slice.to_s.split("::").last) + elsif node.is_a?(Prism::ConstantWriteNode) + # `Foo = Struct.new(...)` and similar constant-assigned types + # are also legitimate dependency targets. Without recognising + # them, the promoter inserts the new struct ABOVE its own + # field-type reference, breaking `require` at load time + # (e.g. MIR::NameRecord with field MIR::StructInit, where + # StructInit is `StructInit = Struct.new(...)` later in the + # same file). + names.include?(node.name.to_s) + else + false + end + end.each { |node| matches << node } + end + matches + end + + def hash_record_struct_dependency_names(data) + scope = Array(data["scope"]).map(&:to_s).reject(&:empty?) + return [] if scope.empty? + namespace = scope.join("::") + Array(data["fields"]).flat_map do |field| + field["type"].to_s.scan(/\b#{Regexp.escape(namespace)}::([A-Z]\w*)\b/).flatten + end.uniq + end + + def top_level_struct_insert_index(lines) + insert_at = 0 + lines.each_with_index do |line, idx| + stripped = line.strip + if stripped.empty? || line.start_with?("#") || stripped.match?(/\Arequire\s+["'][^"']+["']/) + insert_at = idx + 1 + else + break + end + end + insert_at + end + + def hash_record_struct_lines(struct_name, fields, indent) + lines = ["#{indent}class #{struct_name} < T::Struct\n"] + fields.each do |field| + keyword = field["optional"] ? "prop" : "const" + lines << "#{indent} #{keyword} :#{field["name"]}, #{field["type"]}\n" + end + lines << "#{indent}end\n" + lines << "\n" + lines + end + + def hash_record_all_struct_lines(data, indent) + nested = Array(data["nested_structs"]) + nested.flat_map { |record| hash_record_struct_lines(record["struct_name"], record["fields"], indent) } + + hash_record_struct_lines(data["struct_name"], data["fields"], indent) + end + + def ensure_sorbet_runtime(lines) + return if lines.any? { |line| line.match?(/require ["']sorbet-runtime["']/) } + insert_at = 0 + lines.each_with_index do |line, idx| + if line.start_with?("#") || line.strip.empty? + insert_at = idx + 1 + else + break + end + end + lines.insert(insert_at, "require \"sorbet-runtime\"\n", "\n") + end + + def ensure_sig_extensions(lines, rel_path, sig_actions) + scopes = sig_actions.filter_map { |action| action.dig("data", "scope") }.uniq + return if scopes.empty? + parsed = Prism.parse(lines.join) + return unless parsed.success? + insertions = [] + scopes.each do |scope| + next if scope.empty? + node = find_scope_node(parsed.value, scope) + next unless node + body_range = node.location.start_line..node.location.end_line + next if body_range.any? { |line_no| lines[line_no - 1]&.match?(/\bextend\s+T::Sig\b/) } + indent = lines[node.location.start_line - 1][/^\s*/] + " " + insertions << [node.location.start_line, "#{indent}extend T::Sig\n", "\n"] + end + insertions.sort_by { |line_no, _text, _blank| line_no }.reverse_each do |line_no, *text| + lines.insert(line_no, *text) + end + rescue StandardError => e + warn "#{rel_path}: could not ensure extend T::Sig: #{e.message}" + end + + def find_scope_node(root, scope) + found = nil + walk = lambda do |node, stack| + return if found + case node + when Prism::ClassNode, Prism::ModuleNode + new_stack = stack + [node.constant_path.slice] + found = node if new_stack == scope + node.child_nodes.compact.each { |child| walk.call(child, new_stack) } if node.respond_to?(:child_nodes) + else + node.child_nodes.compact.each { |child| walk.call(child, stack) } if node.respond_to?(:child_nodes) + end + end + walk.call(root, []) + found + end + + def find_sig_idx(lines, def_idx) + (def_idx - 1).downto([def_idx - 5, 0].max) { |i| return i if lines[i]&.match?(/\bsig\s*\{/) } + nil + end + + DEF_HEADER = /\A\s*(?:(?:private|public|protected|private_class_method|public_class_method|module_function)\s+)?def\s+(?:self\.)?([A-Za-z_]\w*[!?=]?)/.freeze + + # The raw `action["line"]` is only a HINT. If the source shifted + # under it (a rebase, or another edit in this batch) the indexed + # line is no longer the target `def` -- blindly inserting there + # drops the sig as dead code mid-body / before module_function and + # poisons sorbet-runtime's global pending-sig state (the c34cc62f + # corruption). Trust the hint ONLY when it actually lands on the + # named def; otherwise re-locate the def by name via Prism, and if + # it cannot be resolved unambiguously, REFUSE (a skipped sig is + # always better than a corrupted source). + def resolve_add_sig_idx(lines, idx, action) + want = action.dig("data", "method").to_s + hinted = lines[idx] && lines[idx][DEF_HEADER, 1] + return idx if hinted && (want.empty? || hinted == want) + return nil if want.empty? # no anchor + bad hint -> never guess + relocate_def_idx(lines, want, Array(action.dig("data", "scope"))) + end + + # Prism-locate the `def want` whose enclosing class/module scope + # best matches `scope`. Exactly one candidate -> its 0-based line; + # zero or ambiguous -> nil (caller skips rather than corrupt). + def relocate_def_idx(lines, want, scope) + parsed = Prism.parse(lines.join) + return nil unless parsed.success? + cands = [] + walk = lambda do |node, stack| + return unless node.is_a?(Prism::Node) + case node + when Prism::DefNode + cands << [stack.dup, node.location.start_line - 1] if node.name.to_s == want + when Prism::ClassNode, Prism::ModuleNode + stack = stack + [node.constant_path.slice] + end + node.compact_child_nodes.each { |c| walk.call(c, stack) } + end + walk.call(parsed.value, []) + return nil if cands.empty? + return cands.first[1] if cands.size == 1 + scoped = cands.select { |st, _| st.last == scope.last || st == scope } + scoped.size == 1 ? scoped.first[1] : nil + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/cli.rb b/gems/nil-kill/lib/nil_kill/cli.rb new file mode 100644 index 000000000..1c584f670 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/cli.rb @@ -0,0 +1,298 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class CLI + def initialize(argv) + @argv = argv.dup + end + + def run + # Self-heal first: if a prior `collect` crashed mid-instrumentation + # src/ is still wrapped. Restore pristine bytes BEFORE any guard or + # subcommand reads src (a stale wrapped tree poisons everything). + NilKill.ensure_src_restored! + command = @argv.shift + case command + when "collect" then collect + when "infer" then guard_fresh_runtime!; Infer.new(@argv).run + when "focus-hash-record" then FocusHashRecord.new(@argv).run + when "apply" then Apply.new(@argv).run + when "review" then InteractiveReview.new(@argv).run + when "loop" then Loop.new(@argv).run + when "report" then guard_fresh_evidence!; Report.new(@argv).run + when "struct-rbi" then StructRBI.new(@argv).run + when "guarded-autocorrect" then GuardedAutocorrect.new(@argv).run + when "doctor" then Doctor.new.run + when "help", nil then help + else + warn "unknown command: #{command}" + help + exit 2 + end + end + + STALE_OVERRIDE = "--allow-stale-runtime" + COLLECT_HINT = "bundle exec tools/nil-kill collect -- bash tools/clear-nil-kill-runtime.sh" + + def collect_meta_path + File.join(RUNTIME_DIR, "collect-meta.json") + end + + def git_capture(*args) + out, status = Open3.capture2e("git", "-C", ROOT, *args) + status.success? ? out : nil + rescue StandardError + nil + end + + # Snapshot the src/ state the collect ran against: HEAD + the + # working-tree status of the target dirs. Compared content-wise at + # guard time, so a git-touched mtime (the false-positive that + # forced an override) no longer trips it. + def write_collect_meta! + head = git_capture("rev-parse", "HEAD") + return unless head + status = git_capture("status", "--porcelain", "--", *NilKill.target_dirs) + File.write(collect_meta_path, + JSON.generate("head" => head.strip, "dirty" => status.to_s)) + rescue StandardError + nil + end + + # false = targets provably unchanged since collect (fresh) + # Array = [collect_head, cur_head, [changed files]] (stale) + # :unknown = no metadata / git unavailable -> caller falls back to mtime + def targets_changed_since_collect + return :unknown unless File.file?(collect_meta_path) + meta = JSON.parse(File.read(collect_meta_path)) + cur_head = git_capture("rev-parse", "HEAD")&.strip or return :unknown + status = git_capture("status", "--porcelain", "--", *NilKill.target_dirs) or return :unknown + return false if cur_head == meta["head"] && status == meta["dirty"].to_s + files = [] + if cur_head != meta["head"] + diff = git_capture("diff", "--name-only", "#{meta["head"]}..#{cur_head}", "--", *NilKill.target_dirs) + files.concat(diff.to_s.split("\n")) + end + files.concat(status.to_s.lines.map { |l| l[3..].to_s }) + files = files.map(&:strip).reject { |f| f.empty? || f == "." }.uniq + # HEAD moved / status differs but ZERO target files actually + # changed (e.g. commits only touched gems/nil-kill, not src/) -> + # the runtime is still valid. Fresh, not stale. + return false if files.empty? + [meta["head"], cur_head, files] + rescue StandardError + :unknown + end + + def mtime_stale?(against_time) + newest_src = NilKill.target_files.map { |f| File.mtime(f) rescue nil }.compact.max + newest_src && against_time && newest_src > against_time + end + + # Default-safe: refuse to infer on stale/partial runtime. Inferring + # against stale runtime joins old method records onto changed code + # -> joins miss, NoEvidence balloons. Git-aware (content, not mtime) + # with an mtime fallback when git/metadata is unavailable. + def guard_fresh_runtime! + return if @argv.delete(STALE_OVERRIDE) + runtime = Dir.glob(File.join(RUNTIME_DIR, "*.jsonl")) + if runtime.empty? + abort "nil-kill: NO runtime evidence in #{RUNTIME_DIR}. Inference would be 100% static -- partial and useless.\nCollect FULL evidence first:\n #{COLLECT_HINT}\n(knowing override: nil-kill infer #{STALE_OVERRIDE})" + end + case (chg = targets_changed_since_collect) + when false + nil # provably unchanged since the collect -> fresh + when Array + meta_h, cur_h, files = chg + more = files.size > 8 ? ", +#{files.size - 8} more" : "" + abort "nil-kill: src/ changed since the collect that produced this runtime " \ + "(collect @ #{meta_h.to_s[0, 9]}, now #{cur_h.to_s[0, 9]}). Changed: #{files.first(8).join(", ")}#{more}.\n" \ + "Inferring now joins STALE runtime against changed code -- the partial-evidence trap.\n" \ + "Re-collect FULL evidence first:\n #{COLLECT_HINT}\n(knowing override: nil-kill infer #{STALE_OVERRIDE})" + else # :unknown -> conservative mtime fallback + if mtime_stale?(runtime.map { |f| File.mtime(f) }.max) + abort "nil-kill: src/ mtime is newer than the runtime and git metadata is unavailable (conservative).\n" \ + "Re-collect FULL evidence first:\n #{COLLECT_HINT}\n(knowing override: nil-kill infer #{STALE_OVERRIDE})" + end + end + end + + # Reports render from evidence.json; refuse a stale one (same + # git-aware signal, then mtime fallback vs evidence.json). + def guard_fresh_evidence! + return if @argv.delete(STALE_OVERRIDE) + evidence = File.join(TMP_DIR, "evidence.json") + unless File.file?(evidence) + abort "nil-kill: no evidence.json. Run a full collect + infer first.\n(knowing override: nil-kill report #{STALE_OVERRIDE})" + end + case (chg = targets_changed_since_collect) + when false + nil + when Array + meta_h, cur_h, files = chg + more = files.size > 8 ? ", +#{files.size - 8} more" : "" + abort "nil-kill: src/ changed since the collect behind this evidence " \ + "(collect @ #{meta_h.to_s[0, 9]}, now #{cur_h.to_s[0, 9]}). Changed: #{files.first(8).join(", ")}#{more}.\n" \ + "This report would be stale/partial. Re-collect + infer first.\n(knowing override: nil-kill report #{STALE_OVERRIDE})" + else + if mtime_stale?(File.mtime(evidence)) + abort "nil-kill: evidence.json is older than src/ and git metadata is unavailable (conservative). " \ + "Re-collect + infer first.\n(knowing override: nil-kill report #{STALE_OVERRIDE})" + end + end + end + + def collect + append = @argv.delete("--append-runtime") + instrument_source = !@argv.delete("--no-instrument-source") + instrument_source = true if @argv.delete("--instrument-source") + commands = collect_commands + abort "usage: bundle exec tools/nil-kill collect [--commands FILE] [--continue-on-error] -- " if commands.empty? + FileUtils.rm_rf(RUNTIME_DIR) unless append + FileUtils.mkdir_p(RUNTIME_DIR) + write_collect_meta! + trace_plan_enabled = ENV["NIL_KILL_TRACE_PLAN"] != "0" + TracePlan.write if trace_plan_enabled + snapshot_dir = File.join(RUNTIME_DIR, "src-snapshot") + if instrument_source + acquire_inplace_lock! + # Sentinel + traps BEFORE the first byte of src is overwritten, + # with the full candidate list -> a crash mid-wrap is healed. + NilKill.write_inplace_sentinel!(snapshot_dir, NilKill.target_files.map { |f| NilKill.rel(f) }) + install_inplace_restore_traps! + SourceInstrumenter.new.run_in_place(snapshot_dir) + end + tracer = File.expand_path("runtime_trace.rb", __dir__) + rubyopt = (ENV["RUBYOPT"].to_s.split + ["-r#{tracer}"]).join(" ") + env = ENV.to_h.merge("NIL_KILL_TRACE" => "1", "RUBYOPT" => rubyopt) + # Source-wrap path: targeted TracePoints off by default (the + # injected recorder is authoritative). No NIL_KILL_INSTRUMENTED_ROOT + # any more -- the wrapped file IS the real src path. + env["NIL_KILL_TRACE_METHODS"] ||= "0" if instrument_source && trace_plan_enabled + continue = @argv.delete("--continue-on-error") + begin + commands.each_with_index do |cmd, i| + puts "[#{i + 1}/#{commands.size}] NIL_KILL_TRACE=1 RUBYOPT=#{rubyopt.shellescape} #{cmd.shelljoin}" + ok = system(env, *cmd) + next if ok || continue + exit($?&.exitstatus || 1) + end + ensure + NilKill.restore_inplace_snapshot! if instrument_source + end + assert_collect_coverage_produced! if instrument_source + end + + # A second concurrent `collect` would race in-place writes against + # this one and corrupt src/. flock auto-releases on process exit. + def acquire_inplace_lock! + FileUtils.mkdir_p(RUNTIME_DIR) + @collect_lock = File.open(File.join(RUNTIME_DIR, ".nk-collect.lock"), File::RDWR | File::CREAT, 0o644) + return if @collect_lock.flock(File::LOCK_EX | File::LOCK_NB) + abort "nil-kill: another `collect` is already running (in-place src instrumentation is exclusive). " \ + "Wait for it to finish or kill it; src/ will self-heal on the next nil-kill run." + end + + # Restore pristine src on INT/TERM/HUP too (the ensure covers normal + # exit and `exit`/raises; signals would otherwise leave src wrapped + # until the next run's ensure_src_restored!). `prev` is block-local + # per iteration, so the three traps don't clobber each other. + def install_inplace_restore_traps! + %w[INT TERM HUP].each do |sig| + Signal.trap(sig) do + NilKill.restore_inplace_snapshot! + exit(false) + end + end + end + + # A traced collect that produced ZERO Ruby Coverage means Coverage + # failed to start in the workload; the dead-vs-missed split (unseen + # vs collect_ran_untraced) then cannot be computed and would silently + # degrade to "never_run". Make that a hard, loud failure instead. + def assert_collect_coverage_produced! + return unless Dir.glob(File.join(RUNTIME_DIR, "coverage-*.jsonl")).empty? + abort "nil-kill: the traced collect produced NO Ruby Coverage " \ + "(#{NilKill.rel(RUNTIME_DIR)}/coverage-*.jsonl is empty). The dead-vs-missed " \ + "split cannot be computed -- Coverage failed to start in the workload " \ + "(see NilKillRuntimeTrace.start_coverage!). Fix the workload/tracer; do " \ + "not infer on this collect." + end + + def collect_commands + commands = [] + while (idx = @argv.index("--commands")) + file = @argv[idx + 1] || abort("--commands requires a file") + @argv.slice!(idx, 2) + commands.concat(read_command_file(file)) + end + while (idx = @argv.index("--cmd")) + command = @argv[idx + 1] || abort("--cmd requires a command string") + @argv.slice!(idx, 2) + commands << Shellwords.split(command) + end + while (idx = @argv.index("--glob")) + pattern = @argv[idx + 1] || abort("--glob requires a pattern") + template_idx = @argv.index("--template") || abort("--glob requires --template") + template = @argv[template_idx + 1] || abort("--template requires a command template") + [idx, template_idx].sort.reverse_each { |remove_idx| @argv.slice!(remove_idx, 2) } + Dir.glob(pattern).sort.each do |file| + commands << Shellwords.split(template.gsub("{file}", file)) + end + end + sep = @argv.index("--") + commands << @argv[(sep + 1)..] if sep + commands + end + + def read_command_file(path) + File.readlines(path, chomp: true).filter_map do |line| + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + Shellwords.split(stripped) + end + end + + def help + puts <<~TEXT + Usage: + bundle exec tools/nil-kill collect -- + bundle exec tools/nil-kill collect --commands runtime-commands.txt + bundle exec tools/nil-kill collect --cmd "bundle exec rspec" --cmd "./clear test transpile-tests" + bundle exec tools/nil-kill collect --glob "lib/**/*.rb" --template "ruby {file}" + bundle exec tools/nil-kill collect --append-runtime --commands more-runtime-commands.txt + bundle exec tools/nil-kill collect --instrument-source -- + bundle exec tools/nil-kill collect --no-instrument-source -- + bundle exec tools/nil-kill infer [--no-sorbet] + bundle exec tools/nil-kill focus-hash-record STRUCT [--targets path[:path...]] + bundle exec tools/nil-kill apply [--dry-run] + bundle exec tools/nil-kill review [--kind replace_nil_with_default] + bundle exec tools/nil-kill loop [--defaults] [--try-levenshtein] [--hash-records] [--signature-backflow] [--return-backflow] [--narrow-generic] [--narrow-tlet] -- + bundle exec tools/nil-kill report [--with-links] [--output-path PATH] [--hygiene] + bundle exec tools/nil-kill struct-rbi [--complete] [--output sorbet/rbi/nil-kill-structs.rbi] + bundle exec tools/nil-kill guarded-autocorrect [--max-iterations N] + bundle exec tools/nil-kill doctor + + Config: + NIL_KILL_TARGETS=src[:other_dir] target Ruby source roots + NIL_KILL_EXCLUDE_TARGETS=src/tools exclude Ruby source roots + NIL_KILL_MIN_CALLS=20 runtime confidence threshold + NIL_KILL_UNION_POLICY=untyped|any default: untyped + NIL_KILL_AUTO_DEFAULTS=1 promote safe nil default rewrites into loop/apply + NIL_KILL_LEVENSHTEIN_DISTANCE=2 max param-name/class-name distance for speculative narrowing + NIL_KILL_LEVENSHTEIN_LIMIT=50 max speculative actions per loop iteration; 0 = unlimited + NIL_KILL_HASH_RECORD_LIMIT=1 max review hash-record promotions per loop iteration; 0 = unlimited + NIL_KILL_SIGNATURE_BACKFLOW_LIMIT=5 max review static param backflow fixes per loop iteration; 0 = unlimited + NIL_KILL_RETURN_BACKFLOW_LIMIT=5 max review return-backflow fixes per loop iteration; 0 = unlimited + NIL_KILL_NARROW_GENERIC_LIMIT=0 max review narrow-generic fixes per loop iteration; 0 = unlimited + NIL_KILL_NARROW_TLET_LIMIT=0 max review narrow-tlet fixes per loop iteration; 0 = unlimited + NIL_KILL_UNSAFE_APPLY_ALL=1 debug-only: allow raw apply --all without verification + NIL_KILL_PRESSURE_SORT=priority|slots|hotness + NIL_KILL_ELEMENT_SAMPLE=20 container elements sampled by runtime tracing + NIL_KILL_TRACE_PLAN=0 disable trace-plan pruning during collect + NIL_KILL_TRACE_METHODS=0 disable TracePoint method collection + TEXT + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/doctor.rb b/gems/nil-kill/lib/nil_kill/doctor.rb new file mode 100644 index 000000000..3fa82d026 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/doctor.rb @@ -0,0 +1,30 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class Doctor + def run + puts "ruby: #{RUBY_VERSION}" + puts "prism: #{Prism::VERSION}" + puts "targets: #{NilKill.target_dirs.map { |d| NilKill.rel(d) }.join(File::PATH_SEPARATOR)}" + puts "excluded targets: #{NilKill.target_exclude_dirs.map { |d| NilKill.rel(d) }.join(File::PATH_SEPARATOR)}" unless NilKill.target_exclude_dirs.empty? + puts "runtime traces: #{Dir.glob(File.join(RUNTIME_DIR, "*.jsonl")).size}" + puts "sorbet: #{command_ok?(%w[bundle exec srb --version])}" + puts "tapioca: #{command_ok?(%w[bundle exec tapioca --version])}" + puts "rbs-trace: #{gem_ok?("rbs/trace") || gem_ok?("rbs-trace")}" + puts "parlour: #{gem_ok?("parlour")}" + end + + def command_ok?(cmd) + _out, _err, status = Open3.capture3(*cmd) + status.success? ? "ok" : "missing/error" + end + + def gem_ok?(feature) + require feature + "ok" + rescue LoadError + nil + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/flow_graph.rb b/gems/nil-kill/lib/nil_kill/flow_graph.rb new file mode 100644 index 000000000..f2963fbcd --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/flow_graph.rb @@ -0,0 +1,283 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class FlowGraph + attr_reader :nodes, :edges + + EDGE_KINDS = Set.new(%w[ + assignment branch_join call_argument return_forward implicit_return explicit_return + hash_write hash_read array_write array_read set_write set_read block_param runtime_observation + struct_field call_result + ]).freeze + + def self.from_evidence(evidence) + graph = new + graph.import_evidence(evidence) + graph + end + + def initialize + @nodes = {} + @edges = [] + @edge_keys = Set.new + @types = Hash.new { |hash, key| hash[key] = Set.new } + end + + def add_node(kind, id, data = {}) + id = id.to_s + current = @nodes[id] || { "id" => id, "kind" => kind.to_s } + current.merge!(stringify_keys(data)) + @nodes[id] = current + add_types(id, data["types"] || data[:types]) + add_types(id, [data["type"] || data[:type]]) + id + end + + def add_edge(kind, from, to, data = {}) + kind = kind.to_s + edge = stringify_keys(data).merge("kind" => EDGE_KINDS.include?(kind) ? kind : "assignment", + "from" => from.to_s, "to" => to.to_s) + key = [edge["kind"], edge["from"], edge["to"], edge["line"], edge["code"], edge["callee"], edge["slot"]] + return edge unless @edge_keys.add?(key) + @edges << edge + add_types(to, types_for(from)) + edge + end + + def add_types(id, types) + Array(types).compact.each do |type| + next if type.to_s.empty? + @types[id.to_s] << type.to_s + end + end + + def types_for(id) + @types[id.to_s].to_a.sort + end + + def sorbet_type_for(id) + NilKill.static_sorbet_type(types_for(id)) + end + + def outgoing(id, kind = nil) + @edges.select { |edge| edge["from"] == id.to_s && (!kind || edge["kind"] == kind.to_s) } + end + + def incoming(id, kind = nil) + @edges.select { |edge| edge["to"] == id.to_s && (!kind || edge["kind"] == kind.to_s) } + end + + def reachable?(from, to, edge_kinds: nil) + allowed = edge_kinds && edge_kinds.map(&:to_s).to_set + seen = Set.new + queue = [from.to_s] + until queue.empty? + current = queue.shift + return true if current == to.to_s + next unless seen.add?(current) + outgoing(current).each do |edge| + next if allowed && !allowed.include?(edge["kind"]) + queue << edge["to"] + end + end + false + end + + def hash_record_identity_for_lookup(lookup, include_field: true) + key = hash_record_key(lookup) + receiver = lookup["receiver"].to_s + origin = lookup["origin"] || {} + base = + case origin["kind"].to_s + when "method parameter" + "param:#{origin["path"]}:#{origin["line"]}:#{origin["name"]}" + when "hash literal", "array literal" + "#{origin["kind"].tr(" ", "_")}:#{origin["path"]}:#{origin["line"]}:#{origin["name"]}" + when "forwarded return" + "return:#{origin["path"]}:#{origin["line"]}:#{origin["callee"] || origin["code"]}" + when "instance variable" + "ivar:#{origin["name"]}" + else + scope = lookup["enclosing_scope"].to_s + path = lookup["path"].to_s + receiver.empty? ? "unknown:#{path}:#{lookup["line"]}" : "local:#{path}:#{scope}:#{receiver}" + end + include_field && key ? "#{base}[:#{key}]" : base + end + + def hash_record_label_for_lookup(lookup) + origin = lookup["origin"] || {} + key = hash_record_key(lookup) + case origin["kind"].to_s + when "method parameter" + if origin["path"] + "hash record param #{origin["name"]} at #{origin["path"]}:#{origin["line"]}" + else + "method parameter hash record #{origin["name"]}" + end + when "hash literal", "array literal" + "hash record #{origin["kind"]} at #{origin["path"]}:#{origin["line"]}" + when "forwarded return" + "hash record return #{origin["callee"] || origin["code"]} at #{origin["path"]}:#{origin["line"]}" + when "instance variable" + "instance variable hash record #{origin["name"]}" + else + receiver = lookup["receiver"].to_s + path = lookup["path"].to_s + receiver.empty? ? "unknown hash record" : "local hash record #{receiver} at #{path}" + end + end + + def import_evidence(evidence) + import_methods(evidence) + import_return_origins(evidence) + import_param_origins(evidence) + import_collection_lookups(evidence) + import_struct_fields(evidence) + import_runtime(evidence) + self + end + + def to_h + { + "nodes" => @nodes.values.sort_by { |node| node["id"] }, + "edges" => @edges.sort_by { |edge| [edge["from"], edge["to"], edge["kind"]] }, + "types" => @types.each_with_object({}) { |(id, types), out| out[id] = types.to_a.sort }, + } + end + + private + + def stringify_keys(hash) + Hash(hash).each_with_object({}) { |(key, value), out| out[key.to_s] = value } + end + + def import_methods(evidence) + methods = Array(evidence.dig("facts", "existing_sigs")) + Array(evidence.dig("facts", "unsigned_methods")) + methods.each do |method| + method_id = method_node_id(method) + add_node("method", method_id, method.slice("path", "line", "class", "method", "kind")) + Array(method["params"]).each_with_index do |param, idx| + add_node("param", param_node_id(method, param["name"] || idx), method.merge("slot" => param["name"] || idx, "type" => param["type"])) + end + add_node("return", return_node_id(method), method.merge("slot" => "return", "type" => NilKill.extract_return_type(method["sig"].to_s))) + end + end + + def import_return_origins(evidence) + Array(evidence.dig("facts", "return_origins")).each do |origin| + ret_id = return_node_id(origin) + add_node("return", ret_id, origin.merge("type" => origin["candidate_type"])) + Array(origin["sources"]).each_with_index do |source, idx| + source_id = return_source_node_id(origin, source, idx) + add_node(source_node_kind(source), source_id, source) + add_edge(return_edge_kind(origin, source), source_id, ret_id, source.slice("line", "code", "callee")) + if source["kind"].to_s == "call_untyped" && !source["callee"].to_s.empty? + callee_id = "return:method_name:#{source["callee"]}" + add_node("return", callee_id, "method" => source["callee"]) + add_edge("return_forward", callee_id, ret_id, source.slice("line", "code", "callee")) + end + end + end + end + + def import_param_origins(evidence) + Array(evidence.dig("facts", "param_origins")).each do |origin| + param_id = "param:callee:#{origin["callee"]}:#{origin["arg_kind"]}:#{origin["slot"]}" + add_node("param", param_id, origin.merge("type" => origin["type"])) + source_id = "call_arg:#{origin["path"]}:#{origin["line"]}:#{origin["callee"]}:#{origin["arg_kind"]}:#{origin["slot"]}" + add_node(source_node_kind("kind" => origin["origin_kind"]), source_id, origin.merge("type" => origin["type"])) + add_edge("call_argument", source_id, param_id, origin.slice("path", "line", "callee", "slot", "code")) + if %w[typed_return untyped_return].include?(origin["origin_kind"].to_s) && !origin["source_method"].to_s.empty? + ret_id = "return:method_name:#{origin["source_method"]}" + add_node("return", ret_id, "method" => origin["source_method"], "type" => origin["type"]) + add_edge("call_result", ret_id, source_id, origin.slice("path", "line", "source_method")) + end + end + end + + def import_collection_lookups(evidence) + Array(evidence.dig("facts", "collection_index_lookups")).each do |lookup| + receiver_id = hash_record_identity_for_lookup(lookup) + add_node("hash_field", receiver_id, lookup.merge("type" => lookup["lookup_type"])) + read_id = "hash_read:#{lookup["path"]}:#{lookup["line"]}:#{lookup["code"]}" + add_node("call_result", read_id, lookup.merge("type" => lookup["lookup_type"])) + add_edge("hash_read", receiver_id, read_id, lookup.slice("path", "line", "code", "index")) + end + end + + def import_struct_fields(evidence) + Array(evidence.dig("facts", "struct_field_static")).each do |field| + field_id = "struct_field:#{field["class"]}:#{field["field"]}" + add_node("struct_field", field_id, field.merge("type" => field["type"])) + value_id = "literal:#{field["path"]}:#{field["line"]}:#{field["expression"]}" + add_node("literal", value_id, field.merge("type" => field["type"])) + add_edge("struct_field", value_id, field_id, field.slice("path", "line", "expression")) + end + end + + def import_runtime(evidence) + Array(evidence["methods"]).each do |method| + source = method["source"] || {} + next unless source["path"] + ret_id = return_node_id(source) + add_node("return", ret_id, source) + add_types(ret_id, method["returns"]) + add_edge("runtime_observation", "runtime:#{source["path"]}:#{source["line"]}:return", ret_id, + "classes" => Array(method["returns"]), "calls" => method["calls"]) + Hash(method["params_by_name"]).each do |name, classes| + param_id = param_node_id(source, name) + add_node("param", param_id, source.merge("slot" => name)) + add_types(param_id, classes) + add_edge("runtime_observation", "runtime:#{source["path"]}:#{source["line"]}:param:#{name}", param_id, + "classes" => classes, "calls" => method["calls"]) + end + end + Array(evidence.dig("facts", "collection_runtime")).each do |rec| + id = "runtime_collection:#{rec["owner_kind"]}:#{rec["path"]}:#{rec["line"]}:#{rec["name"]}:#{rec["kind"]}" + add_node("runtime_observation", id, rec) + end + end + + def method_node_id(method) + "method:#{method["path"]}:#{method["line"]}:#{method["class"]}:#{method["method"]}:#{method["kind"]}" + end + + def return_node_id(method) + "return:#{method["path"]}:#{method["line"]}:#{method["class"]}:#{method["method"]}:#{method["kind"]}" + end + + def param_node_id(method, name) + "param:#{method["path"]}:#{method["line"]}:#{method["class"]}:#{method["method"]}:#{method["kind"]}:#{name}" + end + + def return_source_node_id(origin, source, idx) + "#{source_node_kind(source)}:#{origin["path"]}:#{source["line"] || origin["line"]}:#{source["code"] || source["callee"] || idx}" + end + + def source_node_kind(source) + kind = source["kind"].to_s + return "runtime_observation" if kind == "runtime" + return "literal" if %w[static nil].include?(kind) + return "call_result" if kind.include?("return") || kind.include?("call") + kind.empty? ? "unknown" : kind + end + + def return_edge_kind(origin, source) + return "explicit_return" if origin["return_syntax"] == "explicit" + return "return_forward" if source["kind"].to_s.include?("call") + "implicit_return" + end + + def hash_record_key(lookup) + index = lookup["index"].to_s + case index + when /\A:([A-Za-z_]\w*[!?=]?)\z/ + Regexp.last_match(1) + when /\A["']([^"']+)["']\z/ + Regexp.last_match(1) + end + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/focus_hash_record.rb b/gems/nil-kill/lib/nil_kill/focus_hash_record.rb new file mode 100644 index 000000000..1ed5c6885 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/focus_hash_record.rb @@ -0,0 +1,88 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class FocusHashRecord + def initialize(argv) + @struct_name = argv.shift.to_s + abort "usage: bundle exec tools/nil-kill focus-hash-record STRUCT [--targets path[:path...]]" if @struct_name.empty? + @targets = [] + @two_pass = !!argv.delete("--two-pass") + while (idx = argv.index("--targets")) + value = argv[idx + 1] || abort("--targets requires a #{File::PATH_SEPARATOR}-separated path list") + argv.slice!(idx, 2) + @targets.concat(value.split(File::PATH_SEPARATOR)) + end + @targets.concat(argv) + end + + def run + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + paths = focus_paths + abort "no focused paths found for #{@struct_name}; pass --targets path[:path...]" if paths.empty? + + store = Store.new + SourceIndex.reset_global_shape_indexes + paths.each { |path| SourceIndex.new(path) } if @two_pass + paths.each do |path| + idx = SourceIndex.new(path) + store.facts["files"][NilKill.rel(path)] = idx.summary + store.facts["unsigned_methods"].concat(idx.methods.reject { |method| method["has_sig"] }) + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["tlet_sites"].concat(idx.tlet_sites) + store.facts["dead_nil_checks"].concat(idx.dead_nil_checks) + store.facts["struct_declarations"].concat(idx.struct_declarations) + store.facts["struct_field_static"].concat(idx.struct_field_static) + store.facts["tuple_arrays"].concat(idx.tuple_arrays) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["hash_record_blockers"].concat(idx.hash_record_blockers) + store.facts["hash_record_member_calls"].concat(idx.hash_record_member_calls) + store.facts["type_normalizers"].concat(idx.type_normalizers) + store.facts["dispatcher_inferences"].concat(idx.dispatcher_inferences) + store.facts["return_origins"].concat(idx.return_origins) + store.facts["param_origins"].concat(idx.param_origins) + idx.methods.each do |method| + rec = store.method_record([method["class"], method["method"], method["kind"], File.expand_path(method["path"], ROOT), method["line"]]) + rec["source"] = method + rec["has_sig"] = method["has_sig"] + end + end + + infer = Infer.allocate + infer.instance_variable_set(:@store, store) + infer.send(:propose_hash_record_cluster_actions) + action = store.actions.find { |candidate| candidate.dig("data", "struct_name").to_s == @struct_name } + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + + puts "focused hash-record #{@struct_name}" + puts "files: #{paths.map { |path| NilKill.rel(path) }.join(", ")}" + puts "elapsed: #{format("%.3f", elapsed)}s" + if action + data = action["data"] || {} + puts "pressure: #{data.dig("pressure", "total")}; producers: #{Array(data["producers"]).size}; consumers: #{Array(data["consumers"]).size}; signatures: #{Array(data["signatures"]).size}" + puts "blockers: #{Array(data["blockers"]).empty? ? "none" : Array(data["blockers"]).join("; ")}" + puts JSON.pretty_generate(action) + else + puts "no #{@struct_name} action produced from focused evidence" + end + end + + def focus_paths + explicit = @targets.map { |path| File.expand_path(path, ROOT) }.select { |path| File.file?(path) } + return explicit.uniq.sort unless explicit.empty? + + evidence = Store.read + action = Array(evidence["actions"]).find { |candidate| candidate.dig("data", "struct_name").to_s == @struct_name } + return [] unless action + data = action["data"] || {} + (Array(data["producers"]) + Array(data["consumers"]) + Array(data["signatures"])) + .map { |site| site["path"].to_s } + .reject(&:empty?) + .map { |path| File.expand_path(path, ROOT) } + .select { |path| File.file?(path) } + .uniq + .sort + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/guarded_autocorrect.rb b/gems/nil-kill/lib/nil_kill/guarded_autocorrect.rb new file mode 100644 index 000000000..fcd14c884 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/guarded_autocorrect.rb @@ -0,0 +1,105 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class GuardedAutocorrect + BOGUS_AUTOCORRECT_PATTERNS = [ + /\.class\.module_eval\b/, + /\.class\.class_eval\b/, + ].freeze + + def initialize(argv) + @max_iterations = option_value(argv, "--max-iterations").to_i + @max_iterations = 8 if @max_iterations <= 0 + end + + def run + previous_count = nil + @max_iterations.times do |idx| + safe_nav = snapshot_safe_navigation + full_files = snapshot_full_files + run_srb_autocorrect + restored_safe_nav = restore_safe_navigation(safe_nav) + restored_bogus = restore_bogus_replacements(full_files) + count = srb_error_count + puts "Iter #{idx + 1}: errors=#{count || "unknown"}, &. restored=#{restored_safe_nav}, bogus reverted=#{restored_bogus}" + break if count.nil? + break if previous_count && count >= previous_count && restored_safe_nav.zero? && restored_bogus.zero? + previous_count = count + end + end + + def option_value(argv, flag) + idx = argv.index(flag) + idx ? argv[idx + 1] : nil + end + + def target_files + NilKill.target_files + end + + def snapshot_safe_navigation + target_files.each_with_object({}) do |path, snap| + File.readlines(path).each_with_index do |line, idx| + next unless line.include?("&.") + snap[path] ||= [] + snap[path] << { line: idx + 1, content: line } + end + end + end + + def snapshot_full_files + target_files.each_with_object({}) { |path, hash| hash[path] = File.readlines(path) } + end + + def restore_safe_navigation(snapshot) + restored = 0 + snapshot.each do |path, entries| + next unless File.exist?(path) + lines = File.readlines(path) + changed = false + entries.each do |entry| + idx = entry[:line] - 1 + next if idx >= lines.length + current = lines[idx] + original = entry[:content] + next if current == original + next unless current == original.gsub("&.", ".") + lines[idx] = original + restored += 1 + changed = true + end + File.write(path, lines.join) if changed + end + restored + end + + def restore_bogus_replacements(snapshot) + restored = 0 + snapshot.each do |path, original_lines| + next unless File.exist?(path) + current = File.readlines(path) + changed = false + current.each_with_index do |line, idx| + original = original_lines[idx] + next unless original && line != original + next unless BOGUS_AUTOCORRECT_PATTERNS.any? { |pattern| line.match?(pattern) && !original.match?(pattern) } + current[idx] = original + restored += 1 + changed = true + end + File.write(path, current.join) if changed + end + restored + end + + def run_srb_autocorrect + Open3.capture3({ "SRB_YES" => "1", "NO_COLOR" => "1" }, "bundle", "exec", "srb", "tc", "-a") + end + + def srb_error_count + _out, err, _status = Open3.capture3({ "SRB_YES" => "1", "NO_COLOR" => "1" }, "bundle", "exec", "srb", "tc") + err.match(/Errors: (\d+)/)&.[](1)&.to_i + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/hash_shape_ops.rb b/gems/nil-kill/lib/nil_kill/hash_shape_ops.rb new file mode 100644 index 000000000..580ffa710 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/hash_shape_ops.rb @@ -0,0 +1,50 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + module HashShapeOps + module_function + + def dup_shape(shape, stringify_keys: false) + return nil unless shape + { + "keys" => Hash[Hash(shape["keys"]).map { |key, types| [shape_key(key, stringify_keys), Array(types).dup] }], + "value_hash_shapes" => Hash[Hash(shape["value_hash_shapes"]).map { |key, nested| [shape_key(key, stringify_keys), dup_shape(nested, stringify_keys: stringify_keys)] }], + "value_array_element_shapes" => Hash[Hash(shape["value_array_element_shapes"]).map { |key, nested| [shape_key(key, stringify_keys), dup_shape(nested, stringify_keys: stringify_keys)] }], + "poisoned" => !!shape["poisoned"], + } + end + + def merge_shapes(left, right, stringify_keys: false) + return poisoned_shape if left["poisoned"] || right["poisoned"] + keys = Hash(left["keys"]).keys | Hash(right["keys"]).keys + { + "keys" => keys.each_with_object({}) do |key, merged| + out_key = shape_key(key, stringify_keys) + merged[out_key] = (Array(left.dig("keys", key)) + Array(right.dig("keys", key))).uniq + end, + "value_hash_shapes" => merge_nested_shape_maps(left["value_hash_shapes"], right["value_hash_shapes"], stringify_keys: stringify_keys), + "value_array_element_shapes" => merge_nested_shape_maps(left["value_array_element_shapes"], right["value_array_element_shapes"], stringify_keys: stringify_keys), + "poisoned" => false, + } + end + + def merge_nested_shape_maps(left, right, stringify_keys: false) + keys = Hash(left).keys | Hash(right).keys + keys.each_with_object({}) do |key, merged| + l = Hash(left)[key] + r = Hash(right)[key] + out_key = shape_key(key, stringify_keys) + merged[out_key] = l && r ? merge_shapes(l, r, stringify_keys: stringify_keys) : dup_shape(l || r, stringify_keys: stringify_keys) + end + end + + def poisoned_shape + { "keys" => {}, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => true } + end + + def shape_key(key, stringify) + stringify ? key.to_s : key + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/infer.rb b/gems/nil-kill/lib/nil_kill/infer.rb new file mode 100644 index 000000000..d26cb0fa5 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/infer.rb @@ -0,0 +1,2306 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class Infer + def initialize(argv) + @run_sorbet = !argv.include?("--no-sorbet") + @store = Store.new + end + + def run + load_runtime + index_sources + load_sorbet if @run_sorbet + build_actions + sorbet_validate_high_actions! if @run_sorbet + build_flow_graph + @store.write + Report.new.run + end + + def load_runtime + Dir.glob(File.join(RUNTIME_DIR, "methods-*.jsonl")).each do |file| + File.foreach(file) do |line| + obs = JSON.parse(line) + next unless NilKill.target_path?(obs["path"]) + key = [obs["class"], obs["method"], obs["kind"], obs["path"], obs["line"]] + rec = @store.method_record(key) + rec["calls"] += obs["calls"].to_i + rec["ok_calls"] += obs["ok_calls"].to_i + rec["raised_calls"] += obs["raised_calls"].to_i + %w[returns return_elem raised].each { |k| rec[k] = (rec[k] + Array(obs[k])).uniq.sort } + merge_hash_sets(rec["params_by_name"], obs["params_by_name"]) + merge_hash_sets(rec["params_ok"], obs["params_ok"]) + merge_hash_sets(rec["params_raised"], obs["params_raised"]) + merge_hash_counts(rec["param_sites"], obs["param_sites"]) + merge_hash_counts(rec["param_sites_ok"], obs["param_sites_ok"]) + merge_hash_counts(rec["param_sites_raised"], obs["param_sites_raised"]) + merge_hash_counts(rec["param_traces"], obs["param_traces"]) + merge_hash_counts(rec["param_traces_ok"], obs["param_traces_ok"]) + merge_hash_counts(rec["param_traces_raised"], obs["param_traces_raised"]) + merge_hash_sets(rec["param_elem"], obs["param_elem"]) + merge_hash_kv(rec["param_kv"], obs["param_kv"]) + merge_hash_shapes(rec["param_elem_shapes"], obs["param_elem_shapes"]) + merge_hash_kv_shapes(rec["param_kv_shapes"], obs["param_kv_shapes"]) + merge_kv(rec["return_kv"], obs["return_kv"]) + merge_shapes(rec["return_elem_shapes"], obs["return_elem_shapes"]) + merge_kv_shapes(rec["return_kv_shapes"], obs["return_kv_shapes"]) + end + end + Dir.glob(File.join(RUNTIME_DIR, "tlets-*.jsonl")).each do |file| + File.foreach(file) do |line| + obs = JSON.parse(line) + next unless NilKill.target_path?(obs["path"]) + key = "#{obs["path"]}:#{obs["line"]}" + rec = (@store.tlets[key] ||= { "path" => obs["path"], "line" => obs["line"], "calls" => 0, "classes" => [] }) + rec["calls"] += obs["calls"].to_i + rec["classes"] = (rec["classes"] + Array(obs["classes"])).uniq.sort + end + end + Dir.glob(File.join(RUNTIME_DIR, "structs-*.jsonl")).each do |file| + File.foreach(file) do |line| + obs = JSON.parse(line) + next unless NilKill.target_path?(obs["path"]) + @store.facts["struct_field_runtime"] ||= [] + @store.facts["struct_field_runtime"] << obs + end + end + Dir.glob(File.join(RUNTIME_DIR, "ivars-*.jsonl")).each do |file| + File.foreach(file) do |line| + obs = JSON.parse(line) + @store.facts["ivar_runtime"] ||= [] + @store.facts["ivar_runtime"] << obs + end + end + cov = Hash.new { |h, k| h[k] = [] } + Dir.glob(File.join(RUNTIME_DIR, "coverage-*.jsonl")).each do |file| + File.foreach(file) do |line| + obs = JSON.parse(line) + next unless NilKill.target_path?(obs["path"]) + cov[NilKill.rel(obs["path"])].concat(Array(obs["lines"])) + end + end + @store.facts["collect_coverage"] = cov.transform_values { |ls| ls.uniq.sort } unless cov.empty? + Dir.glob(File.join(RUNTIME_DIR, "tuples-*.jsonl")).each do |file| + File.foreach(file) do |line| + obs = JSON.parse(line) + next unless NilKill.target_path?(obs["path"]) + @store.facts["tuple_runtime"] ||= [] + @store.facts["tuple_runtime"] << obs + end + end + Dir.glob(File.join(RUNTIME_DIR, "collections-*.jsonl")).each do |file| + File.foreach(file) do |line| + obs = JSON.parse(line) + next unless NilKill.target_path?(obs["path"]) + @store.facts["collection_runtime"] ||= [] + @store.facts["collection_runtime"] << obs + end + end + end + + def index_sources + SourceIndex.reset_global_shape_indexes + files = NilKill.target_files + files.each { |path| SourceIndex.new(path) } + # Propagate cross-file T.noreturn until the global set stabilises. + # Each pass picks up methods whose body resolves to noreturn via + # calls to methods registered by an earlier pass. Bounded at 5 + # iterations -- typical chain depth is 1-2. + 5.times do + before = SourceIndex.noreturn_methods.size + files.each { |path| SourceIndex.new(path) } + break if SourceIndex.noreturn_methods.size == before + end + files.each do |path| + idx = SourceIndex.new(path) + @store.facts["files"][NilKill.rel(path)] = idx.summary + @store.facts["unsigned_methods"].concat(idx.methods.reject { |m| m["has_sig"] }) + @store.facts["existing_sigs"].concat(idx.methods.select { |m| m["has_sig"] }) + @store.facts["tlet_sites"].concat(idx.tlet_sites) + @store.facts["dead_nil_checks"].concat(idx.dead_nil_checks) + @store.facts["struct_declarations"].concat(idx.struct_declarations) + @store.facts["struct_field_static"].concat(idx.struct_field_static) + @store.facts["tuple_arrays"].concat(idx.tuple_arrays) + @store.facts["hash_shapes"].concat(idx.hash_shapes) + @store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + @store.facts["hash_record_blockers"].concat(idx.hash_record_blockers) + @store.facts["hash_record_member_calls"].concat(idx.hash_record_member_calls) + @store.facts["type_normalizers"].concat(idx.type_normalizers) + @store.facts["dispatcher_inferences"].concat(idx.dispatcher_inferences) + @store.facts["return_origins"].concat(idx.return_origins) + @store.facts["param_origins"].concat(idx.param_origins) + @store.facts["ivar_protocols"] ||= {} + idx.ivar_protocols.each do |(klass, ivar), methods| + key = "#{klass}\0#{ivar}" + @store.facts["ivar_protocols"][key] ||= [] + @store.facts["ivar_protocols"][key] = (@store.facts["ivar_protocols"][key] + methods.to_a).uniq + end + @store.facts["ivar_param_origins"] ||= {} + idx.ivar_param_origins.each do |(klass, ivar), sources| + key = "#{klass}\0#{ivar}" + @store.facts["ivar_param_origins"][key] ||= [] + @store.facts["ivar_param_origins"][key] = (@store.facts["ivar_param_origins"][key] + sources.to_a).uniq + end + idx.methods.each do |method| + rec = @store.method_record([method["class"], method["method"], method["kind"], File.expand_path(method["path"], ROOT), method["line"]]) + rec["source"] = method + rec["has_sig"] = method["has_sig"] + end + end + end + + def load_sorbet + _out, err, _status = Open3.capture3({ "SRB_YES" => "1", "NO_COLOR" => "1" }, "bundle", "exec", "srb", "tc") + @store.diagnostics["sorbet_errors"] = parse_sorbet_errors(err) + @store.diagnostics["nil_origins"] = parse_nil_origins(err) + @store.diagnostics["sorbet_feedback"] = parse_sorbet_feedback(err) + rescue Errno::ENOENT + @store.diagnostics["sorbet_errors"] = [] + @store.diagnostics["sorbet_feedback"] = [] + end + + def build_actions + unused_return_methods = unused_return_methods_by_location + enrich_return_origins_with_receiver_inference! + enrich_return_origins_with_callee_propagation! + @store.methods.each_value do |rec| + src = rec["source"] + next unless src + report_bad_input_candidates(rec, src) + report_nil_param_candidates(rec, src) + report_union_candidates(rec, src) + rec["has_sig"] ? validate_sig(rec, src, unused_return_methods) : propose_sig(rec, src) + end + propose_dispatcher_inference_actions + propose_static_param_backflow_actions + propose_forwarded_return_chain_actions + propose_hash_record_struct_actions + propose_hash_record_cluster_actions + propose_struct_field_sig_actions + @store.facts["tlet_sites"].each { |site| propose_tlet_action(site) } + @store.facts["dead_nil_checks"].each do |finding| + if finding["kind"] == "nil_check" + @store.actions << base_action("replace_dead_nil_check", REVIEW, finding["path"], finding["line"], finding["reason"], + { "code" => finding["code"] }) + else + @store.actions << base_action("remove_dead_safe_nav", REVIEW, finding["path"], finding["line"], finding["reason"], + { "code" => finding["code"] }) + end + end + @store.diagnostics["sorbet_errors"].each do |diag| + kind = %w[7002 7003 7005 7007].include?(diag["code"]) ? "annotation_conflict" : "sorbet_warning" + conf = kind == "annotation_conflict" ? REVIEW : GAP + @store.actions << base_action(kind, conf, diag["path"], diag["line"], + "Sorbet #{diag["code"]}: #{diag["message"]}", { "code" => diag["code"] }) + end + @store.diagnostics["sorbet_feedback"].each do |feedback| + @store.actions << base_action("sorbet_feedback_widening", REVIEW, feedback["path"], feedback["line"], + feedback["message"], feedback) + end + end + + STRUCT_FIELD_RBI_PATH = "sorbet/rbi/ast-struct-fields.rbi" + + # One `add_struct_field_sig` action per typeable struct field, fed + # through the SAME verified loop (apply_verified) that bisects every + # other action. This replaces struct-rbi's all-or-nothing + # error-parse-blocklist convergence: the loop applies the maximal + # srb-tc-clean subset and skips (surfaces) only the few fields whose + # typing breaks srb tc -- no full revert, and blocked slots become + # first-class REVIEW actions the report already counts/prioritises. + def propose_struct_field_sig_actions + candidates = Report.new.struct_field_candidates( + Array(@store.facts["struct_field_runtime"]), Array(@store.facts["struct_field_static"]) + ) + already = SourceIndex.rbi_field_types + candidates.each do |c| + type = c["type"].to_s + next if type.empty? || type == "T.untyped" + klass = c["class"].to_s + field = c["field"].to_s + next if klass.empty? || field.empty? + existing = already[[klass, field]].to_s + next if NilKill.useful_type?(existing) # already typed in an RBI + @store.actions << base_action("add_struct_field_sig", REVIEW, STRUCT_FIELD_RBI_PATH, 1, + "type #{klass}##{field} as #{type} (struct field RBI)", + { "class" => klass, "field" => field, "type" => type }) + end + end + + def propose_hash_record_struct_actions + shapes_by_site = Array(@store.facts["hash_shapes"]).each_with_object({}) do |shape, index| + index[[shape["path"], shape["line"].to_i, shape["code"].to_s]] = shape + end + lookups = Array(@store.facts["collection_index_lookups"]).select do |lookup| + origin = lookup["origin"] || {} + origin["kind"] == "hash literal" && + lookup["path"] == origin["path"] && + literal_hash_read_code?(lookup["code"], lookup["receiver"], lookup["index"]) && + NilKill.useful_type?(lookup["lookup_type"]) + end + lookups.group_by { |lookup| [lookup.dig("origin", "path"), lookup.dig("origin", "line").to_i, lookup.dig("origin", "name"), lookup.dig("origin", "code").to_s] }.each do |(path, line, name, code), group| + next if path.to_s.empty? || line <= 0 || name.to_s.empty? || code.to_s.empty? + shape = shapes_by_site[[path, line, code]] + next unless shape + fields = hash_record_struct_fields(shape) + next if fields.size < 2 + struct_name = hash_record_struct_name(name) + read_rewrites = group.filter_map { |lookup| hash_record_read_rewrite(lookup) }.uniq { |rw| [rw["line"], rw["code"]] } + signatures = hash_record_local_param_signatures(path, name, shape, struct_name) + read_rewrites.concat(hash_record_param_read_rewrites(path, signatures, shape)) + read_rewrites.uniq! { |rw| [rw["line"], rw["code"]] } + next if read_rewrites.empty? + blockers = hash_record_field_blockers(fields) + hash_record_param_signature_blockers(signatures) + @store.actions << base_action("promote_hash_record_to_struct", REVIEW, path, line, + "promote local hash record #{name} to #{struct_name}; rewrite #{read_rewrites.size} literal field read(s)", + { "name" => name, "struct_name" => struct_name, "scope" => group.first["enclosing_scope"].to_s.split("::").reject(&:empty?), + "literal" => { "line" => line, "code" => code }, + "fields" => fields, "read_rewrites" => read_rewrites, "signatures" => signatures, + "nested_structs" => hash_record_nested_structs(fields), + "blockers" => blockers }) + end + propose_return_hash_record_struct_actions + end + + def hash_record_local_param_signatures(path, local_name, shape, struct_name) + existing = Array(@store.facts["existing_sigs"]) + methods_by_name = existing.group_by { |method| method["method"].to_s } + Array(@store.facts["param_origins"]).filter_map do |origin| + next unless origin["path"].to_s == path.to_s + next unless origin["origin_kind"] == "local" + next unless hash_record_shape_matches_shape?(origin["hash_shape"], shape) + matches = hash_record_signature_candidate_methods(origin, methods_by_name) + next unless matches.size == 1 + method = matches.first + param = hash_record_origin_param(origin, method) + next unless param + from = NilKill.extract_param_entries(method["sig"].to_s).to_h[param["name"].to_s] || param["type"].to_s + to = hash_record_signature_target(from, struct_name) + next unless to + { "path" => method["path"], "line" => method["line"], "kind" => "param", + "name" => param["name"], "from" => from, "type" => to, "method" => method["method"] } + end.uniq { |sig| [sig["path"], sig["line"], sig["kind"], sig["name"], sig["from"], sig["type"]] } + end + + def hash_record_param_read_rewrites(path, signatures, shape) + params = Array(signatures).select { |sig| sig["kind"] == "param" && sig["path"].to_s == path.to_s } + return [] if params.empty? + Array(@store.facts["collection_index_lookups"]).filter_map do |lookup| + origin = lookup["origin"] || {} + next unless lookup["path"].to_s == path.to_s + next unless origin["kind"] == "method parameter" + next unless params.any? { |sig| sig["line"].to_i == origin["line"].to_i && sig["name"].to_s == origin["name"].to_s } + next unless hash_record_shape_matches_shape?(origin["shape"], shape) + hash_record_read_rewrite(lookup) + end + end + + def propose_return_hash_record_struct_actions + methods_by_location = Array(@store.facts["existing_sigs"]).each_with_object({}) do |method, index| + index[[method["path"], method["line"].to_i]] = method + end + returns_by_method = Array(@store.facts["return_origins"]).each_with_object(Hash.new { |h, k| h[k] = [] }) do |origin, index| + next unless origin["hash_shape"] && !origin.dig("hash_shape", "poisoned") + index[[origin["path"], origin["class"], origin["method"]]] << origin + end + lookups = Array(@store.facts["collection_index_lookups"]).select do |lookup| + origin = lookup["origin"] || {} + origin["kind"] == "forwarded return" && + lookup["path"] == origin["path"] && + literal_hash_read_code?(lookup["code"], lookup["receiver"], lookup["index"]) && + NilKill.useful_type?(lookup["lookup_type"]) + end + lookups.group_by { |lookup| [lookup["path"], lookup["enclosing_scope"], lookup.dig("origin", "callee"), lookup.dig("origin", "name")] }.each do |(path, scope, callee, name), group| + next if path.to_s.empty? || callee.to_s.empty? || name.to_s.empty? + returns = returns_by_method[[path, scope.to_s, callee.to_s]] + next unless returns&.one? + ret = returns.first + source = Array(ret["sources"]).find { |src| src["code"].to_s.start_with?("{") && src["code"].to_s.end_with?("}") } + next unless source + fields = hash_record_struct_fields_from_shape(ret["hash_shape"]) + next if fields.size < 2 + struct_name = hash_record_struct_name(name) + read_rewrites = group.filter_map { |lookup| hash_record_read_rewrite(lookup) }.uniq { |rw| [rw["line"], rw["code"]] } + next if read_rewrites.empty? + signatures = [] + if (method = methods_by_location[[ret["path"], ret["line"].to_i]]) + from = NilKill.extract_return_type(method["sig"].to_s) + to = hash_record_signature_target(from, struct_name) + if to + signatures << { "path" => method["path"], "line" => method["line"], "kind" => "return", + "from" => from, "type" => to, "method" => method["method"] } + end + end + blockers = hash_record_field_blockers(fields) + @store.actions << base_action("promote_hash_record_to_struct", REVIEW, path, source["line"], + "promote hash record returned by #{callee} to #{struct_name}; rewrite #{read_rewrites.size} forwarded field read(s)", + { "name" => name, "struct_name" => struct_name, "scope" => scope.to_s.split("::").reject(&:empty?), + "literal" => { "line" => source["line"], "code" => source["code"] }, + "fields" => fields, "read_rewrites" => read_rewrites, "signatures" => signatures, + "nested_structs" => hash_record_nested_structs(fields), + "blockers" => blockers, + "producer" => { "method" => callee, "line" => ret["line"] } }) + end + end + + def literal_hash_read_code?(code, receiver, index) + recv = Regexp.escape(receiver.to_s) + idx = Regexp.escape(index.to_s) + code.to_s.match?(/\A#{recv}\s*\[\s*#{idx}\s*\]\z/) || + code.to_s.match?(/\A#{recv}\.fetch\(\s*#{idx}\s*\)\z/) + end + + def hash_record_struct_fields(shape) + nested = hash_record_nested_field_shapes(shape) + Array(shape["keys"]).zip(Array(shape["value_types"])).filter_map do |key, type| + name = key.to_s + next unless name.match?(/\A[a-z_]\w*\z/) + if (nested_field = hash_record_nested_field(name, nested[name])) + next nested_field + end + next unless NilKill.useful_type?(type) + { "name" => name, "type" => type.to_s } + end + end + + def hash_record_struct_fields_from_shape(shape) + nested = hash_record_nested_field_shapes(shape) + Hash(shape["keys"]).sort.filter_map do |key, types| + name = key.to_s + next unless name.match?(/\A[a-z_]\w*\z/) + if (nested_field = hash_record_nested_field(name, nested[name])) + next nested_field + end + type = NilKill.static_sorbet_type(types) + { "name" => name, "type" => type.to_s } + end + end + + def hash_record_nested_field_shapes(shape) + direct = Hash(shape["value_hash_shapes"]) + arrays = Hash(shape["value_array_element_shapes"]) + (direct.keys | arrays.keys).each_with_object({}) do |key, index| + index[key.to_s] = + if arrays[key] + { "kind" => "array", "shape" => arrays[key] } + elsif direct[key] + { "kind" => "hash", "shape" => direct[key] } + end + end + end + + def hash_record_nested_field(name, nested) + return nil unless nested && nested["shape"] && !nested["shape"]["poisoned"] + fields = hash_record_struct_fields_from_shape(nested["shape"]) + return nil if fields.empty? + struct_name = hash_record_struct_name(name) + type = nested["kind"] == "array" ? "T::Array[#{struct_name}]" : struct_name + { "name" => name, "type" => type, "nested" => nested.merge("struct_name" => struct_name, "type_name" => struct_name, "fields" => fields) } + end + + def hash_record_nested_structs(fields) + Array(fields).flat_map do |field| + nested = field["nested"] + next [] unless nested + hash_record_nested_structs(nested["fields"]) + [nested] + end.uniq { |nested| nested["type_name"] || nested["struct_name"] } + end + + def hash_record_field_blockers(fields) + Array(fields).filter_map do |field| + type = field["type"].to_s + if type.empty? || !NilKill.useful_type?(type) + "field #{field["name"]} needs type evidence; currently unknown" + elsif type == "NilClass" || type == "T.nilable(NilClass)" + "field #{field["name"]} needs non-nil value evidence; currently #{type}" + elsif NilKill.weak_type?(type) + "field #{field["name"]} needs stronger element/value evidence; currently #{type}" + end + end + end + + def hash_record_param_signature_blockers(signatures) + params = Array(signatures).select { |sig| sig["kind"] == "param" } + return [] if params.empty? + Array(@store.facts["hash_record_blockers"]).filter_map do |blocker| + origin = blocker["origin"] || {} + next unless origin["kind"] == "method parameter" + next unless params.any? do |sig| + sig["path"].to_s == blocker["path"].to_s && + sig["line"].to_i == origin["line"].to_i && + sig["name"].to_s == origin["name"].to_s + end + site = [blocker["path"], blocker["line"]].compact.join(":") + [blocker["message"].to_s, site].reject(&:empty?).join(" at ") + end.uniq + end + + def hash_record_struct_name(name) + base = name.to_s.gsub(/[^A-Za-z0-9_]/, "_").split("_").reject(&:empty?).map(&:capitalize).join + base = "Record" if base.empty? + "#{base}Record" + end + + def hash_record_read_rewrite(lookup) + key = hash_record_lookup_key(lookup) + return nil unless key && key.match?(/\A[a-z_]\w*\z/) + { "line" => lookup["line"], "code" => lookup["code"], "replacement" => "#{lookup["receiver"]}.#{key}" } + end + + def hash_record_lookup_key(lookup) + case lookup["index"].to_s + when /\A:([A-Za-z_]\w*[!?=]?)\z/ + Regexp.last_match(1) + when /\A["']([^"']+)["']\z/ + Regexp.last_match(1) + end + end + + def build_flow_graph + @store.facts["flow_graph"] = FlowGraph.from_evidence(@store.to_h).to_h + end + + def propose_hash_record_cluster_actions + evidence = @store.to_h + report = Report.allocate + report.instance_variable_set(:@evidence, evidence) + report.hash_record_struct_candidates(evidence).first(30).each do |row| + row = hash_record_expand_row_from_return_origins(row, evidence) + next unless row["total_pressure"].to_i.positive? + producers = Array(row["producers"]) + consumers = Array(row["consumers"]) + blockers = hash_record_cluster_blockers(row) + signatures = hash_record_cluster_signatures(row, evidence) + next if producers.empty? && consumers.empty? + struct_path = row["struct_path"].to_s + first = if !struct_path.empty? + { "path" => struct_path, "line" => 1 } + else + (producers + consumers).min_by { |site| [site["path"].to_s, site["line"].to_i] } + end + @store.actions << base_action("promote_hash_record_cluster_to_struct", REVIEW, first["path"], first["line"], + "plan #{row["type_name"] || row["struct_name"]} from #{row["shape_count"]} hash literal shape(s), #{row["total_pressure"]} pressure slot(s)", + { "struct_name" => row["struct_name"], "type_name" => row["type_name"], "scope" => row["scope"], "struct_path" => row["struct_path"], "fields" => row["fields"], + "nested_structs" => row["nested_structs"], + "common_keys" => row["common_keys"], "optional_keys" => row["optional_keys"], + "producers" => producers, "consumers" => consumers, "signatures" => signatures, + "blockers" => blockers, "pressure" => { + "total" => row["total_pressure"], "return" => row["return_slots"], + "param" => row["param_slots"], "ivar" => row["ivar_slots"], + "collection" => row["collection_slots"], + } }) + end + end + + def hash_record_expand_row_from_return_origins(row, evidence) + row = row.transform_values { |value| value.is_a?(Array) ? value.map { |entry| entry.is_a?(Hash) ? entry.dup : entry } : value } + producers = Array(row["producers"]).map(&:dup) + producer_sites = producers.map { |producer| [producer["path"], producer["line"].to_i, producer["code"].to_s] }.to_set + shape_by_site = Array(evidence.dig("facts", "hash_shapes")).each_with_object({}) do |shape, index| + index[[shape["path"], shape["line"].to_i, shape["code"].to_s]] = shape + end + common = Array(row["common_keys"]).map(&:to_s).sort + union = (common + Array(row["optional_keys"]).map(&:to_s)).uniq.sort + field_types = Array(row["fields"]).each_with_object(Hash.new { |h, k| h[k] = [] }) do |field, index| + type = field["type"].to_s + base = type.start_with?("T.nilable(") ? type[10..-2] : type + index[field["name"].to_s] |= [base] unless base.empty? + end + + Array(evidence.dig("facts", "return_origins")).each do |origin| + sources = Array(origin["sources"]) + next unless sources.any? { |source| producer_sites.include?([origin["path"], source["line"].to_i, source["code"].to_s]) } || + hash_record_origin_shape_matches_row?(origin, row) + sources.each do |source| + next unless source["code"].to_s.start_with?("{") && source["code"].to_s.end_with?("}") + shape = shape_by_site[[origin["path"], source["line"].to_i, source["code"].to_s]] + next unless shape + keys = Array(shape["keys"]).map(&:to_s).sort + next if keys.empty? || (common - keys).any? + next unless similar_hash_keysets_for_action?(union, keys) + site = [origin["path"], source["line"].to_i, source["code"].to_s] + unless producer_sites.include?(site) + producers << { "path" => origin["path"], "line" => source["line"], "code" => source["code"], "keys" => keys } + producer_sites.add(site) + end + union = (union | keys).sort + Array(shape["keys"]).zip(Array(shape["value_types"])).each do |key, type| + field_types[key.to_s] |= [type.to_s] if NilKill.useful_type?(type) + end + end + end + + optional = union - common + fields = union.map do |field| + existing = Array(row["fields"]).find { |entry| entry["name"].to_s == field } + type = NilKill.static_sorbet_type(field_types[field]) + type = existing["type"].to_s if existing && (type.empty? || type == "T.untyped") + type = "T.untyped" unless NilKill.useful_type?(type) + type = "T.nilable(#{type})" if optional.include?(field) && type != "T.untyped" && type != "NilClass" && !type.start_with?("T.nilable(") + data = { "name" => field, "type" => type, "optional" => optional.include?(field) } + data["required_members"] = existing["required_members"] if existing&.key?("required_members") + data + end + row.merge("producers" => producers, "common_keys" => common, "optional_keys" => optional, "fields" => fields) + end + + def similar_hash_keysets_for_action?(left, right) + left = Array(left).to_set + right = Array(right).to_set + return false if left.empty? || right.empty? + intersection = (left & right).size + smaller = [left.size, right.size].min + union = (left | right).size + intersection == smaller || (intersection.to_f / union) >= 0.5 + end + + def hash_record_cluster_signatures(row, evidence) + type_name = (row["type_name"] || row["struct_name"]).to_s + return [] if type_name.empty? + existing = Array(evidence.dig("facts", "existing_sigs")) + methods_by_location = existing.each_with_object({}) { |method, index| index[[method["path"], method["line"].to_i]] = method } + signatures = [] + + producer_sites = Array(row["producers"]).map { |producer| [producer["path"], producer["line"].to_i, producer["code"].to_s] }.to_set + Array(evidence.dig("facts", "return_origins")).each do |origin| + next unless Array(origin["sources"]).any? { |source| producer_sites.include?([origin["path"], source["line"].to_i, source["code"].to_s]) } || + hash_record_origin_shape_matches_row?(origin, row) + method = methods_by_location[[origin["path"], origin["line"].to_i]] + next unless method + from = NilKill.extract_return_type(method["sig"].to_s) + to = hash_record_signature_target(from, type_name) + next unless to + signatures << { "path" => method["path"], "line" => method["line"], "kind" => "return", + "from" => from, "type" => to, "method" => method["method"] } + end + + Array(row["consumers"]).each do |consumer| + origin = consumer["origin"] || {} + next unless origin["kind"] == "method parameter" + method = methods_by_location[[origin["path"], origin["line"].to_i]] + next unless method + name = origin["name"].to_s + from = NilKill.extract_param_entries(method["sig"].to_s).to_h[name] || origin["type"].to_s + to = hash_record_signature_target(from, type_name) + next unless to + signatures << { "path" => method["path"], "line" => method["line"], "kind" => "param", + "name" => name, "from" => from, "type" => to, "method" => method["method"] } + end + + methods_by_name = existing.group_by { |method| method["method"].to_s } + Array(evidence.dig("facts", "param_origins")).each do |origin| + next unless producer_sites.include?([origin["path"], origin["line"].to_i, origin["code"].to_s]) || + hash_record_origin_shape_matches_row?(origin, row) + matches = hash_record_signature_candidate_methods(origin, methods_by_name) + next unless matches.size == 1 + method = matches.first + param = + if origin["arg_kind"] == "positional" + Array(method["params"])[origin["slot"].to_i] + elsif origin["arg_kind"] == "keyword" + Array(method["params"]).find { |entry| entry["name"].to_s == origin["slot"].to_s } + end + next unless param + from = NilKill.extract_param_entries(method["sig"].to_s).to_h[param["name"].to_s] || param["type"].to_s + to = hash_record_signature_target(from, type_name) + next unless to + signatures << { "path" => method["path"], "line" => method["line"], "kind" => "param", + "name" => param["name"], "from" => from, "type" => to, "method" => method["method"] } + end + + signatures.uniq { |sig| [sig["path"], sig["line"], sig["kind"], sig["name"], sig["from"], sig["type"]] } + end + + def hash_record_origin_shape_matches_row?(origin, row) + row_keys = (Array(row["common_keys"]) + Array(row["optional_keys"])).map(&:to_s).sort + return false if row_keys.empty? + [origin["hash_shape"], origin["array_element_shape"]].compact.any? do |shape| + keys = Hash(shape["keys"]).keys.map(&:to_s).sort + keys.any? && (keys - row_keys).empty? && (Array(row["common_keys"]).map(&:to_s) - keys).empty? + end + end + + def hash_record_shape_matches_shape?(left, right) + left_keys = hash_record_shape_keys(left) + right_keys = hash_record_shape_keys(right) + return false if left_keys.empty? || right_keys.empty? + left_keys == right_keys + end + + def hash_record_shape_keys(shape) + keys = shape&.fetch("keys", nil) + keys.is_a?(Hash) ? keys.keys.map(&:to_s).sort : Array(keys).map(&:to_s).sort + end + + def hash_record_origin_param(origin, method) + if origin["arg_kind"] == "positional" + Array(method["params"])[origin["slot"].to_i] + elsif origin["arg_kind"] == "keyword" + Array(method["params"]).find { |entry| entry["name"].to_s == origin["slot"].to_s } + end + end + + def hash_record_signature_candidate_methods(origin, methods_by_name) + callee = origin["callee"].to_s + if callee == "new" && origin["receiver"].to_s != "" + receiver = origin["receiver"].to_s + return Array(methods_by_name["initialize"]).select do |method| + klass = method["class"].to_s + klass == receiver || klass.end_with?("::#{receiver}") || klass.split("::").last == receiver.split("::").last + end + end + Array(methods_by_name[callee]) + end + + def hash_record_signature_target(type, struct_name) + raw = type.to_s.strip + return nil if raw.empty? + if raw.match?(/\AT\.nilable\(\s*T::Hash\[/) + "T.nilable(#{struct_name})" + elsif raw.match?(/\AT::Hash\[/) || raw == "Hash" + struct_name + elsif raw.match?(/\AT\.nilable\(\s*T::Array\[T::Hash\[/) + "T.nilable(T::Array[#{struct_name}])" + elsif raw.match?(/\AT::Array\[T::Hash\[/) + "T::Array[#{struct_name}]" + end + end + + def hash_record_cluster_blockers(row) + blockers = hash_record_field_blockers(Array(row["fields"])) + # A hash that flows into a collection is the NORMAL case: the + # collection has a hidden element type == the hash shape. The + # correct end state is to promote the hash to a Struct AND type + # the collection `T::Array[Struct]`, then convert iteration + # readers (`coll.each { |f| f[:k] }` -> `f.k`). The genuine + # exception is a heterogeneous dumping-ground (AST/MIR node lists) + # where many divergent shapes / T.any value-types share a + # collection and no single struct describes them. + # + # Classify which case this cluster is, from data already computed: + # - common/optional key ratio (divergent shapes merge as many + # optional keys) + # - fraction of fields whose value type is T.any / T.untyped + # (same key, many value-type families == heterogeneous) + # + # Two distinct outcomes (NOT lumped together): + # * heterogeneous -> hard block, "not a struct candidate" + # * coherent + escaping -> the record IS a real struct and the + # collection has a hidden shape, but auto-applying requires + # the element-typed-collection rewrite (type the container + + # convert every iteration reader). That rewrite is the + # twice-reverted hard part and is NOT implemented, so block + # with a DISTINCT message that flags it as a real + # opportunity, not a dead end. + escaping = hash_record_producers_escaping_into_collection(Array(row["producers"])) + unless escaping.empty? + if hash_record_collection_shape_coherent?(row) + first = escaping.first + blockers << "coherent record escapes into a collection at #{first["path"]}:#{first["line"]}; " \ + "this collection has a hidden element type (#{row["type_name"] || row["struct_name"]}) -- " \ + "promoting requires the element-typed-collection rewrite (type the container + convert " \ + "iteration readers), which is not yet implemented" + else + common = Array(row["common_keys"]).size + optional = Array(row["optional_keys"]).size + anyf = Array(row["fields"]).count { |f| f["type"].to_s.match?(/T\.any|T\.untyped/) } + blockers << "heterogeneous collection: #{common} common / #{optional} optional key(s), " \ + "#{anyf}/#{Array(row["fields"]).size} fields are T.any/T.untyped -- no single struct " \ + "describes these shapes; not a struct candidate" + end + end + existing_paths = hash_record_existing_struct_paths(row["struct_name"].to_s) + unless existing_paths.empty? + blockers << "struct name #{row["struct_name"]} already exists at #{existing_paths.first}" + end + Array(row["blockers"]).each do |blocker| + message = blocker["message"].to_s + site = [blocker["path"], blocker["line"]].compact.join(":") + blockers << [message, site].reject(&:empty?).join(" at ") + end + blockers + end + + # A cluster describes ONE coherent struct (the collection it flows + # into has a real hidden element type) when: + # - there are at least 2 common keys present in every shape + # (a single stable skeleton, not a grab-bag), and + # - optional keys don't dominate -- many optional keys means many + # divergent shapes were merged (the heterogeneous case), and + # - value types are mostly concrete -- a high fraction of + # T.any/T.untyped fields means the same key carries unrelated + # value-type families across sites (also heterogeneous). + # Thresholds are intentionally permissive: the user's stated model + # is that homogeneous-shape-into-collection is the NORM and the + # heterogeneous node-list is the exception, so only clearly + # divergent clusters fall through to "not a struct candidate". + def hash_record_collection_shape_coherent?(row) + common = Array(row["common_keys"]).size + optional = Array(row["optional_keys"]).size + fields = Array(row["fields"]) + return false if common < 2 + total_keys = common + optional + return false if total_keys.zero? + optional_ratio = optional.to_f / total_keys + any_ratio = fields.empty? ? 1.0 : + fields.count { |f| f["type"].to_s.match?(/T\.any|T\.untyped/) }.to_f / fields.size + optional_ratio <= 0.5 && any_ratio <= 0.5 + end + + COLLECTION_APPEND_METHODS = %w[<< push unshift append prepend concat].freeze + + # Returns the producer entries whose record value escapes into an + # untyped container -- so downstream readers iterate it via block + # params / generic walkers the proposer cannot enumerate. Three + # escape vectors, all statically decidable: + # 1. the hash literal is an element of an ArrayNode literal + # (`Foo.new(x, [ { ... } ])`) + # 2. the hash literal (or a local bound to it) is the argument of a + # collection-append call (`fields << { ... }`, `arr.push(x)`) + # 3. the hash literal (or its bound local) is the value of an + # index-write that stores it into a container + # (`@functions[key] = f` where `f = { ... }`) + # One-hop local aliasing is followed (`f = { ... }` then `f` used); + # deeper alias chains are conservatively treated as escaping when + # the local is passed as a bare call argument anywhere. + def hash_record_producers_escaping_into_collection(producers) + by_path = Array(producers).group_by { |p| p["path"].to_s } + by_path.flat_map do |rel_path, entries| + next [] if rel_path.empty? + abs = File.expand_path(rel_path, ROOT) + next [] unless File.file?(abs) + parsed = Prism.parse(File.read(abs)) + next [] unless parsed.success? + entries.select do |producer| + line = producer["line"].to_i + code = producer["code"].to_s.strip + hash_node = find_hash_literal_node(parsed.value, line, code) + next false unless hash_node + hash_record_value_escapes?(parsed.value, hash_node) + end + end + end + + def hash_record_value_escapes?(root, hash_node) + return true if hash_literal_in_array_literal?(root, hash_node) + return true if value_in_collection_append_or_index_write?(root, hash_node) + # One-hop alias: `local = { ... }` then escape uses of `local`. + writer = enclosing_local_write_for(root, hash_node) + return false unless writer + name = writer.name.to_s + escape_uses_of_local?(root, name, hash_node) + end + + # The hash node is the direct value argument of `coll << x` / + # `coll.push(x)` / ... or the RHS value of an index write + # `recv[k] = x`. + def value_in_collection_append_or_index_write?(root, target) + each_node(root) do |node| + if node.is_a?(Prism::CallNode) && COLLECTION_APPEND_METHODS.include?(node.name.to_s) + args = node.arguments&.arguments || [] + return true if args.any? { |a| a.equal?(target) } + end + if node.is_a?(Prism::IndexOperatorWriteNode) || node.is_a?(Prism::IndexAndWriteNode) || + node.is_a?(Prism::IndexOrWriteNode) + return true if node.respond_to?(:value) && node.value.equal?(target) + end + if node.is_a?(Prism::CallNode) && node.name.to_s == "[]=" && node.arguments + last = node.arguments.arguments.last + return true if last && last.equal?(target) + end + end + false + end + + def enclosing_local_write_for(root, target) + found = nil + each_node(root) do |node| + if node.is_a?(Prism::LocalVariableWriteNode) && node.value.equal?(target) + found = node + end + end + found + end + + # Conservatively: a local that holds the record escapes if it is + # ever read as an element of an array literal, an append-call arg, + # an index-write value, or any bare call argument (could be retained + # by the callee). + def escape_uses_of_local?(root, name, origin_hash) + each_node(root) do |node| + next unless node.is_a?(Prism::CallNode) + args = node.arguments&.arguments || [] + reads = args.select { |a| a.is_a?(Prism::LocalVariableReadNode) && a.name.to_s == name } + next if reads.empty? + # `local[:k]` / `local.fetch(:k)` style reads have the local as + # the RECEIVER, not an argument -- those are safe accessors and + # never land here. Any local-as-argument is a potential escape. + return true + end + # element of an array literal: `arr = [local]` / `[ local ]` + each_node(root) do |node| + next unless node.is_a?(Prism::ArrayNode) + return true if node.elements.any? { |e| e.is_a?(Prism::LocalVariableReadNode) && e.name.to_s == name } + end + false + end + + def each_node(root) + stack = [root] + until stack.empty? + node = stack.pop + next unless node + yield node + stack.concat(node.child_nodes.compact) if node.respond_to?(:child_nodes) + end + end + + def find_hash_literal_node(root, line, code) + stack = [root] + until stack.empty? + node = stack.pop + next unless node + if node.is_a?(Prism::HashNode) && + node.location.start_line == line && + node.slice.strip == code + return node + end + stack.concat(node.child_nodes.compact) if node.respond_to?(:child_nodes) + end + nil + end + + # True if `target` (a HashNode) sits in an array-element position: + # its nearest enclosing container before the statement is an + # ArrayNode. Parent links aren't available in Prism, so search from + # the root for an ArrayNode that (transitively) contains the target. + def hash_literal_in_array_literal?(root, target) + stack = [root] + until stack.empty? + node = stack.pop + next unless node + if node.is_a?(Prism::ArrayNode) && node_contains?(node, target) + return true + end + stack.concat(node.child_nodes.compact) if node.respond_to?(:child_nodes) + end + false + end + + def node_contains?(node, target) + return false unless node + return true if node.equal?(target) + return false unless node.respond_to?(:child_nodes) + node.child_nodes.compact.any? { |child| node_contains?(child, target) } + end + + def hash_record_existing_struct_paths(struct_name) + return [] if struct_name.empty? + @hash_record_existing_struct_paths ||= {} + @hash_record_existing_struct_paths[struct_name] ||= begin + pattern = /\bclass\s+#{Regexp.escape(struct_name)}\b/ + candidates = Dir.glob("src/**/*.rb") + Dir.glob("gems/*/lib/**/*.rb") + candidates.filter_map do |path| + next unless File.file?(path) + path if File.read(path).match?(pattern) + rescue StandardError + nil + end + end + end + + def propose_sig(rec, src) + sig = sig_for(rec, src) + conf = sig.include?("T.untyped") || rec["calls"].to_i.zero? ? REVIEW : NilKill.confidence(rec["calls"]) + if src["uses_yield"] && conf == HIGH + conf = REVIEW + end + message = src["uses_yield"] ? "add missing sig; method uses implicit yield, block typing needs review" : "add missing sig" + @store.actions << base_action("add_sig", conf, src["path"], src["line"], message, { "sig" => sig, "scope" => src["scope"], "method" => src["method"] }) + end + + def validate_sig(rec, src, unused_return_methods = {}) + sig = src["sig"].to_s + params_for_typing(rec).each do |name, classes| + observed = NilKill.sorbet_type(classes) + next unless NilKill.useful_type?(observed) + next unless sig.match?(/\b#{Regexp.escape(name)}:\s*T\.untyped\b/) + @store.actions << base_action("fix_sig_param", REVIEW, src["path"], src["line"], + "existing sig param #{name} is T.untyped; observed #{observed}", { "name" => name, "type" => observed }) + end + observed_return = runtime_return_type_candidate(rec) + if NilKill.useful_type?(observed_return) && sig.include?("returns(T.untyped)") + @store.actions << base_action("fix_sig_return", REVIEW, src["path"], src["line"], + "existing sig return is T.untyped; observed #{observed_return}", { "type" => observed_return }) + end + propose_void_return_action(src, sig, unused_return_methods, rec) + propose_noreturn_action(src, sig, rec) + propose_static_return_action(src, sig, rec) + propose_generic_narrowing_actions(rec, src, sig) + end + + def propose_void_return_action(src, sig, unused_return_methods, rec = nil) + return unless sig.include?("returns(T.untyped)") + return if src["noreturn_candidate"] + return if runtime_contradicts?(rec, :return, nil, "void") + if unused_return_methods[method_location_key(src)] + @store.actions << base_action("fix_sig_return", HIGH, src["path"], src["line"], + "existing sig return is T.untyped; return value is never used, prefer .void", + { "type" => "void", "source" => "unused_return" }) + elsif rec && rec["calls"].to_i.positive? && + Array(rec["returns"]).reject { |c| c == "NilClass" }.empty? + # Runtime-void: the method ran but never produced a usable + # return value (only nil / nothing), and the STATIC usage scan + # couldn't prove it unused (name collision / ambiguous dispatch + # / return read on an unexercised path). Weaker than the static + # proof -> REVIEW, gated by the verified loop. + @store.actions << base_action("fix_sig_return", REVIEW, src["path"], src["line"], + "existing sig return is T.untyped; ran #{rec["calls"]}x, return value never a usable type at runtime -- likely .void", + { "type" => "void", "source" => "runtime_void" }) + end + end + + def propose_noreturn_action(src, sig, rec = nil) + return unless sig.include?("returns(T.untyped)") + return unless src["noreturn_candidate"] + return if runtime_contradicts?(rec, :return, nil, "T.noreturn") + @store.actions << base_action("fix_sig_return", HIGH, src["path"], src["line"], + "existing sig return is T.untyped; method body cannot return normally", + { "type" => "T.noreturn", "source" => "noreturn_body" }) + end + + def method_location_key(method) + [method["path"], method["line"].to_i, method["class"].to_s, method["method"].to_s, method["kind"].to_s] + end + + def unused_return_methods_by_location + unused_return_methods(@store.to_h).each_with_object({}) do |method, lookup| + lookup[method_location_key(method)] = method + end + end + + def unused_return_methods(evidence) + untyped_candidates = evidence["facts"]["existing_sigs"].select do |method| + method["sig"].to_s.include?(".returns(T.untyped)") || method["sig"].to_s.include?(" returns(T.untyped)") + end + untyped_candidates_by_name = untyped_candidates.group_by { |method| method["method"].to_sym } + all_candidates_by_name = Array(evidence.dig("facts", "existing_sigs")).select do |method| + sig = method["sig"].to_s + sig.match?(/\bvoid\b/) || NilKill.extract_return_type(sig) + end.group_by { |method| method["method"].to_sym } + candidate_names = all_candidates_by_name.select { |_name, methods| methods.size == 1 }.keys.to_set + untyped_candidate_names = untyped_candidates_by_name.select { |name, methods| methods.size == 1 && candidate_names.include?(name) }.keys.to_set + return [] if untyped_candidate_names.empty? + method_return_types = unambiguous_method_return_types(evidence) + + used = Set.new + return_edges = Hash.new { |hash, key| hash[key] = Set.new } + # Scan a broader file set than just target_files: a method only used by + # specs / transpile-tests / tools is still "used", and narrowing it to + # `void` would replace its return value with a Void marker and break + # those callers at runtime. `usage_scan_files` respects NIL_KILL_TARGETS + # for test isolation. + NilKill.usage_scan_files.each do |path| + parsed = Prism.parse_file(path) + next unless parsed.success? + mark_return_usage_graph(parsed.value, :statement, nil, candidate_names, method_return_types, used, return_edges) + end + propagate_return_usage!(used, return_edges) + (untyped_candidate_names - used).filter_map { |name| untyped_candidates_by_name.fetch(name).first } + end + + def unambiguous_method_return_types(evidence) + by_name = Array(evidence.dig("facts", "existing_sigs")).group_by { |method| method["method"].to_sym } + by_name.each_with_object({}) do |(name, methods), types| + next unless methods.size == 1 + sig = methods.first["sig"].to_s + types[name] = sig.include?("void") ? "void" : NilKill.extract_return_type(sig) + end + end + + def propagate_return_usage!(used, return_edges) + changed = true + while changed + changed = false + return_edges.each do |caller, callees| + next unless used.include?(caller) + callees.each do |callee| + next if used.include?(callee) + used << callee + changed = true + end + end + end + end + + def mark_return_usage_graph(node, context, current_method, candidate_names, method_return_types, used, return_edges) + return unless node + case node + when Prism::DefNode + mark_return_usage_graph(node.body, :return, node.name, candidate_names, method_return_types, used, return_edges) + when Prism::StatementsNode + body = node.body || [] + body.each_with_index do |child, idx| + child_context = idx == body.length - 1 ? context : :statement + mark_return_usage_graph(child, child_context, current_method, candidate_names, method_return_types, used, return_edges) + end + when Prism::ReturnNode + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, :return, current_method, candidate_names, method_return_types, used, return_edges) } + when Prism::ArgumentsNode + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, context, current_method, candidate_names, method_return_types, used, return_edges) } + when Prism::IfNode + mark_return_usage_graph(node.predicate, :value, current_method, candidate_names, method_return_types, used, return_edges) if node.respond_to?(:predicate) + mark_return_usage_graph(node.statements, context, current_method, candidate_names, method_return_types, used, return_edges) + mark_return_usage_graph(node.subsequent, context, current_method, candidate_names, method_return_types, used, return_edges) + when Prism::ElseNode + mark_return_usage_graph(node.statements, context, current_method, candidate_names, method_return_types, used, return_edges) + when Prism::CallNode + if candidate_names.include?(node.name) + if context == :return && current_method && candidate_names.include?(current_method) + if typed_value_return?(method_return_types[current_method]) + used << node.name + else + return_edges[current_method] << node.name + end + elsif context == :return && method_return_types[current_method] != "void" + used << node.name + elsif context == :value + used << node.name + end + end + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, :value, current_method, candidate_names, method_return_types, used, return_edges) } + else + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, :value, current_method, candidate_names, method_return_types, used, return_edges) } if node.respond_to?(:child_nodes) + end + end + + def typed_value_return?(return_type) + return_type && return_type != "void" && return_type != "T.untyped" + end + + def propose_static_return_action(src, sig, rec) + return unless sig.include?("returns(T.untyped)") + origin = src["return_origin"] || return_origin_for(src) + return unless origin + type = origin["candidate_type"] + return unless NilKill.useful_type?(type) + return if runtime_contradicts?(rec, :return, nil, type) + confidence = high_confidence_static_return_origin?(origin, rec) ? HIGH : REVIEW + @store.actions << base_action("fix_sig_return", confidence, src["path"], src["line"], + "existing sig return is T.untyped; static return origins suggest #{type}", + { "type" => type, "source" => "static_return_origin", "origin_confidence" => origin["confidence"], + "blockers" => Array(origin["blockers"]).first(8) }) + end + + # HIGH must mean "statically guaranteed to typecheck" -- a high + # action that fails `srb tc` is a calibration bug. Three gates: + # 1. origin confidence must be strong; + # 2. NO blockers -- a blocker is the static analysis itself + # reporting it could not cleanly determine the return, so the + # candidate is a guess, never HIGH; + # 3. every useful source is static/RBI AND, for a BARE static + # source (a heuristic guess, not RBI/stdlib-backed), the method + # must be runtime-corroborated. runtime_contradicts? has + # already rejected incompatible observed returns, so any + # observed return here agrees; if there is NO runtime return at + # all, a bare-static guess is unverifiable -> REVIEW (the loop + # filters it; a review rejection is by-design, not miscalib). + # RBI/stdlib-backed sources are statically provable and stay HIGH + # without runtime backing. + def high_confidence_static_return_origin?(origin, rec = nil) + return false unless origin["confidence"] == "strong" + return false if Array(origin["blockers"]).any? + sources = Array(origin["sources"]) + useful = sources.reject { |source| source["kind"].to_s == "nil" } + return false if useful.empty? + return false unless useful.all? { |source| static_or_rbi_return_source?(source) } + return true unless useful.any? { |source| bare_static_return_source?(source) } + Array(rec && rec["returns"]).any? + end + + # A bare static source: kind "static", not RBI/stdlib-backed, and + # whose return expression is NOT self-evidently typed. A literal / + # constructor (`"x"`, `[...]`, `{...}`, `:sym`, `123`, `Foo.new`, + # true/false/nil) is statically provable -> not bare -> stays HIGH. + # A heuristic guess like `@samples << {...}` (operator call whose + # type is not self-evident) is bare -> needs corroboration. + def bare_static_return_source?(source) + return false unless source["kind"].to_s == "static" + return false if source["stdlib"] + return false if source["callee"] && NilKill.rbi_return_type(source["callee"].to_s) + !self_evident_return_code?(source["code"].to_s) + end + + SELF_EVIDENT_RETURN_RE = /\A(?:"|'|:|\[|\{|%[wi]\[|-?\d|true\b|false\b|nil\b|[A-Z][\w:]*\.new\b)/.freeze + + def self_evident_return_code?(code) + code = code.to_s.strip + return false if code.empty? + SELF_EVIDENT_RETURN_RE.match?(code) + end + + def static_or_rbi_return_source?(source) + return true if source["kind"].to_s == "static" + return false unless %w[typed_call safe_call].include?(source["kind"].to_s) + return true if source["stdlib"] + callee = source["callee"].to_s + !callee.empty? && NilKill.rbi_return_type(callee) + end + + def return_origin_for(src) + @return_origin_by_location ||= @store.facts["return_origins"].each_with_object({}) do |origin, lookup| + lookup[[origin["path"], origin["line"]]] = origin + end + @return_origin_by_location[[src["path"], src["line"]]] + end + + # Feature A: receiver-type inference. After SourceIndex collects all + # `param_origins` and `return_origins`, walk each `call_untyped` return + # source of the form `recv.method` where `recv` is a param of the + # enclosing method. Look up classes callers pass for that param slot + # via `param_origins`; for each, look up `.method`'s return type in + # `RbiReturnIndex`. If consistent (single class with a strong sig), + # replace the source's `call_untyped` kind with `typed_call_inferred` + # and recompute the origin's candidate_type/confidence so downstream + # proposers (propose_static_return_action, propose_forwarded_return_chain) + # pick up the new info. + # + # Guards: + # - Only matches simple `recv.method` calls (regex-bounded). + # - Only emits when ALL caller classes agree on the return type. + # - Drops T.nilable and weak-collection narrowings (same cascade rules + # as elsewhere). + # - Cross-check against runtime via `runtime_contradicts?` -- if runtime + # observed returns that the inferred type doesn't accept, skip. + # Fixed-point iteration: each pass turns some `call_untyped` sources into + # `typed_call_inferred`, which feeds into the next iteration's project- + # method-return index via `build_project_method_return_index` (C2 pulls + # strong return_origins into the index). A method whose return becomes + # strong in iteration N can be the receiver-typed narrowing source for + # another method in iteration N+1. + # + # Bounded at 5 iterations as a safety stop. In practice convergence is + # 1-3 iterations; cycles can't make progress beyond the first hit + # because the second visit is a no-op (kind already changed from + # call_untyped to typed_call_inferred). When a pass enriches zero + # sources we stop early. + MAX_RECEIVER_ENRICHMENT_ITERS = 5 + + def enrich_return_origins_with_receiver_inference! + origins_by_callee = Array(@store.facts["param_origins"]).group_by { |o| o["callee"].to_s } + methods_by_location = Array(@store.facts["existing_sigs"]).each_with_object({}) do |m, h| + h[[m["path"], m["line"].to_i]] = m + end + rbi = NilKill.rbi_return_index + MAX_RECEIVER_ENRICHMENT_ITERS.times do + project_method_returns = build_project_method_return_index + any_enriched = false + Array(@store.facts["return_origins"]).each do |origin| + enriched = false + Array(origin["sources"]).each_with_index do |source, idx| + next unless source["kind"].to_s == "call_untyped" + narrowed = receiver_inferred_call_return(origin, source, origins_by_callee, methods_by_location, project_method_returns, rbi) + next unless narrowed + origin["sources"][idx] = source.merge("kind" => "typed_call_inferred", "type" => narrowed) + enriched = true + any_enriched = true + end + recompute_origin_candidate_and_confidence!(origin) if enriched + end + break unless any_enriched + end + end + + MAX_CALLEE_PROPAGATION_ITERS = 8 + + # Whole-program return-type propagation. + # + # A `call_untyped` return source means the enclosing method returns + # `callee(...)` whose return type wasn't known *in the callee's own + # file*. But the callee's return IS often resolvable program-wide -- + # from its Sorbet sig, an RBI, or a strong return_origin computed for + # the callee elsewhere. nil-kill already stores the whole-program + # call graph (param_origins) and per-method return facts; this pass + # is the missing transitive closure over them. + # + # Fixpoint: a leaf method that resolves in iteration N feeds + # `build_project_method_return_index` (which folds in strong + # return_origins), so its callers resolve in iteration N+1. Bounded + # at MAX_CALLEE_PROPAGATION_ITERS. + # + # Resolution order per call_untyped source (conservative, matching + # the existing forwarded-return ambiguity stance): + # 1. `[enclosing_class, callee]` exact (self / inherited call) + # 2. unique program-wide return for the callee NAME (skip if the + # name resolves to >1 distinct type across classes -- a + # collision we can't disambiguate without receiver typing, + # which is Feature A's job) + # + # Guards mirror receiver inference: useful, non-weak, non-nilable, + # and a runtime cross-check. On resolution the stale + # "untyped callee " blocker is pruned so the origin can + # actually reach `strong` in recompute. + def enrich_return_origins_with_callee_propagation! + MAX_CALLEE_PROPAGATION_ITERS.times do + index = build_project_method_return_index + name_returns = Hash.new { |h, k| h[k] = [] } + index.each { |(_cls, m), t| name_returns[m] << t } + name_unique = {} + name_returns.each do |m, types| + uniq = types.uniq + name_unique[m] = uniq.first if uniq.size == 1 + end + any = false + Array(@store.facts["return_origins"]).each do |origin| + enriched = false + enclosing_class = origin["class"].to_s + Array(origin["sources"]).each_with_index do |source, idx| + next unless source["kind"].to_s == "call_untyped" + callee = source["callee"].to_s + next if callee.empty? + # Noreturn helpers (error!, fixable!, raise-wrappers) are the + # single most common residual blocker. Their noreturn-ness + # lives in SourceIndex.noreturn_methods (populated by the + # cross-file noreturn fixpoint), NOT as a strong return type + # in the index, because their body raises. Resolve them to + # T.noreturn directly; static_sorbet_type treats it as + # bottom so a `return x if c; error!(...)` origin unifies to + # x's type instead of staying blocked. + resolved = + if SourceIndex.noreturn_methods.include?(callee) + "T.noreturn" + else + index[[enclosing_class, callee]] || name_unique[callee] + end + next unless NilKill.useful_type?(resolved) + next if NilKill.weak_type?(resolved) + # NOTE: no T.nilable refusal here. That guard is correct for + # PARAM narrowing (cascade-prone -- copied from Feature A's + # receiver path) but wrong for RETURN propagation: a callee + # whose resolved return is `T.nilable(Foo)` genuinely + # returns that, and propagating it is the correct, sound + # answer. Refusing it stranded ~21 otherwise-resolvable + # return origins as false NoEvidence/blocked. + # The runtime cross-check compares `resolved` against the + # ENCLOSING method's observed return. That is correct for a + # concrete-type substitution, but incoherent for T.noreturn: + # the callee (`error!`) genuinely never returns at THAT call + # site; it makes no claim about the enclosing method, which + # legitimately returns via other paths. static_sorbet_type + # drops T.noreturn as bottom in recompute anyway. Applying + # the guard here wrongly skips every noreturn-helper caller. + unless resolved == "T.noreturn" + rec = method_record_for_origin(origin) + next if rec && runtime_contradicts?(rec, :return, nil, resolved) + end + origin["sources"][idx] = source.merge("kind" => "typed_call_inferred", "type" => resolved) + prune_resolved_callee_blocker!(origin, callee) + enriched = true + any = true + end + recompute_origin_candidate_and_confidence!(origin) if enriched + end + break unless any + end + end + + # Remove the static "untyped callee " blocker once that + # callee's return has been resolved by propagation. Without this the + # origin keeps a stale blocker and recompute can never reach strong. + def prune_resolved_callee_blocker!(origin, callee) + blockers = Array(origin["blockers"]) + return if blockers.empty? + escaped = Regexp.escape(callee) + # The blocker string is "untyped callee at :". + # A trailing \b is WRONG for predicate/bang callees (`error!`, + # `auto?`): `!`/`?` are non-word chars so there is no word + # boundary after them and the blocker never gets pruned, leaving + # the origin stuck non-strong even though the source resolved. + # Anchor on the literal " at " separator (or end) instead. + origin["blockers"] = blockers.reject { |b| b.to_s.match?(/(?:\A|\s)untyped callee #{escaped}(?=\s|\z)/) } + end + + # Project-class method-return index keyed by [class, method]. Reads from + # `existing_sigs` (which captures every Sorbet-typed method definition in + # src/) and extracts the return type per (class, method) pair. The + # RbiReturnIndex's owner-keyed lookup only succeeds when the class has + # an RBI file entry; this index closes the gap for the bulk of project + # methods that have inline `sig {}` declarations. + def build_project_method_return_index + index = {} + Array(@store.facts["existing_sigs"]).each do |method| + klass = method["class"].to_s + name = method["method"].to_s + next if klass.empty? || name.empty? + ret = NilKill.extract_return_type(method["sig"].to_s).to_s + next if ret.empty? || ret == "T.untyped" + # Multiple definitions of the same name across classes are fine + # because the key is [class, method] -- but if a single class has + # two `def name` blocks with different sigs, we lose all but the + # first. Acceptable for first cut. + index[[klass, name]] ||= ret + end + # RBI-declared struct-field accessors: the regenerated + # sorbet/rbi/ast-struct-fields.rbi (and any other RBIs) carry typed + # returns for accessors that lack inline `sig {}` and therefore never + # made it into existing_sigs. Merge them keyed by [class, field]. + SourceIndex.rbi_field_types.each do |(klass, name), ret| + next if klass.to_s.empty? || name.to_s.empty? + next if ret.to_s.empty? || ret == "T.untyped" + index[[klass, name]] ||= ret + end + # Static-inferred returns: when an existing_sigs entry's return is + # T.untyped but return_origins produced a strong candidate, promote + # the inferred type into the lookup. This is the bridge that lets + # newly-inferred returns participate in subsequent receiver-type + # narrowing without first re-running the signature autofix. + Array(@store.facts["return_origins"]).each do |origin| + next unless origin["confidence"].to_s == "strong" + klass = origin["class"].to_s + name = origin["method"].to_s + next if klass.empty? || name.empty? + type = origin["candidate_type"].to_s + next unless NilKill.useful_type?(type) + next if NilKill.weak_type?(type) + index[[klass, name]] ||= type + end + index + end + + def receiver_inferred_call_return(origin, source, origins_by_callee, methods_by_location, project_method_returns, rbi) + code = source["code"].to_s + # `recv.method` optionally followed by args, block, or end of expression. + # Caller-supplied evidence drives the receiver type; subsequent chains + # (`recv.method.foo`) are out of scope here. + m = code.match(/\A([a-z_][a-z_0-9]*)\.([a-z_][a-z_0-9]*[?!]?)(?:[\s({]|\z)/) + return nil unless m + recv_name, called_method = m[1], m[2] + enclosing_method = origin["method"].to_s + return nil if enclosing_method.empty? + method_record = methods_by_location[[origin["path"], origin["line"].to_i]] + return nil unless method_record + param_index = enclosing_method_param_index(method_record, recv_name) + return nil unless param_index + callers = Array(origins_by_callee[enclosing_method]) + return nil if callers.empty? + param_callers = callers.select do |o| + slot = o["slot"].to_s + slot == recv_name || slot == param_index.to_s + end + return nil if param_callers.empty? + # Accept any caller whose `type` field is a useful type, regardless of + # origin_kind. Real-world `param_origins` use `kind` of "local", + # "typed_return", "static", etc. The relevant filter is whether the + # type was inferable, not how it was inferred. Exclude "unknown" + # explicitly so we don't fall back to global RBI semantics. + classes = param_callers.filter_map do |o| + next nil if o["origin_kind"].to_s == "unknown" + type = o["type"].to_s + NilKill.useful_type?(type) ? type : nil + end + classes = classes.uniq + # NilClass is not a distinct dispatch target -- a nil receiver + # means the slot is nilable, not that callers diverge. Don't + # count it toward divergence; resolve on the non-nil class. + classes.delete("NilClass") + return nil if classes.empty? + return nil if classes.size > 1 # >=2 NON-NIL = real divergence + cls = classes.first + # Strip container parameterisation so a class-keyed lookup matches the + # bare class name. T::Array[X] -> Array. Used for both the project-side + # method-return index (which keys on raw class names) and as a hint + # for the stdlib RBI lookup (whose internal owner_name_for also + # strips, so this is belt-and-suspenders). + stdlib_owner = NilKill.strip_to_stdlib_owner(cls) + narrowed = project_method_returns[[cls, called_method]] || + (stdlib_owner && project_method_returns[[stdlib_owner, called_method]]) || + (stdlib_owner && rbi.return_type(called_method, stdlib_owner)) || + rbi.return_type(called_method, cls) + return nil unless NilKill.useful_type?(narrowed) + return nil if NilKill.weak_type?(narrowed) + return nil if narrowed.include?("T.nilable") + # Runtime cross-check: build a synthetic action shape so we reuse the + # existing guard. rec is the enclosing method's runtime record. + rec = method_record_for_origin(origin) + return nil if rec && runtime_contradicts?(rec, :return, nil, narrowed) + narrowed + end + + def enclosing_method_param_index(method_record, recv_name) + entries = NilKill.extract_param_entries(method_record["sig"].to_s) + idx = entries.find_index { |name, _type| name.to_s == recv_name } + idx + end + + def method_record_for_origin(origin) + key = [origin["class"].to_s, origin["method"].to_s, origin["kind"].to_s, + File.expand_path(origin["path"].to_s, ROOT), origin["line"].to_i] + @store.methods["#{key.join("\0")}"] + end + + def recompute_origin_candidate_and_confidence!(origin) + sources = Array(origin["sources"]) + type_sources = sources.filter_map { |s| s["type"] } + candidate = NilKill.static_sorbet_type(type_sources) + has_call_untyped = sources.any? { |s| s["kind"].to_s == "call_untyped" || s["kind"].to_s == "unknown" } + candidate = "T.untyped" if candidate == "NilClass" && has_call_untyped + useful = NilKill.useful_type?(candidate) + blockers = Array(origin["blockers"]) + confidence = + if useful && !NilKill.weak_type?(candidate) && blockers.empty? && !has_call_untyped + "strong" + elsif useful + "weak" + else + "blocked" + end + origin["candidate_type"] = useful ? candidate : "T.untyped" + origin["confidence"] = confidence + end + + # Sorbet-validates every HIGH-confidence action before they leave infer. + # Catches over-narrow signatures where the proposer trusted incomplete + # static or runtime evidence (e.g. method body returns a broader type than + # observed at runtime; nilable receivers wrapped in T.must that Sorbet now + # flags as redundant). + # + # Strategy: snapshot the affected files, apply the HIGH batch, run srb tc. + # On srb tc success: restore files and keep the actions at HIGH. + # On srb tc failure: bisect to isolate the failing actions and downgrade + # them to REVIEW so the user-facing verified loop can re-attempt them + # under a stronger gate. Always restores files before returning. + def sorbet_validate_high_actions! + high = @store.actions.select { |a| a["confidence"] == HIGH } + return if high.empty? + paths = high.map { |a| a["path"].to_s }.uniq.reject(&:empty?) + snapshot = paths.each_with_object({}) do |rel, h| + abs = File.expand_path(rel, ROOT) + h[abs] = File.read(abs) if File.file?(abs) + end + begin + failing = sorbet_validate_batch(high, snapshot) + ensure + snapshot.each { |path, content| File.write(path, content) } + end + return if failing.empty? + failing_fps = failing.map { |a| sorbet_validate_fingerprint(a) }.to_set + @store.actions.each do |action| + next unless action["confidence"] == HIGH + next unless failing_fps.include?(sorbet_validate_fingerprint(action)) + action["confidence"] = REVIEW + action["message"] = "[downgraded from high by sorbet pre-validate] #{action["message"]}" + end + warn "nil-kill: sorbet pre-validate downgraded #{failing.size} HIGH action(s) to REVIEW" + end + + def sorbet_validate_batch(actions, snapshot) + return [] if actions.empty? + snapshot.each { |path, content| File.write(path, content) } + Apply.new([]).apply_actions(actions) + return [] if sorbet_clean? + return actions if actions.size == 1 + mid = actions.size / 2 + sorbet_validate_batch(actions.first(mid), snapshot) + + sorbet_validate_batch(actions.drop(mid), snapshot) + end + + def sorbet_clean? + _, _, status = Open3.capture3({ "SRB_YES" => "1", "NO_COLOR" => "1" }, "bundle", "exec", "srb", "tc") + status.success? + end + + def sorbet_validate_fingerprint(action) + JSON.generate([action["kind"], action["path"], action["line"], action["message"], action["data"]]) + end + + # Returns true when runtime observations for the given slot contain a class + # that is not accepted by `proposed_type`. Used to prevent Sorbet-clean-but- + # runtime-broken narrowings: e.g. caller passes `Symbol :Any` via `node.x || :Any` + # fallthrough, all statically visible callers pass `Type`, proposer narrows + # the param to `Type` -- runtime then violates the contract. + # + # Returns false when no runtime observation exists for the slot (proposers + # fall back to their existing static behavior). + def runtime_contradicts?(rec, slot_kind, slot_name, proposed_type) + return false unless rec + observed_classes = + case slot_kind + when :return then Array(rec["returns"]) + when :param then Array(rec.dig("params_by_name", slot_name.to_s)) + end + observed_classes = observed_classes.compact.reject { |c| c.to_s.empty? } + return false if observed_classes.empty? + observed_classes.any? { |observed| !proposed_type_accepts?(proposed_type, observed.to_s) } + end + + def proposed_type_accepts?(proposed_type, observed_class) + type = proposed_type.to_s.strip + return false if type.empty? + return true if type == "T.untyped" + return false if observed_class.empty? + # Ignore non-informative observations the proposers themselves filter out. + return true if observed_class.include?("#") || observed_class.start_with?("Sorbet::Private::") + # void / T.noreturn: the slot must not return anything; treat any concrete + # observation as a contradiction. NilClass is also a contradiction for + # T.noreturn since the method must not return normally at all. + return observed_class == "NilClass" if type == "void" + return false if type == "T.noreturn" + if type.start_with?("T.nilable(") && type.end_with?(")") + inner = NilKill.strip_nilable_type(type) + return true if observed_class == "NilClass" + return proposed_type_accepts?(inner, observed_class) + end + if type.start_with?("T.any(") && type.end_with?(")") + inner = NilKill.extract_call_args(type, "T.any") || "" + return NilKill.split_top_level(inner).any? { |alt| proposed_type_accepts?(alt.strip, observed_class) } + end + return %w[TrueClass FalseClass T::Boolean].include?(observed_class) if type == "T::Boolean" + # Parameterised collection types: the container shape must match. Runtime + # observing `Hash` against a proposed `T::Array[T.untyped]` is a hard + # contradiction -- sorbet-runtime would raise TypeError on that path. + # Previously this returned `true` unconditionally, which was the root cause + # of nine of the twelve prspec-only rejections in Move 2: narrowings to + # `T.nilable(T::Array[T.untyped])` whose runtime trace included other + # container classes (Hash, custom classes) that the proposer's static + # analysis missed. Catching them here means the verified loop never + # attempts them. + return observed_class == "Array" if type.start_with?("T::Array[") + return observed_class == "Hash" if type.start_with?("T::Hash[") + return observed_class == "Set" if type.start_with?("T::Set[") + return %w[Array Hash Set].include?(observed_class) if type.start_with?("T::Enumerable[") + type == observed_class + end + + def runtime_return_type_candidate(rec) + observed = NilKill.sorbet_type(rec["returns"]) + case observed + when "Array" + generic_candidate_type("T::Array[T.untyped]", rec["return_elem"], rec["return_kv"], rec["return_elem_shapes"], rec["return_kv_shapes"]) || observed + when "Hash" + generic_candidate_type("T::Hash[T.untyped, T.untyped]", rec["return_elem"], rec["return_kv"], rec["return_elem_shapes"], rec["return_kv_shapes"]) || observed + when "Set" + generic_candidate_type("T::Set[T.untyped]", rec["return_elem"], rec["return_kv"], rec["return_elem_shapes"], rec["return_kv_shapes"]) || observed + else + observed + end + end + + def propose_generic_narrowing_actions(rec, src, sig) + NilKill.extract_param_entries(sig).each do |name, current_type| + next unless generic_type?(current_type) + inner_type = NilKill.strip_nilable_type(current_type) + candidate = generic_candidate_type(inner_type, rec["param_elem"][name], rec["param_kv"][name], + rec.dig("param_elem_shapes", name), rec.dig("param_kv_shapes", name)) + candidate = preserve_nilable_wrapper(current_type, candidate) + next unless candidate && candidate != current_type + confidence = collection_narrowing_confidence(rec, candidate) + @store.actions << base_action("narrow_generic_param", confidence, src["path"], src["line"], + "narrow generic param #{name} from #{current_type} to #{candidate}", + { "name" => name, "from" => current_type, "type" => candidate, "source" => "collection_runtime" }) + end + current_return = NilKill.extract_return_type(sig) + return unless generic_type?(current_return) + inner_return = NilKill.strip_nilable_type(current_return) + candidate = generic_candidate_type(inner_return, rec["return_elem"], rec["return_kv"], rec["return_elem_shapes"], rec["return_kv_shapes"]) + candidate = preserve_nilable_wrapper(current_return, candidate) + return unless candidate && candidate != current_return + confidence = collection_narrowing_confidence(rec, candidate) + @store.actions << base_action("narrow_generic_return", confidence, src["path"], src["line"], + "narrow generic return from #{current_return} to #{candidate}", + { "from" => current_return, "type" => candidate, "source" => "collection_runtime" }) + end + + def generic_type?(type) + raw = NilKill.strip_nilable_type(type) + raw.match?(/\A(?:Array|Hash|Set|T::Array|T::Hash|T::Set)\b/) && raw.include?("T.untyped") + end + + def preserve_nilable_wrapper(current_type, candidate) + return nil unless candidate + current_type.to_s.start_with?("T.nilable(") ? "T.nilable(#{candidate})" : candidate + end + + def collection_narrowing_confidence(rec, candidate) + return REVIEW unless NilKill.acceptable_shape_candidate?(candidate) + return REVIEW unless simple_autofix_collection_candidate?(candidate) + NilKill.confidence(rec["calls"]) + end + + def simple_autofix_collection_candidate?(candidate) + raw = NilKill.strip_nilable_type(candidate) + scalar = /(?:String|Symbol|Integer|Float|T::Boolean)/ + raw.match?(/\AT::Array\[#{scalar}\]\z/) || + raw.match?(/\AT::Set\[#{scalar}\]\z/) || + raw.match?(/\AT::Hash\[(?:String|Symbol|Integer), #{scalar}\]\z/) + end + + def generic_candidate_type(current_type, elem_classes, kv_classes, elem_shapes = nil, kv_shapes = nil) + case current_type.to_s + when /\A(?:Array|T::Array)\b/ + elem = NilKill.shape_union_type(elem_shapes) + elem ||= NilKill.conservative_element_type(elem_classes) + candidate = elem ? "T::Array[#{elem}]" : nil + candidate if candidate && NilKill.acceptable_shape_candidate?(candidate) + when /\A(?:Set|T::Set)\b/ + elem = NilKill.shape_union_type(elem_shapes) + elem ||= NilKill.conservative_element_type(elem_classes) + candidate = elem ? "T::Set[#{elem}]" : nil + candidate if candidate && NilKill.acceptable_shape_candidate?(candidate) + when /\A(?:Hash|T::Hash)\b/ + kv_shape = Array(kv_shapes) + key = NilKill.shape_union_type(kv_shape[0]) + value = NilKill.shape_union_type(kv_shape[1]) + kv = Array(kv_classes) + key ||= NilKill.conservative_element_type(kv[0]) + value ||= NilKill.conservative_element_type(kv[1]) + candidate = key && value ? "T::Hash[#{key}, #{value}]" : nil + candidate if candidate && NilKill.acceptable_shape_candidate?(candidate) + end + end + + def propose_dispatcher_inference_actions + methods = (@store.facts["existing_sigs"] + @store.facts["unsigned_methods"]).each_with_object({}) do |method, hash| + hash[[method["path"], method["class"], method["kind"], method["method"]]] = method + end + @store.facts["dispatcher_inferences"].each do |inf| + method = methods[[inf["path"], inf["class"], inf["kind"], inf["helper"]]] + next unless method && method["params"].size == 1 + param = method["params"].first["name"] + type = inf["type"] + if method["has_sig"] + next unless method["sig"].to_s.match?(/\b#{Regexp.escape(param)}:\s*T\.untyped\b/) + @store.actions << base_action("fix_sig_param", REVIEW, method["path"], method["line"], + "dispatcher #{inf["dispatcher"]} proves #{method["method"]} param #{param} is #{type}", + { "name" => param, "type" => type, "source" => "dispatcher", "dispatcher_line" => inf["line"] }) + else + sig = "sig { params(#{param}: #{type}).returns(T.untyped) }" + @store.actions << base_action("add_sig", REVIEW, method["path"], method["line"], + "add dispatcher-inferred sig from #{inf["dispatcher"]}", { "sig" => sig, "scope" => method["scope"], "source" => "dispatcher", "dispatcher_line" => inf["line"] }) + end + end + end + + def propose_static_param_backflow_actions + methods_by_name = Array(@store.facts["existing_sigs"]).group_by { |method| method["method"].to_s } + origins_by_callee = Array(@store.facts["param_origins"]).group_by { |origin| origin["callee"].to_s } + protocol_index = static_param_backflow_protocol_index + protocol_resolver = ProtocolResolver.new(@store) + methods_by_name.each do |name, methods| + # Class-scoped: a shared method name no longer blocks the whole + # group. Each method is evaluated independently; the per-method + # runtime_contradicts? + protocol-rejection guards below, and the + # verified loop's bisection, reject any candidate that does not + # actually hold for a given class. (B:34 name-shared bucket.) + methods.each do |method| + sig = method["sig"].to_s + NilKill.extract_param_entries(sig).each_with_index do |(param_name, current_type), idx| + next unless current_type == "T.untyped" + origins = origins_by_callee[name].to_a.select do |origin| + origin["slot"].to_s == idx.to_s || origin["slot"].to_s == param_name.to_s + end + candidate, reason = static_param_backflow_candidate(origins) + next unless candidate && NilKill.strong_trace_type?(candidate) + protocol_rejection = static_param_backflow_protocol_rejection(method, param_name, candidate, protocol_index, protocol_resolver) + next if protocol_rejection + rec = @store.method_record([method["class"], method["method"], method["kind"], File.expand_path(method["path"], ROOT), method["line"]]) + next if runtime_contradicts?(rec, :param, param_name, candidate) + next if existing_signature_action?(method["path"], method["line"], "fix_sig_param", param_name, candidate) + @store.actions << base_action("fix_sig_param", REVIEW, method["path"], method["line"], + "static callsites prove param #{param_name} is #{candidate}; #{reason}", + { "name" => param_name, "type" => candidate, "source" => "static_param_backflow", + "callsites" => static_param_backflow_callsites(origins), "callsite_count" => origins.size }) + end + end + end + end + + def static_param_backflow_candidate(origins) + origins = Array(origins) + return [nil, "no static callsites"] if origins.empty? + return [nil, "unknown/dynamic callsite expression"] if origins.any? { |origin| origin["origin_kind"].to_s == "unknown" || origin["type"].to_s.empty? } + # A `local` origin means the caller passed the arg via a local + # variable. SourceIndex has ALREADY resolved that local's type + # via expression_type when it recorded `origin["type"]` -- the + # same machinery trusted for `static`/`typed_return` origins. The + # old blanket "any local -> bail" guard threw away every such + # case even when the type was concretely known (e.g. a caller + # `name = "x"; closest_name(name)` -> origin type "String"). + # Only reject locals whose type is NOT resolved; resolved-type + # locals flow into the normal aggregation, still gated downstream + # by weak/Object/conflicting checks, runtime_contradicts?, the + # protocol resolver, and the verified loop. + return [nil, "local alias with unresolved type"] if origins.any? do |origin| + origin["origin_kind"].to_s == "local" && !NilKill.useful_type?(origin["type"].to_s) + end + types = origins.filter_map { |origin| origin["type"].to_s unless origin["type"].to_s.empty? } + candidate = NilKill.static_sorbet_type(types) + return [nil, "conflicting static callsite types"] unless NilKill.useful_type?(candidate) + return [nil, "weak static callsite type #{candidate}"] if NilKill.weak_type?(candidate) + return [nil, "non-informative static callsite type #{candidate}"] if NilKill.strip_nilable_type(candidate) == "Object" + [candidate, "#{origins.size} static callsite(s) agree"] + end + + def static_param_backflow_protocol_rejection(method, param_name, candidate, protocol_index, protocol_resolver = nil) + gaps = Array(method.dig("protocols", param_name.to_s, "gaps")).map(&:to_s) + + if gaps.empty? + required = Array(method.dig("protocols", param_name.to_s, "methods")) + .map(&:to_s) + .reject { |name| static_param_backflow_ignorable_protocol_method?(name) } + .uniq + else + return "candidate #{candidate} requires recursive protocol analysis: #{gaps.first}" unless protocol_resolver + resolved = protocol_resolver.required_methods(method["class"], method["method"], param_name) + if resolved["blocked"] + return "candidate #{candidate} hit unresolvable forwarding chain: #{resolved["chain"].first(3).join(' -> ')}" + end + required = resolved["methods"] + end + return nil if required.empty? + + type = NilKill.strip_nilable_type(candidate) + return nil if type == "T::Boolean" || type == "Object" + + available = protocol_index[type] + return "candidate #{candidate} has no known protocol for required ##{required.sort.join(", #")}" unless available + + missing = required.reject { |name| available.include?(name) } + return nil if missing.empty? + + "candidate #{candidate} lacks required ##{missing.sort.join(", #")}" + end + + def static_param_backflow_protocol_index + index = Hash.new { |hash, key| hash[key] = Set.new } + (Array(@store.facts["existing_sigs"]) + Array(@store.facts["unsigned_methods"])).each do |method| + next unless method["kind"] == "instance" && !method["class"].to_s.empty? + index[method["class"].to_s] << method["method"].to_s + end + Array(@store.facts["struct_declarations"]).each do |decl| + Array(decl["fields"]).each { |field| index[decl["class"].to_s] << field.to_s } + end + index + end + + def static_param_backflow_ignorable_protocol_method?(name) + %w[ + nil? class is_a? kind_of? instance_of? object_id respond_to? + instance_variable_get instance_variable_set itself tap then yield_self + ].include?(name.to_s) + end + + def static_param_backflow_callsites(origins) + origins.each_with_object(Hash.new(0)) do |origin, calls| + key = "#{origin["path"]}:#{origin["line"]}:#{origin["code"]}" + calls[key] += 1 + end + end + + def existing_signature_action?(path, line, kind, name, type) + @store.actions.any? do |action| + action["kind"] == kind && + action["path"].to_s == path.to_s && + action["line"].to_i == line.to_i && + action.dig("data", "name").to_s == name.to_s && + action.dig("data", "type").to_s == type.to_s + end + end + + def propose_forwarded_return_chain_actions + untyped_methods = @store.facts["existing_sigs"].select do |method| + NilKill.extract_return_type(method["sig"].to_s) == "T.untyped" + end + return if untyped_methods.empty? + + origin_by_location = @store.facts["return_origins"].each_with_object({}) do |origin, lookup| + lookup[[origin["path"], origin["line"].to_i, origin["class"].to_s, origin["method"].to_s, origin["kind"].to_s]] = origin + end + resolver = ForwardedReturnResolver.new(@store) + untyped_methods.each do |method| + origin = method["return_origin"] || origin_by_location[[method["path"], method["line"].to_i, method["class"].to_s, method["method"].to_s, method["kind"].to_s]] + next unless origin + resolved = resolver.resolve(origin) + next unless resolved && resolved["forwarded"] + type = resolved["type"] + next unless NilKill.useful_type?(type) + next if NilKill.weak_type?(type) + rec = @store.method_record([method["class"], method["method"], method["kind"], File.expand_path(method["path"], ROOT), method["line"]]) + next if runtime_contradicts?(rec, :return, nil, type) + + confidence = simple_forwarded_return_candidate?(type) ? HIGH : REVIEW + @store.actions << base_action("fix_sig_return", confidence, method["path"], method["line"], + "existing sig return is T.untyped; forwarded-return chain resolves to #{type}", + { "type" => type, "source" => "forwarded_return_chain", "chain" => resolved["chain"].first(12) }) + end + end + + def simple_forwarded_return_candidate?(type) + %w[String Integer Float Symbol T::Boolean].include?(type.to_s) + end + + class ForwardedReturnResolver + def initialize(store) + @store = store + @origins_by_name = Array(store.facts["return_origins"]).group_by { |origin| origin["method"].to_s } + @sig_counts_by_name = Array(store.facts["existing_sigs"]).each_with_object(Hash.new(0)) do |method, counts| + counts[method["method"].to_s] += 1 + end + @sig_types_by_name = Array(store.facts["existing_sigs"]).each_with_object(Hash.new { |h, k| h[k] = [] }) do |method, types| + ret = NilKill.extract_return_type(method["sig"].to_s) + types[method["method"].to_s] << ret if NilKill.useful_type?(ret) + end + @resolved = {} + end + + def resolve(origin, stack = []) + key = origin_key(origin) + return @resolved[key] if @resolved.key?(key) + return nil if stack.include?(key) + + sources = Array(origin["sources"]) + return nil if sources.empty? + + types = [] + chain = [format_origin(origin)] + forwarded = false + sources.each do |source| + case source["kind"].to_s + when "static", "assignment", "typed_call", "safe_call" + type = source["type"] + return nil unless NilKill.useful_type?(type) + types << type + forwarded ||= %w[typed_call safe_call].include?(source["kind"].to_s) + chain << format_source(source) + when "nil" + types << "NilClass" + when "call_untyped" + callee = source["callee"].to_s + callee_resolved = resolve_callee(callee, stack + [key]) + return nil unless callee_resolved + types << callee_resolved["type"] + forwarded = true + chain << format_source(source) + chain.concat(Array(callee_resolved["chain"])) + else + return nil + end + end + + type = NilKill.static_sorbet_type(types) + return nil unless NilKill.useful_type?(type) + + @resolved[key] = { "type" => type, "chain" => chain.uniq, "forwarded" => forwarded } + end + + private + + def resolve_callee(callee, stack) + sig_types = Array(@sig_types_by_name[callee]).compact.uniq + typed_sig_types = sig_types.reject { |type| type == "T.untyped" || type == "void" } + if @sig_counts_by_name[callee] == 1 && typed_sig_types.size == 1 && sig_types.size == 1 + return { "type" => typed_sig_types.first, "chain" => ["typed signature #{callee}: #{typed_sig_types.first}"], "forwarded" => true } + end + + origins = Array(@origins_by_name[callee]) + return nil unless origins.size == 1 + resolve(origins.first, stack) + end + + def origin_key(origin) + [origin["path"], origin["line"].to_i, origin["class"].to_s, origin["method"].to_s, origin["kind"].to_s] + end + + def format_origin(origin) + "#{origin["path"]}:#{origin["line"]} #{origin["class"]}##{origin["method"]}" + end + + def format_source(source) + callee = source["callee"] || source["code"] || source["kind"] + "#{source["kind"]} #{callee} at line #{source["line"]}" + end + end + + # Resolves the transitive protocol required of a method's param by + # walking forwarded helpers and ivar captures. Used by + # `static_param_backflow_protocol_rejection` to decide whether a + # narrowing candidate satisfies the chain. + # + # Cycle-safe via a stub cache entry written before recursion. + # Returns { "methods" => Set, "chain" => Array, "blocked" => bool }. + # `blocked` is true when ANY hop hits a forwarded helper not in the + # project method index, an ivar with no observed protocol, or an + # unrecognised gap shape. The caller falls back to the conservative + # rejection in that case. + class ProtocolResolver + FORWARDED_GAP_RE = /\Aforwarded to (\S+) slot (\d+) at /.freeze + LEGACY_FORWARDED_GAP_RE = /\Aforwarded to (\S+) at /.freeze + CAPTURED_GAP_RE = /\Acaptured in (@\S+) at /.freeze + + IGNORABLE_METHODS = %w[ + nil? class is_a? kind_of? instance_of? object_id respond_to? + instance_variable_get instance_variable_set itself tap then yield_self + ].to_set.freeze + + def initialize(store) + @store = store + @methods_by_name = (Array(store.facts["existing_sigs"]) + Array(store.facts["unsigned_methods"])) + .group_by { |method| method["method"].to_s } + @ivar_protocols = (store.facts["ivar_protocols"] || {}).each_with_object({}) do |(key, methods), h| + klass, ivar = key.split("\0", 2) + h[[klass, ivar]] = Set.new(methods.map(&:to_s)) + end + @cache = {} + end + + def resolve(class_name, method_name, param_name) + key = [class_name.to_s, method_name.to_s, param_name.to_s] + return @cache[key] if @cache.key?(key) + # Stub the cache before recursing so cycles see an empty entry + # and return without infinite descent. + @cache[key] = { "methods" => Set.new, "chain" => [], "blocked" => false } + method = lookup_method(class_name, method_name) + unless method + return @cache[key] = { "methods" => Set.new, "chain" => ["unknown #{class_name}##{method_name}"], "blocked" => true } + end + + protocol = method.dig("protocols", param_name.to_s) || {} + methods = Set.new(Array(protocol["methods"]).map(&:to_s)) + chain = ["#{class_name}##{method_name}(#{param_name})"] + blocked = false + + Array(protocol["gaps"]).each do |gap| + gap = gap.to_s + helper_name = nil + slot = 0 + if (m = FORWARDED_GAP_RE.match(gap)) + helper_name = m[1] + slot = m[2].to_i + elsif (m = LEGACY_FORWARDED_GAP_RE.match(gap)) + helper_name = m[1] + end + + if helper_name + helper = lookup_helper(helper_name) + if helper && helper["params"] && helper["params"][slot] + helper_param = helper["params"][slot]["name"].to_s + sub = resolve(helper["class"], helper["method"], helper_param) + methods.merge(sub["methods"]) + chain.concat(sub["chain"]) + blocked ||= sub["blocked"] + else + chain << "unresolved forward to #{helper_name} slot #{slot}" + blocked = true + end + elsif (m = CAPTURED_GAP_RE.match(gap)) + ivar = m[1] + ivar_methods = @ivar_protocols[[class_name.to_s, ivar]] + if ivar_methods && !ivar_methods.empty? + methods.merge(ivar_methods) + chain << "captured to #{ivar} (#{ivar_methods.size} method(s))" + else + chain << "captured to #{ivar} (no observed methods)" + blocked = true + end + else + chain << "unparseable gap: #{gap}" + blocked = true + end + end + + @cache[key] = { "methods" => methods, "chain" => chain, "blocked" => blocked } + end + + def required_methods(class_name, method_name, param_name) + result = resolve(class_name, method_name, param_name) + { + "methods" => result["methods"].reject { |m| IGNORABLE_METHODS.include?(m) }.sort, + "chain" => result["chain"], + "blocked" => result["blocked"], + } + end + + private + + def lookup_method(class_name, method_name) + Array(@methods_by_name[method_name.to_s]).find { |m| m["class"].to_s == class_name.to_s } + end + + # A forwarded gap stores the helper as written at the call-site + # (`helper_name(args)`), so it's a bare method name. There can be + # multiple project methods sharing the name across classes -- if + # ambiguous, prefer one in the same class as the caller would + # require per-method-record class context, which we don't have + # here. Return nil for ambiguous lookups -> chain is blocked. + def lookup_helper(helper_name) + candidates = Array(@methods_by_name[helper_name.to_s]) + return candidates.first if candidates.size == 1 + nil + end + end + + def propose_tlet_action(site) + abs = File.expand_path(site["path"], ROOT) + obs = @store.tlets["#{abs}:#{site["line"]}"] + if site["tlet"] && site["type"] == "T.untyped" && obs + type = NilKill.sorbet_type(obs["classes"]) + return unless NilKill.useful_type?(type) + return if type == "NilClass" + @store.actions << base_action("narrow_tlet", NilKill.confidence(obs["calls"]), site["path"], site["line"], + "narrow existing T.let to #{type}", { "type" => type }) + elsif !site["tlet"] && site["candidate_type"] + return unless NilKill.useful_type?(site["candidate_type"]) + return if site["candidate_type"] == "NilClass" + @store.actions << base_action("add_tlet", HIGH, site["path"], site["line"], + "add T.let for #{site["name"]}", { "name" => site["name"], "type" => site["candidate_type"] }) + end + end + + def sig_for(rec, src) + params = src["params"].map do |param| + type = NilKill.sorbet_type(params_for_typing(rec)[param["name"]] || []) + type = "T.untyped" unless NilKill.useful_type?(type) + type = "T.nilable(#{type})" if param["nil_default"] && !type.start_with?("T.nilable(") && type != "T.untyped" + "#{param["name"]}: #{type}" + end + ret = src["method"] == "initialize" && src["kind"] == "instance" ? nil : NilKill.sorbet_type(rec["returns"]) + if !NilKill.useful_type?(ret) + origin_type = src.dig("return_origin", "candidate_type") + ret = origin_type if NilKill.useful_type?(origin_type) + end + ret = "T.untyped" unless ret.nil? || NilKill.useful_type?(ret) + clause = ret.nil? ? "void" : "returns(#{ret})" + params.empty? ? "sig { #{clause} }" : "sig { params(#{params.join(", ")}).#{clause} }" + end + + def params_for_typing(rec) + rec["params_ok"].empty? ? rec["params_by_name"] : rec["params_ok"] + end + + def report_bad_input_candidates(rec, src) + return if rec["params_ok"].empty? || rec["params_raised"].empty? + rec["params_by_name"].each do |name, all_classes| + ok_classes = Array(rec["params_ok"][name]) + raised_classes = Array(rec["params_raised"][name]) + next if ok_classes.empty? || raised_classes.empty? + extra = all_classes - ok_classes + next if extra.empty? + broad = NilKill.display_union(all_classes) + narrow = NilKill.sorbet_type(ok_classes) + next unless broad.include?("T.any(") && NilKill.useful_type?(narrow) + @store.actions << base_action("bad_input_type_candidate", REVIEW, src["path"], src["line"], + "param #{name} would become #{broad} only because raised calls saw #{extra.sort.join(", ")}; normal calls suggest #{narrow}", + { "name" => name, "broad_type" => broad, "candidate_type" => narrow, "raised_only_classes" => extra.sort, + "callsites" => filtered_sites(rec["param_sites_raised"][name], extra) }) + end + end + + def report_nil_param_candidates(rec, src) + params_for_typing(rec).each do |name, classes| + next unless Array(classes).include?("NilClass") + non_nil = Array(classes) - ["NilClass"] + candidate = NilKill.sorbet_type(non_nil) + @store.actions << base_action("nil_param_observed", REVIEW, src["path"], src["line"], + "param #{name} observed nil; source should be traced before adding T.nilable#{NilKill.useful_type?(candidate) ? " (non-nil candidate: #{candidate})" : ""}", + { "name" => name, "candidate_type" => candidate, "callsites" => filtered_sites(param_sites_for_typing(rec)[name], ["NilClass"]) }) + propose_nil_default_actions(rec, src, name, candidate) + end + end + + def propose_nil_default_actions(rec, src, name, candidate) + default = default_for_type(candidate) + return unless default + filtered_sites(param_sites_for_typing(rec)[name], ["NilClass"]).each do |site, count| + root = site.sub(/:[^:]+\z/, "") + path, line = split_site(root) + next unless path && line && NilKill.target_path?(path) + rel_path = NilKill.rel(path) + next unless callsite_default_rewrite_safe?(rel_path, line) + @store.actions << base_action("replace_nil_with_default", REVIEW, rel_path, line, + "replace nil with #{default} for #{src["class"]}##{src["method"]} param #{name} (#{count} observed call(s))", + { "default" => default, "name" => name, "candidate_type" => candidate, "observed_calls" => count.to_i, + "target_path" => src["path"], "target_line" => src["line"], "target_method" => "#{src["class"]}##{src["method"]}" }) + end + end + + def default_for_type(type) + case type + when "Array", /\AT::Array\b/ then "[]" + when "Hash", /\AT::Hash\b/ then "{}" + when "String" then "\"\"" + else nil + end + end + + def split_site(site) + match = site.match(/\A(.+):(\d+)\z/) + match ? [match[1], match[2].to_i] : [nil, nil] + end + + def callsite_default_rewrite_safe?(rel_path, line) + source = File.readlines(File.join(ROOT, rel_path))[line - 1] + return false unless source + return false if source.match?(/^\s*def\b/) + source.scan(/\bnil\b/).size == 1 + rescue Errno::ENOENT + false + end + + def report_union_candidates(rec, src) + params_for_typing(rec).each do |name, classes| + others = Array(classes).reject { |c| c == "NilClass" } + next unless others.uniq.size > 1 + @store.actions << base_action("union_observed", REVIEW, src["path"], src["line"], + "param #{name} observed #{others.uniq.sort.join(", ")}; leaving as T.untyped by default until more evidence or design intent is clear", + { "name" => name, "classes" => others.uniq.sort, "callsites" => filtered_sites(param_sites_for_typing(rec)[name], others.uniq) }) + end + end + + def param_sites_for_typing(rec) + rec["param_sites_ok"].empty? ? rec["param_sites"] : rec["param_sites_ok"] + end + + def filtered_sites(sites, classes) + wanted = Array(classes).to_set + (sites || {}).select { |site, _count| wanted.include?(site_class(site)) } + end + + def site_class(site) + site.to_s.split(":").last + end + + def base_action(kind, conf, path, line, message, data) + { "kind" => kind, "confidence" => conf, "path" => path, "line" => line, "message" => message, "data" => data } + end + + def merge_hash_sets(target, source) + (source || {}).each { |name, vals| target[name] = (Array(target[name]) + Array(vals)).uniq.sort } + end + + def merge_hash_kv(target, source) + (source || {}).each { |name, kv| merge_kv((target[name] ||= [[], []]), kv) } + end + + def merge_hash_shapes(target, source) + (source || {}).each { |name, shapes| merge_shapes((target[name] ||= []), shapes) } + end + + def merge_hash_kv_shapes(target, source) + (source || {}).each { |name, kv| merge_kv_shapes((target[name] ||= [[], []]), kv) } + end + + def merge_hash_counts(target, source) + (source || {}).each do |name, sites| + bucket = (target[name] ||= {}) + (sites || {}).each { |site, count| bucket[site] = bucket.fetch(site, 0) + count.to_i } + end + end + + def merge_kv(target, source) + return unless source + target[0] = (Array(target[0]) + Array(source[0])).uniq.sort + target[1] = (Array(target[1]) + Array(source[1])).uniq.sort + end + + def merge_shapes(target, source) + seen = target.map { |shape| JSON.generate(shape) }.to_set + Array(source).each do |shape| + parsed = NilKill.parse_shape(shape) + key = JSON.generate(parsed) + next if seen.include?(key) + target << parsed + seen << key + end + target.sort_by! { |shape| JSON.generate(shape) } + end + + def merge_kv_shapes(target, source) + return unless source + merge_shapes(target[0], Array(source)[0]) + merge_shapes(target[1], Array(source)[1]) + end + + def parse_sorbet_errors(output) + output.lines.filter_map do |line| + next unless line =~ /^(.*?\.rb):(\d+):\s+(.*?)\s+https:\/\/srb\.help\/(\d+)/ + { "path" => $1, "line" => $2.to_i, "message" => $3, "code" => $4 } + end + end + + def parse_nil_origins(output) + origins = Hash.new(0) + current = false + output.gsub(/\e\[[0-9;]*m/, "").each_line do |line| + if line =~ /^(.*?\.rb):(\d+):.*does not exist on/ + current = true + elsif current && line =~ /^\s+(.*?\.rb):(\d+):/ + origins["#{$1}:#{$2}"] += 1 + current = false + end + end + origins.sort_by { |_, count| -count }.map { |origin, count| { "origin" => origin, "count" => count } } + end + + def parse_sorbet_feedback(output) + feedback = [] + lines = output.gsub(/\e\[[0-9;]*m/, "").lines + lines.each_with_index do |line, idx| + case line + when /^(.+?\.rb):(\d+): Expected `(.+?)` but found `(.+?)` for argument `(.+?)` https:\/\/srb\.help\/7002/ + path = $1 + line_no = $2.to_i + expected = $3 + found = $4 + arg = $5 + sig_path, sig_line = following_sig_location(lines, idx, /for argument `#{Regexp.escape(arg)}` of method/) + feedback << { "code" => "7002", "path" => sig_path || path, "line" => sig_line || line_no, + "arg" => arg, "expected" => expected, "found" => found, + "message" => "Sorbet 7002 suggests widening param #{arg}: expected #{expected}, found #{found}" } + when /^(.+?\.rb):(\d+): Expected `(.+?)` but found `(.+?)` for method result type https:\/\/srb\.help\/7005/ + path = $1 + line_no = $2.to_i + expected = $3 + found = $4 + sig_path, sig_line = following_sig_location(lines, idx, /for result type of method/) + feedback << { "code" => "7005", "path" => sig_path || path, "line" => sig_line || line_no, + "expected" => expected, "found" => found, + "message" => "Sorbet 7005 suggests widening return: expected #{expected}, found #{found}" } + when /^(.+?\.rb):(\d+): Used `&\.` operator on `(.+?)`, which can never be nil https:\/\/srb\.help\/7034/ + path = $1 + line_no = $2.to_i + type = $3 + origin_path, origin_line = following_origin_location(lines, idx) + feedback << { "code" => "7034", "path" => origin_path || path, "line" => origin_line || line_no, + "site_path" => path, "site_line" => line_no, "type" => type, + "message" => "Sorbet 7034 says defensive safe navigation is unreachable; review before removing or widen origin" } + end + end + feedback + end + + def following_sig_location(lines, start_idx, marker) + idx = start_idx + 1 + while idx < lines.length && idx < start_idx + 30 + if lines[idx].match?(marker) + loc = lines[idx + 1] + return [$1, $2.to_i] if loc && loc =~ /^\s+(.+?\.rb):(\d+):$/ + end + idx += 1 + end + [nil, nil] + end + + def following_origin_location(lines, start_idx) + idx = start_idx + 1 + while idx < lines.length && idx < start_idx + 25 + if lines[idx] =~ /^\s+Got `.+` originating from:$/ + loc = lines[idx + 1] + return [$1, $2.to_i] if loc && loc =~ /^\s+(.+?\.rb):(\d+):/ + end + idx += 1 + end + [nil, nil] + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/interactive_review.rb b/gems/nil-kill/lib/nil_kill/interactive_review.rb new file mode 100644 index 000000000..788edba95 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/interactive_review.rb @@ -0,0 +1,89 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class InteractiveReview + def initialize(argv) + @kind = option_value(argv, "--kind") || "replace_nil_with_default" + @dry_run = argv.include?("--dry-run") + @evidence = Store.read + @selected = Set.new + end + + def run + actions = @evidence["actions"].select { |action| action["kind"] == @kind } + abort "no #{@kind} actions found; run `bundle exec tools/nil-kill infer` first" if actions.empty? + if !$stdin.tty? + print_noninteractive(actions) + return + end + loop do + render(actions) + print "nil-kill review> " + input = $stdin.gets&.strip + break if input.nil? || input == "q" + case input + when "a" + selected = @selected.map { |idx| actions[idx] } + Apply.new(@dry_run ? ["--dry-run"] : []).apply_actions(selected) + break + when "all" + actions.each_index { |idx| @selected.add(idx) } + when "none" + @selected.clear + when /\Ao\s+(\d+)\z/ + open_context(actions, $1.to_i - 1) + when /\A\d+\z/ + toggle(input.to_i - 1, actions.size) + else + puts "commands: number toggles, o N opens context, all, none, a applies, q quits" + end + end + end + + def option_value(argv, flag) + idx = argv.index(flag) + idx ? argv[idx + 1] : nil + end + + def render(actions) + puts "" + puts "Review #{@kind} actions" + actions.each_with_index do |action, idx| + mark = @selected.include?(idx) ? "x" : " " + puts "#{idx + 1}. [#{mark}] #{action["path"]}:#{action["line"]} #{action["message"]}" + end + puts "commands: number toggles, o N opens context, all, none, a applies, q quits" + end + + def print_noninteractive(actions) + actions.each_with_index do |action, idx| + puts "#{idx + 1}. [ ] #{action["path"]}:#{action["line"]} #{action["message"]}" + end + end + + def toggle(idx, size) + return puts "out of range" if idx.negative? || idx >= size + @selected.include?(idx) ? @selected.delete(idx) : @selected.add(idx) + end + + def open_context(actions, idx) + action = actions[idx] + return puts "out of range" unless action + path = File.join(ROOT, action["path"]) + line = action["line"].to_i + lines = File.readlines(path) + first = [line - 4, 1].max + last = [line + 4, lines.size].min + puts "" + puts "#{action["path"]}:#{line}" + puts "#{action["message"]}" + (first..last).each do |line_no| + marker = line_no == line ? ">" : " " + puts "#{marker} #{line_no.to_s.rjust(5)} #{lines[line_no - 1]}" + end + rescue Errno::ENOENT + puts "missing file: #{action["path"]}" + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/loop.rb b/gems/nil-kill/lib/nil_kill/loop.rb new file mode 100644 index 000000000..debc369ae --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/loop.rb @@ -0,0 +1,610 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class Loop + SKIP_FILE = File.join(ROOT, "tools", "nil-kill-skip.json") + Z3_SOLVER_PATH = File.expand_path("z3_solver.rb", __dir__) + + def initialize(argv) + if argv.delete("--defaults") + warn "nil-kill: --defaults is review-only; nil default rewrites are not auto-applied" + end + @try_levenshtein = !!argv.delete("--try-levenshtein") + @try_hash_records = !!argv.delete("--hash-records") + @try_struct_rbi = !!argv.delete("--struct-rbi") + @try_signature_backflow = !!argv.delete("--signature-backflow") + @try_return_backflow = !!argv.delete("--return-backflow") + @try_narrow_generic = !!argv.delete("--narrow-generic") + @try_narrow_tlet = !!argv.delete("--narrow-tlet") + @verify_spec_subset = !!argv.delete("--verify-spec-subset") + @levenshtein_distance = ENV.fetch("NIL_KILL_LEVENSHTEIN_DISTANCE", "2").to_i + @levenshtein_limit = ENV.fetch("NIL_KILL_LEVENSHTEIN_LIMIT", "50").to_i + @hash_record_limit = ENV.fetch("NIL_KILL_HASH_RECORD_LIMIT", "1").to_i + @signature_backflow_limit = ENV.fetch("NIL_KILL_SIGNATURE_BACKFLOW_LIMIT", "5").to_i + @return_backflow_limit = ENV.fetch("NIL_KILL_RETURN_BACKFLOW_LIMIT", "5").to_i + @narrow_generic_limit = ENV.fetch("NIL_KILL_NARROW_GENERIC_LIMIT", "0").to_i + @narrow_tlet_limit = ENV.fetch("NIL_KILL_NARROW_TLET_LIMIT", "0").to_i + sep = argv.index("--") + @verify_cmd = sep ? argv[(sep + 1)..] : [] + @max_iters = ENV.fetch("NIL_KILL_MAX_ITERS", "10").to_i + @skipped = Set.new + @permanent_skip = load_permanent_skip + @z3_solver = nil + load_z3_solver + end + + def load_z3_solver + require Z3_SOLVER_PATH + rescue LoadError, SyntaxError => e + warn "nil-kill: Z3 solver not loaded (#{e.message}); running without pre-filter" + end + + def load_permanent_skip + return [] unless File.file?(SKIP_FILE) + JSON.parse(File.read(SKIP_FILE)) + rescue JSON::ParserError + [] + end + + def permanently_skipped?(action) + @permanent_skip.any? do |entry| + action["kind"] == entry["kind"] && + action["path"] == entry["path"] && + (entry["code"].nil? || action.dig("data", "code")&.include?(entry["code"])) + end + end + + def run + abort "usage: bundle exec tools/nil-kill loop -- " if @verify_cmd.empty? && !@verify_spec_subset + iter = 0 + loop do + iter += 1 + puts "nil-kill loop iteration #{iter}" + Infer.new([]).run + evidence = Store.read + @z3_solver = init_z3_solver(evidence) + emit_z3_inferred_actions(@z3_solver, evidence) if @z3_solver + high_actions = evidence["actions"].select do |action| + next false unless action["confidence"] == HIGH + next false if @skipped.include?(fingerprint(action)) + next false if permanently_skipped?(action) + next false if z3_preflight_skip?(action) + # A4: block nil-check removal actions whose receiver might actually be nil + if @z3_solver && (action["kind"] == "remove_dead_safe_nav" || action["kind"] == "replace_dead_nil_check") + next false unless @z3_solver.provably_dead_safe_nav?(action) + end + true + end + if @try_levenshtein + speculative = levenshtein_actions(evidence).reject do |action| + @skipped.include?(fingerprint(action)) || permanently_skipped?(action) || z3_preflight_skip?(action) + end + seen = high_actions.map { |action| fingerprint(action) }.to_set + speculative.reject! { |action| seen.include?(fingerprint(action)) } + puts "Levenshtein speculative actions: #{speculative.size}" + high_actions.concat(speculative) + end + if @try_hash_records + review_hash_records = hash_record_review_actions(evidence, high_actions) + puts "hash-record review actions: #{review_hash_records.size}" + high_actions.concat(review_hash_records) + end + if @try_struct_rbi + review_struct_rbi = struct_rbi_review_actions(evidence, high_actions) + puts "struct-rbi review actions: #{review_struct_rbi.size}" + high_actions.concat(review_struct_rbi) + end + if @try_signature_backflow + review_signature_backflow = signature_backflow_review_actions(evidence, high_actions) + puts "signature-backflow review actions: #{review_signature_backflow.size}" + high_actions.concat(review_signature_backflow) + end + if @try_return_backflow + review_return_backflow = return_backflow_review_actions(evidence, high_actions) + puts "return-backflow review actions: #{review_return_backflow.size}" + high_actions.concat(review_return_backflow) + end + if @try_narrow_generic + review_narrow_generic = narrow_generic_review_actions(evidence, high_actions) + puts "narrow-generic review actions: #{review_narrow_generic.size}" + high_actions.concat(review_narrow_generic) + end + if @try_narrow_tlet + review_narrow_tlet = narrow_tlet_review_actions(evidence, high_actions) + puts "narrow-tlet review actions: #{review_narrow_tlet.size}" + high_actions.concat(review_narrow_tlet) + end + high = high_actions.size + puts "high-confidence actions: #{high}" + break if high.zero? + + applied = apply_verified(high_actions) + puts "verified actions applied: #{applied}; skipped this run: #{@skipped.size}" + break if applied.zero? + break if iter >= @max_iters + end + end + + def init_z3_solver(evidence) + return nil unless defined?(NilKill::Z3Solver) + Z3Solver.new(evidence, NilKill.target_files) + rescue StandardError => e + warn "nil-kill: Z3 solver init failed: #{e.message}" + nil + end + + def z3_preflight_skip?(action) + return false unless @z3_solver + reason = @z3_solver.preflight_rejection(action) + return false unless reason + @skipped << fingerprint(action) + warn "Z3 preflight skipped #{action["path"]}:#{action["line"]} #{action["kind"]}: #{reason}" + true + end + + # Mirrors hash_record_review_actions: feed add_struct_field_sig + # actions into the same apply_verified bisection. The loop lands the + # maximal srb-tc-clean subset and records the rest in @skipped -- + # those skipped slots ARE the surfaced "blocked struct fields" + # (logged here, counted in the report's action sections), replacing + # struct-rbi --validate's all-or-nothing revert. + def struct_rbi_review_actions(evidence, existing_actions = []) + seen = existing_actions.map { |action| fingerprint(action) }.to_set + Array(evidence["actions"]).select do |action| + next false unless action["confidence"] == REVIEW + next false unless action["kind"] == "add_struct_field_sig" + next false if seen.include?(fingerprint(action)) + next false if @skipped.include?(fingerprint(action)) + next false if permanently_skipped?(action) + true + end + end + + def hash_record_review_actions(evidence, existing_actions = []) + seen = existing_actions.map { |action| fingerprint(action) }.to_set + actions = Array(evidence["actions"]).select do |action| + next false unless action["confidence"] == REVIEW + next false unless %w[promote_hash_record_to_struct promote_hash_record_cluster_to_struct].include?(action["kind"]) + next false if seen.include?(fingerprint(action)) + next false if @skipped.include?(fingerprint(action)) + next false if permanently_skipped?(action) + next false if z3_preflight_skip?(action) + blockers = Array(action.dig("data", "blockers")) + unless blockers.empty? + warn "hash-record promotion blocked #{action["path"]}:#{action["line"]}: #{blockers.join("; ")}" + next false + end + true + end + actions.sort_by! { |action| [-hash_record_action_pressure(action), action["path"], action["line"].to_i] } + @hash_record_limit.positive? ? actions.first(@hash_record_limit) : actions + end + + def hash_record_action_pressure(action) + action.dig("data", "pressure", "total").to_i + end + + def signature_backflow_review_actions(evidence, existing_actions = []) + seen = existing_actions.map { |action| fingerprint(action) }.to_set + actions = Array(evidence["actions"]).select do |action| + next false unless action["confidence"] == REVIEW + next false unless action["kind"] == "fix_sig_param" + next false unless action.dig("data", "source") == "static_param_backflow" + next false if seen.include?(fingerprint(action)) + next false if @skipped.include?(fingerprint(action)) + next false if permanently_skipped?(action) + next false if z3_preflight_skip?(action) + true + end + actions.sort_by! { |action| [-signature_backflow_action_pressure(action), action["path"], action["line"].to_i, action.dig("data", "name").to_s] } + @signature_backflow_limit.positive? ? actions.first(@signature_backflow_limit) : actions + end + + def signature_backflow_action_pressure(action) + action.dig("data", "callsite_count").to_i + end + + RETURN_BACKFLOW_SOURCES = %w[forwarded_return_chain static_return_origin].freeze + + def return_backflow_review_actions(evidence, existing_actions = []) + seen = existing_actions.map { |action| fingerprint(action) }.to_set + actions = Array(evidence["actions"]).select do |action| + next false unless action["confidence"] == REVIEW + next false unless action["kind"] == "fix_sig_return" + next false unless RETURN_BACKFLOW_SOURCES.include?(action.dig("data", "source").to_s) + next false if seen.include?(fingerprint(action)) + next false if @skipped.include?(fingerprint(action)) + next false if permanently_skipped?(action) + next false if z3_preflight_skip?(action) + true + end + actions.sort_by! { |action| [-return_backflow_action_pressure(action), action["path"], action["line"].to_i, action.dig("data", "type").to_s] } + @return_backflow_limit.positive? ? actions.first(@return_backflow_limit) : actions + end + + def return_backflow_action_pressure(action) + case action.dig("data", "source").to_s + when "forwarded_return_chain" then Array(action.dig("data", "chain")).size + when "static_return_origin" then Array(action.dig("data", "blockers")).size + 1 + else 0 + end + end + + def narrow_tlet_review_actions(evidence, existing_actions = []) + seen = existing_actions.map { |action| fingerprint(action) }.to_set + actions = Array(evidence["actions"]).select do |action| + next false unless action["confidence"] == REVIEW + next false unless action["kind"] == "narrow_tlet" + next false if seen.include?(fingerprint(action)) + next false if @skipped.include?(fingerprint(action)) + next false if permanently_skipped?(action) + next false if z3_preflight_skip?(action) + true + end + # Sort by file/line for deterministic bisection batches across loop + # iterations. narrow_tlet actions don't carry a natural pressure metric. + actions.sort_by! { |action| [action["path"].to_s, action["line"].to_i, action.dig("data", "type").to_s] } + @narrow_tlet_limit.positive? ? actions.first(@narrow_tlet_limit) : actions + end + + NARROW_GENERIC_KINDS = %w[narrow_generic_param narrow_generic_return].freeze + + def narrow_generic_review_actions(evidence, existing_actions = []) + seen = existing_actions.map { |action| fingerprint(action) }.to_set + actions = Array(evidence["actions"]).select do |action| + next false unless action["confidence"] == REVIEW + next false unless NARROW_GENERIC_KINDS.include?(action["kind"]) + next false unless action.dig("data", "source") == "collection_runtime" + next false if seen.include?(fingerprint(action)) + next false if @skipped.include?(fingerprint(action)) + next false if permanently_skipped?(action) + next false if z3_preflight_skip?(action) + true + end + # Stable order: parameterised collection narrowings have no natural + # pressure metric in their data, so sort by file/line/name for + # deterministic bisection batches across loop iterations. + actions.sort_by! { |action| [action["path"].to_s, action["line"].to_i, action.dig("data", "name").to_s, action.dig("data", "type").to_s] } + @narrow_generic_limit.positive? ? actions.first(@narrow_generic_limit) : actions + end + + # A3: run static inference for unobserved params, write to z3-inferred.json, + # and print a one-line summary. Actions are REVIEW confidence -- not auto-applied. + def emit_z3_inferred_actions(solver, evidence) + actions = solver.infer_unobserved_params(evidence) + out = File.join(TMP_DIR, "z3-inferred.json") + File.write(out, JSON.pretty_generate(actions)) + puts "Z3 A3: #{actions.size} static param inference(s) written to #{NilKill.rel(out)}" + rescue StandardError => e + warn "nil-kill: Z3 A3 inference failed: #{e.message}" + end + + def levenshtein_actions(evidence) + rec_by_source = evidence["methods"].each_with_object({}) do |rec, lookup| + src = rec["source"] + lookup[[src["path"], src["line"]]] = rec if src + end + actions = [] + Array(evidence.dig("facts", "existing_sigs")).each do |src| + sig = src["sig"].to_s + next unless sig.include?("T.untyped") + rec = rec_by_source[[src["path"], src["line"]]] + next unless rec + classes_by_name = params_for_levenshtein(rec) + Array(src["params"]).each do |param| + name = param["name"].to_s + next unless sig.match?(/\b#{Regexp.escape(name)}:\s*T\.untyped\b/) + observed = Array(classes_by_name[name]).compact.uniq + concrete = observed.reject { |klass| ignored_levenshtein_class?(klass) } + next unless concrete.size > 1 + candidate = levenshtein_candidate(name, concrete) + next unless candidate + actions << { "kind" => "fix_sig_param", "confidence" => HIGH, "path" => src["path"], "line" => src["line"], + "message" => "try Levenshtein param #{name} -> #{candidate[:type]} from observed #{concrete.first(8).join(", ")}", + "data" => { "name" => name, "type" => candidate[:type], "distance" => candidate[:distance], + "observed_classes" => concrete.sort, "callsites" => param_sites_for_levenshtein(rec)[name] || {} } } + end + end + actions.sort_by! { |action| [action.dig("data", "distance").to_i, -action.dig("data", "observed_classes").to_a.size, action["path"], action["line"].to_i] } + @levenshtein_limit.positive? ? actions.first(@levenshtein_limit) : actions + end + + def params_for_levenshtein(rec) + rec["params_ok"].empty? ? rec["params_by_name"] : rec["params_ok"] + end + + def param_sites_for_levenshtein(rec) + rec["param_sites_ok"].empty? ? rec["param_sites"] : rec["param_sites_ok"] + end + + def ignored_levenshtein_class?(klass) + klass == "NilClass" || klass == "T.untyped" || klass.to_s.include?("#") || klass.to_s.start_with?("Sorbet::Private::") + end + + def levenshtein_candidate(param_name, classes) + scored = classes.filter_map do |klass| + base = klass.to_s.split("::").last + score = normalized_param_names(param_name).map { |name| levenshtein_distance(name, normalize_type_name(base)) }.min + next unless score && score <= @levenshtein_distance + { type: klass, distance: score, base: base } + end + return nil if scored.empty? + best_distance = scored.map { |item| item[:distance] }.min + best = scored.select { |item| item[:distance] == best_distance } + return nil if best.map { |item| normalize_type_name(item[:base]) }.uniq.size > 1 + best.min_by { |item| item[:type].length } + end + + def normalized_param_names(name) + normalized = normalize_type_name(name) + variants = [normalized] + variants << normalized.delete_suffix("s") if normalized.end_with?("s") + variants << normalized.delete_suffix("node") if normalized.end_with?("node") + variants << normalized.delete_suffix("tok") + "token" if normalized.end_with?("tok") + variants.reject(&:empty?).uniq + end + + def normalize_type_name(name) + name.to_s.downcase.gsub(/[^a-z0-9]/, "") + end + + def levenshtein_distance(a, b) + prev = (0..b.length).to_a + a.each_char.with_index do |char_a, idx_a| + cur = [idx_a + 1] + b.each_char.with_index do |char_b, idx_b| + cur << [ + cur[idx_b] + 1, + prev[idx_b + 1] + 1, + prev[idx_b] + (char_a == char_b ? 0 : 1), + ].min + end + prev = cur + end + prev[b.length] + end + + def apply_verified(actions) + return 0 if actions.empty? + + # Z3 pre-filter: if the batch is provably inconsistent, bisect without + # running the (expensive) spec suite. + if actions.size > 1 && @z3_solver + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + consistent = @z3_solver.consistent?(actions) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + unless consistent + warn "Z3: batch of #{actions.size} actions is UNSAT (#{elapsed.round(3)}s); bisecting without spec run" + mid = actions.size / 2 + return apply_verified(actions.first(mid)) + apply_verified(actions.drop(mid)) + end + puts "Z3: batch of #{actions.size} actions is SAT (#{elapsed.round(3)}s); proceeding to verify" + end + + snapshot = snapshot_files(actions) + changed = Apply.new([]).apply_actions(actions) + ok, verify_output = verify(actions: actions) + return changed if ok + + restore_files(snapshot) + if actions.size == 1 + action = actions.first + if (fallback = nilable_widening_fallback(action, verify_output)) + warn "retrying failing action as nilable: #{fallback["path"]}:#{fallback["line"]} #{fallback["kind"]}: #{fallback["message"]}" + fallback_changed = apply_verified([fallback]) + return fallback_changed if fallback_changed.positive? + end + if hash_record_action?(action) && useless_tcast_feedback(verify_output).any? + tcast_changed = retry_with_useless_tcast_cleanup(action, snapshot, verify_output) + return tcast_changed if tcast_changed.positive? + end + @skipped << fingerprint(action) + warn "skipping failing action: #{action["path"]}:#{action["line"]} #{action["kind"]}: #{action["message"]}" + return 0 + end + + mid = actions.size / 2 + apply_verified(actions.first(mid)) + apply_verified(actions.drop(mid)) + end + + # rspec / parallel_rspec return exit 0 even when EVERY spec failed to + # load (e.g. a NameError when the changed src/ file forward-references + # something defined later in the same file). The summary in that case + # is "0 examples, 0 failures, N errors occurred outside of examples" + # but the process exit code is 0 because no examples ran. + # + # Without this check, the verify pipeline silently accepts code that + # breaks `require` at the load step -- the bisecting loop then lands + # broken actions and we discover the regression at the next full + # spec-suite run. Match the two phrases rspec uses to signal load + # failures and treat their presence as verify failure. + RSPEC_LOAD_FAILURE_PATTERNS = [ + /\d+ errors? occurred outside of examples/, + /An error occurred while loading \.\/spec\//, + ].freeze + + def verify(actions: nil) + cmd = @verify_spec_subset && actions ? subset_verify_cmd(actions) : @verify_cmd + puts cmd.shelljoin + out, err, status = Open3.capture3(*cmd) + print out unless out.empty? + warn err unless err.empty? + combined = out + err + ok = status.success? && RSPEC_LOAD_FAILURE_PATTERNS.none? { |re| re.match?(combined) } + if status.success? && !ok + warn "nil-kill loop: verify exit was 0 but rspec reported spec-load failures; treating as failure" + end + [ok, combined] + end + + # When --verify-spec-subset is active, compute the rspec invocation + # for ONLY the specs that transitively require any file the action + # batch touches. Falls back to the full spec suite if no specs are + # found (e.g. action touches a file no spec exercises -- still want + # srb tc + at least minimum coverage). + def subset_verify_cmd(actions) + paths = snapshot_paths_for_actions(actions).map { |rel| File.expand_path(rel, ROOT) } + specs = SpecDependencyIndex.instance.specs_depending_on(paths) + rel_specs = specs.map { |abs| Pathname.new(abs).relative_path_from(Pathname.new(ROOT)).to_s } + runner = self.class.spec_runner_command + if rel_specs.empty? + ["bash", "-c", "bundle exec srb tc"] + else + spec_args = rel_specs.map { |s| Shellwords.shellescape(s) }.join(" ") + ["bash", "-c", "bundle exec srb tc && bundle exec #{runner} #{spec_args}"] + end + end + + def snapshot_paths_for_actions(actions) + actions.flat_map { |action| snapshot_paths_for_action(action) }.uniq + end + + # `prspec` (parallel_rspec) when installed, plain `rspec` otherwise. + # Detected once per process; both run the same spec files just with + # different parallelism semantics. + def self.spec_runner_command + return @spec_runner_command if defined?(@spec_runner_command) + out, _, status = Open3.capture3("bundle", "exec", "which", "prspec") + @spec_runner_command = status.success? && !out.strip.empty? ? "prspec" : "rspec" + end + + def nilable_widening_fallback(action, verify_output) + case action["kind"] + when "fix_sig_return" + nilable_return_fallback(action, verify_output) + when "fix_sig_param" + nilable_param_fallback(action, verify_output) + end + end + + def nilable_return_fallback(action, verify_output) + original = action.dig("data", "type").to_s + return nil if original.empty? || original.start_with?("T.nilable(") || original == "T.untyped" + feedback = feedback_for_action(action, verify_output, "7005") + return nil unless feedback && feedback["expected"] == original && feedback["found"] == "T.nilable(#{original})" + widened_action(action, "T.nilable(#{original})", "retry return as T.nilable(#{original}) after Sorbet 7005") + end + + def nilable_param_fallback(action, verify_output) + original = action.dig("data", "type").to_s + name = action.dig("data", "name").to_s + return nil if original.empty? || name.empty? || original.start_with?("T.nilable(") || original == "T.untyped" + feedback = feedback_for_action(action, verify_output, "7002") + return nil unless feedback && feedback["arg"] == name && feedback["expected"] == original && feedback["found"] == "T.nilable(#{original})" + widened_action(action, "T.nilable(#{original})", "retry param #{name} as T.nilable(#{original}) after Sorbet 7002") + end + + def feedback_for_action(action, verify_output, code) + infer = Infer.allocate + infer.send(:parse_sorbet_feedback, verify_output).find do |feedback| + feedback["code"] == code && feedback["path"] == action["path"] && feedback["line"].to_i == action["line"].to_i + end + end + + def widened_action(action, type, message) + copy = Marshal.load(Marshal.dump(action)) + copy["message"] = message + copy["data"]["type"] = type + copy + end + + def hash_record_action?(action) + %w[promote_hash_record_to_struct promote_hash_record_cluster_to_struct].include?(action["kind"]) + end + + def retry_with_useless_tcast_cleanup(action, snapshot, verify_output) + restore_files(snapshot) + changed = Apply.new([]).apply_actions([action]) + cleaned = apply_useless_tcast_feedback(useless_tcast_feedback(verify_output), snapshot.keys) + return 0 if changed.zero? && cleaned.zero? + + # `actions:` is mandatory under --verify-spec-subset, otherwise + # verify falls through to @verify_cmd which is [] when the user + # didn't supply a `-- cmd...` suffix. Open3.capture3(*[]) raises + # ArgumentError, leaving src/ in the just-applied state because + # the exception bypasses restore_files. Pass the action; wrap in + # ensure so any unexpected exception still restores the snapshot. + begin + ok, second_output = verify(actions: [action]) + return changed + cleaned if ok + + restore_files(snapshot) + warn second_output unless second_output.empty? + 0 + rescue StandardError => e + restore_files(snapshot) + warn "retry_with_useless_tcast_cleanup: verify raised #{e.class}: #{e.message}; restored snapshot" + 0 + end + end + + def useless_tcast_feedback(output) + feedback = [] + current = nil + output.gsub(/\e\[[0-9;]*m/, "").lines.each do |line| + if line =~ /^(.+?\.rb):(\d+): `T\.cast` is useless because .+ https:\/\/srb\.help\/7015/ + current = { "path" => $1, "line" => $2.to_i } + elsif current && line =~ /^\s+.+?\.rb:\d+: Replace with `(.+?)`/ + feedback << current.merge("replacement" => $1) + current = nil + end + end + feedback + end + + def apply_useless_tcast_feedback(feedback, allowed_paths) + allowed = allowed_paths.map { |path| File.expand_path(path, ROOT) }.to_set + grouped = feedback.group_by { |item| File.expand_path(item["path"], ROOT) } + grouped.sum do |path, items| + next 0 unless allowed.include?(path) && File.file?(path) + source = File.read(path) + parsed = Prism.parse(source) + next 0 unless parsed.success? + edits = [] + applier = Apply.allocate + items.each do |item| + replacement = item["replacement"].to_s + next if replacement.empty? + applier.send(:nodes_matching, parsed.value) do |node| + node.is_a?(Prism::CallNode) && + node.location.start_line == item["line"].to_i && + node.name == :cast && + node.receiver&.slice == "T" && + node.arguments&.arguments&.first&.slice == replacement + end.each do |node| + edits << [node.location.start_offset, node.location.end_offset, replacement] + end + end + next 0 if edits.empty? + File.write(path, applier.send(:apply_source_edits, source, edits)) + edits.size + end + end + + def snapshot_files(actions) + actions.flat_map { |action| snapshot_paths_for_action(action) }.uniq.each_with_object({}) do |rel_path, snapshot| + path = File.join(ROOT, rel_path) + snapshot[path] = File.read(path) if File.file?(path) + end + end + + def snapshot_paths_for_action(action) + paths = [action["path"].to_s] + if action["kind"] == "promote_hash_record_cluster_to_struct" + data = action["data"] || {} + paths.concat((Array(data["producers"]) + Array(data["consumers"]) + Array(data["signatures"])) + .map { |site| site["path"].to_s }) + end + paths.reject(&:empty?).uniq + end + + def restore_files(snapshot) + snapshot.each { |path, content| File.write(path, content) } + end + + def fingerprint(action) + JSON.generate([action["kind"], action["path"], action["line"], action["message"], action["data"]]) + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/rbi_return_index.rb b/gems/nil-kill/lib/nil_kill/rbi_return_index.rb new file mode 100644 index 000000000..8b7831b6d --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/rbi_return_index.rb @@ -0,0 +1,206 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class RbiReturnIndex + AMBIGUOUS_GLOBAL_METHODS = Set.new(%w[ + __send__ method public_send send + ]).freeze + + def self.build + index = new + index.load_sorbet_payload + index.load_paths(Dir.glob(File.join(ROOT, "sorbet", "rbi", "**", "*.rbi")).sort) + index + end + + def initialize + @returns = Hash.new { |hash, key| hash[key] = [] } + @owner_returns = Hash.new { |hash, key| hash[key] = Hash.new { |inner, method| inner[method] = [] } } + end + + def return_type(method_name, receiver_type = nil) + owner_candidates_for(receiver_type).each do |owner| + types = @owner_returns[owner][method_name.to_s].compact.uniq.select { |type| concrete_return_type?(type) } + candidate = normalize_candidate_set(types) + return candidate if NilKill.useful_type?(candidate) + end + return nil if AMBIGUOUS_GLOBAL_METHODS.include?(method_name.to_s) + types = @returns[method_name.to_s].compact.uniq.select { |type| concrete_return_type?(type) } + normalize_candidate_set(types) + end + + def load_sorbet_payload + return if ENV["NIL_KILL_NO_SORBET_PAYLOAD_RBI"] == "1" + if Dir.glob(File.join(SORBET_PAYLOAD_DIR, "**", "*.rbi")).empty? + FileUtils.rm_rf(SORBET_PAYLOAD_DIR) + FileUtils.mkdir_p(SORBET_PAYLOAD_DIR) + _out, _err, status = Open3.capture3("bundle", "exec", "srb", "tc", "--no-config", + "--print=payload-sources:#{SORBET_PAYLOAD_DIR}", "--stop-after", "init", chdir: ROOT) + return unless status.success? + end + load_paths(Dir.glob(File.join(SORBET_PAYLOAD_DIR, "**", "*.rbi")).sort) + rescue StandardError + nil + end + + def load_paths(paths) + paths.each { |path| load_path(path) } + end + + def load_path(path) + pending_sigs = [] + current_sig = nil + owner_stack = [] + File.readlines(path).each do |line| + stripped = line.strip + if current_sig + current_sig << " " << stripped + if stripped == "end" || stripped.end_with?("}") + pending_sigs << current_sig + current_sig = nil + end + next + end + + if (match = stripped.match(/\A(?:class|module)\s+([^\s<;]+)/)) + owner_stack << normalize_owner_name(match[1]) + next + end + + if stripped.start_with?("sig ") + if stripped.include?("{") && stripped.include?("}") + pending_sigs << stripped + else + current_sig = stripped.dup + end + next + end + + if (match = stripped.match(/\Adef\s+(?:self\.)?([^\s(;]+)/)) + method_name = match[1] + owner = owner_stack.last + pending_sigs.each do |sig| + type = extract_rbi_return_type(sig) + next unless NilKill.useful_type?(type) + normalized = normalize_return_type(type) + @returns[method_name] << normalized + @owner_returns[owner][method_name] << normalized if owner + end + pending_sigs = [] + elsif stripped == "end" + owner_stack.pop + elsif !stripped.empty? && !stripped.start_with?("#") + pending_sigs = [] + end + end + rescue StandardError + nil + end + + def normalize_return_type(type) + type.to_s + .gsub(/\bT\.self_type\b/, "T.self_type") + .gsub(/T\.type_parameter\(:\w+\)/, "T.untyped") + .gsub(/\b(?:Elem|K|V)\b/, "T.untyped") + .sub(/\A::T\./, "T.") + .strip + end + + def normalize_owner_name(owner) + owner.to_s.delete_prefix("::").gsub(/\A(::)?/, "") + end + + def owner_name_for(type) + raw = type.to_s + return nil if raw.empty? + case raw + when /\AT::Array\b/ then "Array" + when /\AT::Hash\b/ then "Hash" + when /\AT::Enumerable\b/ then "Enumerable" + when /\AT::Set\b/ then "Set" + when "T::Boolean" then nil + else + raw.delete_prefix("::") + end + end + + def owner_candidates_for(type) + owner = owner_name_for(type) + return [] unless owner + candidates = [owner] + candidates << "Enumerable" if %w[Array Hash Set Range Enumerator].include?(owner) + candidates << "Object" + candidates << "BasicObject" + candidates.uniq + end + + def extract_rbi_return_type(sig) + matches = sig.to_s.enum_for(:scan, /returns\(/).map { Regexp.last_match.begin(0) } + matches.reverse_each do |idx| + ret = extract_call_args_at(sig, idx, "returns") + return ret if ret + end + nil + end + + def extract_call_args_at(source, idx, name) + start = idx + name.length + 1 + depth = 1 + i = start + while i < source.length + case source[i] + when "(" then depth += 1 + when ")" + depth -= 1 + return source[start...i] if depth.zero? + end + i += 1 + end + nil + end + + # Stdlib RBI classes that aren't project types but happen to have common + # method names. When the static analyser can't resolve a receiver and + # falls back to the global RBI lookup, these classes contaminate the + # candidate set (e.g. `obj.name` -> `Resolv::DNS::Name` purely because + # Resolv::DNS::Name defines a `name` method). Strip them so the bare- + # receiver fallback can't produce wrong narrowings. + AMBIGUOUS_RBI_OWNERS = %w[ + Resolv:: URI:: OpenSSL:: Net:: WEBrick:: CGI:: REXML:: DRb:: + Gem:: Bundler:: RubyVM:: ObjectSpace Logger:: Thread:: Mutex + Tempfile Pathname FileUtils:: FileTest + ].freeze + + def concrete_return_type?(type) + return false unless NilKill.useful_type?(type) + str = type.to_s + return false if AMBIGUOUS_RBI_OWNERS.any? { |prefix| str.include?(prefix) } + !str.match?(/\b(?:Return|Args)\b/) && + !str.include?("T.self_type") && + !str.include?("T.attached_class") && + !str.include?("Sorbet::Private::") && + str != "BasicObject" + end + + def normalize_candidate_set(types) + normalized = Array(types).compact.map { |type| normalize_return_candidate(type) }.uniq + arrays = normalized.select { |type| type.start_with?("T::Array[") } + enumerators = normalized.select { |type| type.start_with?("T::Enumerator[") } + if !arrays.empty? && (normalized - arrays - enumerators).empty? + return arrays.uniq.first if arrays.uniq.size == 1 + end + return nil if normalized.empty? + return "T::Boolean" if normalized.all? { |type| type == "T::Boolean" } + return normalized.first if normalized.size == 1 && NilKill.useful_type?(normalized.first) + nil + end + + def normalize_return_candidate(type) + case type.to_s + when "TrueClass", "FalseClass" then "T::Boolean" + else type.to_s + end + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/report.rb b/gems/nil-kill/lib/nil_kill/report.rb new file mode 100644 index 000000000..274e2784a --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/report.rb @@ -0,0 +1,4318 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class Report + def initialize(argv = []) + @argv = argv.dup + @with_links = @argv.delete("--with-links") + @full = @argv.delete("--full") + @hygiene_only = @argv.delete("--hygiene") + @report_path = parse_output_path(@argv) || REPORT_PATH + end + + def run + evidence = Store.read + @evidence = evidence + actions = evidence["actions"] + lines = build_header(evidence) + if @hygiene_only + append_hygiene_overview_summary(lines, evidence, actions) + lines = lines.map { |line| format_report_line(line) } + puts lines.join("\n") + return + end + by_conf = actions.group_by { |a| a["confidence"] } + append_project_prioritization(lines, evidence, actions) + append_hygiene_overview(lines, evidence) + append_action_sections(lines, actions, by_conf) + append_untyped_breakdown(lines, evidence) + unless evidence["diagnostics"]["nil_origins"].empty? + lines << "" + lines << "## Nil origins" + evidence["diagnostics"]["nil_origins"].first(20).each { |o| lines << "- #{o["origin"]}: #{o["count"]}" } + end + append_callsite_pressure(lines, actions) + append_return_origin_report(lines, evidence) + append_param_origin_report(lines, evidence) + append_foreign_class_pressure(lines, evidence) + append_type_normalizer_report(lines, evidence) + append_struct_report(lines, evidence) + append_collection_report(lines, evidence) + append_tuple_report(lines, evidence) + lines = lines.map { |line| format_report_line(line) } + lines = prepare_linked_report(lines, full: @full) if @with_links + FileUtils.mkdir_p(File.dirname(@report_path)) + File.write(@report_path, lines.join("\n") + "\n") + puts File.read(@report_path) + end + + def build_header(evidence) + lines = ["# Nil Kill Report", ""] + lines << "- Target dirs: #{evidence["target_dirs"].join(", ")}" + lines << "- Excluded target dirs: #{Array(evidence["target_exclude_dirs"]).join(", ")}" unless Array(evidence["target_exclude_dirs"]).empty? + lines << "- Methods indexed: #{evidence["methods"].size}" + lines << "- Runtime-observed methods: #{evidence["methods"].count { |m| m["calls"].to_i.positive? }}" + lines << "- Missing sigs: #{evidence["facts"]["unsigned_methods"].size}" + lines << "- Existing sigs: #{evidence["facts"]["existing_sigs"].size}" + lines << "- Existing/candidate T.let sites: #{evidence["facts"]["tlet_sites"].size}" + lines << "- Sorbet errors captured: #{evidence["diagnostics"]["sorbet_errors"].size}" + lines + end + + # Compact slot-only emission for `nil-kill report --hygiene`. Skips the + # full report's expensive sections (action lists, callsite pressure, + # origin breakdowns) so a before/after sweep is fast (~ms vs minutes). + def append_hygiene_overview_summary(lines, evidence, actions) + lines << "" + lines << "## Hygiene Overview" + append_type_soundness_table(lines, evidence) + append_untyped_cause_table(lines, evidence) + lines << "" + lines << "## Auto-Fix Action Counts" + counts = actions.group_by { |a| [a["confidence"], a["kind"], a.dig("data", "source") || "(none)"] } + .transform_values(&:size) + .sort_by { |_, count| -count } + high = counts.select { |key, _| key[0] == "high" } + review = counts.select { |key, _| key[0] == "review" } + lines << "- HIGH (auto-applied): #{high.sum { |_, c| c }}" + high.each { |(_, kind, src), c| lines << " - #{c.to_s.rjust(4)} #{kind} / #{src}" } + lines << "- REVIEW (manual or verified-loop): #{review.sum { |_, c| c }}" + review.first(8).each { |(_, kind, src), c| lines << " - #{c.to_s.rjust(4)} #{kind} / #{src}" } + lines << " - ... #{review.size - 8} more action categories" if review.size > 8 + end + + def parse_output_path(argv) + value = nil + if (idx = argv.index("--output-path")) + value = argv[idx + 1] || abort("--output-path requires a path") + argv.slice!(idx, 2) + elsif (arg = argv.find { |item| item.start_with?("--output-path=") }) + value = arg.split("=", 2).last + argv.delete(arg) + end + return nil unless value + + path = File.expand_path(value, ROOT) + output_directory_path?(path) ? File.join(path, "report.md") : path + end + + def output_directory_path?(path) + File.directory?(path) || File.extname(path).empty? + end + + def format_report_line(line) + formatted = relativize_project_paths(line.to_s) + formatted = format_code_references(formatted) + @with_links ? link_report_paths(formatted) : formatted + end + + def format_code_references(text) + text.split("`", -1).each_with_index.map do |part, idx| + next part if idx.odd? + part.gsub(/\b([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*#[A-Za-z_][A-Za-z0-9_]*[!?=]?)(?=\s|[:;,.)\]]|$)/, '`\1`') + .gsub(/\b([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\.[A-Za-z_][A-Za-z0-9_]*[!?=]?)(?!\()(?=\s|[:;,.)\]]|$)/, '`\1`') + end.join("`") + end + + def relativize_project_paths(text) + root = File.expand_path(ROOT) + text.gsub(root + File::SEPARATOR, "").gsub(root, ".") + end + + def link_report_paths(text) + text.gsub(%r{(? 2 + title = lines[0, 2] + summary = lines[2...first_section_idx].reject(&:empty?) + body = lines[first_section_idx..] + title + body + ["", "## Run Summary"] + summary + end + + def insert_table_of_contents(lines) + headings = lines.each_with_index.filter_map do |line, idx| + next if idx.zero? + match = line.match(/\A([#]{2,3})\s+(.+)\z/) + next unless match + level = match[1].length + text = match[2] + [level, text, github_anchor(text)] + end + return lines if headings.empty? + toc = ["## Table of Contents"] + headings.each do |level, text, anchor| + indent = level == 3 ? " " : "" + toc << "#{indent}- [#{text}](##{anchor})" + end + lines[0, 2] + toc + [""] + lines[2..] + end + + def github_anchor(text) + text.downcase.gsub(/<[^>]+>/, "") + .gsub(/`([^`]+)`/, "\\1") + .gsub(/[^a-z0-9 _-]/, "") + .strip + .gsub(/\s+/, "-") + end + + def collapse_long_bullet_runs(lines, visible_count = 10) + out = [] + top_level_bullets = 0 + in_details = false + in_toc = false + close_details = lambda do + if in_details + out << "" + out << "" + out << "" + in_details = false + end + end + lines.each do |line| + if line.match?(/\A[#]{1,6}\s+/) + close_details.call + top_level_bullets = 0 + in_toc = line == "## Table of Contents" + out << line + elsif in_toc + out << line + elsif line.start_with?("- ") + top_level_bullets += 1 + if top_level_bullets == visible_count + 1 + out << "" + out << "
More items" + out << "" + in_details = true + end + out << line + elsif line.match?(/\A\s+- /) + out << line + elsif !line.empty? + close_details.call + top_level_bullets = 0 + out << line + else + out << line + end + end + close_details.call + out + end + + def truncate_long_bullet_runs(lines, visible_count = 10) + out = [] + top_level_bullets = 0 + hidden_top_level_bullets = 0 + in_toc = false + flush_hidden = lambda do + if hidden_top_level_bullets.positive? + out << "- ... and #{hidden_top_level_bullets} more (run with `--full` to see all)" + hidden_top_level_bullets = 0 + end + end + lines.each do |line| + if line.match?(/\A[#]{1,6}\s+/) + flush_hidden.call unless in_toc + out << "" unless out.empty? || out.last == "" + top_level_bullets = 0 + in_toc = line == "## Table of Contents" + out << line + elsif in_toc + out << line + elsif line.start_with?("- ") + top_level_bullets += 1 + if top_level_bullets <= visible_count + out << line + else + hidden_top_level_bullets += 1 + end + elsif line.match?(/\A\s+- /) + out << line if top_level_bullets <= visible_count + elsif !line.empty? + flush_hidden.call + top_level_bullets = 0 + out << line + else + flush_hidden.call + out << line + end + end + flush_hidden.call + out + end + + def append_action_sections(lines, actions, by_conf) + lines << "" + append_review_actions(lines, by_conf[REVIEW] || []) + append_high_actions(lines, by_conf[HIGH] || []) + append_gap_actions(lines, by_conf[GAP] || []) + extra_conf = by_conf.keys - [HIGH, REVIEW, GAP] + extra_conf.each do |conf| + list = by_conf[conf] || [] + lines << "" + lines << "## #{conf} actions (#{list.size})" + list.first(50).each { |a| lines << "- #{a["path"]}:#{a["line"]} #{a["kind"]}: #{a["message"]}" } + lines << "- ... #{list.size - 50} more" if list.size > 50 + end + end + + def append_project_prioritization(lines, evidence, actions) + lines << "" + lines << "## Project Prioritization" + append_project_action_summary(lines, "Nil Source Fixes", actions.select { |action| action["kind"] == "nil_param_observed" }, "`T.nilable` slot(s)") + append_project_action_summary(lines, "Union / T.any Candidates", actions.select { |action| %w[union_observed bad_input_type_candidate].include?(action["kind"]) }, "union slot(s)") + append_project_hash_summary(lines, evidence) + end + + def grouped_action_priorities(actions) + groups = {} + actions.each do |action| + site, calls = primary_action_callsite(action) + site ||= "#{action["path"]}:#{action["line"]}" + group = groups[site] ||= { "site" => site, "calls" => 0, "actions" => [] } + group["calls"] += calls.to_i + group["actions"] << action + end + groups.values.sort_by { |group| [-group["actions"].size, -group["calls"].to_i, group["site"]] } + end + + def primary_action_callsite(action) + callsites = action.dig("data", "callsites") || {} + return [nil, 0] if callsites.empty? + site, calls = callsites.max_by { |candidate, count| [count.to_i, candidate.to_s] } + [site.to_s.sub(/:[^:]+\z/, ""), calls.to_i] + end + + def append_project_action_summary(lines, title, actions, label) + return if actions.empty? + groups = grouped_action_priorities(actions) + top = groups.first + summary = "#{groups.size} action item(s), #{actions.size} #{label}" + summary += "; top source affects #{top["actions"].size} slot(s), #{top["calls"].to_i} source calls" if top + lines << "- #{report_section_link("#{title} (#{actions.size})")}: #{summary}" + end + + def append_project_hash_summary(lines, evidence) + candidates = hash_record_struct_candidates(evidence) + pressure_rows = hash_record_struct_pressure(evidence) + return if candidates.empty? && pressure_rows.empty? + top = candidates.first + unmatched = pressure_rows.count { |row| !candidate_matches_pressure?(candidates, row) } + summary = "#{candidates.size} struct candidate(s), #{pressure_rows.size} pressure record(s)" + summary += "; top candidate #{top["struct_name"]} has pressure #{top["total_pressure"]}" if top + summary += "; #{unmatched} pressure record(s) without a literal shape cluster" if unmatched.positive? + lines << "- #{report_section_link("Hash Record Struct Candidates (Shapes + Pressure)")}: #{summary}" + end + + def report_section_link(title) + "[#{title}](##{github_anchor(title)})" + end + + def append_high_actions(lines, actions) + lines << "## High-Confidence Actions (#{actions.size})" + if actions.empty? + lines << "- none" + return + end + actions.first(50).each { |action| append_action_detail(lines, action) } + lines << "- ... #{actions.size - 50} more" if actions.size > 50 + end + + def append_action_detail(lines, action) + lines << "- #{action["path"]}:#{action["line"]} #{action["kind"]}: #{action["message"]}" + method = method_at(action["path"], action["line"]) + if method + lines << " - method: #{method["class"]}##{method["method"]}" + lines << " - current: #{method["sig"]}" if method["sig"] + end + proposed = proposed_action_text(action, method) + lines << " - proposed: #{proposed}" if proposed + evidence = action_evidence_text(action) + lines << " - evidence: #{evidence}" if evidence && !evidence.empty? + end + + def append_review_actions(lines, actions) + lines << "" + lines << "## Review Actions (#{actions.size})" + if actions.empty? + lines << "- none" + return + end + groups = [ + ["Default Replacement Candidates", actions.select { |a| a["kind"] == "replace_nil_with_default" }], + ["Nil Source Fixes", actions.select { |a| a["kind"] == "nil_param_observed" }], + ["Union / T.any Candidates", actions.select { |a| %w[union_observed bad_input_type_candidate].include?(a["kind"]) }], + ["Missing Sigs Needing Manual Review", actions.select { |a| a["kind"] == "add_sig" }], + ["Other Review Actions", actions.reject { |a| %w[replace_nil_with_default nil_param_observed union_observed bad_input_type_candidate add_sig].include?(a["kind"]) }], + ] + groups.each do |title, list| + next if list.empty? + list = list.sort_by { |action| action_sort_key(action) } + lines << "" + lines << "### #{title} (#{list.size})" + if title == "Nil Source Fixes" + append_grouped_review_actions(lines, list, "nil source fix", "nil source fixes") + elsif title == "Union / T.any Candidates" + append_grouped_review_actions(lines, list, "union candidate", "union candidates") + else + list.first(20).each { |action| append_review_action_line(lines, action) } + lines << "- ... #{list.size - 20} more" if list.size > 20 + end + end + end + + def action_sort_key(action) + site, calls = primary_action_callsite(action) + [-calls.to_i, action["path"].to_s, action["line"].to_i, action.dig("data", "name").to_s, site.to_s] + end + + def append_grouped_review_actions(lines, actions, singular_label, plural_label) + groups = grouped_action_priorities(actions) + total = actions.size + groups.first(20).each do |group| + affected = group["actions"].size + calls = group["calls"].to_i + noun = affected == 1 ? singular_label : plural_label + lines << "- #{group["site"]}: affects #{affected} of #{total} #{noun}; source calls #{calls}" + group["actions"].first(6).each { |action| append_review_action_line(lines, action, indent: " ") } + end + lines << "- ... #{groups.size - 20} more source group(s)" if groups.size > 20 + end + + def append_review_action_line(lines, action, indent: "") + case action["kind"] + when "nil_param_observed" + sites = top_action_sites(action) + candidate = action.dig("data", "candidate_type") + default = default_for_type(candidate) + suffix = [] + suffix << "candidate #{candidate}" if NilKill.useful_type?(candidate) + suffix << "auto-default #{default}" if default + suffix << "top source #{sites.first}" unless sites.empty? + detail = suffix.empty? ? "no non-nil candidate yet" : suffix.join("; ") + lines << "#{indent}- #{action["path"]}:#{action["line"]} #{action.dig("data", "name")}; #{detail}" + when "union_observed", "bad_input_type_candidate" + classes = Array(action.dig("data", "classes") || action.dig("data", "raised_only_classes")).first(8).join(", ") + classes += ", ..." if Array(action.dig("data", "classes") || action.dig("data", "raised_only_classes")).size > 8 + lines << "#{indent}- #{action["path"]}:#{action["line"]} #{action.dig("data", "name")}; observed #{classes}; #{top_action_sites(action).first || "no source callsite"}" + when "replace_nil_with_default" + lines << "#{indent}- #{action["path"]}:#{action["line"]} replace nil with #{action.dig("data", "default")} for #{action.dig("data", "target_method")}##{action.dig("data", "name")}; observed calls #{action.dig("data", "observed_calls")}" + else + lines << "#{indent}- #{action["path"]}:#{action["line"]} #{action["kind"]}: #{action["message"]}" + end + end + + def append_gap_actions(lines, actions) + lines << "" + lines << "## Gap Actions (#{actions.size})" + if actions.empty? + lines << "- none" + else + actions.first(50).each { |a| lines << "- #{a["path"]}:#{a["line"]} #{a["kind"]}: #{a["message"]}" } + lines << "- ... #{actions.size - 50} more" if actions.size > 50 + end + end + + def method_at(path, line) + @method_at ||= (Array(@evidence["facts"]["existing_sigs"]) + Array(@evidence["facts"]["unsigned_methods"])).each_with_object({}) do |m, h| + h[[m["path"], m["line"]]] = m + end + @method_at[[path, line]] + end + + def default_for_type(type) + case type + when "Array", /\AT::Array\b/ then "[]" + when "Hash", /\AT::Hash\b/ then "{}" + when "String" then "\"\"" + else nil + end + end + + def proposed_action_text(action, method) + case action["kind"] + when "fix_sig_return" + "change return to #{action.dig("data", "type")}" + when "fix_sig_param" + "change param #{action.dig("data", "name")} to #{action.dig("data", "type")}" + when "narrow_tlet" + "change T.let type to #{action.dig("data", "type")}" + when "add_tlet" + "wrap #{action.dig("data", "name")} in T.let(..., #{action.dig("data", "type")})" + when "replace_nil_with_default" + "replace nil with #{action.dig("data", "default")}" + when "add_sig" + action.dig("data", "sig") || (method && "add #{method["sig"]}") + end + end + + def action_evidence_text(action) + data = action["data"] || {} + parts = [] + parts << "#{data["observed_calls"]} observed call(s)" if data["observed_calls"] + if data["type"] + label = data["source"] == "static_return_origin" ? "static candidate" : "observed" + parts << "#{label} #{data["type"]}" + end + sites = top_action_sites(action) + parts << "top source #{sites.first}" unless sites.empty? + parts.join("; ") + end + + def top_action_sites(action, limit = 3) + (action.dig("data", "callsites") || {}).sort_by { |_site, count| -count.to_i }.first(limit).map do |site, count| + "#{site.sub(/:[^:]+\z/, "")}; source calls #{count}" + end + end + + def append_callsite_pressure(lines, actions) + nil_pressure = callsite_pressure(actions, "nil_param_observed") + union_pressure = merge_pressure( + callsite_pressure(actions, "union_observed"), + callsite_pressure(actions, "bad_input_type_candidate") + ) + lines << "" + lines << "## Nilability Pressure By Root Callsite" + lines << "- pressure: how many review actions are attributed to the same source location" + lines << "- root callsite: the caller/source location where nil entered one or more typed slots" + append_pressure_list(lines, nil_pressure, "T.nilable") + lines << "" + lines << "## Union Pressure Downgraded To T.untyped" + lines << "- downgrade: a slot observed with multiple runtime types was kept as `T.untyped` instead of emitted as `T.any(...)`" + lines << "- why it happens: `T.any(...)` is risky when the runtime sample may not include every type that can reach the slot" + lines << "Changing these to T.any(...) can be dangerous unless you are certain the runtime sample includes every type that can reach the slot. Static analysis can separately look for other types that could be passed without breaking the function." + append_pressure_list(lines, union_pressure, "T.any") + lines << "" + lines << "## T.any Downgrades By Signature" + lines << "- signature downgrade: an individual param or return slot where union evidence exists but the report kept the current `T.untyped` signature" + actions.select { |a| a["kind"] == "union_observed" }.first(50).each do |action| + classes = Array(action.dig("data", "classes")).join(", ") + lines << "- #{action["path"]}:#{action["line"]} #{action.dig("data", "name")}: observed #{classes}; kept as T.untyped" + end + end + + def append_return_origin_report(lines, evidence) + origins = untyped_return_origins(evidence) + lines << "" + lines << "## Return Origin Pressure" + lines << "- origin: the expression or forwarded callee that currently determines a method's return type" + lines << "- pressure: how many untyped returns could be improved by fixing the same origin" + lines << "- cascading return fix: a return annotation that can unlock other forwarded-return annotations after it becomes typed" + if origins.empty? + lines << "- none" + return + end + grouped = origins.group_by { |origin| origin["confidence"] } + %w[blocked weak strong].each do |confidence| + list = grouped[confidence] || [] + lines << "- #{confidence}: #{list.size}" + end + root_pressure = return_root_pressure(origins, evidence) + cascade_pressure = return_cascade_pressure(origins, evidence) + forwarded_pressure = forwarded_return_blocker_pressure(origins, evidence) + if root_pressure.empty? + lines << "- no untyped/nil root pressure found" + else + lines << "" + lines << "Top root return blockers:" + root_pressure.first(30).each do |root, data| + suggestion = data["suggestion"] ? "; suggestion #{data["suggestion"]}" : "" + lines << "- #{root}; affects #{data["methods"].size} return(s); #{data["count"]} source occurrence(s)#{suggestion}" + data["examples"].first(4).each { |example| lines << " - #{example}" } + end + end + unless cascade_pressure.empty? + lines << "" + lines << "Top cascading return fixes:" + cascade_pressure.first(20).each do |root, data| + suggestion = data["suggestion"] ? "; suggestion #{data["suggestion"]}" : "" + lines << "- #{root}; may unlock #{data["returns"].size} return(s) (#{data["direct"].size} direct, #{data["cascade"].size} cascading), #{data["params"].size} possible param flow(s)#{suggestion}" + data["examples"].first(4).each { |example| lines << " - #{example}" } + end + end + unless forwarded_pressure.empty? + lines << "" + lines << "Forwarded return blocker pressure:" + forwarded_pressure.first(20).each do |callee, data| + lines << "- #{callee}: #{data["status"]}; affects #{data["returns"].size} return(s), #{data["params"].size} possible param flow(s)" + data["examples"].first(4).each { |example| lines << " - #{example}" } + end + end + action_items = root_pressure.select { |_root, data| data["suggestion"] }.first(20) + unless action_items.empty? + lines << "" + lines << "High-impact root return actions:" + action_items.each do |root, data| + lines << "- #{root}: #{data["suggestion"]}; may unblock #{data["methods"].size} return(s)" + end + end + blocked = origins.select { |origin| origin["confidence"] == "blocked" } + unless blocked.empty? + lines << "" + lines << "Blocked return examples:" + blocked.first(12).each do |origin| + method = "#{origin["class"]}##{origin["method"]}" + blocker = Array(origin["blockers"]).first || "no blocker recorded" + lines << "- #{origin["path"]}:#{origin["line"]} #{method}: #{blocker}" + end + end + end + + def untyped_return_origins(evidence) + untyped = Array(evidence.dig("facts", "existing_sigs")).each_with_object(Set.new) do |method, set| + next unless extract_return_type(method["sig"].to_s) == "T.untyped" + set << [method["path"], method["line"].to_i, method["class"].to_s, method["method"].to_s, method["kind"].to_s] + end + Array(evidence.dig("facts", "return_origins")).select do |origin| + untyped.include?([origin["path"], origin["line"].to_i, origin["class"].to_s, origin["method"].to_s, origin["kind"].to_s]) + end + end + + def return_root_pressure(origins, evidence) + usage = return_usage_by_name(evidence) + pressure = Hash.new { |hash, key| hash[key] = { "count" => 0, "methods" => Set.new, "examples" => [] } } + origins.each do |origin| + method_key = "#{origin["path"]}:#{origin["line"]} #{origin["class"]}##{origin["method"]}" + Array(origin["sources"]).each do |source| + root = case source["kind"] + when "call_untyped" + "untyped callee #{source["callee"]}" + when "setter_assignment_unknown" + "setter assignment #{source["callee"]}" + when "nil" + "nil return at #{origin["path"]}:#{source["line"] || origin["line"]}" + when "unknown" + "unknown expression at #{origin["path"]}:#{source["line"] || origin["line"]}" + else + next + end + data = pressure[root] + data["count"] += 1 + data["methods"] << method_key + data["suggestion"] ||= root_return_suggestion(root, source, usage) + data["examples"] << method_key if data["examples"].size < 6 + end + Array(origin["blockers"]).each do |blocker| + next unless blocker.include?("untyped callee") || blocker.include?("unknown return") || blocker.include?("safe navigation") || + blocker.include?("setter assignment") + data = pressure[blocker] + data["count"] += 1 + data["methods"] << method_key + data["suggestion"] ||= root_return_suggestion(blocker, nil, usage) + data["examples"] << method_key if data["examples"].size < 6 + end + end + pressure.sort_by { |_root, data| [-data["methods"].size, -data["count"]] } + end + + def return_cascade_pressure(origins, evidence) + usage = return_usage_by_name(evidence) + method_name_counts = return_method_name_counts(evidence) + methods = origins.each_with_object({}) do |origin, lookup| + key = return_method_key(origin) + deps = return_unresolved_dependencies(origin) + next if deps.empty? + lookup[key] = { + "origin" => origin, + "deps" => deps, + "method_token" => return_method_token(origin), + "method_name" => origin["method"].to_s, + } + end + roots = methods.values.flat_map { |entry| entry["deps"].select { |dep| dep.start_with?("root:") } }.uniq + param_flows = Array(evidence.dig("facts", "param_origins")).select { |origin| %w[typed_return untyped_return].include?(origin["origin_kind"]) } + roots.each_with_object({}) do |root_dep, pressure| + resolved = Set[root_dep] + unlocked = Set.new + changed = true + while changed + changed = false + methods.each do |key, entry| + next if unlocked.include?(key) + next unless entry["deps"].all? { |dep| resolved.include?(dep) } + unlocked << key + resolved << entry["method_token"] + resolved << "root:untyped callee #{entry["method_name"]}" if unambiguous_return_name?(entry["method_name"], method_name_counts) + changed = true + end + end + next if unlocked.empty? + direct = methods.select { |key, entry| entry["deps"].include?(root_dep) && unlocked.include?(key) }.keys.to_set + cascade = unlocked - direct + method_names = unlocked.map { |key| methods[key]["method_name"] }.select { |name| unambiguous_return_name?(name, method_name_counts) }.to_set + params = param_flows.select { |flow| method_names.include?(flow["source_method"].to_s) }.map { |flow| "#{flow["path"]}:#{flow["line"]} #{flow["callee"]}(#{flow["slot"]})" }.to_set + root = root_dep.delete_prefix("root:") + pressure[root] = { + "returns" => unlocked, + "direct" => direct, + "cascade" => cascade, + "params" => params, + "suggestion" => root_return_suggestion(root, nil, usage), + "examples" => unlocked.first(6).to_a, + } + end.sort_by { |_root, data| [-data["returns"].size, -data["cascade"].size, -data["params"].size] } + end + + def forwarded_return_blocker_pressure(origins, evidence) + status = forwarded_return_status_index(evidence) + param_flows = Array(evidence.dig("facts", "param_origins")).select { |origin| %w[typed_return untyped_return].include?(origin["origin_kind"]) } + pressure = Hash.new { |hash, key| hash[key] = { "returns" => Set.new, "params" => Set.new, "examples" => [], "status" => "unknown" } } + origins.each do |origin| + callees = Array(origin["sources"]).select { |source| source["kind"].to_s == "call_untyped" }.map { |source| source["callee"].to_s }.reject(&:empty?) + next if callees.empty? + method_key = return_method_key(origin) + callees.each do |callee| + data = pressure[callee] + data["status"] = status[callee] || "unresolved forwarded callee" + data["returns"] << method_key + data["examples"] << method_key if data["examples"].size < 6 + end + end + pressure.each do |callee, data| + param_flows.select { |flow| flow["source_method"].to_s == callee }.each do |flow| + data["params"] << "#{flow["path"]}:#{flow["line"]} #{flow["callee"]}(#{flow["slot"]})" + end + end + pressure.sort_by { |callee, data| [-data["returns"].size, -data["params"].size, callee] }.to_h + end + + def forwarded_return_status_index(evidence) + sig_types = Array(evidence.dig("facts", "existing_sigs")).each_with_object(Hash.new { |h, k| h[k] = [] }) do |method, types| + ret = extract_return_type(method["sig"].to_s) + types[method["method"].to_s] << ret if NilKill.useful_type?(ret) + end + sig_counts = Array(evidence.dig("facts", "existing_sigs")).each_with_object(Hash.new(0)) do |method, counts| + counts[method["method"].to_s] += 1 + end + origins = Array(evidence.dig("facts", "return_origins")).group_by { |origin| origin["method"].to_s } + names = (sig_types.keys + origins.keys).uniq + names.each_with_object({}) do |name, index| + typed = Array(sig_types[name]).compact.uniq.reject { |type| type == "T.untyped" || type == "void" } + if sig_counts[name] == 1 && typed.size == 1 && Array(sig_types[name]).compact.uniq.size == 1 + index[name] = "typed signature #{typed.first}" + next + end + origin_list = Array(origins[name]) + if origin_list.size > 1 + index[name] = "ambiguous method name" + elsif origin_list.size == 1 && NilKill.useful_type?(origin_list.first["candidate_type"]) && !NilKill.weak_type?(origin_list.first["candidate_type"]) + index[name] = "static candidate #{origin_list.first["candidate_type"]}" + elsif origin_list.size == 1 + index[name] = "callee return still untyped" + end + end + end + + def return_method_name_counts(evidence) + Array(evidence["methods"]).each_with_object(Hash.new(0)) do |method, counts| + source = method["source"] || method + name = source["method"] + counts[name.to_s] += 1 if name + end + end + + def unambiguous_return_name?(name, method_name_counts) + count = method_name_counts[name.to_s] + count.nil? || count <= 1 + end + + def return_unresolved_dependencies(origin) + deps = Set.new + Array(origin["sources"]).each do |source| + case source["kind"] + when "call_untyped" + deps << "root:untyped callee #{source["callee"]}" + when "setter_assignment_unknown" + deps << "root:setter assignment #{source["callee"]}" + when "nil" + deps << "root:nil return at #{origin["path"]}:#{source["line"] || origin["line"]}" + when "unknown" + deps << "root:unknown expression at #{origin["path"]}:#{source["line"] || origin["line"]}" + end + end + Array(origin["blockers"]).each do |blocker| + if blocker.include?("untyped callee") || blocker.include?("unknown return") || blocker.include?("safe navigation") || + blocker.include?("setter assignment") + deps << "root:#{blocker}" + end + end + deps.delete(return_method_token(origin)) + deps + end + + def return_method_key(origin) + "#{origin["path"]}:#{origin["line"]} #{origin["class"]}##{origin["method"]}" + end + + def return_method_token(origin) + "method:#{origin["method"]}" + end + + def root_return_suggestion(root, source, usage = {}) + if root.start_with?("setter assignment ") || root.match?(/\b\w+=\b/) + return "model assignment syntax as returning the assigned RHS; type the RHS source or avoid using setter assignment as method return" + end + callee = source&.dig("callee") || root[/untyped callee ([^ ;]+)/, 1] + stats = usage[callee.to_s] + if stats && stats["value"].zero? && stats["return"].positive? + return "void candidate: return is only forwarded into other returns, never used as a value" + elsif stats && stats["value"].zero? && stats["statement"].positive? + return "void candidate: return is only used as a statement" + end + case callee + when "raise" + "mark as no-return/raises path, or keep callers from using it as a value" + when "puts", "print" + "review as void side-effect helper" + when "each", "each_pair", "each_value" + "review as receiver-returning iterator; callers probably want explicit return value" + when "any?", "include?", "==", "!" + "review as boolean return" + when "join", "to_s", "chomp", "rstrip" + "review as String return" + when "[]" + "review as nilable lookup or replace with fetch/typed accessor" + when "[]=", "<<" + "review as mutation expression; prefer explicit return after side effect" + else + nil + end + end + + def return_usage_by_name(evidence) + names = Array(evidence.dig("facts", "existing_sigs")).filter_map { |method| method["method"].to_s }.to_set + usage = Hash.new { |hash, key| hash[key] = { "value" => 0, "return" => 0, "statement" => 0 } } + NilKill.target_files.each do |path| + parsed = Prism.parse_file(path) + next unless parsed.success? + mark_return_usage(parsed.value, :statement, names, usage) + rescue StandardError + next + end + usage + end + + def mark_return_usage(node, context, names, usage) + return unless node + case node + when Prism::DefNode + mark_return_usage(node.body, :return, names, usage) + when Prism::StatementsNode + body = node.body || [] + body.each_with_index do |child, idx| + mark_return_usage(child, idx == body.length - 1 ? context : :statement, names, usage) + end + when Prism::ReturnNode, Prism::ArgumentsNode + node.child_nodes.compact.each { |child| mark_return_usage(child, :return, names, usage) } + when Prism::IfNode + mark_return_usage(node.predicate, :value, names, usage) if node.respond_to?(:predicate) + mark_return_usage(node.statements, context, names, usage) + mark_return_usage(node.subsequent, context, names, usage) + when Prism::ElseNode + mark_return_usage(node.statements, context, names, usage) + when Prism::CallNode + usage[node.name.to_s][context.to_s] += 1 if names.include?(node.name.to_s) + node.child_nodes.compact.each { |child| mark_return_usage(child, :value, names, usage) } + else + node.child_nodes.compact.each { |child| mark_return_usage(child, :value, names, usage) } if node.respond_to?(:child_nodes) + end + end + + def append_return_hygiene_report(lines, evidence, heading_level: 2) + rows = return_hygiene_rows(evidence) + lines << "" + lines << "#{"#" * heading_level} Return Hygiene" + lines << "- control shape: whether the method return is branchless or depends on branching control flow" + lines << "- return syntax: whether the method uses implicit return, explicit `return`, or a mix" + lines << "- return value usage: whether static callsites use this method's return value, forward it, or ignore it" + lines << "- return source kind: the kind of expression that produces the return value" + lines << "- fixability: the report's estimate of whether the return is already addressed, directly fixable, cascading, or needs more evidence" + lines << "- row percent: share of all return slots; strength percents: share within that row" + if rows.empty? + lines << "- none" + return + end + + total = rows.size + counts = return_hygiene_type_counts(rows) + lines << "- Return slots indexed: #{total}" + lines << "- Return slot strength: #{format_hygiene_strength_counts(counts, total)}" + append_hygiene_bucket_lines(lines, "Control Shape", rows, "control_shape", total) + append_hygiene_bucket_lines(lines, "Return Syntax", rows, "return_syntax", total) + append_hygiene_bucket_lines(lines, "Return Value Usage", rows, "usage", total) + append_hygiene_bucket_lines(lines, "Return Source Kind", rows, "source_kind", total) + append_hygiene_bucket_lines(lines, "Fixability", rows, "fixability", total) + + easy = rows.select { |row| row["fixability"].start_with?("addressed") || row["fixability"].start_with?("auto-fixable") } + addressed = easy.count { |row| row["fixability"].start_with?("addressed") } + lines << "- Easily addressable/addressed returns: #{format_hygiene_count(addressed, easy.size)}" + + action_rows = rows.select { |row| row["return_type"] == "T.untyped" && !row["fixability"].start_with?("addressed") } + .sort_by { |row| [hygiene_fixability_rank(row["fixability"]), row["path"], row["line"].to_i] } + unless action_rows.empty? + lines << "" + lines << "#### Top Return Hygiene Actions" + lines << "" + action_rows.first(20).each do |row| + lines << "- #{row["path"]}:#{row["line"]} #{row["class"]}##{row["method"]}: #{row["fixability"]}; #{row["usage"]}; #{row["source_kind"]}" + end + end + end + + def append_hygiene_bucket_lines(lines, title, rows, key, total) + lines << "" + lines << "#### #{title}" + lines << "" + rows.group_by { |row| row[key] }.sort_by { |name, list| [-list.size, name] }.each do |name, list| + counts = return_hygiene_type_counts(list) + lines << "- #{name}: total #{format_hygiene_count(list.size, total)} of all returns; #{format_hygiene_strength_counts(counts, list.size)} within row" + end + end + + def format_hygiene_count(count, total) + return "#{count} (0.0%)" if total.to_i.zero? + "#{count} (#{format("%.1f", (count.to_f * 100.0) / total)}%)" + end + + def return_hygiene_type_counts(rows) + rows.each_with_object(empty_type_counts) do |row, counts| + classify_type!(counts, row["return_type"]) + end + end + + def format_hygiene_strength_counts(counts, total) + "strong #{format_hygiene_count(counts["strong"], total)}; " \ + "weak #{format_hygiene_count(counts["weak"], total)}; " \ + "untyped #{format_hygiene_count(counts["untyped"], total)}; " \ + "nilable #{format_hygiene_count(counts["nilable"], total)}" + end + + def hygiene_fixability_rank(fixability) + case fixability + when /\Aauto-fixable: void/ then 0 + when /\Aauto-fixable/ then 1 + when /\Acascade/ then 2 + when /\Aneeds collection/ then 3 + else 4 + end + end + + def return_hygiene_rows(evidence) + origins = Array(evidence.dig("facts", "return_origins")).each_with_object({}) do |origin, lookup| + lookup[[origin["path"], origin["line"].to_i, origin["class"].to_s, origin["method"].to_s, origin["kind"].to_s]] = origin + end + graph = return_usage_graph_summary(evidence) + direct_usage = return_usage_by_name(evidence) + action_lookup = return_fix_action_lookup(evidence) + + Array(evidence.dig("facts", "existing_sigs")).filter_map do |method| + sig = method["sig"].to_s + return_type = sig.match?(/\bvoid\b/) ? "void" : extract_return_type(sig) + next unless return_type + + origin = method["return_origin"] || + origins[[method["path"], method["line"].to_i, method["class"].to_s, method["method"].to_s, method["kind"].to_s]] || {} + usage = return_usage_bucket(method, return_type, graph, direct_usage) + source_kind = return_hygiene_source_kind(origin) + action = action_lookup[[method["path"], method["line"].to_i]] + { + "path" => method["path"], + "line" => method["line"], + "class" => method["class"], + "method" => method["method"], + "return_type" => return_type, + "control_shape" => origin["control_shape"] || "unknown control shape", + "return_syntax" => origin["return_syntax"] || (origin["implicit"] ? "implicit" : "unknown syntax"), + "usage" => usage, + "source_kind" => source_kind, + "fixability" => return_hygiene_fixability(return_type, usage, source_kind, action, origin), + } + end + end + + def return_fix_action_lookup(evidence) + Array(evidence["actions"]).each_with_object({}) do |action, lookup| + next unless action["kind"] == "fix_sig_return" + key = [action["path"], action["line"].to_i] + current = lookup[key] + lookup[key] = action if current.nil? || return_fix_action_rank(action) < return_fix_action_rank(current) + end + end + + def return_fix_action_rank(action) + return 0 if action["confidence"] == HIGH + return 1 if action["confidence"] == REVIEW + 2 + end + + def return_usage_graph_summary(evidence) + candidates = Array(evidence.dig("facts", "existing_sigs")).select do |method| + sig = method["sig"].to_s + sig.match?(/\bvoid\b/) || extract_return_type(sig) + end + candidates_by_name = candidates.group_by { |method| method["method"].to_sym } + candidate_names = candidates_by_name.select { |_name, methods| methods.size == 1 }.keys.to_set + method_return_types = unambiguous_method_return_types(evidence) + used = Set.new + return_edges = Hash.new { |hash, key| hash[key] = Set.new } + NilKill.target_files.each do |path| + parsed = Prism.parse_file(path) + next unless parsed.success? + mark_return_usage_graph(parsed.value, :statement, nil, candidate_names, method_return_types, used, return_edges) + rescue StandardError + next + end + propagate_return_usage!(used, return_edges) + { "candidate_names" => candidate_names, "used" => used, "return_edges" => return_edges } + end + + def return_usage_bucket(method, return_type, graph, direct_usage) + return "declared void" if return_type == "void" + return "declared noreturn" if return_type == "T.noreturn" + + name = method["method"].to_sym + stats = direct_usage[method["method"].to_s] || { "value" => 0, "return" => 0, "statement" => 0 } + return "ambiguous method name" unless graph["candidate_names"].include?(name) + return "used as value" if graph["used"].include?(name) || stats["value"].positive? + return "unused via return-forwarding" if stats["return"].positive? + return "unused statement-only" if stats["statement"].positive? + "no static callsites found" + end + + def return_hygiene_source_kind(origin) + sources = Array(origin["sources"]) + return "unknown source" if sources.empty? + + kinds = sources.map { |source| source["kind"].to_s }.to_set + return "struct/class field or instance variable" if kinds.include?("ivar_read") + return "collection lookup" if sources.any? { |source| collection_lookup_source?(source) } + return "mutation/setter assignment" if kinds.include?("assignment") || kinds.include?("setter_assignment_unknown") + return "mixed sources" if sources.any? { |source| ruby_stdlib_source?(source) } && !ruby_stdlib_return_sources?(sources) + return "Ruby stdlib call" if ruby_stdlib_return_sources?(sources) + if kinds.any? { |kind| %w[typed_call call_untyped safe_call].include?(kind) } + return "#{origin["return_syntax"] || "unknown syntax"}/direct forwarded return" + end + return "literal/static" if kinds.all? { |kind| %w[static nil].include?(kind) } + return "mixed sources" if kinds.size > 1 + "unknown source" + end + + def collection_lookup_source?(source) + source["callee"].to_s == "[]" || source["code"].to_s.match?(/\[[^\]]*\]/) || + source["type"].to_s.match?(/\AT::(?:Array|Hash|Enumerable|Set)\b/) + end + + def ruby_stdlib_source?(source) + return false unless %w[typed_call safe_call].include?(source["kind"].to_s) + callee = source["callee"].to_s + source["stdlib"] || (!callee.empty? && NilKill.rbi_return_type(callee)) + end + + def ruby_stdlib_return_sources?(sources) + useful = Array(sources).reject { |source| source["kind"].to_s == "nil" } + return false unless useful.any? { |source| ruby_stdlib_source?(source) } + !useful.empty? && useful.all? do |source| + ruby_stdlib_source?(source) || source["kind"].to_s == "static" + end + end + + def return_hygiene_fixability(return_type, usage, source_kind, action = nil, origin = nil) + return "addressed: void" if return_type == "void" + return "addressed: noreturn" if return_type == "T.noreturn" + return "addressed: #{return_hygiene_type_strength(return_type)}" if return_type != "T.untyped" + if action && action["confidence"] == HIGH + type = action.dig("data", "type") || "return" + return "auto-fixable: #{type}" + end + if action + type = action.dig("data", "type") || "return" + source = action.dig("data", "source") || action["confidence"] + return "review action: #{type} from #{source}" + end + if ["literal/static", "Ruby stdlib call", "mutation/setter assignment"].include?(source_kind) + candidate = origin && origin["candidate_type"] + return "missing action: static/RBI candidate #{candidate}" if NilKill.useful_type?(candidate) + return "missing action: no singular static/RBI candidate" + end + return "cascade: forwarded return" if source_kind.include?("forwarded return") + return "needs collection/field evidence" if source_kind == "collection lookup" || source_kind.include?("instance variable") + "manual review" + end + + def return_hygiene_type_strength(type) + inner = strip_nilable(type.to_s.strip) + return "untyped" if untyped_type?(inner) + return "weak" if weak_type?(inner) + "strong" + end + + def append_param_origin_report(lines, evidence) + origins = Array(evidence.dig("facts", "param_origins")) + lines << "" + lines << "## Input Param Origin Backflow" + lines << "- origin: the caller-side expression passed into a parameter slot" + lines << "- backflow: tracing weak or untyped parameter pressure backward from the callee slot to the caller expression that supplied it" + lines << "- return-to-param flow: a method return value that is later passed into another method's parameter" + if origins.empty? + lines << "- none" + return + end + counts = origins.group_by { |origin| origin["origin_kind"] }.transform_values(&:size) + lines << "- Origins indexed: #{origins.size}" + counts.sort_by { |kind, count| [-count, kind] }.each do |kind, count| + lines << "- #{kind}: #{count}" + end + return_flows = origins.select { |origin| %w[typed_return untyped_return].include?(origin["origin_kind"]) } + unless return_flows.empty? + lines << "" + lines << "Return-to-param flows:" + return_flows.group_by { |origin| origin["source_method"] || "unknown" }.sort_by { |_method, list| -list.size }.first(20).each do |method, list| + examples = list.first(4).map { |origin| "#{origin["path"]}:#{origin["line"]} -> #{origin["callee"]}(#{origin["slot"]})" } + lines << "- #{method}: #{list.size} flow(s); #{examples.join("; ")}" + end + end + end + + def append_foreign_class_pressure(lines, evidence) + pressure = foreign_class_pressure(evidence) + lines << "" + lines << "## Foreign Scalar Inputs Into Object-Typed Params" + lines << "This ranks caller origins where `String`/`Symbol` values flow into params that also receive object instances. It skips `src/tools` origins unless `NIL_KILL_FOREIGN_INCLUDE_TOOLS=1`." + if pressure.empty? + lines << "- none" + return + end + pressure.sort_by { |_origin, data| [-data["calls"], -data["slots"].size] }.first(50).each do |origin, data| + lines << "- #{origin} #{source_line(origin)}; #{data["calls"]} foreign scalar call(s), affects #{data["slots"].size} slot(s)" + data["examples"].values.sort_by { |example| -example["calls"] }.first(6).each do |example| + desired = Array(example["desired"]).first(5).join(", ") + foreign = Array(example["foreign"]).first(5).join(", ") + trace = example["trace"].empty? ? "" : "; trace #{example["trace"].first(4).join(" -> ")}" + lines << " - #{example["sink"]} #{example["param"]}: #{foreign} into #{desired} (#{example["calls"]})#{trace}" + end + end + end + + def foreign_class_pressure(evidence) + pressure = Hash.new { |h, k| h[k] = { "calls" => 0, "slots" => Set.new, "examples" => {} } } + Array(evidence["methods"]).each do |rec| + source = rec["source"] + next unless source + params = rec["params_ok"].empty? ? rec["params_by_name"] : rec["params_ok"] + traces = rec["param_traces_ok"].empty? ? rec["param_traces"] : rec["param_traces_ok"] + sites = rec["param_sites_ok"].empty? ? rec["param_sites"] : rec["param_sites_ok"] + params.each do |name, classes| + foreign = Array(classes) & foreign_scalar_classes + desired = desired_object_classes(classes) + next if foreign.empty? || desired.empty? + each_foreign_origin(rec, name, foreign, traces[name], sites[name]) do |origin, count, trace| + next if skip_foreign_origin?(origin) + slot = "#{source["path"]}:#{source["line"]}:#{name}" + sink = "#{source["path"]}:#{source["line"]} #{source["class"]}##{source["method"]}" + data = pressure[origin] + data["calls"] += count.to_i + data["slots"] << slot + key = "#{slot}:#{foreign.sort.join("/")}" + ex = (data["examples"][key] ||= { "sink" => sink, "param" => name, "desired" => desired, + "foreign" => foreign, "calls" => 0, "trace" => trace }) + ex["calls"] += count.to_i + end + end + end + pressure + end + + def foreign_scalar_classes + %w[String Symbol] + end + + def desired_object_classes(classes) + Array(classes).compact.uniq.reject do |klass| + klass == "NilClass" || klass == "T.untyped" || foreign_scalar_classes.include?(klass) || + klass.include?("#") || klass.start_with?("Sorbet::Private::") || klass.match?(/\A(?:Integer|Float|TrueClass|FalseClass)\z/) + end.select { |klass| klass.match?(/\A[A-Z]/) }.sort + end + + def each_foreign_origin(rec, name, foreign, traces, sites) + if traces && !traces.empty? + traces.each do |trace_key, count| + trace, klass = split_trace_key(trace_key) + next unless foreign.include?(klass) + origin = trace_origin(rec, trace) + yield origin, count, trace if origin + end + else + filter_sites_by_class(sites, foreign).each do |site, count| + root = site.sub(/:[^:]+\z/, "") + yield root, count, [root] + end + end + end + + def split_trace_key(trace_key) + trace_part, _sep, klass = trace_key.to_s.rpartition(":") + [trace_part.split("|"), klass] + end + + def trace_origin(rec, trace) + source = rec["source"] || {} + trace.find do |frame| + path, line = split_site(frame) + next false unless path && line + rel = NilKill.rel(path) + !(rel == source["path"] && line >= source["line"].to_i && line <= source.fetch("end_line", source["line"]).to_i) + end + end + + def skip_foreign_origin?(origin) + return false if ENV["NIL_KILL_FOREIGN_INCLUDE_TOOLS"] == "1" + rel = NilKill.rel(origin.sub(/:\d+\z/, "")) + rel.start_with?("src/tools/") + end + + def source_line(origin) + path, line = split_site(origin) + return "" unless path && line + source = File.readlines(path)[line - 1]&.strip + source && !source.empty? ? "`#{source[0, 160]}`" : "" + rescue Errno::ENOENT + "" + end + + def split_site(site) + match = site.to_s.match(/\A(.+):(\d+)\z/) + match ? [match[1], match[2].to_i] : [nil, nil] + end + + def filter_sites_by_class(sites, classes) + wanted = Array(classes).to_set + (sites || {}).select { |site, _count| wanted.include?(site.to_s.split(":").last) } + end + + def append_type_normalizer_report(lines, evidence) + normalizers = Array(evidence.dig("facts", "type_normalizers")) + lines << "" + lines << "## Type Normalizer Sites" + lines << "- Sites matching `is_a?(Type)` plus `Type.new(...)`: #{normalizers.size}" + if normalizers.empty? + lines << "- none" + return + end + grouped = normalizers.group_by { |site| site["path"] } + grouped.sort_by { |path, sites| [-sites.size, path] }.first(20).each do |path, sites| + lines << "- #{path}: #{sites.size}" + sites.first(5).each do |site| + method = [site["class"], site["method"]].compact.reject(&:empty?).join("#") + method = "top-level" if method.empty? + lines << " - line #{site["line"]} #{method}: #{site["code"]}" + end + lines << " - ... #{sites.size - 5} more" if sites.size > 5 + end + end + + def append_hygiene_overview(lines, evidence) + lines << "" + lines << "## Hygiene Overview" + append_type_soundness_table(lines, evidence) + append_untyped_cause_table(lines, evidence) + append_union_decomplexity(lines, evidence) + append_node_alias_candidates(lines, evidence) + append_untyped_evidence_gaps(lines, evidence) + append_signature_slot_evidence(lines, evidence) + append_return_hygiene_report(lines, evidence, heading_level: 3) + end + + UNION_DECOMPLEXITY_TOP_N = 30 + + GUARD_RECEIVER_RE = /(@?[A-Za-z_]\w*)\.is_a\?\(Type\)/.freeze + + # type_normalizers are defensive `recv.is_a?(Type) ? recv : + # Type.new(recv)` guards. Each one exists because some slot feeding + # `recv` is sometimes Type, sometimes raw. Index: [class,method] -> + # receiver-name -> { count:, sites:[loc...] }. Receiver-name is the + # contract the guard protects; strip a leading @ so an ivar receiver + # keys the same as its slot. + def type_normalizer_guard_index(evidence) + idx = Hash.new { |h, k| h[k] = Hash.new { |g, r| g[r] = { "count" => 0, "sites" => [] } } } + Array(evidence.dig("facts", "type_normalizers")).each do |site| + recv = site["code"].to_s[GUARD_RECEIVER_RE, 1] + next unless recv + recv = recv.sub(/\A@/, "") + cell = idx[[site["class"].to_s, site["method"].to_s]][recv] + cell["count"] += 1 + cell["sites"] << "#{site["path"]}:#{site["line"]}" if cell["sites"].size < 3 + # Keep the first resolved one-hop origin for this receiver (the + # collector tags every guard site identically per receiver). + if cell["origin_kind"].nil? && site["origin_kind"] + cell["origin_kind"] = site["origin_kind"] + cell["origin_name"] = site["origin_name"] + end + end + idx + end + + # Runtime classes empirically observed flowing OUT of every method + # name (its return values) and into every ivar (its assignments). + # This is the lower-cost producer substrate: no static points-to, + # just the facts the tracer already gathers, keyed for the origin + # join below. + def runtime_return_classes_by_method(evidence) + idx = Hash.new { |h, k| h[k] = [] } + Array(evidence["methods"]).each do |m| + next unless m["method"] + idx[m["method"].to_s].concat(Array(m["returns"])) + end + idx.transform_values { |cs| cs.uniq.select { |c| NilKill.useful_type?(c.to_s) } } + end + + def runtime_ivar_classes(evidence) + idx = Hash.new { |h, k| h[k] = [] } + (Array(evidence.dig("facts", "struct_field_runtime")) + + Array(evidence.dig("facts", "struct_field_static"))).each do |f| + next unless f["class"] && f["field"] + cs = Array(f["classes"]) + [f["type"]].compact + idx[[f["class"].to_s, f["field"].to_s]].concat(cs) + end + Array(evidence.dig("facts", "ivar_runtime")).each do |f| + next unless f["class"] && f["name"] + idx[[f["class"].to_s, f["name"].to_s.sub(/\A@/, "")]].concat(Array(f["classes"])) + end + idx.transform_values { |cs| cs.uniq.select { |c| NilKill.useful_type?(c.to_s) } } + end + + # Accessor/ivar contracts in Union Decomplexity aggregate GLOBALLY + # by name (`.type_info` is one contract across ~38 classes/methods). + # A like-named accessor is overwhelmingly backed by the like-named + # ivar, so the producer types for `.type_info` = the union of every + # `@type_info` runtime class set, regardless of declaring class. + def runtime_ivar_classes_by_name(evidence) + by_name = Hash.new { |h, k| h[k] = [] } + runtime_ivar_classes(evidence).each { |(_cls, name), cs| by_name[name].concat(cs) } + by_name.transform_values { |cs| cs.uniq.select { |c| NilKill.useful_type?(c.to_s) } } + end + + # Join type_normalizers (N defensive guards on a slot) with the + # slot's producer distribution (param_origins): the actionable + # "fix K outlier producers -> N guards collapse" row, ranked by N. + # Slot source is widened past sig params to every method nil-kill + # indexes (existing_sigs + unsigned_methods), so an unsigned/ + # T.untyped param that is guarded still surfaces -- the guard, not + # the declared type, is what makes it a union in practice. + def guard_collapse_rows(evidence) + guards = type_normalizer_guard_index(evidence) + methods = (Array(evidence.dig("facts", "existing_sigs")) + + Array(evidence.dig("facts", "unsigned_methods"))) + .each_with_object({}) { |m, h| h[[m["class"].to_s, m["method"].to_s]] ||= m } + po_by_callee = Array(evidence.dig("facts", "param_origins")).group_by { |o| o["callee"].to_s } + rt_returns = runtime_return_classes_by_method(evidence) + rt_ivars = runtime_ivar_classes(evidence) + rt_ivars_by_name = runtime_ivar_classes_by_name(evidence) + rows = [] + guards.each do |(klass, mname), receivers| + meth = methods[[klass, mname]] + receivers.each do |recv, g| + param_names = Array(meth && meth["params"]).map { |p| p["name"].to_s } + slot_idx = param_names.index(recv) + producers = + if meth && slot_idx + Array(po_by_callee[mname]).select do |o| + (o["slot"].to_s == slot_idx.to_s || o["slot"].to_s == recv) && + NilKill.useful_type?(o["type"].to_s) && o["origin_kind"].to_s != "unknown" + end + else + [] + end + dominant = nil + share = 0.0 + outliers = [] + via = nil + members = [] + total = producers.size + if !producers.empty? + by_type = producers.group_by { |o| o["type"].to_s } + dominant, dom = by_type.max_by { |_, os| os.size } + share = dom.size.to_f / total + outliers = by_type.reject { |t, _| t == dominant }.flat_map do |t, os| + os.first(3).map { |o| { "type" => t, "loc" => "#{o["path"]}:#{o["line"]}", "code" => o["code"].to_s.gsub(/\s+/, " ").strip[0, 50] } } + end + else + # Lower-cost origin join: receiver is a local fed by a call + # return or an ivar -- use the runtime classes already + # gathered for that origin. Gives the empirical member set + # (and a 100%/collapse verdict when it is a singleton); does + # NOT pinpoint the producing return statement (runtime is + # per-method aggregate, not per-return-site). + case g["origin_kind"] + when "call", "attr" + # An attr reader (`node.type_info`) is a zero-arg method; + # try its runtime return classes first. attr_reader-backed + # accessors have no traced `def`, so fall back to the + # like-named ivar's runtime classes (the accessor's + # backing store) -- the producer types feeding the guard. + nm = g["origin_name"].to_s + members = rt_returns[nm] + if members&.any? + via = "returns of #{nm}#{g["origin_kind"] == "call" ? "()" : ""}" + else + members = rt_ivars_by_name[nm.sub(/\A@/, "")] + via = "@#{nm.sub(/\A@/, "")} assignments" if members&.any? + end + when "ivar" + nm = g["origin_name"].to_s.sub(/\A@/, "") + members = rt_ivars[[klass, nm]] + members = rt_ivars_by_name[nm] if members.nil? || members.empty? + via = "@#{nm} assignments" if members&.any? + end + members = Array(members) + if via && members.size == 1 + dominant = members.first + share = 1.0 + end + end + rows << { + "guards" => g["count"], + "guard_sites" => g["sites"], + "method" => "#{klass}##{mname}", + "slot" => recv, + "slot_kind" => slot_idx ? "param" : (g["origin_kind"] || "local/ivar"), + "origin_kind" => slot_idx ? "param" : g["origin_kind"], + "origin_name" => slot_idx ? recv : g["origin_name"], + "dominant" => dominant, + "dominant_share" => share, + "producers" => total, + "outliers" => outliers, + "via" => via, + "members" => members, + } + end + end + rows.sort_by { |r| [-r["guards"], -r["dominant_share"], -r["producers"], -r["members"].size, r["method"]] } + end + + # The canonical contract a guarded receiver resolves to. attr / + # hashkey / ivar / call origins aggregate GLOBALLY by name (the + # whole point: `.type_info` is one contract feeding hundreds of + # guards across ~90 methods, not 90 separate 2-guard locals). param + # / unresolved-local cannot aggregate cross-method, so they stay + # keyed to their method. + def canonical_contract(row) + case row["origin_kind"] + when "attr" then [".#{row["origin_name"]}", "accessor"] + when "hashkey" then ["#{row["origin_name"]}", "hash-key"] + when "ivar" then ["#{row["origin_name"]}", "ivar"] + when "call" then ["#{row["origin_name"]}()", "call"] + when "param" then ["param `#{row["slot"]}` (#{row["method"]})", "param"] + else ["local `#{row["slot"]}` (#{row["method"]})", "local"] + end + end + + def append_union_decomplexity(lines, evidence) + rows = guard_collapse_rows(evidence) + lines << "" + lines << "### Union Decomplexity" + lines << "- Each entry is a canonical origin contract (an accessor like `.type_info`, a hash key like `[:type]`, an ivar, a call) and the TOTAL `is_a?(Type)` guards that collapse if that one contract is given a concrete type. Guards are aggregated across every method that reads the contract. Producer types come from runtime evidence for that contract; `unattributed` = no runtime trace yet for it." + if rows.empty? + lines << "- none" + return + end + agg = {} + rows.each do |r| + key, kind = canonical_contract(r) + a = (agg[key] ||= { "kind" => kind, "guards" => 0, "methods" => [], "sites" => [], + "via" => nil, "members" => [], "dominant" => nil, "share" => 0.0, "outliers" => [] }) + a["guards"] += r["guards"] + a["methods"] |= [r["method"]] + a["sites"] |= r["guard_sites"] + if a["via"].nil? && (r["via"] || r["producers"].positive?) + a["via"] = r["via"] + a["members"] = r["members"] + a["dominant"] = r["dominant"] + a["share"] = r["dominant_share"] + a["outliers"] = r["outliers"] + end + end + agg.sort_by { |_, a| -a["guards"] }.first(UNION_DECOMPLEXITY_TOP_N).each do |key, a| + head = "- #{a["guards"]} guards collapse | `#{key}` (#{a["kind"]}) across #{a["methods"].size} method(s)" + if a["dominant"] && a["share"].to_f >= 0.99 && a["outliers"].empty? + head += " -> always `#{a["dominant"]}`: collapse, all #{a["guards"]} die" + elsif a["dominant"] + pct = (a["share"] * 100).round(1) + head += " -> #{pct}% `#{a["dominant"]}`#{a["outliers"].any? ? " + #{a["outliers"].size} outlier producer(s)" : ""}" + elsif a["via"] && a["members"].any? + head += " -> via #{a["via"]} (runtime) {#{a["members"].join(", ")}}: tighten that contract" + else + head += " -> producers unattributed (no runtime trace for this contract yet)" + end + lines << head.gsub(/\s+/, " ").strip + lines << " - methods: #{a["methods"].first(6).join(", ")}#{a["methods"].size > 6 ? ", +#{a["methods"].size - 6} more" : ""}" + lines << " - guards at: #{a["sites"].first(5).join(", ")}" if a["sites"].any? + a["outliers"].first(6).each do |o| + lines << " - outlier producer `#{o["type"]}` at #{o["loc"]} `#{o["code"]}`" + end + end + end + + NODE_ALIAS_NAMES = { "AST" => "AstNode", "MIR" => "MirNode" }.freeze + NODE_ALIAS_MIN = 3 + + # Heterogeneous param slots whose ENTIRE observed concrete class set + # lives in a single namespace (AST::*, MIR::*, ...) are not really + # "untyped" -- they are one node-union. One `T.type_alias` per + # namespace types ~80% of them. Returns namespace -> sorted rows. + def node_alias_candidate_rows(evidence) + method_lookup = Array(evidence["methods"]).each_with_object({}) do |m, h| + s = m["source"] + h[[s["path"], s["line"]]] = m if s + end + origins_by_callee = Array(evidence.dig("facts", "param_origins")).group_by { |o| o["callee"].to_s } + by_ns = Hash.new { |h, k| h[k] = [] } + total_het = 0 + Array(evidence.dig("facts", "existing_sigs")).each do |method| + rec = method_lookup[[method["path"], method["line"]]] + extract_param_entries(method["sig"].to_s).each_with_index do |(name, type), idx| + next unless type == "T.untyped" + classes = Array(rec && rec.dig("params_ok", name)) + classes = Array(rec && rec.dig("params_by_name", name)) if classes.empty? + slot_origins = Array(origins_by_callee[method["method"].to_s]).select do |o| + o["slot"].to_s == idx.to_s || o["slot"].to_s == name.to_s + end + next unless classify_param_untyped_cause(method, name, classes, rec, slot_origins) == "Heterogeneous" + concrete = Array(classes).reject { |c| c == "NilClass" || c.to_s.empty? } + next if concrete.empty? + total_het += 1 + namespaces = concrete.map { |c| c.split("::").first }.uniq + next unless namespaces.size == 1 + by_ns[namespaces.first] << { + "loc" => "#{method["path"]}:#{method["line"]}", + "method" => "#{method["class"]}##{method["method"]}", + "param" => name, "classes" => concrete.size, + } + end + end + [by_ns, total_het] + end + + EVIDENCE_GAP_CAP = 50 + # Only HONEST, ACTIONABLE reasons are table columns. The two + # impossible-in-a-healthy-collect states are NOT columns -- they are + # hard, loud failures (see untyped_evidence_gaps), so a regression + # can never be a silently-dropped row or a misread "no data == dead": + # - collect_ran_untraced: ran in THIS collect but no record => a + # tracer/trace-plan regression. RAISES. + # - never_run: evidence has NO collect_coverage at all => the + # report was built without a real collect. RAISES (precondition). + EVIDENCE_GAP_REASONS = { + "unseen" => "Not reached by the collect workload (a superset of every suite) and no runtime record -- genuinely dead/unreachable, or a real missing test. Investigate or delete.", + "arg_untraced" => "Block / kwarg / splat arg -- the tracer types only positional named args (these are ~always Proc; low value)", + "only_nil" => "Only ever nil at runtime -- likely unused / optional-dead; verify it is reachable with a real value", + "discarded_return" => "Return value never consumed -- likely should be `sig { ... .void }`", + "collection_no_elements" => "Collection never observed holding an element -- only-empty, or built/consumed off any instrumented path", + "struct_unobserved" => "Struct/class field never observed assigned during collect -- the tracer signal for fields is struct_field_runtime/ivar_runtime, not line coverage, so the method-oriented coverage split does not apply. Either the class is never constructed by the workload, or the field is always left at its default.", + }.freeze + + # NOTE: the foreign SimpleCov baseline (SIMPLECOV_RESULTSET / + # simplecov_covered_files) was DELETED. It was a SEPARATE, + # file-granular, stale-prone artifact produced by a DIFFERENT + # workload than the collect; comparing the collect against it made + # "untraced_covered" structurally non-zeroable. The collect's own + # aggregated Ruby Coverage (facts.collect_coverage) -- a superset of + # every suite, freshness-gated by guard_fresh_* -- is now the SOLE + # source of truth for "was this reached by the workload". + + # A block / Proc / splat-block param: the tracer types only + # positional named args and TracePlan prunes a method whose only + # untyped slot is one of these (sample=false -> no record). Detect + # from the sig so a pruned block-arg method is arg_untraced, not + # mislabeled never_run. + def untraceable_arg_kind?(name, type) + n = name.to_s + t = type.to_s + n == "block" || n == "blk" || n.start_with?("&", "*") || + n.end_with?("_block", "_blk") || + t.include?("T.proc") || t == "Proc" || t.start_with?("T.nilable(Proc") + end + + # ROOT-relative path -> Set of line numbers executed during THIS + # collect run (Ruby stdlib Coverage dumped by the tracer, unioned + # across every traced process). nil when the collect produced no + # Coverage at all -> never_run_reason falls back to "never_run". + def collect_coverage_index(evidence) + return @collect_coverage_index if defined?(@collect_coverage_index) + cc = evidence.dig("facts", "collect_coverage") + @collect_coverage_index = + if cc.is_a?(Hash) && !cc.empty? + cc.each_with_object({}) { |(p, lines), h| h[p.to_s] = Array(lines).map(&:to_i).to_set } + end + end + + # "Did the method BODY execute" -- NOT "is any line in lo..hi + # covered". Ruby's Coverage marks the `def` line as executed the + # moment the method is *defined* (class-body evaluation at file + # load), independent of whether it is ever called. Counting the + # `def` line therefore makes every defined method in any loaded + # file look "ran", mass-mislabeling defined-but-never-called methods + # as collect_ran_untraced ("tracer defect"). Require a covered line + # strictly inside the def (lo, hi) -- the open interval excludes the + # signature line and the trailing `end`. For 1-2 line bodies the + # interior is empty; those cannot be proven run from line coverage + # alone, so they fall through to "unseen" unless a source-wrapped + # runtime record exists (in-place wrapping guarantees the record + # whenever the body actually executes). + def collect_ran?(idx, rel_path, lo, hi) + return false unless idx + ls = idx[rel_path.to_s] + return false unless ls + lo = lo.to_i + hi = (hi || lo).to_i + ls.any? { |n| n > lo && n < hi } + end + + # A method with no runtime record. ONE source of truth -- the + # collect's own aggregated Ruby Coverage (facts.collect_coverage), + # which is a superset of every suite. There is no foreign baseline + # to diverge from, so the old "untraced_covered" state cannot occur: + # 1. body interior ran in THIS collect but no record => + # "collect_ran_untraced" -- a tracer/trace-plan regression. + # NOT a category: enforce_no_hard_gaps! RAISES on it. + # 2. collect produced Coverage but this body did NOT run anywhere + # in the (superset) workload => genuinely dead/missing test + # ("unseen" -- the one honest, actionable bucket). + # 3. no collect Coverage at all => "never_run" -- means the report + # was built without a real collect. NOT a category: + # enforce_no_hard_gaps! RAISES on it (you must collect first). + # lo/hi = the method's def line range (always supplied by callers; + # struct fields use the dedicated "struct_unobserved" reason, not + # this method-oriented split). + def never_run_reason(evidence, rel_path, lo = nil, hi = nil) + cc = collect_coverage_index(evidence) + return "collect_ran_untraced" if lo && collect_ran?(cc, rel_path, lo, hi) + return "never_run" if cc.nil? + "unseen" + end + + # Break the residual NoEvidence out by WHY, with locations, so each + # is triageable: dead code, missing test, should-be-void, or an + # inherently untraceable arg. Mirrors the exact NoEvidence gate of + # the cause classifiers so the counts reconcile. + def untyped_evidence_gaps(evidence) + ml = Array(evidence["methods"]).each_with_object({}) do |m, h| + s = m["source"] + h[[s["path"], s["line"]]] = m if s + end + po = Array(evidence.dig("facts", "param_origins")).group_by { |o| o["callee"].to_s } + build_program_return_index!(evidence) + unused = unused_return_method_names(evidence) + gaps = Hash.new { |h, k| h[k] = [] } + Array(evidence.dig("facts", "existing_sigs")).each do |m| + rec = ml[[m["path"], m["line"]]] + loc = "#{m["path"]}:#{m["line"]}" + who = "#{m["class"]}##{m["method"]}" + extract_param_entries(m["sig"].to_s).each_with_index do |(n, t), i| + next unless t == "T.untyped" + cs = Array(rec && rec.dig("params_ok", n)) + cs = Array(rec && rec.dig("params_by_name", n)) if cs.empty? + so = Array(po[m["method"].to_s]).select { |o| o["slot"].to_s == i.to_s || o["slot"].to_s == n.to_s } + next unless classify_param_untyped_cause(m, n, cs, rec, so) == "NoEvidence" + # A block/Proc/kwarg/splat param is untraceable by design -- + # the tracer types only positional named args, and TracePlan + # prunes a method whose only untyped slot is one of these + # (sample=false -> no record). That is arg_untraced, NOT + # "never_run": classify it from the SIG before the + # rec.nil?/pruned check, or pruned block-arg methods get + # mislabeled dead/untraced. + reason = if untraceable_arg_kind?(n, t) || Array(m["untraceable_params"]).include?(n) then "arg_untraced" + elsif rec.nil? || rec["calls"].to_i <= 0 then never_run_reason(evidence, m["path"], m["line"], m["end_line"]) + elsif Array(cs).any? { |c| c != "NilClass" } then "arg_untraced" + elsif Array(cs).include?("NilClass") then "only_nil" + else "arg_untraced" + end + gaps[reason] << { "cat" => "Params", "text" => "#{loc} `#{who}` param `#{n}`" } + end + next unless extract_return_type(m["sig"].to_s) == "T.untyped" + next unless classify_return_untyped_cause(m, rec, unused) == "NoEvidence" + reason = rec.nil? || rec["calls"].to_i <= 0 ? never_run_reason(evidence, m["path"], m["line"], m["end_line"]) : "discarded_return" + gaps[reason] << { "cat" => "Returns", "text" => "#{loc} `#{who}` return" } + end + rt = Hash.new { |h, k| h[k] = [] } + Array(evidence.dig("facts", "struct_field_runtime")).each { |r| rt[[r["class"].to_s, r["field"].to_s]].concat(Array(r["classes"])) } + Array(evidence.dig("facts", "ivar_runtime")).each { |r| rt[[r["class"].to_s, r["name"].to_s.sub(/\A@/, "")]].concat(Array(r["classes"])) } + resolvable = Array(evidence["actions"]).each_with_object(Set.new) { |a, s| s << [a.dig("data", "class").to_s, a.dig("data", "field").to_s] if a["kind"] == "add_struct_field_sig" } + rbi = struct_rbi_types + # A field with a strong static type was deliberately NOT sampled + # at runtime (trace_plan.rb sets struct_fields[key]=false when + # !strong_trace_type?). It is already typed -- not a NoEvidence + # gap. The report previously consulted only struct_rbi_types + # (sorbet RBI file); when that key missed, a fully-typed field was + # mislabeled "never constructed/assigned". Consult the SAME signal + # the trace plan used: facts.struct_field_static. + strong_static = Set.new + Array(evidence.dig("facts", "struct_field_static")).each do |f| + next unless NilKill.strong_trace_type?(f["type"].to_s) + strong_static << [f["class"].to_s, f["field"].to_s] + end + Array(evidence.dig("facts", "struct_declarations")).each do |decl| + Array(decl["fields"]).each do |field| + next if strong_static.include?([decl["class"].to_s, field.to_s]) + type = rbi[[decl["class"], field]] + next if type && !untyped_type?(strip_nilable(type.to_s)) + observed = rt[[decl["class"].to_s, field.to_s]].uniq + non_nil = observed.reject { |c| c == "NilClass" || c.to_s.empty? } + useful = non_nil.select { |c| NilKill.useful_type?(c) && !weak_collection_type?(c) } + next unless useful.empty? && !(observed.any? && non_nil.empty?) && !resolvable.include?([decl["class"].to_s, field.to_s]) + gaps["struct_unobserved"] << { "cat" => "Struct/ivar", "text" => "#{decl["path"]}:#{decl["line"]} `#{decl["class"]}.#{field}` (field never constructed/assigned)" } + end + end + collection_evidence_slots(evidence).each do |slot| + next unless slot["elems"].empty? && slot["shapes"].empty? + gaps["collection_no_elements"] << { "cat" => "Collections", "text" => "#{slot["loc"]} #{slot["what"]}" } + end + enforce_no_hard_gaps!(gaps) + gaps + end + + # Neither collect_ran_untraced nor never_run is a report column: + # + # - collect_ran_untraced: ran during THIS collect but produced NO + # record -- a tracer/trace-plan regression. It MUST be impossible + # (the in-place wrapper records every executed body), so the + # report does not paper over it with a permanently-zero column: + # it RAISES, loudly. A regression can never be a silently-dropped + # row. + # + # - never_run: only arises when evidence has NO collect_coverage at + # all -- i.e. not a real collect (production is already guarded: + # cli.rb aborts a traced collect with zero Coverage). It carries + # no signal (there is nothing to tell dead from un-exercised), so + # it is simply dropped from the table -- NOT raised (raising + # would break every unit test that classifies synthetic evidence + # without a collect) and NOT folded into "unseen" (that would + # misread "no data" as "dead"). + # + # Both are deleted from `gaps` so the renderer (and its Total) only + # ever sees honest, actionable reasons. + EVIDENCE_GAP_HARD = { + "collect_ran_untraced" => "ran during THIS collect but produced NO nil-kill record -- a tracer/trace-plan regression (the in-place wrapper must record every executed body)", + "never_run" => "no collect_coverage in evidence -- not a real collect (dropped, not actionable)", + }.freeze + + def enforce_no_hard_gaps!(gaps) + gaps.delete("never_run") # degenerate (no real collect) -> drop, no signal + rows = gaps.delete("collect_ran_untraced") + return if rows.nil? || rows.empty? + sample = rows.first(20).map { |g| g["text"] } + more = rows.size > 20 ? " (+#{rows.size - 20} more)" : "" + raise "nil-kill: #{rows.size} collect_ran_untraced -- " \ + "#{EVIDENCE_GAP_HARD["collect_ran_untraced"]}. This MUST be zero " \ + "(see spec/zero_evidence_gap_guarantee_spec.rb). Offenders: #{sample.join("; ")}#{more}" + end + + EVIDENCE_GAP_CATEGORIES = ["Params", "Returns", "Struct/ivar", "Collections"].freeze + + def append_untyped_evidence_gaps(lines, evidence) + gaps = untyped_evidence_gaps(evidence) + lines << "" + lines << "### Untyped Evidence Gaps" + lines << "- The residual NoEvidence, by category x WHY, then listed with locations. Each is a triage candidate (dead code / missing test / should-be-void / untraceable arg), not a classifier defect." + total = gaps.values.sum(&:size) + if total.zero? + lines << "- none" + return + end + reasons = EVIDENCE_GAP_REASONS.keys + hdr = ["", *reasons.map { |r| r.tr("_", " ") }, "Total"] + lines << "" + lines << "| #{hdr.join(" | ")} |" + lines << "|#{(["---"] * hdr.size).join("|")}|" + EVIDENCE_GAP_CATEGORIES.each do |cat| + cells = reasons.map { |r| gaps[r].count { |g| g["cat"] == cat } } + next if cells.sum.zero? + lines << "| #{cat} | #{cells.join(" | ")} | #{cells.sum} |" + end + tot = reasons.map { |r| gaps[r].size } + lines << "| **Total** | #{tot.join(" | ")} | #{tot.sum} |" + reasons.each { |r| lines << "- `#{r.tr("_", " ")}`: #{EVIDENCE_GAP_REASONS[r]}" } + EVIDENCE_GAP_REASONS.each do |reason, _why| + rows = gaps[reason] + next if rows.empty? + lines << "- #{rows.size} #{reason.tr("_", " ")}" + if reason == "struct_unobserved" + # Grouped by struct so the pattern is visible: which classes, + # how many of their fields, and where each is declared. + by_class = Hash.new { |h, k| h[k] = { "loc" => nil, "fields" => [] } } + rows.each do |g| + m = g["text"].match(/\A(\S+) `([^`]+)\.([^`]+)`/) + next unless m + grp = by_class[m[2]] + grp["loc"] ||= m[1] + grp["fields"] << m[3] + end + by_class.sort_by { |cls, info| [-info["fields"].size, cls] }.each do |cls, info| + lines << " - `#{cls}` (#{info["loc"]}): #{info["fields"].size} field(s) -- #{info["fields"].sort.join(", ")}" + end + else + rows.map { |g| g["text"] }.sort.first(EVIDENCE_GAP_CAP).each { |t| lines << " - #{t}" } + lines << " - ... +#{rows.size - EVIDENCE_GAP_CAP} more" if rows.size > EVIDENCE_GAP_CAP + end + end + end + + def append_node_alias_candidates(lines, evidence) + by_ns, total_het = node_alias_candidate_rows(evidence) + ranked = by_ns.sort_by { |_, rows| -rows.size }.select { |_, rows| rows.size >= NODE_ALIAS_MIN } + lines << "" + lines << "### Node-Union Alias Candidates" + lines << "- Heterogeneous param slots whose every observed class is in ONE namespace. Each namespace below collapses to a single `T.type_alias` (e.g. `AstNode = T.type_alias { T.any(AST::...) }`); applying it types every listed param at once. `classes` = distinct node types observed at that slot (small = a precise sub-union; large = the full node grab-bag)." + if ranked.empty? + lines << "- none" + return + end + resolvable = ranked.sum { |_, rows| rows.size } + lines << "- #{resolvable} of #{total_het} Heterogeneous params (#{total_het.zero? ? 0 : (100.0 * resolvable / total_het).round}%) collapse to #{ranked.size} alias(es)." + ranked.each do |ns, rows| + alias_name = NODE_ALIAS_NAMES[ns] || "#{ns}Node" + lines << "- `#{alias_name}` (#{ns}::*): #{rows.size} param slot(s)" + rows.sort_by { |r| [-r["classes"], r["loc"]] }.each do |r| + lines << " - #{r["loc"]} `#{r["method"]}` param `#{r["param"]}` (#{r["classes"]} node types)" + end + end + end + + def append_signature_coverage(lines, evidence, accumulator: nil) + param_counts = empty_type_counts + return_counts = empty_type_counts + evidence["facts"]["existing_sigs"].each do |method| + sig = method["sig"].to_s + extract_param_types(sig).each { |type| classify_type!(param_counts, type) } + return_type = extract_return_type(sig) + classify_type!(return_counts, return_type) if return_type + end + lines << "" + lines << "### Signature Slots" + lines << "- Param slots: #{format_type_counts(param_counts)}" + lines << " - of which weak primitive collection (T::Array[T.untyped] etc.): #{param_counts["weak_collection"]}" if param_counts["weak_collection"].to_i.positive? + lines << "- Return slots: #{format_type_counts(return_counts)}" + lines << " - of which weak primitive collection (T::Array[T.untyped] etc.): #{return_counts["weak_collection"]}" if return_counts["weak_collection"].to_i.positive? + lines << "- Nilable param slots: #{param_counts["nilable"]}" + lines << "- Nilable return slots: #{return_counts["nilable"]}" + accumulator&.add("param", param_counts) + accumulator&.add("return", return_counts) + end + + def append_variable_assignment_coverage(lines, evidence, accumulator: nil) + sites = Array(evidence.dig("facts", "tlet_sites")) + typed = sites.select { |site| site["tlet"] && site["type"] } + candidates = sites.reject { |site| site["tlet"] } + typed_counts = empty_type_counts + typed.each { |site| classify_type!(typed_counts, site["type"]) } + + lines << "" + lines << "### Class And Instance Variable Slots" + lines << "- Existing T.let assignment slots: #{format_type_counts(typed_counts)}" + lines << " - of which weak primitive collection (T::Array[T.untyped] etc.): #{typed_counts["weak_collection"]}" if typed_counts["weak_collection"].to_i.positive? + lines << "- Candidate T.let assignment slots: #{candidates.size}" + accumulator&.add("tlet", typed_counts) + candidates.first(8).each do |site| + lines << " - #{site["path"]}:#{site["line"]} #{site["name"]} -> #{site["candidate_type"]}" + end + lines << " - ... #{candidates.size - 8} more" if candidates.size > 8 + end + + SOUNDNESS_CATEGORIES = ["Param inputs", "Returns", "Struct/class fields & ivars", "Arrays/Sets/Hashmaps"].freeze + + # A parameterised stdlib container (strong OR weak element). Such a + # slot is routed to the Arrays/Sets/Hashmaps row so the four + # categories stay mutually exclusive and the two tables reconcile: + # the Collections "Weak" column == the Untyped-Causes Collections + # denominator. + def collection_typed?(type) + strip_nilable(type.to_s).match?(/\AT::(?:Array|Hash|Set|Enumerable)\b/) + end + + # Table 1: Type Soundness. One row per slot category, columns + # Total / Strong / Weak / Untyped / Nilable. Nilable is a cross-cut + # sub-count (a T.nilable(String) slot is Strong AND Nilable), so + # Total = Strong + Weak + Untyped; Nilable <= Total. + def type_soundness_table(evidence) + rows = SOUNDNESS_CATEGORIES.each_with_object({}) do |c, h| + h[c] = { "total" => 0, "strong" => 0, "weak" => 0, "untyped" => 0, "nilable" => 0 } + end + tally = lambda do |structural_category, type| + type = type.to_s.strip + return if type.empty? + cat = collection_typed?(type) ? "Arrays/Sets/Hashmaps" : structural_category + r = rows[cat] + r["total"] += 1 + r["nilable"] += 1 if nilable_type?(type) + inner = strip_nilable(type) + if untyped_type?(inner) + r["untyped"] += 1 + elsif weak_type?(inner) + r["weak"] += 1 + else + r["strong"] += 1 + end + end + + Array(evidence.dig("facts", "existing_sigs")).each do |m| + extract_param_entries(m["sig"].to_s).each { |_n, t| tally.("Param inputs", t) } + rt = extract_return_type(m["sig"].to_s) + tally.("Returns", rt) if rt + end + rbi_types = struct_rbi_types + Array(evidence.dig("facts", "struct_declarations")).each do |decl| + Array(decl["fields"]).each do |field| + tally.("Struct/class fields & ivars", rbi_types[[decl["class"], field]] || "T.untyped") + end + end + Array(evidence.dig("facts", "tlet_sites")).each do |s| + next unless s["tlet"] && s["type"] + tally.("Struct/class fields & ivars", s["type"]) + end + rows + end + + def append_type_soundness_table(lines, evidence) + rows = type_soundness_table(evidence) + lines << "" + lines << "### Type Soundness" + lines << "" + lines << "| Slot category | Total | Strong | Weak | Untyped | Nilable |" + lines << "|---|---|---|---|---|---|" + SOUNDNESS_CATEGORIES.each do |cat| + r = rows[cat] + t = r["total"] + pc = ->(n) { t.positive? ? " (#{(100.0 * n / t).round(1)}%)" : "" } + lines << "| #{cat} | #{t} | #{r["strong"]}#{pc.(r["strong"])} | #{r["weak"]}#{pc.(r["weak"])} | #{r["untyped"]}#{pc.(r["untyped"])} | #{r["nilable"]}#{pc.(r["nilable"])} |" + end + lines << "" + lines << "Total = Strong + Weak + Untyped. Nilable is a cross-cut sub-count (a `T.nilable(String)` slot is Strong and Nilable, not a fourth bucket). Collection-typed slots (`T::Array[...]` etc.) are counted only in the Arrays/Sets/Hashmaps row, so the four categories are mutually exclusive. The Param/Returns/Struct Untyped columns equal the per-row denominators in the Untyped Cause Breakdown below." + end + + # Ordered cause taxonomy. First match wins (most-actionable first). + # Keep keys short -- they are the markdown column headers. + UNTYPED_CAUSES = %w[Refused/Pending PropagationGap WeakEvidence Heterogeneous NoEvidence].freeze + UNTYPED_CAUSE_LEGEND = { + "Refused/Pending" => "type IS determinable from local evidence (single observed runtime type, void/unused, boolean pair) -- untyped only because the fix is unapplied or conservatively refused", + "PropagationGap" => "type is determinable elsewhere but needs cross-method/whole-program flow (forwarded return, ivar-from-param capture, callee untyped-but-resolvable, coherent collection needing the typed-collection rewrite)", + "WeakEvidence" => "a type is known but only weakly (T::Array[T.untyped], a union wider than policy) -- the weak-collection / union-policy axis", + "Heterogeneous" => "slot legitimately holds many unrelated types/shapes (AST/MIR node grab-bags, dynamic dispatch) -- T.untyped is the correct type", + "NoEvidence" => "never observed at runtime AND no static expression/callsite to infer from -- needs a test or a hand-written sig", + }.freeze + + # Builds the 7-column untyped-cause breakdown: + # col 1 = slot category (with its untyped total) + # cols 2-7 = the six causes, each "N (P%)" of that row's untyped slots + def untyped_cause_table(evidence) + method_lookup = evidence["methods"].each_with_object({}) do |method, lookup| + src = method["source"] + lookup[[src["path"], src["line"]]] = method if src + end + param_origins = Array(evidence.dig("facts", "param_origins")) + origins_by_callee = param_origins.group_by { |o| o["callee"].to_s } + unused = unused_return_method_names(evidence) + sigs = Array(evidence.dig("facts", "existing_sigs")) + build_program_return_index!(evidence) + + rows = { + "Param inputs" => Hash.new(0), + "Returns" => Hash.new(0), + "Struct/class fields & ivars" => Hash.new(0), + "Arrays/Sets/Hashmaps" => Hash.new(0), + } + + sigs.each do |method| + rec = method_lookup[[method["path"], method["line"]]] + extract_param_entries(method["sig"].to_s).each_with_index do |(name, type), idx| + next unless type == "T.untyped" + classes = Array(rec&.dig("params_ok", name)) + classes = Array(rec&.dig("params_by_name", name)) if classes.empty? + slot_origins = Array(origins_by_callee[method["method"].to_s]).select do |o| + o["slot"].to_s == idx.to_s || o["slot"].to_s == name.to_s + end + rows["Param inputs"][classify_param_untyped_cause(method, name, classes, rec, slot_origins)] += 1 + end + next unless extract_return_type(method["sig"].to_s) == "T.untyped" + rows["Returns"][classify_return_untyped_cause(method, rec, unused)] += 1 + end + + classify_struct_ivar_untyped!(rows["Struct/class fields & ivars"], evidence) + classify_collection_untyped!(rows["Arrays/Sets/Hashmaps"], evidence) + rows + end + + # Program-wide resolvable-return map (method name -> uniq concrete + # return types) + the noreturn-method set. A "far end" of a + # call_untyped edge is resolvable iff its name maps to exactly one + # concrete type here, or it is a known noreturn helper. Mirrors the + # data the whole-program return-propagation pass actually uses, so + # the cause table reports the TRUE fixable size, not "has a + # cross-method-shaped blocker". + def build_program_return_index!(evidence) + ri = Hash.new { |h, k| h[k] = [] } + Array(evidence.dig("facts", "return_origins")).each do |o| + next unless o["confidence"] == "strong" + t = o["candidate_type"].to_s + next if t.empty? || untyped_type?(t) || weak_collection_type?(t) + ri[o["method"].to_s] << t + end + Array(evidence.dig("facts", "existing_sigs")).each do |m| + rt = extract_return_type(m["sig"].to_s).to_s.strip + next if rt.empty? || rt == "T.untyped" + ri[m["method"].to_s] << rt + end + @program_return_index = Hash.new([]).tap { |h| ri.each { |k, v| h[k] = v.uniq } } + @program_noreturn_names = Array(evidence.dig("facts", "return_origins")) + .select { |o| o["candidate_type"].to_s == "T.noreturn" } + .map { |o| o["method"].to_s }.to_set + end + + def classify_param_untyped_cause(method, name, classes, rec, slot_origins) + classes = Array(classes).compact.uniq + non_nil = classes.reject { |c| c == "NilClass" } + hit = rec && rec["calls"].to_i.positive? + return "Refused/Pending" if hit && non_nil.size == 1 + return "Refused/Pending" if hit && non_nil.sort == %w[FalseClass TrueClass] + srccat = untyped_param_source_category(slot_origins) + concrete_types = slot_origins.select do |o| + ty = o["type"].to_s + NilKill.useful_type?(ty) && !weak_collection_type?(ty) && o["origin_kind"].to_s != "unknown" + end.map { |o| o["type"].to_s }.uniq + # Honest classifier (mirrors classify_return_untyped_cause): a + # concrete caller only makes the slot resolvable if the callers + # agree on ONE concrete type. Divergent concrete callers are + # genuinely polymorphic -- narrowing the sig would break the + # other callers (whole-program-consistency wall), so it is + # Heterogeneous, not a propagation we can perform. + unless concrete_types.empty? + return "Heterogeneous" if concrete_types.size > NilKill::MAX_UNION_TYPES + return "Heterogeneous" if concrete_types.size > 1 + return "PropagationGap" + end + # A forwarded return whose far end is a TYPED return is resolvable + # program-wide -> PropagationGap (actionable), independent of + # runtime observation. + if srccat == "untyped forwarded return" && slot_origins.any? { |o| o["origin_kind"].to_s == "typed_return" } + return "PropagationGap" + end + # NoEvidence means (per the legend) NEVER observed at runtime AND + # no static expression. A param the runtime actually observed with + # concrete classes can never be NoEvidence -- runtime polymorphism + # is stronger evidence than the static source shape. So the + # forwarded-return / ivar source categories only collapse to + # NoEvidence when there is genuinely no runtime evidence; with + # runtime classes they fall through to the Heterogeneous / + # WeakEvidence verdict below. + if !(hit && non_nil.any?) + return "NoEvidence" if srccat == "untyped forwarded return" + return "NoEvidence" if srccat == "untyped instance variable" + end + return "WeakEvidence" if srccat == "untyped struct/array/collection value" + return "Heterogeneous" if hit && non_nil.size > NilKill::MAX_UNION_TYPES + return "WeakEvidence" if hit && non_nil.size > 1 + "NoEvidence" + end + + + def classify_return_untyped_cause(method, rec, unused) + return "Refused/Pending" if unused.include?(method["method"].to_sym) + origin = method["return_origin"] || {} + sources = Array(origin["sources"]) + blockers = Array(origin["blockers"]).join(" ; ") + returns = Array(rec&.dig("returns")).compact.uniq + non_nil = returns.reject { |c| c == "NilClass" } + hit = rec && rec["calls"].to_i.positive? + return "Refused/Pending" if hit && non_nil.size == 1 + return "Refused/Pending" if hit && non_nil.sort == %w[FalseClass TrueClass] + # Executed but never produced a usable (non-nil) return: only nil, + # OR no return value ever recorded at runtime (side-effect / bang + # methods). Determinable as .void / T.nilable -- the runtime_void + # proposer emits that fix; per the legend this is Refused/Pending + # ("type IS determinable, fix unapplied"), NOT "no evidence". + # EXCEPTION: only when the return was NEVER observed at runtime + # (returns empty -- not sampled at all, distinct from "observed, + # only nil"). Then the runtime told us nothing; if it forwards to + # another method (call_untyped) or an ivar, the static propagation + # chain still can -- defer to the call_untyped / ivar_read + # resolution below (PropagationGap if the far end resolves, + # NoEvidence if it is the transitive wall). An executed return + # OBSERVED only as nil stays Refused/Pending (runtime says void), + # even with a forwarded source. + never_observed = returns.empty? + propagatable = sources.any? { |s| %w[call_untyped ivar_read].include?(s["kind"].to_s) } + return "Refused/Pending" if hit && non_nil.empty? && !(never_observed && propagatable) + # Honest call_untyped handling: only PropagationGap if the callee's + # return is actually resolvable program-wide. If the far end is + # itself untyped everywhere it's the transitive wall (NoEvidence); + # if the name resolves to >1 distinct type it's genuinely + # polymorphic (Heterogeneous). ivar_read = class-wide ivar typing, + # a propagation we could build. + callees = sources.select { |s| s["kind"].to_s == "call_untyped" } + .map { |s| s["callee"].to_s }.reject(&:empty?) + ri = @program_return_index + nrm = @program_noreturn_names + if sources.any? { |s| s["kind"].to_s == "ivar_read" } && callees.empty? + return "PropagationGap" + end + unless callees.empty? + per = callees.map { |c| nrm.include?(c) ? :resolvable : (sz = ri[c].size; sz == 1 ? :resolvable : sz > 1 ? :ambiguous : :untyped) } + return "PropagationGap" if per.all? { |v| v == :resolvable } + return "Heterogeneous" if per.any? { |v| v == :ambiguous } && per.none? { |v| v == :untyped } + # >=1 callee untyped anywhere = the static transitive wall. But + # the runtime may have OBSERVED concrete returns -- do not + # discard that evidence (same fix already applied to params). + # With runtime non-nil classes, fall through to the runtime + # Heterogeneous / WeakEvidence verdict below. + return "NoEvidence" unless hit && non_nil.any? + end + cand = origin["candidate_type"].to_s + return "WeakEvidence" if weak_collection_return_source?(cand, sources) || weak_collection_type?(cand) + return "Heterogeneous" if hit && non_nil.size > NilKill::MAX_UNION_TYPES + return "WeakEvidence" if hit && non_nil.size > 1 + return "NoEvidence" if !hit && sources.empty? + "NoEvidence" + end + + # Plain `T.untyped` struct fields + ivar T.let slots. Weak-collection + # (`T::Array[T.untyped]`) slots are NOT counted here -- they belong + # to the Arrays/Sets/Hashmaps row so the four categories stay + # mutually exclusive and additive, matching the Hygiene Overview + # split (untyped vs weak-collection are separate there too). + def classify_struct_ivar_untyped!(bucket, evidence) + # Explicit `T.let(x, T.untyped)` -- a deliberate untyped + # declaration that is almost always narrowable. + Array(evidence.dig("facts", "tlet_sites")).each do |site| + next unless site["tlet"] && site["type"].to_s == "T.untyped" + bucket["Refused/Pending"] += 1 + end + rbi_types = struct_rbi_types + # Honest PropagationGap signal (same fix as returns/params): a + # struct field is genuinely propagation-resolvable ONLY if there + # is a concrete `add_struct_field_sig` action for it -- i.e. the + # refill actually resolved a type for its RHS. The old heuristic + # ("RHS expression looks like a local/ivar / is a captured + # param") over-counted massively: ~282 of those have an RHS + # local/ivar that is itself untyped (the transitive wall) and + # were never typeable. Those are NoEvidence, not PropagationGap. + resolvable = Array(evidence["actions"]).each_with_object(Set.new) do |a, set| + set << [a.dig("data", "class").to_s, a.dig("data", "field").to_s] if a["kind"] == "add_struct_field_sig" + end + # Runtime classes observed for each struct field / ivar. Until the + # instrumented-path fix this was always empty, so every field + # collapsed to NoEvidence. Honest classifier (same as + # returns/params): an observed field is determinable / weak / + # heterogeneous, never "no evidence". + rt = Hash.new { |h, k| h[k] = [] } + Array(evidence.dig("facts", "struct_field_runtime")).each do |r| + rt[[r["class"].to_s, r["field"].to_s]].concat(Array(r["classes"])) + end + Array(evidence.dig("facts", "ivar_runtime")).each do |r| + rt[[r["class"].to_s, r["name"].to_s.sub(/\A@/, "")]].concat(Array(r["classes"])) + end + Array(evidence.dig("facts", "struct_declarations")).each do |decl| + Array(decl["fields"]).each do |field| + type = rbi_types[[decl["class"], field]] + inner = strip_nilable(type.to_s) + # "missing" (no RBI type) and plain untyped both count here; + # weak-collection goes to the Arrays/Sets/Hashmaps row. + next if type && !untyped_type?(inner) + observed = rt[[decl["class"].to_s, field.to_s]].uniq + non_nil = observed.reject { |c| c == "NilClass" || c.to_s.empty? } + useful = non_nil.select { |c| NilKill.useful_type?(c) && !weak_collection_type?(c) } + if useful.size == 1 + bucket["Refused/Pending"] += 1 # single observed type -> determinable + elsif observed.any? && non_nil.empty? + bucket["Refused/Pending"] += 1 # only ever nil -> void / T.nilable + elsif resolvable.include?([decl["class"].to_s, field.to_s]) + bucket["PropagationGap"] += 1 # a concrete sig is proposable; loop --struct-rbi lands it + elsif useful.size > NilKill::MAX_UNION_TYPES + bucket["Heterogeneous"] += 1 # grab-bag node field -> T.untyped is correct + elsif useful.size > 1 + bucket["WeakEvidence"] += 1 # known but a small union + else + bucket["NoEvidence"] += 1 # genuinely no runtime + no proposable static type + end + end + end + end + + def classify_collection_untyped!(bucket, evidence) + collection_evidence_slots(evidence).each do |slot| + elems = slot["elems"] + shapes = slot["shapes"] + if elems.size == 1 && NilKill.useful_type?(elems.first) + bucket["Refused/Pending"] += 1 + elsif shapes.size == 1 + bucket["PropagationGap"] += 1 + elsif elems.size > NilKill::MAX_UNION_TYPES || shapes.size > 1 + bucket["Heterogeneous"] += 1 + elsif elems.size > 1 + bucket["WeakEvidence"] += 1 + else + bucket["NoEvidence"] += 1 + end + end + end + + # Single source of truth for weak-collection slots + their merged + # runtime element evidence (mutation hooks + call/return boundary + + # struct construction). Returns [{loc, what, elems, shapes}] so the + # classifier and the evidence-gap breakdown agree exactly. + def collection_evidence_slots(evidence) + runtime = Array(evidence.dig("facts", "collection_runtime")) + rel = ->(p) { p.to_s.sub(/\A#{Regexp.escape(ROOT)}\/?/, "") } + # collection_runtime records the OBSERVATION/mutation site line, + # not the sig/decl line, so a [path,line,name] join misses almost + # everything (the false-NoEvidence bug). Join on owner IDENTITY + # instead: param name within the method's line range, method name + # for returns, and the class-qualified "Class.field" for struct + # fields (already unique). + params_idx = Hash.new { |h, k| h[k] = [] } + returns_idx = Hash.new { |h, k| h[k] = [] } + struct_idx = Hash.new { |h, k| h[k] = [] } + runtime.each do |r| + case r["owner_kind"] + when "method_param" then params_idx[[rel.(r["path"]), r["name"].to_s]] << r + when "method_return" then returns_idx[[rel.(r["path"]), r["name"].to_s]] << r + when "struct_field" then struct_idx[r["name"].to_s] << r + end + end + seen = ->(t) { weak_collection_type?(strip_nilable(t.to_s)) } + # Method-boundary element capture: the tracer ALSO records element + # classes/shapes for every collection param and return at the + # call/return boundary (param_elem/return_elem/*_kv/*_shapes), + # independent of the mutation hooks. That covers read-only params + # and build-and-return values collection_runtime never sees. The + # classifier must consult it too, or those stay false-NoEvidence. + method_lookup = Array(evidence["methods"]).each_with_object({}) do |m, h| + s = m["source"] + h[[s["path"], s["line"]]] = m if s + end + sfr = Hash.new { |h, k| h[k] = [] } + Array(evidence.dig("facts", "struct_field_runtime")).each do |r| + sfr[[r["class"].to_s, r["field"].to_s]] << r + end + # Pull elem/key/value classes + shapes out of a runtime record + # bundle (collection_runtime hits and/or boundary kv pairs). + rec_elems = lambda do |recs| + recs.flat_map { |r| Array(r["elem_classes"]) + Array(r["key_classes"]) + Array(r["value_classes"]) } + end + + mk = lambda do |loc, what, raw_elems, raw_shapes| + { "loc" => loc, "what" => what, + "elems" => Array(raw_elems).uniq.reject { |c| c == "NilClass" || c.to_s.empty? }, + "shapes" => Array(raw_shapes).uniq } + end + slots = [] + Array(evidence.dig("facts", "existing_sigs")).each do |m| + rp = rel.(m["path"]) + lo = m["line"].to_i + hi = (m["end_line"] || m["line"]).to_i + mrec = method_lookup[[m["path"], m["line"]]] + loc = "#{m["path"]}:#{m["line"]}" + who = "#{m["class"]}##{m["method"]}" + extract_param_entries(m["sig"].to_s).each do |n, t| + next unless seen.(t) + hits = params_idx[[rp, n.to_s]].select { |r| (lo..hi).cover?(r["line"].to_i) } + slots << mk.(loc, "#{who} param `#{n}`", + rec_elems.(hits) + Array(mrec && mrec.dig("param_elem", n)) + Array(mrec && mrec.dig("param_kv", n)).flatten, + hits.flat_map { |r| Array(r["elem_shapes"]) } + Array(mrec && mrec.dig("param_elem_shapes", n)) + Array(mrec && mrec.dig("param_kv_shapes", n)).flatten) + end + next unless seen.(extract_return_type(m["sig"].to_s).to_s) + hits = returns_idx[[rp, m["method"].to_s]] + slots << mk.(loc, "#{who} return", + rec_elems.(hits) + Array(mrec && mrec["return_elem"]) + Array(mrec && mrec["return_kv"]).flatten, + hits.flat_map { |r| Array(r["elem_shapes"]) } + Array(mrec && mrec["return_elem_shapes"]) + Array(mrec && mrec["return_kv_shapes"]).flatten) + end + rbi_types = struct_rbi_types + Array(evidence.dig("facts", "struct_declarations")).each do |decl| + Array(decl["fields"]).each do |field| + next unless seen.(rbi_types[[decl["class"], field]].to_s) + recs = struct_idx["#{decl["class"]}.#{field}"] + sfr[[decl["class"].to_s, field.to_s]] + slots << mk.("#{decl["path"]}:#{decl["line"]}", "#{decl["class"]}.#{field}", + rec_elems.(recs), recs.flat_map { |r| Array(r["elem_shapes"]) }) + end + end + Array(evidence.dig("facts", "tlet_sites")).each do |s| + next unless s["tlet"] && seen.(s["type"].to_s) + recs = runtime.select { |r| r["line"].to_i == s["line"].to_i && rel.(r["path"]) == rel.(s["path"]) } + slots << mk.("#{s["path"]}:#{s["line"]}", "T.let `#{s["name"]}`", + rec_elems.(recs), recs.flat_map { |r| Array(r["elem_shapes"]) }) + end + slots + end + + def append_untyped_cause_table(lines, evidence) + rows = untyped_cause_table(evidence) + lines << "" + lines << "### Untyped Cause Breakdown" + lines << "" + lines << "| Slot category | #{UNTYPED_CAUSES.join(" | ")} |" + lines << "|#{(["---"] * (UNTYPED_CAUSES.size + 1)).join("|")}|" + rows.each do |category, counts| + total = UNTYPED_CAUSES.sum { |c| counts[c] } + cells = UNTYPED_CAUSES.map do |cause| + n = counts[cause] + pct = total.positive? ? (100.0 * n / total).round(1) : 0.0 + "#{n} (#{pct}%)" + end + lines << "| #{category} (#{total} untyped) | #{cells.join(" | ")} |" + end + lines << "" + UNTYPED_CAUSES.each { |c| lines << "- **#{c}**: #{UNTYPED_CAUSE_LEGEND[c]}" } + lines << "" + lines << "Actionable by more nil-kill work: PropagationGap (and the policy half of WeakEvidence). Inherent (correct T.untyped or needs human/tests): Heterogeneous + NoEvidence. Refused/Pending is resolvable today but unapplied or conservatively declined." + end + + def append_untyped_breakdown(lines, evidence) + method_lookup = evidence["methods"].each_with_object({}) do |method, lookup| + source = method["source"] + next unless source + lookup[[source["path"], source["line"]]] = method + end + unused_return_names = unused_return_method_names(evidence) + protocol_index = protocol_class_index(evidence) + param_buckets = Hash.new(0) + return_buckets = Hash.new(0) + param_examples = Hash.new { |hash, key| hash[key] = {} } + return_examples = Hash.new { |hash, key| hash[key] = {} } + evidence["facts"]["existing_sigs"].each do |method| + rec = method_lookup[[method["path"], method["line"]]] + extract_param_entries(method["sig"].to_s).each do |name, type| + next unless type == "T.untyped" + classes = Array(rec&.dig("params_ok", name)) + classes = Array(rec&.dig("params_by_name", name)) if classes.empty? + bucket = untyped_param_bucket(method, name, classes, rec) + param_buckets[bucket] += 1 + example = slot_example(method, name, classes, rec, + protocol_hint: bucket == "runtime union; kept T.untyped by policy" ? protocol_hint(method, name, classes, protocol_index) : nil) + record_bucket_example!(param_examples[bucket], slot_example_key(method), example) + end + ret = extract_return_type(method["sig"].to_s) + next unless ret == "T.untyped" + bucket = untyped_return_bucket(method, rec, unused_return_names) + return_buckets[bucket] += 1 + record_bucket_example!(return_examples[bucket], slot_example_key(method), slot_example(method, "return", Array(rec&.dig("returns")), rec)) + end + lines << "" + lines << "## Untyped Slots" + lines << "- bucket: runtime-observation state for the current `T.untyped` slot, such as unobserved, nil-only, single-type, or runtime union" + lines << "- source category: static origin category explaining where the untyped value appears to come from" + lines << "- unknown expression cause: parser/indexer reason the report could not classify the expression more precisely" + lines << "" + lines << "### Param T.untyped Buckets" + append_bucket_lines(lines, param_buckets, param_examples) + lines << "" + lines << "### Return T.untyped Buckets" + append_bucket_lines(lines, return_buckets, return_examples) + append_untyped_param_source_categories(lines, evidence["facts"]["existing_sigs"], Array(evidence.dig("facts", "param_origins"))) + append_untyped_return_source_categories(lines, evidence["facts"]["existing_sigs"]) + append_unknown_expression_breakdowns(lines, evidence["facts"]["existing_sigs"], Array(evidence.dig("facts", "param_origins"))) + end + + def append_signature_slot_evidence(lines, evidence) + rows = signature_slot_evidence_rows(evidence).select { |row| row["strength"] != "strong" } + param_rows = rows.select { |row| row["slot_kind"] == "param" } + return_rows = rows.select { |row| row["slot_kind"] == "return" } + + lines << "" + lines << "### Signature Slot Evidence" + lines << "- primary reason: the single strongest current explanation for why this weak/untyped signature slot has not been safely strengthened" + lines << "- evidence count: runtime observations plus static callsite/origin records feeding the slot" + lines << "- candidate action: an existing nil-kill action that could rewrite this slot, if one exists" + lines << "" + lines << "#### Param Slot Evidence" + append_signature_evidence_bucket_lines(lines, param_rows) + lines << "" + lines << "#### Return Slot Evidence" + append_signature_evidence_bucket_lines(lines, return_rows) + end + + def signature_slot_evidence_rows(evidence) + method_lookup = evidence["methods"].each_with_object({}) do |method, lookup| + source = method["source"] + next unless source + lookup[[source["path"], source["line"]]] = method + end + param_origins = Array(evidence.dig("facts", "param_origins")) + return_origins = Array(evidence.dig("facts", "return_origins")).each_with_object({}) do |origin, lookup| + lookup[[origin["path"], origin["line"].to_i, origin["class"].to_s, origin["method"].to_s, origin["kind"].to_s]] = origin + end + actions = signature_action_lookup(evidence) + unused_return_names = unused_return_method_names(evidence) + protocol_index = protocol_class_index(evidence) + + Array(evidence.dig("facts", "existing_sigs")).flat_map do |method| + rec = method_lookup[[method["path"], method["line"]]] + sig = method["sig"].to_s + rows = [] + extract_param_entries(sig).each_with_index do |(name, type), idx| + strength = signature_slot_strength(type) + next if strength == "strong" + origins = param_origins_for_slot(param_origins, method, name, idx) + classes = Array(rec&.dig("params_ok", name)) + classes = Array(rec&.dig("params_by_name", name)) if classes.empty? + source_category = untyped_param_source_category(origins) + bucket = strip_nilable(type.to_s) == "T.untyped" ? untyped_param_bucket(method, name, classes, rec) : weak_signature_type_reason(type) + action = actions[["param", method["path"], method["line"].to_i, name.to_s]] + primary = param_slot_primary_reason(type, bucket, source_category, origins, action) + rows << signature_slot_row(method, "param", name, type, strength, primary, + evidence_count: origins.size + classes.size, + detail: param_slot_detail(bucket, source_category, origins), + action: action, + protocol_hint: bucket == "runtime union; kept T.untyped by policy" ? protocol_hint(method, name, classes, protocol_index) : nil) + end + + return_type = sig.match?(/\bvoid\b/) ? "void" : extract_return_type(sig) + if return_type + strength = signature_slot_strength(return_type) + if strength != "strong" + origin = method["return_origin"] || + return_origins[[method["path"], method["line"].to_i, method["class"].to_s, method["method"].to_s, method["kind"].to_s]] || {} + classes = Array(rec&.dig("returns")) + source_category = untyped_return_source_category(method.merge("return_origin" => origin)) + bucket = strip_nilable(return_type.to_s) == "T.untyped" ? untyped_return_bucket(method, rec, unused_return_names) : weak_signature_type_reason(return_type) + primary = return_slot_primary_reason(return_type, bucket, source_category, origin) + action = actions[["return", method["path"], method["line"].to_i, nil]] + rows << signature_slot_row(method, "return", "return", return_type, strength, primary, + evidence_count: Array(origin["sources"]).size + classes.size, + detail: return_slot_detail(bucket, source_category, origin), + action: action) + end + end + rows + end + end + + def signature_slot_row(method, slot_kind, slot_name, type, strength, primary, evidence_count:, detail:, action:, protocol_hint: nil) + text = "#{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{slot_name}; #{type}; #{detail}" + text += "; protocol hint #{protocol_hint}" if protocol_hint + text += "; candidate action #{action["kind"]} (#{action["confidence"]})" if action + # A slot's `detail` can embed a raw source slice (e.g. + # MIRLowering#lower's entire `case node ... end`). Collapse all + # whitespace runs to single spaces and cap length so every + # evidence bullet stays a single Markdown line. + text = text.gsub(/\s+/, " ").strip + text = "#{text[0, 240]} ..." if text.length > 240 + { + "slot_kind" => slot_kind, + "slot" => slot_name.to_s, + "type" => type, + "strength" => strength, + "primary_reason" => primary, + "evidence_count" => evidence_count.to_i, + "example" => text, + } + end + + def append_signature_evidence_bucket_lines(lines, rows) + if rows.empty? + lines << "- none" + return + end + rows.group_by { |row| row["primary_reason"] } + .sort_by { |reason, list| [-list.size, reason] } + .each do |reason, list| + evidence_count = list.sum { |row| row["evidence_count"].to_i } + weak = list.count { |row| row["strength"] == "weak" } + untyped = list.count { |row| row["strength"] == "untyped" } + lines << "- #{reason}: #{list.size} slot(s); weak #{weak}, untyped #{untyped}; evidence #{evidence_count}" + list.sort_by { |row| [-row["evidence_count"].to_i, row["example"]] }.first(8).each do |row| + lines << " - #{row["example"]}; evidence #{row["evidence_count"]}" + end + end + end + + def signature_action_lookup(evidence) + Array(evidence["actions"]).each_with_object({}) do |action, lookup| + case action["kind"] + when "fix_sig_param", "narrow_generic_param" + key = ["param", action["path"], action["line"].to_i, action.dig("data", "name").to_s] + when "fix_sig_return", "narrow_generic_return" + key = ["return", action["path"], action["line"].to_i, nil] + else + next + end + current = lookup[key] + lookup[key] = action if current.nil? || signature_action_rank(action) < signature_action_rank(current) + end + end + + def signature_action_rank(action) + return 0 if action["confidence"] == HIGH + return 1 if action["confidence"] == REVIEW + 2 + end + + def param_origins_for_slot(origins, method, name, idx) + origins.select do |origin| + origin["callee"].to_s == method["method"].to_s && + (origin["slot"].to_s == idx.to_s || origin["slot"].to_s == name.to_s) + end + end + + def signature_slot_strength(type) + inner = strip_nilable(type.to_s.strip) + return "untyped" if untyped_type?(inner) + return "weak" if weak_type?(inner) + "strong" + end + + def weak_signature_type_reason(type) + inner = strip_nilable(type.to_s) + if inner.include?("T.any(") + "weak declared type: union" + elsif inner.match?(/\AT::Array\b/) + "weak declared type: array element evidence needed" + elsif inner.match?(/\AT::Hash\b/) + "weak declared type: hash key/value evidence needed" + elsif inner.match?(/\AT::(?:Enumerable|Set)\b/) + "weak declared type: collection element evidence needed" + elsif inner.include?("T.untyped") + "weak declared type: nested T.untyped" + else + "weak declared type" + end + end + + def param_slot_primary_reason(type, bucket, source_category, origins, action = nil) + return weak_signature_type_reason(type) unless strip_nilable(type.to_s) == "T.untyped" + return "candidate: static callsite backflow" if action&.dig("data", "source") == "static_param_backflow" + return "candidate: runtime-only param observation" if bucket.include?("single observed type") || bucket.include?("boolean pair") + return "blocked: no static callsite evidence" if origins.empty? + return "blocked: unknown callsite expression" if source_category == "untyped unknown expression" + return "blocked: forwarded return argument" if source_category == "untyped forwarded return" + return "blocked: collection/hash argument evidence" if source_category == "untyped struct/array/collection value" + return "blocked: runtime union policy" if bucket.include?("runtime union") + bucket + end + + def return_slot_primary_reason(type, bucket, source_category, origin) + return weak_signature_type_reason(type) unless strip_nilable(type.to_s) == "T.untyped" + return "candidate: void return" if bucket.start_with?("void candidate") + return "candidate: runtime-only return observation" if bucket.include?("single observed type") || bucket.include?("boolean pair") + source_kinds = Array(origin["sources"]).map { |source| source["kind"].to_s }.to_set + return "blocked: forwarded return chain" if source_category == "untyped forwarded return" + return "blocked: collection/field return evidence" if source_category == "untyped struct/array/collection value" + return "blocked: instance variable return" if source_category == "untyped instance variable" + return "blocked: unknown return expression" if source_kinds.include?("unknown") || source_category == "untyped unknown expression" + return "blocked: runtime union policy" if bucket.include?("runtime union") + bucket + end + + def param_slot_detail(bucket, source_category, origins) + origin_labels = origins.first(3).map { |origin| "#{origin["path"]}:#{origin["line"]} #{origin["code"]}" } + origin_text = origin_labels.empty? ? "no static callsite origin" : origin_labels.join("; ") + "#{bucket}; #{source_category}; #{origin_text}" + end + + def return_slot_detail(bucket, source_category, origin) + source_labels = Array(origin["sources"]).first(3).map { |source| [source["kind"], source["code"]].compact.join(" ") } + source_text = source_labels.empty? ? "no static return origin" : source_labels.join("; ") + "#{bucket}; #{source_category}; #{source_text}" + end + + def extract_param_entries(sig) + params = extract_call_args(sig, "params") + return [] unless params + split_top_level(params).filter_map do |entry| + name, type = entry.split(/:\s*/, 2) + next unless name && type + [name.strip, type.strip] + end + end + + def untyped_param_bucket(method, name, classes, rec) + classes = Array(classes).compact.uniq + return "slot not observed: no matching runtime record" unless rec + return "slot not observed: method was not hit" if rec["calls"].to_i.zero? + if classes.empty? + param = Array(method["params"]).find { |p| p["name"] == name } + return "slot not observed: source index did not model this param shape" unless param + return "slot not observed: defaultable param not observed" if param["nil_default"] + return "slot not observed: block-like param not captured" if method["uses_yield"] && name.match?(/\A(block|blk|visitor|callback)\z/) + return "slot not observed: method hit but runtime slot was empty" + end + non_nil = classes.reject { |klass| klass == "NilClass" } + return "nil only observed" if non_nil.empty? + return "single observed type; narrow candidate" if non_nil.size == 1 + return "boolean pair; T::Boolean candidate" if non_nil.sort == %w[FalseClass TrueClass] + return "runtime union; kept T.untyped by policy" if non_nil.size > 1 + "unknown" + end + + def untyped_return_bucket(method, rec, unused_return_names) + return "void candidate; return value appears unused" if unused_return_names.include?(method["method"].to_sym) + classes = Array(rec&.dig("returns")).compact.uniq + return "slot not observed: no matching runtime record" unless rec + return "slot not observed: method was not hit" if rec["calls"].to_i.zero? + return "slot not observed: method hit but return was not captured" if classes.empty? + non_nil = classes.reject { |klass| klass == "NilClass" } + return "nil only observed" if non_nil.empty? + return "single observed type; narrow candidate" if non_nil.size == 1 + return "boolean pair; T::Boolean candidate" if non_nil.sort == %w[FalseClass TrueClass] + return "runtime union; kept T.untyped by policy" if non_nil.size > 1 + "unknown" + end + + def append_untyped_param_source_categories(lines, methods, origins) + buckets = Hash.new(0) + examples = Hash.new { |hash, key| hash[key] = [] } + methods.each do |method| + params = Array(method["params"]) + extract_param_entries(method["sig"].to_s).each_with_index do |(name, type), idx| + next unless type == "T.untyped" + slot_origins = origins.select do |origin| + origin["callee"].to_s == method["method"].to_s && + (origin["slot"].to_s == idx.to_s || origin["slot"].to_s == name.to_s) + end + bucket = untyped_param_source_category(slot_origins) + buckets[bucket] += 1 + if examples[bucket].size < 8 + param = params.find { |p| p["name"] == name } + labels = slot_origins.first(3).map { |origin| "#{origin["path"]}:#{origin["line"]} #{origin["code"]}" } + details = labels.empty? ? "no static callsite origin" : labels.join("; ") + details += "; param default nil" if param && param["nil_default"] + examples[bucket] << "#{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{name}; #{details}" + end + end + end + lines << "" + lines << "### Param T.untyped Source Categories" + append_bucket_lines(lines, buckets, examples) + end + + def untyped_param_source_category(origins) + origins = Array(origins) + return "untyped unknown expression" if origins.empty? + origin_kinds = origins.map { |origin| origin["origin_kind"].to_s }.to_set + types = origins.filter_map { |origin| origin["type"].to_s if origin["type"] } + codes = origins.map { |origin| origin["code"].to_s } + if codes.any? { |code| code.match?(/\A@{1,2}[A-Za-z_]\w*\z/) || code.match?(/\A\$[A-Za-z_]\w*\z/) || code.include?("instance_variable_get") } + "untyped instance variable" + elsif origin_kinds.include?("untyped_return") || origin_kinds.include?("typed_return") + "untyped forwarded return" + elsif types.any? { |type| weak_collection_return_source?(type, []) } || codes.any? { |code| code.start_with?("[", "{") } + "untyped struct/array/collection value" + elsif origin_kinds.include?("static") + "untyped literal/static expression" + else + "untyped unknown expression" + end + end + + def append_untyped_return_source_categories(lines, methods) + buckets = Hash.new(0) + examples = Hash.new { |hash, key| hash[key] = [] } + methods.each do |method| + next unless extract_return_type(method["sig"].to_s) == "T.untyped" + bucket = untyped_return_source_category(method) + buckets[bucket] += 1 + examples[bucket] << "#{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]}" if examples[bucket].size < 8 + end + lines << "" + lines << "### Return T.untyped Source Categories" + append_bucket_lines(lines, buckets, examples) + end + + def untyped_return_source_category(method) + origin = method["return_origin"] || {} + sources = Array(origin["sources"]) + source_kinds = sources.map { |source| source["kind"].to_s }.to_set + candidate = origin["candidate_type"].to_s + if source_kinds.include?("ivar_read") + "untyped instance variable" + elsif source_kinds.include?("call_untyped") || source_kinds.include?("setter_assignment_unknown") + "untyped forwarded return" + elsif weak_collection_return_source?(candidate, sources) + "untyped struct/array/collection value" + elsif source_kinds.any? { |kind| %w[static nil typed_call safe_call setter_assignment].include?(kind) } + "untyped literal/static expression" + else + "untyped unknown expression" + end + end + + def weak_collection_return_source?(candidate, sources) + return true if candidate.match?(/\AT::(?:Array|Hash|Enumerable|Set)\b/) + sources.any? { |source| source["type"].to_s.match?(/\AT::(?:Array|Hash|Enumerable|Set)\b/) } + end + + def append_unknown_expression_breakdowns(lines, methods, param_origins) + untyped_param_slots = untyped_param_slot_keys(methods) + param_buckets = Hash.new(0) + param_examples = Hash.new { |hash, key| hash[key] = [] } + param_origins.select { |origin| origin["origin_kind"] == "unknown" && untyped_param_origin?(origin, untyped_param_slots) }.each do |origin| + bucket = unknown_expression_bucket(origin["unknown_reasons"]) + param_buckets[bucket] += 1 + param_examples[bucket] << "#{origin["path"]}:#{origin["line"]} #{origin["callee"]}(#{origin["slot"]}) #{origin["code"]}" if param_examples[bucket].size < 8 + end + + return_buckets = Hash.new(0) + return_examples = Hash.new { |hash, key| hash[key] = [] } + methods.each do |method| + next unless extract_return_type(method["sig"].to_s) == "T.untyped" + unknown_sources = Array(method.dig("return_origin", "sources")).select { |source| source["kind"] == "unknown" } + unknown_sources.each do |source| + bucket = unknown_expression_bucket(source["unknown_reasons"]) + return_buckets[bucket] += 1 + return_examples[bucket] << "#{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{source["code"]}" if return_examples[bucket].size < 8 + end + end + + lines << "" + lines << "### Param Unknown Expression Causes" + append_bucket_lines(lines, param_buckets, param_examples) + lines << "" + lines << "### Return Unknown Expression Causes" + append_bucket_lines(lines, return_buckets, return_examples) + end + + def untyped_param_slot_keys(methods) + methods.each_with_object(Set.new) do |method, slots| + extract_param_entries(method["sig"].to_s).each_with_index do |(name, type), idx| + next unless type == "T.untyped" + slots << [method["method"].to_s, idx.to_s] + slots << [method["method"].to_s, name.to_s] + end + end + end + + def untyped_param_origin?(origin, slots) + slots.include?([origin["callee"].to_s, origin["slot"].to_s]) + end + + def unknown_expression_bucket(reasons) + reasons = Array(reasons).map(&:to_s).reject(&:empty?) + return "unknown expression with no nested cause" if reasons.empty? + operations = reasons.select { |reason| reason.start_with?("operation ") } + non_literals = reasons.reject { |reason| reason.start_with?("operation ") || reason.start_with?("literal/static expression ") } + causes = + if non_literals.any? + non_literals + elsif operations.any? + operations + else + reasons + end + families = causes.map { |reason| unknown_reason_family(reason) }.uniq + return "unknown expression with multiple unknown types" if families.size > 1 + unknown_reason_label(causes.first) + end + + def unknown_reason_family(reason) + case reason + when /\Aforwarded return/ then "forwarded" + when /\Ainstance variable/, /\Aclass variable/, /\Aglobal variable/ then "instance" + when /\Astruct\/array\/collection value/ then "collection" + when /\Aliteral\/static expression/ then "literal" + when /\Alocal variable/ then "local" + else "operation" + end + end + + def unknown_reason_label(reason) + case reason + when /\Aforwarded return (.+)\z/ then "unknown forwarded return #{$1}" + when /\Ainstance variable (.+)\z/ then "unknown instance variable #{$1}" + when /\Aclass variable (.+)\z/ then "unknown class variable #{$1}" + when /\Aglobal variable (.+)\z/ then "unknown global variable #{$1}" + when /\Astruct\/array\/collection value (.+)\z/ then "unknown struct/array/collection value #{$1}" + when /\Aliteral\/static expression (.+)\z/ then "unknown literal/static expression #{$1}" + when /\Alocal variable (.+)\z/ then "unknown local variable #{$1}" + when /\Aoperation (.+)\z/ then "unknown operation #{$1}" + else "unknown expression #{reason}" + end + end + + + def append_bucket_lines(lines, buckets, examples = {}) + if buckets.empty? + lines << "- none" + return + end + buckets.sort_by { |_, count| -count }.each do |name, count| + lines << "- #{name}: #{count}" + bucket_examples(examples[name]).first(8).each do |example| + # Examples can embed a raw source slice (a multi-line `case` + # body etc.). Collapse whitespace runs and cap length so each + # stays a single Markdown bullet. + one_line = example.to_s.gsub(/\s+/, " ").strip + one_line = "#{one_line[0, 240]} ..." if one_line.length > 240 + lines << " - #{one_line}" + end + end + end + + def record_bucket_example!(examples, key, text) + current = examples[key] ||= { "count" => 0, "text" => text } + current["count"] += 1 + end + + def bucket_examples(examples) + case examples + when Hash + examples.values.sort_by { |example| [-example["count"].to_i, example["text"].to_s] }.map do |example| + count = example["count"].to_i + prefix = count == 1 ? "1 slot" : "#{count} slots" + "#{prefix}: #{example["text"]}" + end + else + Array(examples) + end + end + + def slot_example_key(method) + [method["path"], method["line"], method["class"], method["method"]].join(":") + end + + def slot_example(method, slot_name, classes, rec, protocol_hint: nil) + observed = Array(classes).compact.uniq.sort + observed_text = observed.empty? ? "no observed runtime type" : observed.first(8).join(", ") + observed_text += ", ..." if observed.size > 8 + calls = rec ? rec["calls"].to_i : 0 + base = "#{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{slot_name}; #{calls} call(s); observed #{observed_text}" + protocol_hint ? "#{base}; #{protocol_hint}" : base + end + + def protocol_hint(method, name, observed_classes, protocol_index) + protocol = method.dig("protocols", name) || {} + required = Array(protocol["methods"]).reject { |m| ignorable_protocol_method?(m) }.uniq.sort + aliases = Array(protocol["aliases"]) + gaps = Array(protocol["gaps"]) + parts = [] + if required.empty? + parts << "direct protocol: none observed" + else + observed = Array(observed_classes).reject { |klass| klass == "NilClass" || klass == "T.untyped" }.to_set + strength = protocol_strength(required) + candidates = [] + unless strength == "weak" + candidates = protocol_index.filter_map do |klass, methods| + next if observed.include?(klass) + klass if required.all? { |method_name| methods.include?(method_name) } + end.sort.first(8) + end + parts << "#{strength} direct protocol ##{required.join(", #")}" + parts << "other potential options, not exhaustive: #{candidates.join(", ")}" unless candidates.empty? + end + parts << "analysis gaps: aliases seen #{aliases.first(4).join(", ")}" unless aliases.empty? + parts << "analysis gaps: #{gaps.first(3).join("; ")}" unless gaps.empty? + parts.join("; ") + end + + def protocol_strength(methods) + useful = Array(methods).reject { |name| generic_protocol_method?(name) } + return "strong" if useful.size >= 2 + return "medium" if useful.size == 1 + "weak" + end + + def generic_protocol_method?(name) + %w[ + [] []= each each_pair each_value map flat_map select reject find detect any? all? none? one? + include? key? keys values empty? size length first last to_a to_h to_s inspect hash eql? == + ].include?(name) + end + + def ignorable_protocol_method?(name) + %w[ + nil? class is_a? kind_of? instance_of? object_id respond_to? + instance_variable_get instance_variable_set itself tap then yield_self + ].include?(name) + end + + def protocol_class_index(evidence) + index = Hash.new { |h, k| h[k] = Set.new } + all_methods = Array(evidence.dig("facts", "existing_sigs")) + Array(evidence.dig("facts", "unsigned_methods")) + all_methods.each do |method| + next unless method["kind"] == "instance" && !method["class"].to_s.empty? + index[method["class"]] << method["method"] + end + Array(evidence.dig("facts", "struct_declarations")).each do |decl| + Array(decl["fields"]).each { |field| index[decl["class"]] << field } + end + struct_rbi_types.each_key do |klass, field| + index[klass] << field + end + index + end + + def unused_return_method_names(evidence) + unused_return_methods(evidence).map { |method| method["method"].to_sym }.to_set + end + + def unused_return_methods_by_location + unused_return_methods(@store.to_h).each_with_object({}) do |method, lookup| + lookup[method_location_key(method)] = method + end + end + + def unused_return_methods(evidence) + untyped_candidates = evidence["facts"]["existing_sigs"].select do |method| + method["sig"].to_s.include?(".returns(T.untyped)") + end + untyped_candidates_by_name = untyped_candidates.group_by { |method| method["method"].to_sym } + all_candidates_by_name = Array(evidence.dig("facts", "existing_sigs")).select do |method| + sig = method["sig"].to_s + sig.match?(/\bvoid\b/) || extract_return_type(sig) + end.group_by { |method| method["method"].to_sym } + candidate_names = all_candidates_by_name.select { |_name, methods| methods.size == 1 }.keys.to_set + untyped_candidate_names = untyped_candidates_by_name.select { |name, methods| methods.size == 1 && candidate_names.include?(name) }.keys.to_set + return [] if untyped_candidate_names.empty? + method_return_types = unambiguous_method_return_types(evidence) + + used = Set.new + return_edges = Hash.new { |hash, key| hash[key] = Set.new } + NilKill.target_files.each do |path| + parsed = Prism.parse_file(path) + next unless parsed.success? + mark_return_usage_graph(parsed.value, :statement, nil, candidate_names, method_return_types, used, return_edges) + end + propagate_return_usage!(used, return_edges) + (untyped_candidate_names - used).filter_map { |name| untyped_candidates_by_name.fetch(name).first } + end + + def unambiguous_method_return_types(evidence) + by_name = Array(evidence.dig("facts", "existing_sigs")).group_by { |method| method["method"].to_sym } + by_name.each_with_object({}) do |(name, methods), types| + next unless methods.size == 1 + sig = methods.first["sig"].to_s + types[name] = sig.include?("void") ? "void" : extract_return_type(sig) + end + end + + def propagate_return_usage!(used, return_edges) + changed = true + while changed + changed = false + return_edges.each do |caller, callees| + next unless used.include?(caller) + callees.each do |callee| + next if used.include?(callee) + used << callee + changed = true + end + end + end + end + + def mark_return_usage_graph(node, context, current_method, candidate_names, method_return_types, used, return_edges) + return unless node + case node + when Prism::DefNode + mark_return_usage_graph(node.body, :return, node.name, candidate_names, method_return_types, used, return_edges) + when Prism::StatementsNode + body = node.body || [] + body.each_with_index do |child, idx| + child_context = idx == body.length - 1 ? context : :statement + mark_return_usage_graph(child, child_context, current_method, candidate_names, method_return_types, used, return_edges) + end + when Prism::ReturnNode + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, :return, current_method, candidate_names, method_return_types, used, return_edges) } + when Prism::ArgumentsNode + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, context, current_method, candidate_names, method_return_types, used, return_edges) } + when Prism::IfNode + mark_return_usage_graph(node.predicate, :value, current_method, candidate_names, method_return_types, used, return_edges) if node.respond_to?(:predicate) + mark_return_usage_graph(node.statements, context, current_method, candidate_names, method_return_types, used, return_edges) + mark_return_usage_graph(node.subsequent, context, current_method, candidate_names, method_return_types, used, return_edges) + when Prism::ElseNode + mark_return_usage_graph(node.statements, context, current_method, candidate_names, method_return_types, used, return_edges) + when Prism::CallNode + if candidate_names.include?(node.name) + if context == :return && current_method && candidate_names.include?(current_method) + if typed_value_return?(method_return_types[current_method]) + used << node.name + else + return_edges[current_method] << node.name + end + elsif context == :return && method_return_types[current_method] != "void" + used << node.name + elsif context == :value + used << node.name + end + end + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, :value, current_method, candidate_names, method_return_types, used, return_edges) } + else + node.child_nodes.compact.each { |child| mark_return_usage_graph(child, :value, current_method, candidate_names, method_return_types, used, return_edges) } if node.respond_to?(:child_nodes) + end + end + + def typed_value_return?(return_type) + return_type && return_type != "void" && return_type != "T.untyped" + end + + def append_struct_report(lines, evidence) + facts = evidence["facts"] + runtime = Array(facts["struct_field_runtime"]) + static = Array(facts["struct_field_static"]) + declarations = Array(facts["struct_declarations"]) + lines << "" + lines << "## Struct Shape Report" + lines << "- Struct declarations: #{declarations.size}" + lines << "- Runtime-observed struct field slots: #{runtime.map { |r| [r["class"], r["field"]] }.uniq.size}" + lines << "- Static constructor field observations: #{static.size}" + append_struct_field_breakdown(lines, declarations, runtime, static) + append_struct_field_candidates(lines, runtime, static) + end + + def append_struct_field_coverage(lines, declarations, accumulator: nil) + rbi_types = struct_rbi_types + counts = empty_type_counts.merge("missing" => 0) + declarations.each do |decl| + Array(decl["fields"]).each do |field| + type = rbi_types[[decl["class"], field]] + if type + classify_type!(counts, type) + else + counts["missing"] += 1 + end + end + end + total_with_missing = counts["strong"] + counts["weak"] + counts["untyped"] + counts["missing"] + lines << "" + lines << "### Struct Field Slots" + lines << "- Struct field slots: #{format_type_counts(counts, denominator: total_with_missing)}, missing field type #{counts["missing"]}#{total_with_missing.positive? ? " (#{percent(counts["missing"], total_with_missing)})" : ""}" + lines << " - of which weak primitive collection (T::Array[T.untyped] etc.): #{counts["weak_collection"]}" if counts["weak_collection"].to_i.positive? + lines << "- Nilable struct field slots: #{counts["nilable"]}" + accumulator&.add("struct_field", counts) + end + + def append_struct_field_breakdown(lines, declarations, runtime, static) + rbi_types = struct_rbi_types + candidates = struct_field_candidates(runtime, static).each_with_object({}) { |c, h| h[[c["class"], c["field"]]] = c } + buckets = Hash.new { |h, k| h[k] = [] } + declarations.each do |decl| + Array(decl["fields"]).each do |field| + key = [decl["class"], field] + type = rbi_types[key] + candidate = candidates[key] + bucket = + if type.nil? + candidate ? "missing field type with candidate" : "missing field type with no candidate" + elsif untyped_type?(strip_nilable(type)) + if candidate&.fetch("runtime_calls", 0).to_i.positive? + "untyped with runtime candidate" + elsif candidate + "untyped with static candidate" + else + "untyped with no candidate" + end + elsif weak_type?(strip_nilable(type)) + "weak collection or union type" + elsif nilable_type?(type) + "typed but nilable" + else + "strongly typed" + end + buckets[bucket] << { "class" => decl["class"], "field" => field, "type" => type, "candidate" => candidate } + end + end + lines << "" + lines << "### Struct Field Slot Breakdown" + order = ["missing field type with candidate", "missing field type with no candidate", "untyped with runtime candidate", + "untyped with static candidate", "untyped with no candidate", "weak collection or union type", + "typed but nilable", "strongly typed"] + order.each do |bucket| + list = buckets[bucket] + next if list.empty? + lines << "- #{bucket}: #{list.size}" + list.first(8).each do |item| + candidate = item["candidate"] + candidate_text = candidate ? " -> #{candidate["type"]}#{candidate["runtime_calls"].to_i.positive? ? " (runtime #{candidate["runtime_calls"]})" : " (static)"}" : "" + current = item["type"] ? " current #{item["type"]}" : "" + lines << " - #{item["class"]}.#{item["field"]}#{current}#{candidate_text}" + end + lines << " - ... #{list.size - 8} more" if list.size > 8 + end + end + + def struct_rbi_types + types = {} + Dir.glob(File.join(ROOT, "sorbet", "rbi", "**", "*.rbi")).each do |path| + klass = nil + pending_type = nil + File.readlines(path).each do |line| + if line =~ /^\s*class\s+([A-Z]\S*)/ + klass = $1 + elsif klass && line =~ /^\s*sig\s*\{\s*returns\((.+)\)\s*\}/ + pending_type = $1.strip + elsif klass && pending_type && line =~ /^\s*def\s+([a-zA-Z_]\w*)\b/ + types[[klass, $1]] = pending_type + pending_type = nil + elsif line =~ /^\s*end\s*$/ + klass = nil + pending_type = nil + end + end + end + types + end + + def append_struct_field_candidates(lines, runtime, static) + candidates = struct_field_candidates(runtime, static) + lines << "" + lines << "### Struct Field Type Candidates" + if candidates.empty? + lines << "- none" + return + end + candidates.first(50).each do |candidate| + source = candidate["runtime_calls"].positive? ? "runtime" : "static" + parts = ["#{candidate["class"]}.#{candidate["field"]}", candidate["type"], "#{source}"] + parts << "#{candidate["runtime_calls"]} call(s)" if candidate["runtime_calls"].positive? + parts << "#{candidate["nil_count"]} nil observation(s)" if candidate["nil_count"].positive? + lines << "- #{parts.join("; ")}" + end + end + + def struct_field_candidates(runtime, static) + by_slot = Hash.new { |h, k| h[k] = { "class" => k[0], "field" => k[1], "classes" => [], "elem_classes" => [], "runtime_calls" => 0, "static_count" => 0, "has_unknown_static" => false } } + runtime.each do |rec| + key = [rec["class"], rec["field"]] + slot = by_slot[key] + slot["classes"] |= Array(rec["classes"]) + slot["elem_classes"] |= Array(rec["elem_classes"]) + slot["runtime_calls"] += rec["calls"].to_i + end + static.each do |rec| + key = [rec["class"], rec["field"]] + slot = by_slot[key] + if rec["type"].to_s.empty? + # Static record where the assigned local's type couldn't be inferred + # (e.g., `Conflict.new(set_a: inner_op)` where inner_op is T.untyped). + # Without this flag the slot's candidate set would silently drop + # the unknown contributor, producing wrong narrowings (the + # AST::ConcurrentOp.op -> AST::EachOp class of bug). + slot["has_unknown_static"] = true + else + slot["classes"] |= [rec["type"]] + end + slot["static_count"] += 1 + end + by_slot.values.filter_map do |slot| + # Skip slots with any uninferrable constructor argument: the union is + # under-determined and a narrow sig will mis-type the field. + next if slot["has_unknown_static"] && slot["runtime_calls"].zero? + type = struct_slot_type(slot) + next unless type && type != "T.untyped" + # Skip T.nilable candidates -- at any nesting level. Downstream callers + # in src/ rarely nil-handle the field today, so adding a nilable sig + # pushes nil-handling burden onto callers (or forces T.must / &. + # additions -- exactly the pollution we want to delete, not add). + # Also catches element-level T.nilable (e.g. T::Array[T.nilable(String)]) + # which has the same cascade through `.each { |x| x.method }`. + # Re-enable when the proposer can verify callers already nil-handle. + next if type.include?("T.nilable") + # Skip weak-collection candidates (T::Array[T.untyped] / T::Hash[T.untyped, + # T.untyped] / T::Set[T.untyped]). They add minimal type info (Sorbet + # already knows it's some container) AND they trigger the same nil- + # cascade via Array#[] / Array#first / Hash#[] returning T.nilable. + # Element/key/value narrowings are the generic narrowers' job, not here. + next if weak_collection_type?(type) + slot.merge("type" => type, "nil_count" => slot["classes"].count("NilClass")) + end.sort_by { |slot| [-slot["runtime_calls"], -slot["static_count"], slot["class"], slot["field"]] } + end + + def struct_slot_type(slot) + classes = Array(slot["classes"]).compact.reject(&:empty?) + if classes == ["Array"] && !slot["elem_classes"].empty? + elem = NilKill.sorbet_type(slot["elem_classes"], allow_nilable: true) + return elem == "T.untyped" ? "T::Array[T.untyped]" : "T::Array[#{elem}]" + end + NilKill.sorbet_type(classes, allow_nilable: true) + end + + def append_hash_shape_candidates(lines, shapes) + grouped = Hash.new { |h, k| h[k] = { "count" => 0, "sites" => [] } } + shapes.each do |shape| + key = Array(shape["keys"]).sort.join(", ") + grouped[key]["count"] += 1 + grouped[key]["sites"] << "#{shape["path"]}:#{shape["line"]}" + end + lines << "" + lines << "### Hash Shapes That May Want Data/Struct" + if grouped.empty? + lines << "- none" + return + end + grouped.sort_by { |_keys, data| -data["count"] }.first(30).each do |keys, data| + lines << "- {#{keys}} appears #{data["count"]} time(s); first site #{data["sites"].first}" + end + end + + def append_collection_report(lines, evidence) + lines << "" + lines << "## Collection Type Report" + slots = collection_signature_slots(evidence) + append_collection_slot_coverage(lines, slots) + append_hash_record_struct_candidates(lines, evidence) + append_collection_slot_candidates(lines, evidence, slots) + append_collection_blocker_pressure(lines, evidence, slots) + append_runtime_collection_observations(lines, Array(evidence.dig("facts", "collection_runtime"))) + append_collection_index_lookup_report(lines, Array(evidence.dig("facts", "collection_index_lookups"))) + end + + def append_hash_record_struct_candidates(lines, evidence) + rows = hash_record_struct_candidates(evidence) + lines << "" + lines << "### Hash Record Struct Candidates (Shapes + Pressure)" + lines << "- literal shape: a statically observed hash literal instantiation site in this candidate cluster" + lines << "- similar keyset: a distinct hash key set grouped into the same likely record, e.g. `{name, id}` with `{name, id, type}`" + if rows.empty? + lines << "- none" + return + end + rows.first(30).each do |row| + lines << "- #{row["struct_name"]}: #{row["shape_count"]} literal shape(s), #{row["keyset_count"]} similar keyset(s), total pressure #{row["total_pressure"]}" + lines << " - common keys: #{row["common_keys"].join(", ")}" unless row["common_keys"].empty? + lines << " - optional keys: #{row["optional_keys"].join(", ")}" unless row["optional_keys"].empty? + unless row["read_counts"].empty? + used = row["read_counts"].sort_by { |key, count| [-count, key] }.map { |key, count| "#{key}(#{count})" } + lines << " - read keys: #{used.join(", ")}" + end + lines << " - accounts for: return #{row["return_slots"]}, param #{row["param_slots"]}, ivar #{row["ivar_slots"]}, collection #{row["collection_slots"]}" + unless Array(row["related_records"]).empty? + related = row["related_records"].first(5).map { |record| "#{record["label"]} (#{record["total_pressure"]})" } + lines << " - related pressure records: #{related.join("; ")}" + end + row["examples"].first(4).each { |example| lines << " - #{example}" } + lines << " - suggested struct:" + Array(row["nested_structs"]).each do |nested| + lines << " class #{nested["struct_name"]} < T::Struct" + Array(nested["fields"]).each do |field| + lines << " #{field["optional"] ? "prop" : "const"} :#{field["name"]}, #{field["type"]}" + end + lines << " end" + lines << "" + end + lines << " class #{row["struct_name"]} < T::Struct" + row["fields"].each do |field| + lines << " #{field["optional"] ? "prop" : "const"} :#{field["name"]}, #{field["type"]}" + end + lines << " end" + end + end + + def hash_record_struct_candidates(evidence) + if evidence.equal?(@evidence) && defined?(@hash_record_struct_candidates_cache) && @hash_record_struct_candidates_cache + return @hash_record_struct_candidates_cache + end + shape_clusters = clustered_hash_shapes(Array(evidence.dig("facts", "hash_shapes"))) + return [] if shape_clusters.empty? + clusters_by_exact_key = {} + shape_clusters.each do |cluster| + cluster["keysets"].each { |keyset| clusters_by_exact_key[keyset.join("\0")] = cluster } + end + + lookups = Array(evidence.dig("facts", "collection_index_lookups")).select { |lookup| hash_record_lookup_key(lookup) } + blockers = Array(evidence.dig("facts", "hash_record_blockers")) + member_calls = Array(evidence.dig("facts", "hash_record_member_calls")) + param_origins = Array(evidence.dig("facts", "param_origins")) + return_sources = Array(evidence.dig("facts", "return_origins")).flat_map { |origin| Array(origin["sources"]) } + + lookups.each do |lookup| + cluster = cluster_for_hash_lookup(lookup, clusters_by_exact_key) + next unless cluster + key = hash_record_lookup_key(lookup) + cluster["read_counts"][key] += 1 if key + cluster["collection_slot_ids"].add([lookup["path"], lookup["line"], lookup["code"]]) + cluster["ivar_slot_ids"].add([lookup["path"], lookup["line"], lookup["receiver"]]) if hash_record_ivar_lookup?(lookup) + cluster["examples"] << "#{lookup["path"]}:#{lookup["line"]} #{lookup["code"]}; receiver #{lookup["receiver"]}" if cluster["examples"].size < 6 + cluster["consumers"] << { "path" => lookup["path"], "line" => lookup["line"], "code" => lookup["code"], + "receiver" => lookup["receiver"], "index" => lookup["index"], "key" => key, + "lookup_type" => lookup["lookup_type"], "status" => lookup["status"], "origin" => lookup["origin"] } + + param_origins.each do |origin| + next unless origin["code"].to_s == lookup["code"].to_s + cluster["param_slot_ids"].add([origin["path"], origin["line"], origin["callee"], origin["slot"]]) + end + return_sources.each do |source| + next unless source["code"].to_s == lookup["code"].to_s + cluster["return_slot_ids"].add([source["path"], source["line"], source["code"]]) + end + end + + blockers.each do |blocker| + cluster = cluster_for_hash_blocker(blocker, clusters_by_exact_key) + next unless cluster + cluster["blockers"] << blocker + end + + member_calls.each do |call| + cluster = cluster_for_hash_member_call(call, clusters_by_exact_key) + next unless cluster + field = call["field"].to_s + cluster["field_member_calls"][field] << call["member"].to_s + cluster["field_member_examples"][field] << "#{call["path"]}:#{call["line"]} #{call["code"]}" if cluster["field_member_examples"][field].size < 5 + end + + rows = shape_clusters.map do |cluster| + finalize_hash_record_struct_candidate(cluster) + end + attach_related_hash_pressure_records(rows, hash_record_struct_pressure(evidence)) + rows = rows.sort_by { |row| [-row["total_pressure"], -row["shape_count"], row["struct_name"]] } + @hash_record_struct_candidates_cache = rows if evidence.equal?(@evidence) + rows + end + + def attach_related_hash_pressure_records(candidates, pressure_rows) + candidates.each do |candidate| + related = pressure_rows.select { |row| pressure_matches_candidate?(row, candidate) } + .sort_by { |row| [-row["total_pressure"].to_i, row["label"].to_s] } + candidate["related_records"] = related + end + end + + def candidate_matches_pressure?(candidates, pressure_row) + candidates.any? { |candidate| pressure_matches_candidate?(pressure_row, candidate) } + end + + def pressure_matches_candidate?(pressure_row, candidate) + keys = Array(pressure_row["keys"]).map(&:to_s).reject(&:empty?) + return false if keys.empty? + union = Array(candidate["union_keys"]).map(&:to_s).to_set + return false if union.empty? + intersection = (keys.to_set & union).size + intersection == keys.size || (intersection.to_f / keys.size) >= 0.6 + end + + def clustered_hash_shapes(shapes) + exact = {} + shapes.each do |shape| + keys = Array(shape["keys"]).map(&:to_s).sort + next if keys.size < 2 + key = [keys.join("\0"), hash_record_shape_type_partition(shape, keys)].join("\1") + data = exact[key] ||= { + "keys" => keys, + "count" => 0, + "sites" => [], + "producers" => [], + "types" => Hash.new { |h, k| h[k] = [] }, + "value_hash_shapes" => {}, + "value_array_element_shapes" => {}, + } + data["count"] += 1 + data["sites"] << "#{shape["path"]}:#{shape["line"]}" + data["producers"] << { "path" => shape["path"], "line" => shape["line"], "code" => shape["code"], "keys" => keys } + Array(shape["keys"]).zip(Array(shape["value_types"])).each do |field, type| + data["types"][field.to_s] |= [type.to_s] if NilKill.useful_type?(type) + end + merge_cluster_nested_shapes!(data["value_hash_shapes"], shape["value_hash_shapes"]) + merge_cluster_nested_shapes!(data["value_array_element_shapes"], shape["value_array_element_shapes"]) + end + + clusters = [] + exact.values.sort_by { |data| [-data["count"], data["keys"].join(",")] }.each do |data| + cluster = clusters.find do |candidate| + similar_hash_keysets?(candidate["union_keys"], data["keys"]) && + hash_shape_cluster_type_compatible?(candidate, data) + end + unless cluster + cluster = new_hash_shape_cluster(data["keys"]) + clusters << cluster + end + cluster["shape_count"] += data["count"] + cluster["keysets"] << data["keys"] + cluster["sites"].concat(data["sites"]) + cluster["producers"].concat(data["producers"]) + cluster["union_keys"] = (cluster["union_keys"] | data["keys"]).sort + cluster["common_keys"] &= data["keys"] + data["types"].each { |field, types| cluster["types"][field] |= types } + merge_cluster_nested_shapes!(cluster["value_hash_shapes"], data["value_hash_shapes"]) + merge_cluster_nested_shapes!(cluster["value_array_element_shapes"], data["value_array_element_shapes"]) + end + clusters + end + + def hash_record_shape_type_partition(shape, keys) + values = Array(shape["keys"]).zip(Array(shape["value_types"])).each_with_object({}) do |(key, type), index| + index[key.to_s] ||= [] + index[key.to_s] << type.to_s + end + keys.filter_map do |field| + families = hash_record_type_families(values[field]) + next if families.empty? + "#{field}=#{families.sort.join("|")}" + end.join(";") + end + + def merge_cluster_nested_shapes!(target, source) + Hash(source).each do |field, shape| + next unless shape + target[field.to_s] = target[field.to_s] ? merge_hash_record_shapes(target[field.to_s], shape) : dup_hash_shape(shape) + end + end + + def dup_hash_shape(shape) + HashShapeOps.dup_shape(shape, stringify_keys: true) + end + + def merge_hash_record_shapes(left, right) + HashShapeOps.merge_shapes(left, right, stringify_keys: true) + end + + def new_hash_shape_cluster(keys) + { + "shape_count" => 0, + "keysets" => [], + "union_keys" => keys, + "common_keys" => keys, + "sites" => [], + "producers" => [], + "consumers" => [], + "blockers" => [], + "field_member_calls" => Hash.new { |h, k| h[k] = [] }, + "field_member_examples" => Hash.new { |h, k| h[k] = [] }, + "types" => Hash.new { |h, k| h[k] = [] }, + "value_hash_shapes" => {}, + "value_array_element_shapes" => {}, + "read_counts" => Hash.new(0), + "examples" => [], + "collection_slot_ids" => Set.new, + "param_slot_ids" => Set.new, + "return_slot_ids" => Set.new, + "ivar_slot_ids" => Set.new, + } + end + + def similar_hash_keysets?(left, right) + left = Array(left).to_set + right = Array(right).to_set + return false if left.empty? || right.empty? + intersection = (left & right).size + smaller = [left.size, right.size].min + union = (left | right).size + intersection == smaller || (intersection.to_f / union) >= 0.6 + end + + def hash_shape_cluster_type_compatible?(cluster, data) + shared = Array(cluster["union_keys"]) & Array(data["keys"]) + shared.all? do |field| + compatible_hash_record_field_types?(cluster["types"][field], data["types"][field]) + end + end + + def compatible_hash_record_field_types?(left, right) + left_families = hash_record_type_families(left) + right_families = hash_record_type_families(right) + return true if left_families.empty? || right_families.empty? + !(left_families & right_families).empty? + end + + def hash_record_type_families(types) + Array(types).filter_map do |type| + raw = strip_nilable(type.to_s) + case raw + when "String" then "string" + when "Symbol" then "symbol" + when "Integer" then "integer" + when "Float" then "float" + when "T::Boolean", "TrueClass", "FalseClass" then "boolean" + when /\A(?:Array|T::Array)\b/ then parameterized_hash_record_family(raw, "array") + when /\A(?:Hash|T::Hash)\b/ then parameterized_hash_record_family(raw, "hash") + when /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/ then raw + end + end.uniq + end + + def parameterized_hash_record_family(type, family) + params = generic_type_params(type) + return family if params.empty? + "#{family}<#{params.map { |param| hash_record_type_families([param]).first || param }.join(",")}>" + end + + def generic_type_params(type) + start = type.index("[") + return [] unless start && type.end_with?("]") + split_top_level(type[(start + 1)...-1]).map(&:strip).reject(&:empty?) + end + + def cluster_for_hash_lookup(lookup, clusters_by_exact_key) + origin = lookup["origin"] || {} + key = nil + if origin["kind"] == "hash literal" + key = hash_shape_site_key(origin["path"], origin["line"], origin["code"]) + elsif origin["kind"] == "local hash shape" + key = Array(origin.dig("shape", "keys")&.keys || origin.dig("shape", "keys")).map(&:to_s).sort.join("\0") + elsif origin["kind"] == "method parameter" && origin["shape"] + key = Array(origin.dig("shape", "keys")&.keys || origin.dig("shape", "keys")).map(&:to_s).sort.join("\0") + end + return clusters_by_exact_key[key] if key && clusters_by_exact_key[key] + nil + end + + def cluster_for_hash_blocker(blocker, clusters_by_exact_key) + origin = blocker["origin"] || {} + if origin["kind"] == "hash literal" + key = hash_shape_site_key(origin["path"], origin["line"], origin["code"]) + return clusters_by_exact_key[key] if key && clusters_by_exact_key[key] + end + nil + end + + def cluster_for_hash_member_call(call, clusters_by_exact_key) + cluster_for_hash_lookup({ "origin" => call["origin"] }, clusters_by_exact_key) + end + + def hash_shape_site_key(path, line, code) + shape = Array(@evidence&.dig("facts", "hash_shapes")).find do |candidate| + candidate["path"] == path && candidate["line"].to_i == line.to_i && candidate["code"].to_s == code.to_s + end + Array(shape&.fetch("keys", nil)).map(&:to_s).sort.join("\0") unless shape.nil? + end + + def finalize_hash_record_struct_candidate(cluster) + collection_slots = cluster.delete("collection_slot_ids").size + param_slots = cluster.delete("param_slot_ids").size + return_slots = cluster.delete("return_slot_ids").size + ivar_slots = cluster.delete("ivar_slot_ids").size + common = Array(cluster["common_keys"]).sort + union = Array(cluster["union_keys"]).sort + optional = union - common + fields = union.map do |field| + type = hash_record_field_type(cluster, field) + type = "T.untyped" unless NilKill.useful_type?(type) + type = "T.nilable(#{type})" if optional.include?(field) && type != "T.untyped" && type != "NilClass" && !type.start_with?("T.nilable(") + data = { "name" => field, "type" => type, "optional" => optional.include?(field) } + if (nested = hash_record_nested_candidate_field(cluster, field)) + data.merge!(nested) + end + members = Array(cluster["field_member_calls"][field]).uniq.sort + data["required_members"] = members unless members.empty? + data + end + base_name = common.first || union.first || "record" + struct_name = hash_record_struct_name(base_name) + scope = hash_record_struct_scope(fields) + type_name = (scope + [struct_name]).join("::") + fields.each do |field| + if (nested = field["nested"]) + nested["type_name"] = (scope + [nested["struct_name"]]).join("::") + field["type"] = nested["kind"] == "array" ? "T::Array[#{nested["type_name"]}]" : nested["type_name"] + end + end + cluster.merge( + "struct_name" => struct_name, + "type_name" => type_name, + "scope" => scope, + "struct_path" => hash_record_struct_path_for_scope(scope), + "keyset_count" => cluster["keysets"].uniq.size, + "common_keys" => common, + "optional_keys" => optional, + "fields" => fields, + "nested_structs" => hash_record_nested_structs(fields), + "collection_slots" => collection_slots, + "param_slots" => param_slots, + "return_slots" => return_slots, + "ivar_slots" => ivar_slots, + "total_pressure" => collection_slots + param_slots + return_slots + ivar_slots + ) + end + + def hash_record_field_type(cluster, field) + members = Array(cluster["field_member_calls"][field]).uniq.sort + protocol_type = hash_record_protocol_type_for_members(members) + return protocol_type if protocol_type + return "Object" if hash_record_nested_shape_for_field(cluster, field) + NilKill.static_sorbet_type(cluster["types"][field]) + end + + def hash_record_nested_candidate_field(cluster, field) + nested = hash_record_nested_shape_for_field(cluster, field) + return nil unless nested && nested["shape"] && !nested["shape"]["poisoned"] + nested_fields = hash_record_fields_for_nested_shape(nested["shape"]) + return nil if nested_fields.empty? || nested_fields.any? { |candidate| NilKill.weak_type?(candidate["type"].to_s) || candidate["type"] == "T.untyped" } + struct_name = hash_record_struct_name(field) + { "nested" => nested.merge("struct_name" => struct_name, "fields" => nested_fields) } + end + + def hash_record_nested_shape_for_field(cluster, field) + arrays = Hash(cluster["value_array_element_shapes"]) + direct = Hash(cluster["value_hash_shapes"]) + if arrays[field] + { "kind" => "array", "shape" => arrays[field] } + elsif direct[field] + { "kind" => "hash", "shape" => direct[field] } + end + end + + def hash_record_fields_for_nested_shape(shape) + keys = Hash(shape["keys"]).keys.map(&:to_s).sort + keys.filter_map do |field| + type = NilKill.static_sorbet_type(shape.dig("keys", field)) + next unless field.match?(/\A[a-z_]\w*\z/) && NilKill.useful_type?(type) + { "name" => field, "type" => type, "optional" => false } + end + end + + def hash_record_nested_structs(fields) + Array(fields).flat_map do |field| + nested = field["nested"] + next [] unless nested + hash_record_nested_structs(nested["fields"]) + [nested] + end.uniq { |nested| nested["type_name"] || nested["struct_name"] } + end + + def hash_record_protocol_type_for_members(members) + set = members.to_set + return "AST::Locatable" if %w[full_type token].any? { |member| set.include?(member) } + nil + end + + def hash_record_struct_scope(fields) + namespaces = Array(fields).flat_map { |field| namespaces_in_type(field["type"]) }.uniq + namespaces.size == 1 ? namespaces.first.split("::") : [] + end + + def namespaces_in_type(type) + type.to_s.scan(/\b[A-Z]\w*(?:::[A-Z]\w*)+\b/).filter_map do |const| + parts = const.split("::") + next if parts.first == "T" + parts[0...-1].join("::") + end + end + + def hash_record_struct_path_for_scope(scope) + parts = Array(scope).map(&:to_s).reject(&:empty?) + return nil if parts.empty? + namespace = parts.join("::") + preferred = [ + File.join("src", *parts.map { |part| underscore_const(part) }, "#{underscore_const(parts.last)}.rb"), + File.join("src", "#{underscore_const(parts.last)}.rb"), + ] + preferred.find { |path| File.file?(File.join(ROOT, path)) } || begin + pattern = /^\s*(?:module|class)\s+#{Regexp.escape(parts.last)}\b/ + Dir.glob(File.join(ROOT, "src/**/*.rb")).find do |path| + File.readlines(path).any? { |line| line.match?(pattern) } + rescue StandardError + false + end&.then { |path| NilKill.rel(path) } + end + end + + def underscore_const(name) + name.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase + end + + def hash_record_struct_name(name) + base = name.to_s.gsub(/[^A-Za-z0-9_]/, "_").split("_").reject(&:empty?).map(&:capitalize).join + base = "Record" if base.empty? + "#{base}Record" + end + + def append_hash_record_struct_pressure(lines, evidence) + rows = hash_record_struct_pressure(evidence) + lines << "" + lines << "### High-Pressure HashMaps Acting As Structs" + if rows.empty? + lines << "- none" + return + end + rows.first(30).each do |row| + lines << "- #{row["label"]}: total pressure #{row["total_pressure"]}; return #{row["return_slots"]}, param #{row["param_slots"]}, ivar #{row["ivar_slots"]}, collection #{row["collection_slots"]}" + lines << " - keys: #{row["keys"].join(", ")}" unless row["keys"].empty? + row["examples"].first(3).each { |example| lines << " - #{example}" } + end + end + + def hash_record_struct_pressure(evidence) + if evidence.equal?(@evidence) && defined?(@hash_record_struct_pressure_cache) && @hash_record_struct_pressure_cache + return @hash_record_struct_pressure_cache + end + graph = flow_graph(evidence) + lookups = Array(evidence.dig("facts", "collection_index_lookups")).select { |lookup| hash_record_lookup?(lookup) } + param_origins = Array(evidence.dig("facts", "param_origins")) + return_sources = Array(evidence.dig("facts", "return_origins")).flat_map { |origin| Array(origin["sources"]) } + groups = Hash.new do |hash, key| + hash[key] = { "label" => key, "keys" => Set.new, "examples" => [], + "collection_slot_ids" => Set.new, "param_slot_ids" => Set.new, "return_slot_ids" => Set.new, + "ivar_slot_ids" => Set.new } + end + + lookups.each do |lookup| + label = graph.hash_record_label_for_lookup(lookup) + row = groups[label] + key = hash_record_lookup_key(lookup) + row["keys"].add(key) if key + row["collection_slot_ids"].add([lookup["path"], lookup["line"], lookup["code"]]) + row["ivar_slot_ids"].add([lookup["path"], lookup["line"], lookup["receiver"]]) if hash_record_ivar_lookup?(lookup) + row["examples"] << "#{lookup["path"]}:#{lookup["line"]} #{lookup["code"]}; receiver type #{lookup["receiver_type"] || "unknown"}" if row["examples"].size < 5 + + param_origins.each do |origin| + next unless origin["code"].to_s == lookup["code"].to_s + row["param_slot_ids"].add([origin["path"], origin["line"], origin["callee"], origin["slot"]]) + end + return_sources.each do |source| + next unless source["code"].to_s == lookup["code"].to_s + row["return_slot_ids"].add([source["path"], source["line"], source["code"]]) + end + end + + rows = groups.values.map do |row| + collection_slots = row.delete("collection_slot_ids").size + param_slots = row.delete("param_slot_ids").size + return_slots = row.delete("return_slot_ids").size + ivar_slots = row.delete("ivar_slot_ids").size + row.merge( + "keys" => row["keys"].to_a.sort, + "collection_slots" => collection_slots, + "param_slots" => param_slots, + "return_slots" => return_slots, + "ivar_slots" => ivar_slots, + "total_pressure" => collection_slots + param_slots + return_slots + ivar_slots + ) + end.sort_by { |row| [-row["total_pressure"], -row["collection_slots"], row["label"]] } + @hash_record_struct_pressure_cache = rows if evidence.equal?(@evidence) + rows + end + + def flow_graph(evidence = @evidence) + @flow_graph ||= FlowGraph.from_evidence(evidence) + end + + def hash_record_lookup?(lookup) + key = hash_record_lookup_key(lookup) + return false unless key + lookup_type = lookup["lookup_type"].to_s + return false if NilKill.useful_type?(lookup_type) && !NilKill.weak_type?(lookup_type) + status = lookup["status"].to_s + status != "typed collection receiver" || lookup["receiver_type"].to_s.include?("T.untyped") + end + + def hash_record_lookup_key(lookup) + index = lookup["index"].to_s + case index + when /\A:([A-Za-z_]\w*[!?=]?)\z/ + Regexp.last_match(1) + when /\A["']([^"']+)["']\z/ + Regexp.last_match(1) + end + end + + def hash_record_pressure_label(lookup) + origin = lookup["origin"] || {} + receiver = lookup["receiver"].to_s + if origin["kind"].to_s.empty? + receiver.empty? ? "unknown hash record" : "local hash record #{receiver}" + elsif origin["kind"] == "method parameter" + "method parameter hash record #{origin["name"]}" + elsif origin["kind"] == "instance variable" + "instance variable hash record #{origin["name"]}" + elsif receiver.match?(/\A[A-Za-z_]\w*\z/) + scope = lookup["enclosing_scope"].to_s + loc = [lookup["path"], scope.empty? ? nil : scope].compact.join(" ") + "local hash record #{receiver} at #{loc}" + else + collection_origin_label(origin) + end + end + + def hash_record_ivar_lookup?(lookup) + lookup["receiver"].to_s.start_with?("@") || lookup.dig("origin", "kind") == "instance variable" + end + + def collection_signature_slots(evidence) + method_lookup = evidence["methods"].each_with_object({}) do |method, lookup| + source = method["source"] + lookup[[collection_runtime_path_key(source["path"]), source["line"]]] = method if source + end + Array(evidence.dig("facts", "existing_sigs")).flat_map do |method| + rec = method_lookup[[collection_runtime_path_key(method["path"]), method["line"]]] + entries = extract_param_entries(method["sig"].to_s).filter_map do |name, type| + info = collection_type_info(type) + next unless info + { "slot_kind" => "param", "slot" => name, "type" => type, "info" => info, "method" => method, "runtime" => rec } + end + ret = extract_return_type(method["sig"].to_s) + if (info = collection_type_info(ret)) + entries << { "slot_kind" => "return", "slot" => "return", "type" => ret, "info" => info, "method" => method, "runtime" => rec } + end + entries + end + end + + def collection_type_info(type) + raw = strip_nilable(type.to_s.strip) + return nil if raw.empty? + case raw + when /\A(?:Array|T::Array)(?:\[(.*)\])?\z/ + elem = $1 + weak = elem.nil? || elem.include?("T.untyped") + { "kind" => "array", "element" => elem, "weak" => weak, "raw" => raw } + when /\A(?:Hash|T::Hash)(?:\[(.*)\])?\z/ + args = $1 ? split_top_level($1) : [] + key = args[0] + value = args[1] + weak = key.nil? || value.nil? || key.include?("T.untyped") || value.include?("T.untyped") + { "kind" => "hash", "key" => key, "value" => value, "weak" => weak, "raw" => raw } + end + end + + def append_collection_slot_coverage(lines, slots) + counts = Hash.new { |h, k| h[k] = { "total" => 0, "strong" => 0, "weak" => 0, "nilable" => 0 } } + slots.each do |slot| + kind = slot.dig("info", "kind") + counts[kind]["total"] += 1 + counts[kind][slot.dig("info", "weak") ? "weak" : "strong"] += 1 + counts[kind]["nilable"] += 1 if nilable_type?(slot["type"].to_s) + end + %w[array hash].each do |kind| + data = counts[kind] + lines << "- #{kind.capitalize} signature slots: #{data["total"]} total, #{data["strong"]} strong, #{data["weak"]} weak, #{data["nilable"]} nilable" + end + end + + def append_collection_slot_candidates(lines, evidence, slots) + weak = slots.select { |slot| slot.dig("info", "weak") } + lines << "" + lines << "### Weak Collection Slots With Runtime Candidates" + if weak.empty? + lines << "- none" + return + end + candidates = weak.filter_map { |slot| collection_slot_candidate(slot) } + if candidates.empty? + lines << "- none" + else + candidates.first(50).each do |candidate| + method = candidate["method"] + current = candidate["current"] + lines << "- #{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{candidate["slot_kind"]} #{candidate["slot"]}: #{current} -> #{candidate["candidate"]} (#{candidate["calls"]} call(s))" + end + end + + no_candidate = weak.reject { |slot| collection_slot_candidate(slot) } + return if no_candidate.empty? + lines << "" + lines << "### Weak Collection Slots Without Candidate" + no_candidate.first(30).each do |slot| + method = slot["method"] + reason = collection_slot_missing_candidate_reason(slot) + lines << "- #{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{slot["slot_kind"]} #{slot["slot"]}: #{slot["type"]}; #{reason}" + end + lines << "- ... #{no_candidate.size - 30} more" if no_candidate.size > 30 + end + + def collection_slot_candidate(slot) + rec = slot["runtime"] + return nil unless rec && rec["calls"].to_i.positive? + info = slot["info"] + candidate = + if info["kind"] == "array" + elem = slot["slot_kind"] == "return" ? rec["return_elem"] : rec.dig("param_elem", slot["slot"]) + elem_shapes = slot["slot_kind"] == "return" ? rec["return_elem_shapes"] : rec.dig("param_elem_shapes", slot["slot"]) + type = NilKill.shape_union_type(elem_shapes) + type ||= NilKill.conservative_element_type(elem) + candidate = type ? "T::Array[#{type}]" : nil + candidate if candidate && NilKill.acceptable_shape_candidate?(candidate) + else + kv = slot["slot_kind"] == "return" ? rec["return_kv"] : rec.dig("param_kv", slot["slot"]) + kv_shapes = slot["slot_kind"] == "return" ? rec["return_kv_shapes"] : rec.dig("param_kv_shapes", slot["slot"]) + key = NilKill.shape_union_type(Array(kv_shapes)[0]) + value = NilKill.shape_union_type(Array(kv_shapes)[1]) + key ||= NilKill.conservative_element_type(Array(kv)[0]) + value ||= NilKill.conservative_element_type(Array(kv)[1]) + candidate = key && value ? "T::Hash[#{key}, #{value}]" : nil + candidate if candidate && NilKill.acceptable_shape_candidate?(candidate) + end + return nil unless candidate && candidate != slot["type"] + { "method" => slot["method"], "slot_kind" => slot["slot_kind"], "slot" => slot["slot"], + "current" => slot["type"], "candidate" => candidate, "calls" => rec["calls"].to_i } + end + + def collection_slot_missing_candidate_reason(slot) + rec = slot["runtime"] + return "method not observed at runtime" unless rec && rec["calls"].to_i.positive? + info = slot["info"] + if info["kind"] == "array" + elems = slot["slot_kind"] == "return" ? rec["return_elem"] : rec.dig("param_elem", slot["slot"]) + return "no element observations" if Array(elems).empty? + "element observations are heterogeneous or AST/MIR-specific: #{Array(elems).first(6).join(", ")}" + else + kv = slot["slot_kind"] == "return" ? rec["return_kv"] : rec.dig("param_kv", slot["slot"]) + keys = Array(Array(kv)[0]) + values = Array(Array(kv)[1]) + return "no key/value observations" if keys.empty? && values.empty? + "key observations #{keys.first(4).join(", ")}; value observations #{values.first(4).join(", ")}" + end + end + + def append_collection_blocker_pressure(lines, evidence, slots) + pressure = collection_blocker_pressure(evidence, slots) + lines << "" + lines << "### Collection Blocker Pressure" + if pressure.empty? + lines << "- none" + return + end + pressure.first(30).each do |label, data| + lines << "- #{label}: #{data["slots"].size} slot(s), #{data["calls"]} observation(s)" + unless data["mutation_sites"].empty? + top_sites = data["mutation_sites"].sort_by { |site, count| [-count, site] }.first(3) + lines << " - mutation sites: #{top_sites.map { |site, count| "#{site} (#{count})" }.join(", ")}" + end + data["examples"].first(3).each do |example| + lines << " - #{example}" + end + end + end + + def collection_blocker_pressure(evidence, slots) + runtime_owners = collection_runtime_owner_index(evidence) + pressure = Hash.new { |h, k| h[k] = { "slots" => Set.new, "calls" => 0, "mutation_sites" => Hash.new(0), "examples" => [] } } + slots.select { |slot| slot.dig("info", "weak") }.each do |slot| + candidate = collection_slot_candidate(slot) + next if candidate && !candidate["candidate"].include?("T.untyped") + + reason = candidate ? "candidate still contains T.untyped: #{candidate["candidate"]}" : collection_slot_missing_candidate_reason(slot) + owners = collection_slot_runtime_owners(runtime_owners, slot) + owners = [nil] if owners.empty? + owners.each do |owner| + label = collection_blocker_label(slot, owner, reason) + data = pressure[label] + data["slots"] << collection_slot_key(slot) + data["calls"] += owner ? owner["calls"].to_i : slot.dig("runtime", "calls").to_i + Array(owner && owner["mutation_sites"]).each do |site, count| + data["mutation_sites"][site] += count.to_i + end + example = collection_slot_example(slot) + data["examples"] << example unless data["examples"].include?(example) + end + end + pressure.sort_by { |label, data| [-data["slots"].size, -data["calls"], label] }.to_h + end + + def collection_runtime_owner_index(evidence) + Array(evidence.dig("facts", "collection_runtime")).group_by do |rec| + [rec["owner_kind"], rec["name"], collection_runtime_path_key(rec["path"]), rec["line"].to_i, rec["kind"]] + end + end + + def collection_slot_runtime_owners(index, slot) + method = slot["method"] + info = slot["info"] + kind = info["kind"] + owner_kind = slot["slot_kind"] == "return" ? "method_return" : "method_param" + name = slot["slot_kind"] == "return" ? method["method"].to_s : slot["slot"].to_s + index.fetch([owner_kind, name, collection_runtime_path_key(method["path"]), method["line"].to_i, kind], []) + end + + def collection_runtime_path_key(path) + NilKill.rel(File.expand_path(path.to_s, ROOT)) + end + + def collection_blocker_label(slot, owner, reason) + if owner + "#{owner["owner_kind"]} #{owner["name"]} #{owner["kind"]} at #{collection_runtime_path_key(owner["path"])}:#{owner["line"]}; #{reason}" + else + method = slot["method"] + "#{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{slot["slot_kind"]} #{slot["slot"]}; #{reason}" + end + end + + def collection_slot_key(slot) + method = slot["method"] + [method["path"], method["line"], method["class"], method["method"], slot["slot_kind"], slot["slot"]].join(":") + end + + def collection_slot_example(slot) + method = slot["method"] + "#{method["path"]}:#{method["line"]} #{method["class"]}##{method["method"]} #{slot["slot_kind"]} #{slot["slot"]}: #{slot["type"]}" + end + + def append_collection_index_lookup_report(lines, lookups) + lines << "" + lines << "### Collection Index Lookup Provenance" + lines << "- provenance: the inferred origin of the collection receiver being indexed with `[]`, `fetch`, or similar lookup syntax" + lines << "- receiver origin: the parameter, literal, forwarded return, instance variable, or local record that produced the indexed receiver" + lines << "- weak index lookup: an index lookup where the receiver is unknown, `T.untyped`, or a weak collection type" + if lookups.empty? + lines << "- none" + return + end + status_counts = lookups.each_with_object(Hash.new(0)) { |lookup, counts| counts[lookup["status"]] += 1 } + status_counts.sort_by { |status, count| [-count, status] }.each do |status, count| + lines << "- #{status}: #{count}" + end + weak = lookups.select { |lookup| lookup["status"] != "typed collection receiver" } + graph = flow_graph + grouped = weak.each_with_object(Hash.new { |h, k| h[k] = { "count" => 0, "examples" => [] } }) do |lookup, groups| + label = hash_record_lookup_key(lookup) ? graph.hash_record_label_for_lookup(lookup) : collection_origin_label(lookup["origin"]) + groups[label]["count"] += 1 + groups[label]["examples"] << lookup if groups[label]["examples"].size < 5 + end + return if grouped.empty? + lines << "" + lines << "### Unknown Or Weak Index Lookups By Receiver Origin" + grouped.sort_by { |label, data| [-data["count"], label] }.first(40).each do |label, data| + lines << "- #{label}: #{data["count"]}" + data["examples"].each do |lookup| + lines << " - #{lookup["path"]}:#{lookup["line"]} #{lookup["code"]}; receiver #{lookup["receiver"]}; index #{lookup["index"]}; receiver type #{lookup["receiver_type"] || "unknown"}" + end + end + end + + def append_runtime_collection_observations(lines, collections) + lines << "" + lines << "### Runtime Collection Mutation Observations" + if collections.empty? + lines << "- none" + return + end + grouped = collections.group_by { |rec| rec["owner_kind"] } + grouped.sort_by { |kind, recs| [-recs.size, kind] }.each do |kind, recs| + lines << "- #{kind}: #{recs.size} slot(s)" + end + collections.sort_by { |rec| [-rec["calls"].to_i, rec["path"], rec["line"].to_i, rec["name"].to_s] }.first(40).each do |rec| + type = + case rec["kind"] + when "hash" + key = NilKill.conservative_element_type(rec["key_classes"]) + value = NilKill.conservative_element_type(rec["value_classes"]) + key && value ? "T::Hash[#{key}, #{value}]" : "T::Hash[T.untyped, T.untyped]" + when "set" + elem = NilKill.conservative_element_type(rec["elem_classes"]) + elem ? "T::Set[#{elem}]" : "T::Set[T.untyped]" + else + elem = NilKill.conservative_element_type(rec["elem_classes"]) + elem ? "T::Array[#{elem}]" : "T::Array[T.untyped]" + end + lines << " - #{rec["path"]}:#{rec["line"]} #{rec["owner_kind"]} #{rec["name"]}; #{rec["kind"]}; #{type}; #{rec["calls"]} observation(s)" + end + end + + def collection_origin_label(origin) + origin ||= {} + case origin["kind"] + when "method parameter" + "method parameter #{origin["name"]}#{origin["type"] ? " (#{origin["type"]})" : ""} at #{origin["path"]}:#{origin["line"]}" + when "array literal", "hash literal" + "#{origin["kind"]} #{origin["name"]} at #{origin["path"]}:#{origin["line"]}" + when "forwarded return" + "forwarded return #{origin["callee"] || origin["code"]} at #{origin["path"]}:#{origin["line"]}" + when "instance variable" + "unresolved instance/global variable #{origin["name"]}" + else + [origin["kind"], origin["name"] || origin["code"]].compact.join(" ") + end + end + + def append_tuple_report(lines, evidence) + tuples = Array(evidence["facts"]["tuple_arrays"]) + runtime_tuples = Array(evidence["facts"]["tuple_runtime"]) + grouped = Hash.new { |h, k| h[k] = { "count" => 0, "sites" => [], "confidence" => "review" } } + tuples.each do |tuple| + key = Array(tuple["types"]).join(", ") + grouped[key]["count"] += 1 + grouped[key]["sites"] << "#{tuple["path"]}:#{tuple["line"]}" + grouped[key]["confidence"] = "high" if tuple["confidence"] == "high" + end + lines << "" + lines << "## Tuple-Like Array Report" + lines << "- tuple-like array: an array literal whose position-specific element types look meaningful enough to model as a tuple/record" + lines << "- confidence: `high` means the static shape is regular enough for a likely-safe tuple type; `review` means the shape is useful but needs human inspection" + lines << "- Tuple-like array literals: #{tuples.size}" + lines << "- Runtime-observed tuple-like array slots: #{runtime_tuples.map { |tuple| [tuple["kind"], tuple["path"], tuple["line"], tuple["slot"]] }.uniq.size}" + append_runtime_tuple_list(lines, runtime_tuples) + if grouped.empty? + lines << "- none" + return + end + grouped.sort_by { |_types, data| [-data["count"], data["confidence"] == "high" ? 0 : 1] }.first(50).each do |types, data| + lines << "- [#{types}] appears #{data["count"]} time(s), confidence #{data["confidence"]}; first site #{data["sites"].first}" + end + end + + def append_runtime_tuple_list(lines, runtime_tuples) + lines << "" + lines << "### Runtime Tuple-Like Array Slots" + if runtime_tuples.empty? + lines << "- none" + return + end + runtime_tuples.sort_by { |tuple| [-tuple["calls"].to_i, tuple["path"], tuple["line"].to_i] }.first(30).each do |tuple| + labels = [] + labels << "complete" if tuple["complete"] + labels << "mixed" if tuple["mixed"] + labels << "size #{tuple["size"]}" + lines << "- #{NilKill.rel(tuple["path"])}:#{tuple["line"]} #{tuple["kind"]} #{tuple["slot"]}; [#{Array(tuple["types"]).join(", ")}]; #{tuple["calls"]} call(s); #{labels.join(", ")}" + end + end + + # Accumulator that lets per-section coverage emitters hand their counts + # back so we can roll up a cross-section primitive-collection summary at + # the end of the hygiene overview. + class HygieneCountsAcc + attr_reader :sections + def initialize + @sections = {} + end + def add(kind, counts) + @sections[kind] = counts + end + end + + def append_primitive_collection_summary(lines, accumulator) + sections = accumulator.sections + return if sections.empty? + total = sections.values.sum { |c| c["weak_collection"].to_i } + return if total.zero? + lines << "" + lines << "### Primitive Collection Slots (T::Array[T.untyped] / T::Hash[T.untyped, T.untyped] / T::Set[T.untyped])" + lines << "- Total weak-collection slots across all categories: #{total}" + [%w[param Param], %w[return Return], %w[struct_field Struct\ field], %w[tlet T.let\ assignment]].each do |key, label| + c = sections[key] + next unless c && c["weak_collection"].to_i.positive? + lines << " - #{label}: #{c["weak_collection"]}" + end + end + + def empty_type_counts + { "strong" => 0, "weak" => 0, "untyped" => 0, "nilable" => 0, "weak_collection" => 0 } + end + + def classify_type!(counts, type) + type = type.to_s.strip + return if type.empty? + counts["nilable"] += 1 if nilable_type?(type) + inner = strip_nilable(type) + if untyped_type?(inner) + counts["untyped"] += 1 + elsif weak_type?(inner) + counts["weak"] += 1 + counts["weak_collection"] += 1 if weak_collection_type?(inner) + else + counts["strong"] += 1 + end + end + + def format_type_counts(counts, denominator: nil) + total = denominator || (counts["strong"] + counts["weak"] + counts["untyped"]) + return "strong #{counts["strong"]}, weak #{counts["weak"]}, untyped #{counts["untyped"]}" if total.zero? + "strong #{counts["strong"]} (#{percent(counts["strong"], total)}), " \ + "weak #{counts["weak"]} (#{percent(counts["weak"], total)}), " \ + "untyped #{counts["untyped"]} (#{percent(counts["untyped"], total)})" + end + + def percent(part, total) + "%.1f%%" % (100.0 * part / total) + end + + # `T::Array[T.untyped]` and friends -- container shape known, element type + # unknown. Counted in "weak" already; this sub-bucket lets us report the + # primitive-collection-with-untyped-element pressure across all slot kinds. + def weak_collection_type?(type) + type.to_s.match?(/\AT::(?:Array|Hash|Enumerable|Set)\b.*\[T\.untyped/) + end + + def extract_param_types(sig) + params = extract_call_args(sig, "params") + return [] unless params + split_top_level(params).filter_map do |entry| + _name, type = entry.split(/:\s*/, 2) + type&.strip + end + end + + def extract_return_type(sig) + extract_call_args(sig, "returns") + end + + def extract_call_args(source, name) + idx = source.index("#{name}(") + return nil unless idx + start = idx + name.length + 1 + depth = 1 + i = start + while i < source.length + case source[i] + when "(" then depth += 1 + when ")" + depth -= 1 + return source[start...i] if depth.zero? + end + i += 1 + end + nil + end + + def split_top_level(source) + parts = [] + start = 0 + depth = 0 + source.each_char.with_index do |char, idx| + case char + when "(", "[", "{" + depth += 1 + when ")", "]", "}" + depth -= 1 if depth.positive? + when "," + if depth.zero? + parts << source[start...idx].strip + start = idx + 1 + end + end + end + parts << source[start..].to_s.strip + parts.reject(&:empty?) + end + + def nilable_type?(type) + type.start_with?("T.nilable(") || type == "NilClass" + end + + def strip_nilable(type) + return type unless type.start_with?("T.nilable(") + extract_call_args(type, "T.nilable") || type + end + + def untyped_type?(type) + type == "T.untyped" + end + + def weak_type?(type) + type.include?("T.any(") || + type.include?("T.untyped") || + type.match?(/\AT::(?:Array|Hash|Enumerable|Set)\b.*\[T\.untyped/) + end + + def callsite_pressure(actions, kind) + pressure = Hash.new { |h, k| h[k] = { "slots" => Set.new, "calls" => 0, "actions" => [] } } + actions.select { |a| a["kind"] == kind }.each do |action| + next unless pressure_action_unresolved?(action) + + slot = "#{action["path"]}:#{action["line"]}:#{action.dig("data", "name")}" + (action.dig("data", "callsites") || {}).each do |site, count| + root = site.sub(/:[^:]+\z/, "") + pressure[root]["slots"] << slot + pressure[root]["calls"] += count.to_i + pressure[root]["actions"] << action.merge("root_calls" => count.to_i) + end + end + pressure + end + + def pressure_action_unresolved?(action) + param = method_param_for_action(action) + return true unless param + + type = param["type"].to_s + case action["kind"] + when "nil_param_observed" + !nilable_type?(type) + when "union_observed", "bad_input_type_candidate" + weak_untyped_type?(type) + else + true + end + end + + def method_param_for_action(action) + name = action.dig("data", "name").to_s + return nil if name.empty? + + method = method_at(action["path"], action["line"]) + Array(method && method["params"]).find { |param| param["name"].to_s == name } + end + + def nilable_type?(type) + type.include?("T.nilable(") || type == "NilClass" + end + + def weak_untyped_type?(type) + type == "T.untyped" || type.include?("[T.untyped") || type.include?(", T.untyped") || type.include?("T.untyped]") + end + + def merge_pressure(*groups) + merged = Hash.new { |h, k| h[k] = { "slots" => Set.new, "calls" => 0, "actions" => [] } } + groups.each do |group| + group.each do |site, data| + merged[site]["slots"].merge(data["slots"]) + merged[site]["calls"] += data["calls"].to_i + merged[site]["actions"].concat(Array(data["actions"])) + end + end + merged + end + + def append_pressure_list(lines, pressure, label) + if pressure.empty? + lines << "- none" + return + end + pressure.sort_by { |_site, data| pressure_sort_key(data) }.first(50).each do |site, data| + slots = data["slots"].size + calls = data["calls"].to_i + score = pressure_priority(slots, calls) + lines << "- #{site} priority #{format("%.2f", score)}; affects #{label} in #{slots} signature slot(s), #{calls} observed call(s)" + Array(data["actions"]).uniq { |action| [action["kind"], action["path"], action["line"], action.dig("data", "name")] }.first(5).each do |action| + lines << " - #{action["path"]}:#{action["line"]} #{action.dig("data", "name")}#{pressure_action_hint(action)}" + end + end + end + + def pressure_action_hint(action) + data = action["data"] || {} + case action["kind"] + when "nil_param_observed" + candidate = data["candidate_type"] + default = default_for_type(candidate) + parts = [] + parts << "candidate #{candidate}" if NilKill.useful_type?(candidate) + parts << "default #{default}" if default + parts.empty? ? "" : " (#{parts.join("; ")})" + when "union_observed" + classes = Array(data["classes"]) + text = classes.first(5).join(", ") + text += ", ..." if classes.size > 5 + " (observed #{text})" + when "bad_input_type_candidate" + " (normal calls suggest #{data["candidate_type"]}; raised-only #{Array(data["raised_only_classes"]).join(", ")})" + else + "" + end + end + + def pressure_sort_key(data) + slots = data["slots"].size + calls = data["calls"].to_i + case ENV.fetch("NIL_KILL_PRESSURE_SORT", "priority") + when "slots" + [-slots, -calls] + when "hotness", "calls" + [-calls, -slots] + else + [-pressure_priority(slots, calls), -slots, -calls] + end + end + + def pressure_priority(slots, calls) + Math.sqrt([slots, 1].max) * (Math.log10([calls, 0].max + 1) + 1.0) + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/runtime_trace.rb b/gems/nil-kill/lib/nil_kill/runtime_trace.rb new file mode 100755 index 000000000..77a2333c9 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/runtime_trace.rb @@ -0,0 +1,1372 @@ +# typed: false +# frozen_string_literal: true + +require "fileutils" +require "json" +require "pathname" +require "set" + +module NilKillRuntimeTrace + ROOT = File.expand_path("../../../..", __dir__) + OUT_DIR = File.expand_path(File.join(ENV.fetch("NIL_KILL_TMP_DIR", File.join(ROOT, "tmp", "nil-kill")), "runtime"), ROOT) + TRACE_PLAN_PATH = File.expand_path(File.join(ENV.fetch("NIL_KILL_TMP_DIR", File.join(ROOT, "tmp", "nil-kill")), "trace-plan.json"), ROOT) + TARGETS = ENV.fetch("NIL_KILL_TARGETS", "src").split(File::PATH_SEPARATOR).map do |path| + File.expand_path(path, ROOT) + end + ELEMENT_SAMPLE = ENV.fetch("NIL_KILL_ELEMENT_SAMPLE", "20").to_i + TRACE_PARAM_CLASSES = ENV.fetch("NIL_KILL_TRACE_PARAM_CLASSES", "String,Symbol").split(",").map(&:strip).reject(&:empty?).to_set + + @methods = {} + @tlets = {} + @structs = {} + @ivar_runtime = {} + @tuples = {} + @collections = {} + @objects = {} + @frames = Hash.new { |h, k| h[k] = [] } + @shape_lookup = {} + @path_cache = {} + @target_cache = {} + @method_metadata = {} + @planned_methods_by_class = nil + @targeted_tracepoints = [] + @targeted_tracepoint_keys = Set.new + @trace_plan_loaded = false + @trace_plan = nil + @lock = Mutex.new + + class << self + attr_reader :methods, :tlets, :structs, :tuples, :collections, :objects, :frames, :path_cache, :target_cache, :lock + end + + def self.trace_plan + return nil if ENV["NIL_KILL_TRACE_PLAN"] == "0" + return @trace_plan if @trace_plan_loaded + @trace_plan_loaded = true + @trace_plan = File.exist?(TRACE_PLAN_PATH) ? JSON.parse(File.read(TRACE_PLAN_PATH)) : nil + if @trace_plan + plan_targets = Array(@trace_plan["target_dirs"]).map { |path| File.expand_path(path, ROOT) }.sort + current_targets = TARGETS.map { |path| File.expand_path(path, ROOT) }.sort + @trace_plan = nil unless plan_targets == current_targets + end + rescue JSON::ParserError + @trace_plan = nil + end + + def self.planned_methods_by_class + return @planned_methods_by_class if @planned_methods_by_class + plan = trace_plan + return nil unless plan + methods = + if ENV["NIL_KILL_TRACE_METHODS"] == "0" + plan.fetch("tracepoint_methods", {}) + else + fallback = plan.fetch("tracepoint_methods", {}) + fallback.empty? ? plan.fetch("methods", {}) : fallback + end + return nil if methods.empty? + index = Hash.new { |h, k| h[k] = [] } + methods.each do |raw_key, method_plan| + owner, method_id, kind, path, line = raw_key.split("\0", 5) + next if method_plan && method_plan["sample"] == false + index[owner] << { + owner: owner, + method_id: method_id, + kind: kind, + path: path, + line: line.to_i, + params: method_plan.fetch("params", {}), + return: method_plan["return"] != false, + } + end + @planned_methods_by_class = index + end + + def self.targeted_method_tracing? + planned_methods_by_class + end + + def self.install_targeted_method_traces(klass) + index = planned_methods_by_class + return unless index && klass.is_a?(Module) + klass_name = safe_module_name(klass) + return unless klass_name.is_a?(String) + Array(index[klass_name]).each do |entry| + target = + if entry[:kind] == "class" + klass.method(entry[:method_id]) rescue nil + else + klass.instance_method(entry[:method_id]) rescue nil + end + next unless target + trace_key = [klass_name, entry[:kind], entry[:method_id], target.object_id] + next unless @targeted_tracepoint_keys.add?(trace_key) + sample_params = entry[:params].values.any? + sample_return = entry[:return] + @targeted_tracepoints << TracePoint.new(:call) { |tp| record_call(tp, forced_entry: entry) }.enable(target: target) if sample_params + @targeted_tracepoints << TracePoint.new(:return) { |tp| record_return(tp, forced_entry: entry) }.enable(target: target) if sample_return + end + end + + def self.install_targeted_definition_trace + trace = TracePoint.new(:end) do |tp| + install_targeted_method_traces(tp.self) + end + @targeted_tracepoints << trace + trace.enable + end + + def self.method_plan(owner, method_id, kind, path, line) + plan = trace_plan + return nil unless plan + plan.dig("methods", [owner, method_id.to_s, kind, abs_path(path), line].join("\0")) + end + + def self.sample_param?(plan, name) + return true unless plan + plan.dig("params", name.to_s) != false + end + + def self.sample_return?(plan) + return true unless plan + plan["return"] != false + end + + def self.sample_tlet?(path, line) + plan = trace_plan + return true unless plan + plan.dig("tlets", [abs_path(path), line].join("\0")) == true + end + + def self.sample_struct_field?(klass_name, field) + plan = trace_plan + return true unless plan + short = klass_name.to_s.split("::").last + plan.dig("struct_fields", [klass_name.to_s, field.to_s].join("\0")) == true || + plan.dig("struct_fields", [short, field.to_s].join("\0")) == true + end + + # In-place instrumentation: the wrapped file IS at its real src + # path, so there is no parallel-tree remap to undo. Just expand + # (cached). The NIL_KILL_INSTRUMENTED_ROOT translation is gone with + # the parallel tree it served. + def self.abs_path(path) + raw = path.to_s + @path_cache[raw] ||= File.expand_path(raw, ROOT) + end + + def self.target_path?(path) + raw = path.to_s + cached = @target_cache[raw] + return cached unless cached.nil? + abs = abs_path(raw) + TARGETS.any? { |target| abs == target || abs.start_with?(target + File::SEPARATOR) } + .tap { |matched| @target_cache[raw] = matched } + end + + MODULE_NAME = Module.instance_method(:name) + + # A module can override the singleton `.name` (e.g. REXML::Functions + # defines `.name` as an XPath DSL method). Binding Module#name + # directly always yields the real name and never invokes a user + # override that could raise mid-trace and abort the whole collect. + def self.safe_module_name(mod) + return nil unless mod.is_a?(Module) + MODULE_NAME.bind_call(mod) rescue nil + end + + def self.class_name(value) + return "NilClass" if value.nil? + cls = value.class rescue nil + return "T.untyped" unless cls + safe_module_name(cls) || "T.untyped" + end + + def self.shape_payload(key) + @shape_lookup[key] || { "kind" => "class", "name" => "T.untyped" } + end + + def self.remember_shape(key, payload) + @shape_lookup[key] ||= payload + key + end + + def self.class_shape_key(value) + cls = class_name(value) + remember_shape("class:#{cls}", { "kind" => "class", "name" => cls }) + end + + def self.collection_type_shape_key(value, depth = 3) + return class_shape_key(value) unless depth.positive? + case value + when Array + elements = value.first(ELEMENT_SAMPLE).map { |item| collection_type_shape_key(item, depth - 1) }.uniq.sort + remember_shape("array:[#{elements.join(";")}]", { "kind" => "array", "elements" => elements.map { |key| shape_payload(key) } }) + when Hash + key_shapes = [] + value_shapes = [] + value.first(ELEMENT_SAMPLE).each do |key, val| + key_shapes << collection_type_shape_key(key, depth - 1) + value_shapes << collection_type_shape_key(val, depth - 1) + end + key_shapes = key_shapes.uniq.sort + value_shapes = value_shapes.uniq.sort + remember_shape("hash:{#{key_shapes.join(";")}}:{#{value_shapes.join(";")}}", + { "kind" => "hash", + "keys" => key_shapes.map { |key| shape_payload(key) }, + "values" => value_shapes.map { |key| shape_payload(key) } }) + when Set + elements = value.first(ELEMENT_SAMPLE).map { |item| collection_type_shape_key(item, depth - 1) }.uniq.sort + remember_shape("set:[#{elements.join(";")}]", { "kind" => "set", "elements" => elements.map { |key| shape_payload(key) } }) + else + class_shape_key(value) + end + end + + def self.collection_value?(value) + value.is_a?(Array) || value.is_a?(Hash) || (defined?(Set) && value.is_a?(Set)) + end + + def self.nested_collection_shape(value) + collection_type_shape_key(value) if collection_value?(value) + end + + def self.container_shape(value) + case value + when Array, Set + [:array, value.first(ELEMENT_SAMPLE).map { |item| class_name(item) }.to_set, + value.first(ELEMENT_SAMPLE).filter_map { |item| nested_collection_shape(item) }.to_set] + when Hash + keys = Set.new + vals = Set.new + key_shapes = Set.new + value_shapes = Set.new + value.first(ELEMENT_SAMPLE).each do |key, val| + keys << class_name(key) + vals << class_name(val) + key_shape = nested_collection_shape(key) + value_shape = nested_collection_shape(val) + key_shapes << key_shape if key_shape + value_shapes << value_shape if value_shape + end + [:hash, [keys, vals], [key_shapes, value_shapes]] + end + end + + def self.register_collection_owner(value, owner) + return unless value.is_a?(Array) || value.is_a?(Hash) || (defined?(Set) && value.is_a?(Set)) + owners = (@objects[value.object_id] ||= {}) + owners[owner_identity_key(owner)] ||= owner + record_collection_snapshot(value, owner) + end + + def self.owner_identity_key(owner) + [owner[:owner_kind].to_s, owner[:name].to_s, File.expand_path(owner[:path], ROOT), owner[:line]] + end + + def self.collection_kind(value) + if value.is_a?(Hash) + "hash" + elsif defined?(Set) && value.is_a?(Set) + "set" + else + "array" + end + end + + def self.collection_key(value, owner) + [owner[:owner_kind].to_s, owner[:name].to_s, File.expand_path(owner[:path], ROOT), owner[:line], collection_kind(value)] + end + + def self.record_collection_snapshot(value, owner) + shape = container_shape(value) + return unless shape + if shape[0] == :array + record_collection_observation(value, owner, elem_classes: shape[1], elem_shapes: shape[2]) + else + record_collection_observation(value, owner, key_classes: shape[1][0], value_classes: shape[1][1], key_shapes: shape[2][0], value_shapes: shape[2][1]) + end + end + + def self.record_collection_observation(value, owner, elem_classes: [], key_classes: [], value_classes: [], elem_shapes: [], key_shapes: [], value_shapes: [], mutation_site: nil) + with_collection_hooks_disabled do + path = owner[:path] + line = owner[:line] + return unless path && line + kind = collection_kind(value) + key = collection_key(value, owner) + rec = (@collections[key] ||= { calls: 0, classes: Set.new, elem_classes: Set.new, key_classes: Set.new, value_classes: Set.new, + elem_shapes: Set.new, key_shapes: Set.new, value_shapes: Set.new, mutation_sites: Hash.new(0) }) + rec[:calls] += 1 + rec[:classes] << class_name(value) + rec[:elem_classes].merge(Array(elem_classes)) + rec[:key_classes].merge(Array(key_classes)) + rec[:value_classes].merge(Array(value_classes)) + rec[:elem_shapes].merge(Array(elem_shapes)) + rec[:key_shapes].merge(Array(key_shapes)) + rec[:value_shapes].merge(Array(value_shapes)) + rec[:mutation_sites][mutation_site] += 1 if mutation_site + bucket = owner[:bucket] + if bucket + case owner[:owner_kind].to_s + when "method_param" + if kind == "hash" + bucket[:param_kv][owner[:name]][0].merge(Array(key_classes)) + bucket[:param_kv][owner[:name]][1].merge(Array(value_classes)) + bucket[:param_kv_shapes][owner[:name]][0].merge(Array(key_shapes)) + bucket[:param_kv_shapes][owner[:name]][1].merge(Array(value_shapes)) + else + bucket[:param_elem][owner[:name]].merge(Array(elem_classes)) + bucket[:param_elem_shapes][owner[:name]].merge(Array(elem_shapes)) + end + when "method_return" + if kind == "hash" + bucket[:return_kv][0].merge(Array(key_classes)) + bucket[:return_kv][1].merge(Array(value_classes)) + bucket[:return_kv_shapes][0].merge(Array(key_shapes)) + bucket[:return_kv_shapes][1].merge(Array(value_shapes)) + else + bucket[:return_elem].merge(Array(elem_classes)) + bucket[:return_elem_shapes].merge(Array(elem_shapes)) + end + end + end + end + end + + def self.record_collection_mutation(value, elem: :__nil_kill_missing, key: :__nil_kill_missing, val: :__nil_kill_missing) + owners_by_key = @objects[value.object_id] + owners = owners_by_key&.values + return if owners.nil? || owners.empty? + elem_classes = [] + key_classes = [] + value_classes = [] + elem_shapes = [] + key_shapes = [] + value_shapes = [] + elem_classes << class_name(elem) unless elem == :__nil_kill_missing + key_classes << class_name(key) unless key == :__nil_kill_missing + value_classes << class_name(val) unless val == :__nil_kill_missing + elem_shape = nested_collection_shape(elem) unless elem == :__nil_kill_missing + key_shape = nested_collection_shape(key) unless key == :__nil_kill_missing + value_shape = nested_collection_shape(val) unless val == :__nil_kill_missing + elem_shapes << elem_shape if elem_shape + key_shapes << key_shape if key_shape + value_shapes << value_shape if value_shape + mutation_site = collection_mutation_site + @lock.synchronize do + owners.each do |owner| + record_collection_observation(value, owner, elem_classes: elem_classes, key_classes: key_classes, value_classes: value_classes, + elem_shapes: elem_shapes, key_shapes: key_shapes, value_shapes: value_shapes, mutation_site: mutation_site) + end + end + end + + def self.collection_mutation_site + loc = caller_locations(2, 20).find do |candidate| + path = candidate.absolute_path || candidate.path + path && target_path?(path) && File.expand_path(path, ROOT) != File.expand_path(__FILE__, ROOT) + end + return nil unless loc + "#{abs_path(loc.absolute_path || loc.path)}:#{loc.lineno}" + end + + def self.with_collection_hook_guard + previous = Thread.current[:__nil_kill_collection_hook] + return if previous + Thread.current[:__nil_kill_collection_hook] = true + yield + ensure + Thread.current[:__nil_kill_collection_hook] = previous + end + + def self.with_collection_hooks_disabled + previous = Thread.current[:__nil_kill_collection_hook] + Thread.current[:__nil_kill_collection_hook] = true + yield + ensure + Thread.current[:__nil_kill_collection_hook] = previous + end + + def self.source_location_for_class(klass) + return nil unless klass.respond_to?(:instance_method) + init = klass.instance_method(:initialize) rescue nil + loc = init&.source_location + loc && [File.expand_path(loc[0], ROOT), loc[1]] + end + + def self.method_owner(defined_class) + return nil unless defined_class + if defined_class.respond_to?(:singleton_class?) && defined_class.singleton_class? + target = defined_class.respond_to?(:attached_object) ? (defined_class.attached_object rescue nil) : nil + tn = safe_module_name(target) + return [tn, "class"] if tn + nil + else + dn = safe_module_name(defined_class) + dn && [dn, "instance"] + end + end + + def self.bucket(tp) + meta = method_metadata(tp) + return nil unless meta && meta[:target] + bucket_for_meta(meta) + end + + def self.method_metadata(tp, known_target: false) + defined_class = tp.defined_class + class_key = defined_class&.object_id || 0 + by_class = (@method_metadata[class_key] ||= {}) + by_method = (by_class[tp.method_id] ||= {}) + by_path = (by_method[tp.path] ||= {}) + cached = by_path[tp.lineno] + return cached if cached + + path = abs_path(tp.path) + target = known_target || target_path?(path) + owner = target ? method_owner(defined_class) : nil + method_id = tp.method_id.to_s + plan = owner && target ? method_plan(owner[0], tp.method_id, owner[1], path, tp.lineno) : nil + sample_method = plan.nil? || plan["sample"] != false + params = target ? (tp.parameters rescue nil) : nil + by_path[tp.lineno] = { + target: target, + owner: owner, + method_id: method_id, + path: path, + line: tp.lineno, + key: owner && [owner, method_id, path], + bucket_key: owner && [owner[0], method_id, owner[1], path, tp.lineno], + method_site: "#{path}:#{tp.lineno}", + plan: plan, + sample_method: sample_method, + params: params, + } + end + + def self.forced_method_metadata(tp, entry) + plan = { + "sample" => true, + "params" => entry[:params], + "return" => entry[:return], + } + path = abs_path(entry[:path]) + params = entry[:params].keys.map { |name| [:req, name.to_sym] } + { + target: true, + owner: [entry[:owner], entry[:kind]], + method_id: entry[:method_id].to_s, + path: path, + line: entry[:line], + key: [[entry[:owner], entry[:kind]], entry[:method_id].to_s, path], + bucket_key: [entry[:owner], entry[:method_id].to_s, entry[:kind], path, entry[:line]], + method_site: "#{path}:#{entry[:line]}", + plan: plan, + sample_method: true, + params: params, + forced_values: forced_param_values(tp, entry), + } + end + + def self.forced_param_values(tp, entry) + values = {} + args = tp.binding.local_variable_get(:args) rescue nil + return values unless args.is_a?(Array) + entry[:params].keys.each_with_index do |name, index| + values[name.to_s] = args[index] if index < args.length + end + values + end + + def self.bucket_for_meta(meta) + return nil unless meta[:owner] + key = meta[:bucket_key] + method_bucket(key, meta[:plan]) + end + + def self.method_bucket(key, plan = nil) + @methods[key] ||= { + calls: 0, + ok_calls: 0, + raised_calls: 0, + params_by_name: Hash.new { |h, k| h[k] = Set.new }, + params_ok: Hash.new { |h, k| h[k] = Set.new }, + params_raised: Hash.new { |h, k| h[k] = Set.new }, + param_sites: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_sites_ok: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_sites_raised: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_traces: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_traces_ok: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_traces_raised: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_elem: Hash.new { |h, k| h[k] = Set.new }, + param_kv: Hash.new { |h, k| h[k] = [Set.new, Set.new] }, + param_elem_shapes: Hash.new { |h, k| h[k] = Set.new }, + param_kv_shapes: Hash.new { |h, k| h[k] = [Set.new, Set.new] }, + returns: Set.new, + return_elem: Set.new, + return_kv: [Set.new, Set.new], + return_elem_shapes: Set.new, + return_kv_shapes: [Set.new, Set.new], + raised: Set.new, + plan: plan, + } + end + + def self.source_method_plan(owner, method_id, kind, path, line) + method_plan(owner, method_id, kind, path, line) + end + + def self.record_source_method_call(owner, method_id, kind, path, line, params) + return if Thread.current[:__nil_kill_collection_hook] + abs = abs_path(path) + return unless target_path?(abs) + plan = source_method_plan(owner, method_id, kind, abs, line) + key = [owner, method_id.to_s, kind, abs, line] + b = method_bucket(key, plan) + with_collection_hooks_disabled do + @lock.synchronize do + b[:calls] += 1 + params.each do |name, value| + next unless sample_param?(plan, name) + cls = class_name(value) + name = name.to_s + b[:params_by_name][name] << cls + b[:param_sites][name][site_key("#{abs}:#{line}", cls)] += 1 + shape = container_shape(value) + if shape + if shape[0] == :array + b[:param_elem][name].merge(shape[1]) + b[:param_elem_shapes][name].merge(shape[2]) + record_tuple("param", abs, line, name, value) + else + b[:param_kv][name][0].merge(shape[1][0]) + b[:param_kv][name][1].merge(shape[1][1]) + b[:param_kv_shapes][name][0].merge(shape[2][0]) + b[:param_kv_shapes][name][1].merge(shape[2][1]) + end + register_collection_owner(value, owner_kind: "method_param", name: name, path: abs, line: line, bucket: b) + end + end + end + end + nil + end + + def self.record_source_method_return(owner, method_id, kind, path, line, value) + abs = abs_path(path) + return value unless target_path?(abs) + plan = source_method_plan(owner, method_id, kind, abs, line) + return value unless sample_return?(plan) + key = [owner, method_id.to_s, kind, abs, line] + b = method_bucket(key, plan) + with_collection_hooks_disabled do + @lock.synchronize do + b[:ok_calls] += 1 + b[:returns] << class_name(value) + shape = container_shape(value) + if shape + if shape[0] == :array + b[:return_elem].merge(shape[1]) + b[:return_elem_shapes].merge(shape[2]) + record_tuple("return", abs, line, method_id.to_s, value) + else + b[:return_kv][0].merge(shape[1][0]) + b[:return_kv][1].merge(shape[1][1]) + b[:return_kv_shapes][0].merge(shape[2][0]) + b[:return_kv_shapes][1].merge(shape[2][1]) + end + register_collection_owner(value, owner_kind: "method_return", name: method_id.to_s, path: abs, line: line, bucket: b) + end + end + end + value + end + + def self.record_source_method_raise(owner, method_id, kind, path, line, error) + abs = abs_path(path) + return unless target_path?(abs) + plan = source_method_plan(owner, method_id, kind, abs, line) + key = [owner, method_id.to_s, kind, abs, line] + b = method_bucket(key, plan) + @lock.synchronize do + b[:raised_calls] += 1 + b[:raised] << class_name(error) + end + nil + end + + def self.record_call(tp, forced_entry: nil) + return if Thread.current[:__nil_kill_collection_hook] + return unless forced_entry || target_path?(tp.path) + with_collection_hooks_disabled do + meta = forced_entry ? forced_method_metadata(tp, forced_entry) : method_metadata(tp, known_target: true) + return unless meta + params = meta[:params] + return unless params + method_plan = meta[:plan] + sample_method = meta[:sample_method] + b = sample_method ? bucket_for_meta(meta) : nil + binding = tp.binding + frame = { + key: meta[:key], + bucket: b, + sample_method: sample_method, + plan: method_plan, + params: Hash.new { |h, k| h[k] = Set.new }, + param_sites: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_traces: Hash.new { |h, k| h[k] = Hash.new(0) }, + param_elem: Hash.new { |h, k| h[k] = Set.new }, + param_kv: Hash.new { |h, k| h[k] = [Set.new, Set.new] }, + param_elem_shapes: Hash.new { |h, k| h[k] = Set.new }, + param_kv_shapes: Hash.new { |h, k| h[k] = [Set.new, Set.new] }, + callsite: nil, + trace: [], + method_site: meta[:method_site], + } + @lock.synchronize do + active_trace = @frames[Thread.current.object_id].reverse.filter_map { |active| active[:method_site] } + frame[:trace] = (Array(frame[:trace]) + active_trace).uniq + b[:calls] += 1 if b && sample_method + params.each do |kind, name| + next unless name + next if %i[rest keyrest block].include?(kind) + next unless sample_method && sample_param?(method_plan, name) + value = meta[:forced_values]&.fetch(name.to_s, nil) + value = binding.local_variable_get(name) rescue nil unless meta[:forced_values]&.key?(name.to_s) + cls = class_name(value) + frame[:params][name.to_s] << cls + frame[:param_sites][name.to_s][site_key(frame[:method_site], cls)] += 1 + if TRACE_PARAM_CLASSES.include?(cls) + trace_key = trace_key(frame[:trace], cls) + frame[:param_traces][name.to_s][trace_key] += 1 if trace_key + end + shape = container_shape(value) + if shape + if shape[0] == :array + frame[:param_elem][name.to_s].merge(shape[1]) + frame[:param_elem_shapes][name.to_s].merge(shape[2]) + record_tuple("param", meta[:path], meta[:line], name.to_s, value) + else + frame[:param_kv][name.to_s][0].merge(shape[1][0]) + frame[:param_kv][name.to_s][1].merge(shape[1][1]) + frame[:param_kv_shapes][name.to_s][0].merge(shape[2][0]) + frame[:param_kv_shapes][name.to_s][1].merge(shape[2][1]) + end + register_collection_owner(value, owner_kind: "method_param", name: name.to_s, path: meta[:path], line: meta[:line], bucket: b) + end + end + if sample_return?(method_plan) + @frames[Thread.current.object_id] << frame + elsif b + commit_params_observed(b, frame) + end + end + end + end + + def self.record_return(tp, forced_entry: nil) + return if Thread.current[:__nil_kill_collection_hook] + return unless forced_entry || target_path?(tp.path) + with_collection_hooks_disabled do + meta = forced_entry ? forced_method_metadata(tp, forced_entry) : method_metadata(tp, known_target: true) + return unless meta + frame = pop_frame_for_meta(meta) + return if frame && !frame[:sample_method] + b = frame ? frame[:bucket] : bucket_for_meta(meta) + return unless b + value = tp.return_value + @lock.synchronize do + commit_params(b, frame, :ok) if frame + b[:ok_calls] += 1 + b[:returns] << class_name(value) if sample_return?(frame && frame[:plan]) + next_shape = sample_return?(frame && frame[:plan]) + return unless next_shape + shape = container_shape(value) + if shape + if shape[0] == :array + b[:return_elem].merge(shape[1]) + b[:return_elem_shapes].merge(shape[2]) + record_tuple("return", meta[:path], meta[:line], meta[:method_id], value) + else + b[:return_kv][0].merge(shape[1][0]) + b[:return_kv][1].merge(shape[1][1]) + b[:return_kv_shapes][0].merge(shape[2][0]) + b[:return_kv_shapes][1].merge(shape[2][1]) + end + register_collection_owner(value, owner_kind: "method_return", name: meta[:method_id], path: meta[:path], line: meta[:line], bucket: b) + end + end + end + end + + def self.record_raise(tp, forced_entry: nil) + return if Thread.current[:__nil_kill_collection_hook] + return unless forced_entry || target_path?(tp.path) + with_collection_hooks_disabled do + meta = forced_entry ? forced_method_metadata(tp, forced_entry) : method_metadata(tp, known_target: true) + return unless meta + frame = pop_frame_for_meta(meta) + return if frame && !frame[:sample_method] + b = frame ? frame[:bucket] : bucket_for_meta(meta) + return unless b + @lock.synchronize do + commit_params(b, frame, :raised) if frame + b[:raised_calls] += 1 + b[:raised] << class_name(tp.raised_exception) + end + end + end + + def self.pop_frame(tp) + meta = method_metadata(tp) + return nil unless meta + pop_frame_for_meta(meta) + end + + def self.pop_frame_for_meta(meta) + expected = meta[:key] + return nil unless expected + stack = @frames[Thread.current.object_id] + idx = stack.rindex { |frame| frame[:key] == expected } + return nil unless idx + stack.delete_at(idx) + end + + def self.commit_params(bucket, frame, outcome) + frame[:params].each do |name, classes| + bucket[:params_by_name][name].merge(classes) + target = outcome == :ok ? bucket[:params_ok] : bucket[:params_raised] + target[name].merge(classes) + end + frame[:param_sites].each do |name, sites| + sites.each do |site, count| + bucket[:param_sites][name][site] += count + target = outcome == :ok ? bucket[:param_sites_ok] : bucket[:param_sites_raised] + target[name][site] += count + end + end + frame[:param_traces].each do |name, traces| + traces.each do |trace, count| + bucket[:param_traces][name][trace] += count + target = outcome == :ok ? bucket[:param_traces_ok] : bucket[:param_traces_raised] + target[name][trace] += count + end + end + frame[:param_elem].each { |name, classes| bucket[:param_elem][name].merge(classes) } + frame[:param_elem_shapes].each { |name, shapes| bucket[:param_elem_shapes][name].merge(shapes) } + frame[:param_kv].each do |name, kv| + bucket[:param_kv][name][0].merge(kv[0]) + bucket[:param_kv][name][1].merge(kv[1]) + end + frame[:param_kv_shapes].each do |name, kv| + bucket[:param_kv_shapes][name][0].merge(kv[0]) + bucket[:param_kv_shapes][name][1].merge(kv[1]) + end + end + + def self.commit_params_observed(bucket, frame) + frame[:params].each { |name, classes| bucket[:params_by_name][name].merge(classes) } + frame[:param_sites].each do |name, sites| + sites.each { |site, count| bucket[:param_sites][name][site] += count } + end + frame[:param_traces].each do |name, traces| + traces.each { |trace, count| bucket[:param_traces][name][trace] += count } + end + frame[:param_elem].each { |name, classes| bucket[:param_elem][name].merge(classes) } + frame[:param_elem_shapes].each { |name, shapes| bucket[:param_elem_shapes][name].merge(shapes) } + frame[:param_kv].each do |name, kv| + bucket[:param_kv][name][0].merge(kv[0]) + bucket[:param_kv][name][1].merge(kv[1]) + end + frame[:param_kv_shapes].each do |name, kv| + bucket[:param_kv_shapes][name][0].merge(kv[0]) + bucket[:param_kv_shapes][name][1].merge(kv[1]) + end + end + + def self.callsite_for(tp) + "#{abs_path(tp.path)}:#{tp.lineno}" + end + + def self.callstack_for(tp) + [callsite_for(tp)] + end + + def self.site_key(loc, cls) + return "#{loc}:#{cls}" unless loc.respond_to?(:absolute_path) + "#{abs_path(loc.absolute_path || loc.path)}:#{loc.lineno}:#{cls}" + end + + def self.trace_key(trace, cls) + frames = Array(trace).filter_map do |loc| + if loc.respond_to?(:absolute_path) + path = loc.absolute_path || loc.path + next unless path + "#{abs_path(path)}:#{loc.lineno}" + else + loc.to_s + end + end + return nil if frames.empty? + "#{frames.join("|")}:#{cls}" + end + + def self.install_tlet_hook + return unless defined?(T) && T.respond_to?(:let) + return if T.singleton_class.method_defined?(:__nil_kill_orig_let) + T.singleton_class.alias_method(:__nil_kill_orig_let, :let) + T.singleton_class.define_method(:let) do |value, type, **kw| + loc = caller_locations(1, 1)&.first + if loc && NilKillRuntimeTrace.target_path?(loc.absolute_path || loc.path) + raw = loc.absolute_path || loc.path + path = File.expand_path(raw, ROOT) + # Under source instrumentation loc.lineno is the shifted + # instrumented line; the plan is keyed by the real src line. + src_ln = NilKillRuntimeTrace.src_line(raw, loc.lineno) + next T.send(:__nil_kill_orig_let, value, type, **kw) unless NilKillRuntimeTrace.sample_tlet?(path, src_ln) + key = [path, src_ln] + NilKillRuntimeTrace.with_collection_hooks_disabled do + NilKillRuntimeTrace.lock.synchronize do + rec = (NilKillRuntimeTrace.tlets[key] ||= { calls: 0, classes: Set.new }) + rec[:calls] += 1 + rec[:classes] << NilKillRuntimeTrace.class_name(value) + end + end + end + T.send(:__nil_kill_orig_let, value, type, **kw) + end + end + + def self.install_struct_hook + return if Struct.singleton_class.method_defined?(:__nil_kill_orig_new) + Struct.singleton_class.alias_method(:__nil_kill_orig_new, :new) + Struct.singleton_class.define_method(:new) do |*fields, **opts, &blk| + loc = caller_locations(1, 1)&.first + klass = __nil_kill_orig_new(*fields, **opts, &blk) + path = loc && File.expand_path(loc.absolute_path || loc.path, ROOT) + if path && NilKillRuntimeTrace.target_path?(path) && klass.is_a?(Class) && klass < Struct + klass.instance_variable_set(:@__nil_kill_struct_path, path) + klass.instance_variable_set(:@__nil_kill_struct_line, loc.lineno) + NilKillRuntimeTrace.attach_struct(klass) + end + klass + end + + Module.prepend(Module.new do + def const_added(name) + super + value = const_get(name) + NilKillRuntimeTrace.attach_struct(value) if value.is_a?(Class) && value < Struct + NilKillRuntimeTrace.attach_data(value) if defined?(Data) && value.is_a?(Class) && value < Data + rescue NameError + nil + end + end) + end + + def self.install_data_hook + return unless defined?(Data) && Data.respond_to?(:define) + return if Data.singleton_class.method_defined?(:__nil_kill_orig_define) + Data.singleton_class.alias_method(:__nil_kill_orig_define, :define) + Data.singleton_class.define_method(:define) do |*fields, &blk| + loc = caller_locations(1, 1)&.first + klass = __nil_kill_orig_define(*fields, &blk) + path = loc && File.expand_path(loc.absolute_path || loc.path, ROOT) + if path && NilKillRuntimeTrace.target_path?(path) && klass.is_a?(Class) + klass.instance_variable_set(:@__nil_kill_struct_path, path) + klass.instance_variable_set(:@__nil_kill_struct_line, loc.lineno) + NilKillRuntimeTrace.attach_data(klass) + end + klass + end + end + + def self.install_open_struct_hook + require "ostruct" + return if OpenStruct.instance_variable_get(:@__nil_kill_attached) + OpenStruct.instance_variable_set(:@__nil_kill_attached, true) + OpenStruct.prepend(Module.new do + define_method(:initialize) do |hash = nil| + super(hash) + NilKillRuntimeTrace.record_open_struct(self) + end + + define_method(:[]=) do |name, value| + result = super(name, value) + NilKillRuntimeTrace.record_open_struct_field(self, name, value) + result + end + end) + rescue LoadError + nil + end + + def self.install_collection_hook + install_array_hook + install_hash_hook + install_set_hook + end + + # NOTE: the parallel instrumented tree and its require/require_relative + # redirect (instrumented_copy_for / resolve_required_source / + # install_instrumented_require_hook) were DELETED. In-place + # instrumentation puts the single wrapped copy at the real src path, + # so every load mechanism loads instrumented code with no redirect -- + # which is exactly what made collect_ran_untraced non-convergent. + + def self.install_array_hook + return if Array.instance_variable_get(:@__nil_kill_attached) + Array.instance_variable_set(:@__nil_kill_attached, true) + Array.prepend(Module.new do + define_method(:<<) do |value| + result = super(value) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } + end + result + end + + define_method(:push) do |*values| + result = super(*values) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { values.each { |value| NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } } + end + result + end + + define_method(:append) do |*values| + result = super(*values) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { values.each { |value| NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } } + end + result + end + + define_method(:unshift) do |*values| + result = super(*values) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { values.each { |value| NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } } + end + result + end + + define_method(:[]=) do |*args| + value = args.last + result = super(*args) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } + end + result + end + + define_method(:concat) do |other| + result = super(other) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { Array(other).first(NilKillRuntimeTrace::ELEMENT_SAMPLE).each { |value| NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } } + end + result + end + end) + end + + def self.install_hash_hook + return if Hash.instance_variable_get(:@__nil_kill_attached) + Hash.instance_variable_set(:@__nil_kill_attached, true) + Hash.prepend(Module.new do + define_method(:[]=) do |key, value| + result = super(key, value) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { NilKillRuntimeTrace.record_collection_mutation(self, key: key, val: value) } + end + result + end + + define_method(:store) do |key, value| + result = super(key, value) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { NilKillRuntimeTrace.record_collection_mutation(self, key: key, val: value) } + end + result + end + + define_method(:merge!) do |*others, **kw, &blk| + result = super(*others, **kw, &blk) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard do + others.each { |other| other.each { |key, value| NilKillRuntimeTrace.record_collection_mutation(self, key: key, val: value) } if other.respond_to?(:each) } + kw.each { |key, value| NilKillRuntimeTrace.record_collection_mutation(self, key: key, val: value) } + end + end + result + end + + define_method(:update) do |*others, **kw, &blk| + result = super(*others, **kw, &blk) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard do + others.each { |other| other.each { |key, value| NilKillRuntimeTrace.record_collection_mutation(self, key: key, val: value) } if other.respond_to?(:each) } + kw.each { |key, value| NilKillRuntimeTrace.record_collection_mutation(self, key: key, val: value) } + end + end + result + end + end) + end + + def self.install_set_hook + require "set" + return if Set.instance_variable_get(:@__nil_kill_attached) + Set.instance_variable_set(:@__nil_kill_attached, true) + Set.prepend(Module.new do + define_method(:add) do |value| + result = super(value) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } + end + result + end + + define_method(:<<) do |value| + result = super(value) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } + end + result + end + + define_method(:merge) do |enum| + result = super(enum) + if NilKillRuntimeTrace.objects.key?(object_id) + NilKillRuntimeTrace.with_collection_hook_guard { enum.first(NilKillRuntimeTrace::ELEMENT_SAMPLE).each { |value| NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } if enum.respond_to?(:first) } + end + result + end + end) + rescue LoadError + nil + end + + def self.attach_struct(klass) + return unless klass.is_a?(Class) && klass < Struct + return if klass.instance_variable_get(:@__nil_kill_attached) + path = klass.instance_variable_get(:@__nil_kill_struct_path) + line = klass.instance_variable_get(:@__nil_kill_struct_line) + unless path && line + loc = source_location_for_class(klass) + path, line = loc if loc + klass.instance_variable_set(:@__nil_kill_struct_path, path) if path + klass.instance_variable_set(:@__nil_kill_struct_line, line) if line + end + return unless path && target_path?(path) + fields = klass.members + return if fields.empty? + klass.instance_variable_set(:@__nil_kill_attached, true) + klass.prepend(Module.new do + define_method(:initialize) do |*args, **kw, &blk| + class_name = NilKillRuntimeTrace.safe_module_name(self.class) || "AnonymousStruct" + args.each_with_index do |arg, idx| + field = fields[idx] + break unless field + NilKillRuntimeTrace.record_struct_field(self.class, class_name, field, arg) + end + kw.each { |field, value| NilKillRuntimeTrace.record_struct_field(self.class, class_name, field, value) } + super(*args, **kw, &blk) + end + end) + end + + def self.attach_data(klass) + return unless klass.is_a?(Class) + return if klass.instance_variable_get(:@__nil_kill_attached) + path = klass.instance_variable_get(:@__nil_kill_struct_path) + line = klass.instance_variable_get(:@__nil_kill_struct_line) + return unless path && line && target_path?(path) + fields = klass.respond_to?(:members) ? klass.members : [] + return if fields.empty? + klass.instance_variable_set(:@__nil_kill_attached, true) + klass.prepend(Module.new do + define_method(:initialize) do |*args, **kw, &blk| + class_name = NilKillRuntimeTrace.safe_module_name(self.class) || "AnonymousData" + args.each_with_index do |arg, idx| + field = fields[idx] + break unless field + NilKillRuntimeTrace.record_struct_field(self.class, class_name, field, arg) + end + kw.each { |field, value| NilKillRuntimeTrace.record_struct_field(self.class, class_name, field, value) } + super(*args, **kw, &blk) + end + end) + end + + def self.record_open_struct(instance) + table = instance.instance_variable_get(:@table) rescue nil + return unless table.respond_to?(:each) + table.each { |field, value| record_open_struct_field(instance, field, value) } + end + + def self.record_open_struct_field(instance, field, value) + loc = caller_locations(2, 20).find do |candidate| + path = candidate.absolute_path || candidate.path + path && target_path?(path) && File.expand_path(path, ROOT) != File.expand_path(__FILE__, ROOT) + end + return unless loc + singleton_name = NilKillRuntimeTrace.safe_module_name(instance.class) + klass_name = singleton_name && singleton_name != "OpenStruct" ? singleton_name : "OpenStruct" + shim = Object.new + shim.instance_variable_set(:@__nil_kill_struct_path, File.expand_path(loc.absolute_path || loc.path, ROOT)) + shim.instance_variable_set(:@__nil_kill_struct_line, loc.lineno) + record_struct_field(shim, klass_name, field, value) + end + + def self.record_ivar_assignment(receiver, name, value, path, line) + abs = File.expand_path(path, ROOT) + if target_path?(abs) + with_collection_hooks_disabled do + @lock.synchronize do + register_collection_owner(value, owner_kind: "ivar", name: name.to_s, path: abs, line: line) + # Per-(declaring class, ivar) runtime class set. An accessor + # contract like `.type_info` is backed by `@type_info`; the + # Union Decomplexity report joins this to attribute the + # producer types feeding its is_a?(Type) guards. + cls = safe_module_name(receiver.class) + if cls + rec = (@ivar_runtime[[cls, name.to_s]] ||= { calls: 0, classes: Set.new }) + rec[:calls] += 1 + rec[:classes] << class_name(value) + end + end + end + end + value + end + + def self.record_struct_field(klass, klass_name, field, value) + path = klass.instance_variable_get(:@__nil_kill_struct_path) + line = klass.instance_variable_get(:@__nil_kill_struct_line) + return unless path && line + return unless sample_struct_field?(klass_name, field) + # Normalize the caller path to an absolute real-src path (in-place + # instrumentation keeps it at the real path; abs_path is now just a + # cached expand) so the separate `infer` process ingests the row. + path = abs_path(path) + key = [klass_name, field.to_s, path, line] + shape = container_shape(value) + with_collection_hooks_disabled do + @lock.synchronize do + rec = (@structs[key] ||= { calls: 0, classes: Set.new, elem_classes: Set.new, key_classes: Set.new, value_classes: Set.new, array_calls: 0, hash_calls: 0 }) + rec[:calls] += 1 + rec[:classes] << class_name(value) + if shape&.first == :array + rec[:array_calls] += 1 + rec[:elem_classes].merge(shape[1]) + record_tuple("struct_field", path, line, "#{klass_name}.#{field}", value) + register_collection_owner(value, owner_kind: "struct_field", name: "#{klass_name}.#{field}", path: path, line: line) + elsif shape&.first == :hash + rec[:hash_calls] += 1 + rec[:key_classes].merge(shape[1][0]) + rec[:value_classes].merge(shape[1][1]) + register_collection_owner(value, owner_kind: "struct_field", name: "#{klass_name}.#{field}", path: path, line: line) + end + end + end + end + + def self.record_tuple(kind, path, line, slot, value) + return unless value.is_a?(Array) && value.size >= 2 + sampled = value.first(ELEMENT_SAMPLE) + types = sampled.map { |item| class_name(item) } + complete = sampled.size == value.size + mixed = types.uniq.size > 1 + return unless complete || mixed + key = [kind, File.expand_path(path, ROOT), line, slot.to_s, complete ? value.size : ">=#{ELEMENT_SAMPLE}", types] + rec = (@tuples[key] ||= { calls: 0, complete: complete, mixed: mixed }) + rec[:calls] += 1 + rec[:complete] &&= complete + rec[:mixed] ||= mixed + end + + def self.dump_hash_counts(counts) + counts.transform_values(&:to_h) + end + + def self.dump + FileUtils.mkdir_p(OUT_DIR) + pid = Process.pid + File.open(File.join(OUT_DIR, "methods-#{pid}.jsonl"), "w") do |file| + @methods.each do |key, rec| + file.puts JSON.generate( + class: key[0], method: key[1], kind: key[2], path: key[3], line: key[4], + calls: rec[:calls], + ok_calls: rec[:ok_calls], + raised_calls: rec[:raised_calls], + params_by_name: rec[:params_by_name].transform_values { |set| set.to_a.sort }, + params_ok: rec[:params_ok].transform_values { |set| set.to_a.sort }, + params_raised: rec[:params_raised].transform_values { |set| set.to_a.sort }, + param_sites: dump_hash_counts(rec[:param_sites]), + param_sites_ok: rec[:param_sites_raised].empty? ? {} : dump_hash_counts(rec[:param_sites_ok]), + param_sites_raised: dump_hash_counts(rec[:param_sites_raised]), + param_traces: dump_hash_counts(rec[:param_traces]), + param_traces_ok: rec[:param_traces_raised].empty? ? {} : dump_hash_counts(rec[:param_traces_ok]), + param_traces_raised: dump_hash_counts(rec[:param_traces_raised]), + param_elem: rec[:param_elem].transform_values { |set| set.to_a.sort }, + param_kv: rec[:param_kv].transform_values { |kv| [kv[0].to_a.sort, kv[1].to_a.sort] }, + param_elem_shapes: rec[:param_elem_shapes].transform_values { |set| set.to_a.sort.map { |shape| shape_payload(shape) } }, + param_kv_shapes: rec[:param_kv_shapes].transform_values { |kv| [kv[0].to_a.sort.map { |shape| shape_payload(shape) }, kv[1].to_a.sort.map { |shape| shape_payload(shape) }] }, + returns: rec[:returns].to_a.sort, + return_elem: rec[:return_elem].to_a.sort, + return_kv: [rec[:return_kv][0].to_a.sort, rec[:return_kv][1].to_a.sort], + return_elem_shapes: rec[:return_elem_shapes].to_a.sort.map { |shape| shape_payload(shape) }, + return_kv_shapes: [rec[:return_kv_shapes][0].to_a.sort.map { |shape| shape_payload(shape) }, rec[:return_kv_shapes][1].to_a.sort.map { |shape| shape_payload(shape) }], + raised: rec[:raised].to_a.sort, + ) + end + end + File.open(File.join(OUT_DIR, "tlets-#{pid}.jsonl"), "w") do |file| + @tlets.each do |(path, line), rec| + file.puts JSON.generate(path: path, line: line, calls: rec[:calls], classes: rec[:classes].to_a.sort) + end + end + File.open(File.join(OUT_DIR, "structs-#{pid}.jsonl"), "w") do |file| + @structs.each do |(klass, field, path, line), rec| + file.puts JSON.generate( + class: klass, field: field, path: path, line: line, calls: rec[:calls], + classes: rec[:classes].to_a.sort, + elem_classes: rec[:elem_classes].to_a.sort, + key_classes: rec[:key_classes].to_a.sort, + value_classes: rec[:value_classes].to_a.sort, + array_calls: rec[:array_calls], + hash_calls: rec[:hash_calls], + ) + end + end + File.open(File.join(OUT_DIR, "ivars-#{pid}.jsonl"), "w") do |file| + @ivar_runtime.each do |(klass, name), rec| + file.puts JSON.generate(class: klass, name: name, calls: rec[:calls], classes: rec[:classes].to_a.sort) + end + end + File.open(File.join(OUT_DIR, "tuples-#{pid}.jsonl"), "w") do |file| + @tuples.each do |(kind, path, line, slot, size, types), rec| + file.puts JSON.generate(kind: kind, path: path, line: line, slot: slot, size: size, types: types, + complete: rec[:complete], mixed: rec[:mixed], calls: rec[:calls]) + end + end + File.open(File.join(OUT_DIR, "collections-#{pid}.jsonl"), "w") do |file| + @collections.each do |(owner_kind, name, path, line, kind), rec| + file.puts JSON.generate( + owner_kind: owner_kind, name: name, path: path, line: line, kind: kind, calls: rec[:calls], + classes: rec[:classes].to_a.sort, + elem_classes: rec[:elem_classes].to_a.sort, + key_classes: rec[:key_classes].to_a.sort, + value_classes: rec[:value_classes].to_a.sort, + elem_shapes: rec[:elem_shapes].to_a.sort.map { |shape| shape_payload(shape) }, + key_shapes: rec[:key_shapes].to_a.sort.map { |shape| shape_payload(shape) }, + value_shapes: rec[:value_shapes].to_a.sort.map { |shape| shape_payload(shape) }, + mutation_sites: rec[:mutation_sites].sort_by { |site, count| [-count, site.to_s] }.to_h, + ) + end + end + dump_coverage(pid) + end + + # Ruby stdlib line coverage for THIS collect run. Lets the report + # tell a real tracer miss (executed during collect but no nil-kill + # record) from a workload-input gap (just not exercised here) -- + # instead of comparing against a stale aggregate SimpleCov. + def self.start_coverage! + require "coverage" + return if Coverage.respond_to?(:running?) && Coverage.running? + Coverage.start(lines: true) + @coverage_owned = true + rescue StandardError, LoadError + @coverage_owned = false + end + + # instrumented_line -> src_line per src-rel-path, written by + # SourceInstrumenter#run_in_place to RUNTIME_DIR/.nk-linemap.json. + # In-place wrapping keeps the file at its real path but still shifts + # its line numbers (the injected wrapper adds lines), so Coverage's + # line numbers must be translated back to src space or the join + # against src def-ranges (collect_ran?) is systematically wrong. + def self.coverage_line_map + return @coverage_line_map if defined?(@coverage_line_map) && @coverage_line_map + path = File.join(OUT_DIR, ".nk-linemap.json") + @coverage_line_map = File.exist?(path) ? JSON.parse(File.read(path)) : {} + rescue StandardError + @coverage_line_map = {} + end + + # Translate an INSTRUMENTED runtime line number back to its src line + # via .nk-linemap.json. Any runtime hook that keys off the caller's + # lineno (T.let, ...) is otherwise wrong under source instrumentation: + # the wrapper injects lines, so the caller's lineno is shifted and + # never matches the (real-src-line-keyed) trace plan. Methods avoid + # this because the instrumenter injects the plan's src line as a + # literal; line-keyed hooks need this translation. Identity when the + # file was not instrumented (no map entry). + def self.src_line(path, lineno) + rel = Pathname.new(abs_path(path)).relative_path_from(Pathname.new(ROOT)).to_s rescue nil + per_file = rel && coverage_line_map[rel] + (per_file && per_file[lineno]) || lineno + end + + def self.dump_coverage(pid) + return unless @coverage_owned + require "coverage" + require "pathname" + result = Coverage.result(stop: false, clear: false) + lmap = coverage_line_map + File.open(File.join(OUT_DIR, "coverage-#{pid}.jsonl"), "w") do |file| + result.each do |abs, data| + next unless target_path?(abs) + src = abs_path(abs) + rel = Pathname.new(src).relative_path_from(Pathname.new(ROOT)).to_s rescue src + # Coverage.start(lines: true) -> per-file value is + # { lines: [...] } (SYMBOL key); plain mode -> bare array. + lines = data.is_a?(Hash) ? (data[:lines] || data["lines"]) : data + per_file = lmap[rel] # nil => file uninstrumented, lines == src + covered = [] + Array(lines).each_with_index do |hits, i| + next unless hits && hits.to_i.positive? + instr_line = i + 1 + src_line = per_file ? per_file[instr_line] : instr_line + covered << src_line if src_line + end + covered = covered.uniq.sort + next if covered.empty? + file.puts JSON.generate(path: src, lines: covered) + end + end + rescue StandardError + nil + end +end + +if ENV["NIL_KILL_TRACE"] == "1" + NilKillRuntimeTrace.start_coverage! + tracepoint_fallback = NilKillRuntimeTrace.trace_plan&.fetch("tracepoint_methods", {})&.any? + if NilKillRuntimeTrace.targeted_method_tracing? || tracepoint_fallback + NilKillRuntimeTrace.install_targeted_definition_trace + elsif ENV["NIL_KILL_TRACE_METHODS"] != "0" + TracePoint.new(:call) { |tp| NilKillRuntimeTrace.record_call(tp) }.enable + TracePoint.new(:return) { |tp| NilKillRuntimeTrace.record_return(tp) }.enable + TracePoint.new(:raise) { |tp| NilKillRuntimeTrace.record_raise(tp) }.enable + end + begin + require "sorbet-runtime" + rescue LoadError + nil + end + NilKillRuntimeTrace.install_tlet_hook + NilKillRuntimeTrace.install_struct_hook + NilKillRuntimeTrace.install_data_hook + NilKillRuntimeTrace.install_open_struct_hook + NilKillRuntimeTrace.install_collection_hook unless ENV["NIL_KILL_TRACE_COLLECTIONS"] == "0" + TracePoint.new(:end) { NilKillRuntimeTrace.install_tlet_hook }.enable + TracePoint.new(:end) do + NilKillRuntimeTrace.install_data_hook + end.enable + at_exit do + NilKillRuntimeTrace.dump + end +end diff --git a/gems/nil-kill/lib/nil_kill/source_index.rb b/gems/nil-kill/lib/nil_kill/source_index.rb new file mode 100644 index 000000000..6b8717af7 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/source_index.rb @@ -0,0 +1,2624 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class SourceIndex + @attribute_hash_shapes = {} + @attribute_array_element_shapes = {} + @struct_field_hash_shapes = {} + @struct_field_array_element_shapes = {} + @struct_field_static_types = {} + @struct_fields_by_name = {} + @struct_full_by_name = {} + + class << self + attr_reader :attribute_hash_shapes, :attribute_array_element_shapes, + :struct_field_hash_shapes, :struct_field_array_element_shapes, + :struct_field_static_types, :struct_fields_by_name, :struct_full_by_name, + :noreturn_methods + + def reset_global_shape_indexes + @attribute_hash_shapes = {} + @attribute_array_element_shapes = {} + @struct_field_hash_shapes = {} + @struct_field_array_element_shapes = {} + @struct_field_static_types = {} + @struct_fields_by_name = {} + @struct_full_by_name = {} + @rbi_field_types = nil + @noreturn_methods = Set.new + end + + def rbi_field_types + @rbi_field_types ||= load_rbi_field_types + end + + def noreturn_methods + @noreturn_methods ||= Set.new + end + + def register_noreturn_method(name) + return unless name && !name.to_s.empty? + @noreturn_methods ||= Set.new + @noreturn_methods << name.to_s + end + + def load_rbi_field_types + types = {} + Dir.glob(File.join(NilKill::ROOT, "sorbet", "rbi", "**", "*.rbi")).each do |path| + klass = nil + pending_type = nil + File.readlines(path).each do |line| + if line =~ /^\s*class\s+([A-Z]\S*)/ + klass = $1 + elsif klass && line =~ /^\s*sig\s*\{\s*returns\((.+)\)\s*\}/ + pending_type = $1.strip + elsif klass && line =~ /^\s*def\s+([a-zA-Z_]\w*)\b/ + types[[klass, $1]] = pending_type || "T.untyped" + pending_type = nil + elsif line =~ /^\s*end\s*$/ + klass = nil + pending_type = nil + end + end + end + types + end + end + + attr_reader :methods, :tlet_sites, :dead_nil_checks, :struct_declarations, :struct_field_static, :tuple_arrays, :hash_shapes, + :collection_index_lookups, :hash_record_blockers, :hash_record_member_calls, + :type_normalizers, :dispatcher_inferences, :return_origins, :param_origins, + :ivar_protocols, :ivar_param_origins + + def initialize(path) + @path = path + @rel = NilKill.rel(path) + @lines = File.readlines(path) + @methods = [] + @tlet_sites = [] + @dead_nil_checks = [] + @struct_declarations = [] + @struct_field_static = [] + @tuple_arrays = [] + @hash_shapes = [] + @collection_index_lookups = [] + @hash_record_blockers = [] + @hash_record_member_calls = [] + @type_normalizers = [] + @dispatcher_inferences = [] + @return_origins = [] + @param_origins = [] + @ivar_protocols = Hash.new { |hash, key| hash[key] = Set.new } + @ivar_param_origins = Hash.new { |hash, key| hash[key] = Set.new } + @struct_fields_by_name = {} + @struct_full_by_name = {} + @non_nil_locals = Set.new + @maybe_nil_locals = Set.new + @non_nil_method_returns = Set.new + @method_return_types = Hash.new { |hash, key| hash[key] = [] } + @static_return_types = {} + @static_hash_return_shapes = {} + @static_array_element_return_shapes = {} + @inferred_param_hash_shapes = {} + @inferred_param_array_element_shapes = {} + @method_nodes = [] + @current_param_types = {} + @current_local_types = {} + @current_collection_builders = {} + @current_hash_shapes = {} + @current_hash_shape_sources = {} + @current_array_element_shapes = {} + @current_method_name = nil + @local_container_origins = {} + @ivar_container_origins = {} + @ivar_tlet_names = Set.new + @ivar_tlet_types = {} + @current_class_name = nil + @class_like_constants = Set.new + parsed = Prism.parse_file(path) + if parsed.success? + collect_struct_declarations(parsed.value, []) + collect_class_like_constants(parsed.value, []) + collect_non_nil_method_returns(parsed.value) + collect_ivar_tlet_names(parsed.value) + walk(parsed.value, []) + recompute_return_origins_with_inferred_shapes + recompute_collection_index_lookups_with_inferred_shapes + recompute_struct_field_static_with_inferred_locals + end + @method_nodes.each { |def_node, record| collect_type_normalizers!(def_node, record) } + end + + def summary + { "methods" => @methods.size, "unsigned_methods" => @methods.count { |m| !m["has_sig"] }, + "tlet_sites" => @tlet_sites.count { |s| s["tlet"] }, "candidate_tlet_sites" => @tlet_sites.count { |s| !s["tlet"] }, + "dead_nil_checks" => @dead_nil_checks.size, "structs" => @struct_declarations.size, + "tuple_arrays" => @tuple_arrays.size, "hash_shapes" => @hash_shapes.size, + "collection_index_lookups" => @collection_index_lookups.size, + "type_normalizers" => @type_normalizers.size, "return_origins" => @return_origins.size, + "param_origins" => @param_origins.size } + end + + # AST-based normalizer capture (replaces the old single-line + # `is_a?(Type)` + `Type.new(` scanner that undercounted by ~10x and + # mis-tracked methods). Walks the def body for every + # `recv.is_a?(Type)` / `recv.kind_of?(Type)` guard -- multi-line, + # `!`-wrapped, ternary, `T.must` forms all caught -- with exact + # class/method from the def record, and resolves the receiver's + # one-hop origin from the def's own assignment nodes (no points-to). + def collect_type_normalizers!(def_node, record) + body = def_node.respond_to?(:body) ? def_node.body : nil + return unless body + param_names = Array(record["params"]).map { |p| p["name"].to_s } + assigns = {} + each_ast(body) do |n| + assigns[n.name.to_s] ||= n.value if n.is_a?(Prism::LocalVariableWriteNode) + end + each_ast(body) do |n| + next unless n.is_a?(Prism::CallNode) && %i[is_a? kind_of?].include?(n.name) && n.receiver + args = (n.arguments && n.arguments.arguments) || [] + next unless args.size == 1 && args.first.slice == "Type" + kind, name = classify_origin(n.receiver, param_names, assigns, 0) + @type_normalizers << { + "path" => @rel, "line" => n.location.start_line, + "class" => record["class"], "method" => record["method"], + "code" => n.slice.split("\n").first.to_s.strip[0, 120], + "origin_kind" => kind, "origin_name" => name, + } + end + end + + def each_ast(node, &blk) + return unless node.is_a?(Prism::Node) + yield node + node.child_nodes.compact.each { |c| each_ast(c, &blk) } + end + + # One-hop, intra-method origin of a guard receiver, resolved on the + # AST: an accessor read (`node.type_info`), a hash-key read + # (`h[:type]`), an ivar, a call, or a param. A local receiver is + # resolved through its in-method assignment exactly once (depth 1) + # so `ti = node.type_info; ti.is_a?(Type)` keys to `.type_info`. + def classify_origin(node, param_names, assigns, depth) + case node + when Prism::InstanceVariableReadNode + ["ivar", node.slice] + when Prism::LocalVariableReadNode + nm = node.name.to_s + return ["param", nm] if param_names.include?(nm) + if depth.zero? && (rhs = assigns[nm]) + return classify_origin(rhs, param_names, assigns, depth + 1) + end + ["local", nil] + when Prism::CallNode + if node.name == :[] + key = node.arguments && node.arguments.arguments && node.arguments.arguments.first + k = case key + when Prism::SymbolNode then ":#{key.value}" + when Prism::StringNode then ":#{key.unescaped}" + end + ["hashkey", k] + elsif ((node.arguments && node.arguments.arguments) || []).any? + ["call", node.name.to_s] + elsif node.receiver + ["attr", node.name.to_s] + else + ["call", node.name.to_s] + end + else + ["local", nil] + end + end + + def walk(node, scope) + case node + when Prism::ClassNode, Prism::ModuleNode + new_scope = scope + [node.constant_path.slice] + old_class = @current_class_name + @current_class_name = new_scope.join("::") + begin + child_walk(node.body, new_scope) + ensure + @current_class_name = old_class + end + when Prism::DefNode + record = method_record(node, scope) + record["return_origin"] = analyze_return_origin(node, record) + @methods << record + @return_origins << record["return_origin"] if record["return_origin"] + if record["return_origin"] && record["return_origin"]["confidence"] == "strong" + type = record["return_origin"]["candidate_type"] + @static_return_types[record["method"]] = type if NilKill.useful_type?(type) + end + if record["return_origin"] && record["return_origin"]["hash_shape"] && !record["return_origin"]["hash_shape"]["poisoned"] + @static_hash_return_shapes[record["method"]] = record["return_origin"]["hash_shape"] + end + if record["return_origin"] && record["return_origin"]["array_element_shape"] && !record["return_origin"]["array_element_shape"]["poisoned"] + @static_array_element_return_shapes[record["method"]] = record["return_origin"]["array_element_shape"] + end + @method_nodes << [node, record] + inspect_dispatcher(node, record) + scoped_facts(record) { child_walk(node.body, scope) } + when Prism::CallNode + inspect_param_origins(node, scope) + update_collection_builder_call(node) + inspect_call(node) + inspect_index_lookup(node, scope) + inspect_hash_record_blocker(node, scope) + inspect_hash_record_member_call(node, scope) + inspect_struct_constructor(node) + inspect_class_constructor_fields(node) + inspect_attribute_shape_write(node) + walk_call_children(node, scope) + when Prism::ArrayNode + inspect_array_literal(node) + child_walk(node, scope) + when Prism::HashNode + inspect_hash_literal(node) + child_walk(node, scope) + when Prism::LocalVariableWriteNode + update_local_fact(node) + inspect_local_container_origin(node) + child_walk(node, scope) + when Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode + inspect_variable_write(node) + inspect_ivar_container_origin(node) + child_walk(node, scope) + else + child_walk(node, scope) + end + end + + def child_walk(node, scope) + return unless node&.respond_to?(:child_nodes) + node.child_nodes.compact.each { |child| walk(child, scope) } + end + + def walk_call_children(node, scope) + block = node.block + unless block && block.respond_to?(:body) + child_walk(node, scope) + return + end + + old_hash_shapes = @current_hash_shapes + @current_hash_shapes = dup_hash_shapes(@current_hash_shapes) + block_param_names(block).each_with_index do |name, idx| + shape = block_param_shapes_for_call(node)[idx] + @current_hash_shapes[name] = dup_hash_shape(shape) if name && shape + end + child_walk(node, scope) + ensure + @current_hash_shapes = old_hash_shapes if old_hash_shapes + end + + def recompute_return_origins_with_inferred_shapes + return if @method_nodes.empty? + latest_origins = [] + 2.times do + latest_origins = [] + @method_nodes.each do |node, record| + origin = analyze_return_origin(node, record) + record["return_origin"] = origin + latest_origins << origin if origin + if origin && origin["confidence"] == "strong" + type = origin["candidate_type"] + @static_return_types[record["method"]] = type if NilKill.useful_type?(type) + end + if origin && origin["hash_shape"] && !origin["hash_shape"]["poisoned"] + @static_hash_return_shapes[record["method"]] = origin["hash_shape"] + end + if origin && origin["array_element_shape"] && !origin["array_element_shape"]["poisoned"] + @static_array_element_return_shapes[record["method"]] = origin["array_element_shape"] + end + end + end + @return_origins = latest_origins + end + + def recompute_collection_index_lookups_with_inferred_shapes + return if @method_nodes.empty? + @collection_index_lookups = [] + @hash_record_blockers = [] + @method_nodes.each do |node, record| + scoped_facts(record) do + collect_collection_index_facts(node.body, Array(record["scope"])) + end + end + end + + # During the main `walk`, a `Struct.new(x, ...)` argument that is a + # plain local/ivar reference resolves to no type, because + # `@current_local_types` is only populated by the return-origin + # pass, not the main walk. struct_field_candidates then marks the + # field's slot `has_unknown_static` and refuses to type it (the + # AST::ConcurrentOp soundness guard). Re-resolve those args here + # with local-type facts populated (the same machinery the + # return-origin pass uses) and fill the empty `type` in place. + # Existing soundness guards downstream are untouched: a still- + # unresolvable arg keeps its empty type (slot stays skipped), and + # struct-rbi's nilable / weak-collection / --validate srb tc gates + # still apply. + def recompute_struct_field_static_with_inferred_locals + return if @method_nodes.empty? || @struct_field_static.empty? + index = Hash.new { |h, k| h[k] = [] } + @struct_field_static.each do |entry| + index[[entry["path"], entry["line"], entry["class"], entry["field"], entry["expression"]]] << entry + end + @method_nodes.each do |node, record| + scoped_facts(record) do + collect_local_type_facts(node.body) + refill_struct_constructor_types(node.body, index) + end + end + end + + def refill_struct_constructor_types(node, index) + return unless node + return if nested_scope_node?(node) + if node.is_a?(Prism::CallNode) && node.name == :new && node.receiver + klass = const_name(node.receiver) + fields = @struct_fields_by_name[klass] || @struct_fields_by_name[klass.split("::").last] || + self.class.struct_fields_by_name[klass] || self.class.struct_fields_by_name[klass.split("::").last] + if fields + full_class = @struct_full_by_name[klass] || @struct_full_by_name[klass.split("::").last] || + self.class.struct_full_by_name[klass] || self.class.struct_full_by_name[klass.split("::").last] || klass + (node.arguments&.arguments || []).each_with_index do |arg, idx| + next if idx >= fields.size || arg.is_a?(Prism::KeywordHashNode) + entries = index[[@rel, node.location.start_line, full_class, fields[idx], arg.slice]] + next if entries.empty? + next if entries.all? { |e| NilKill.useful_type?(e["type"].to_s) } + resolved = expression_type(arg) + next unless NilKill.useful_type?(resolved) + entries.each { |e| e["type"] = resolved unless NilKill.useful_type?(e["type"].to_s) } + merge_struct_field_static_type(full_class, fields[idx], resolved) + end + end + end + node.child_nodes.compact.each { |child| refill_struct_constructor_types(child, index) } if node.respond_to?(:child_nodes) + end + + def collect_local_container_origins(node) + return unless node + return if nested_scope_node?(node) + case node + when Prism::LocalVariableWriteNode + inspect_local_container_origin(node) + when Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode + inspect_ivar_container_origin(node) + end + node.child_nodes.compact.each { |child| collect_local_container_origins(child) } if node.respond_to?(:child_nodes) + end + + def collect_collection_index_facts(node, scope) + return unless node + return if nested_scope_node?(node) + case node + when Prism::CallNode + update_collection_builder_call(node) + inspect_index_lookup(node, scope) + inspect_hash_record_blocker(node, scope) + inspect_hash_record_member_call(node, scope) + collect_call_collection_index_facts(node, scope) + when Prism::LocalVariableWriteNode + update_local_fact(node) + inspect_local_container_origin(node) + node.child_nodes.compact.each { |child| collect_collection_index_facts(child, scope) } if node.respond_to?(:child_nodes) + else + node.child_nodes.compact.each { |child| collect_collection_index_facts(child, scope) } if node.respond_to?(:child_nodes) + end + end + + def collect_call_collection_index_facts(node, scope) + block = node.block + unless block && block.respond_to?(:body) + node.child_nodes.compact.each { |child| collect_collection_index_facts(child, scope) } + return + end + old_hash_shapes = @current_hash_shapes + @current_hash_shapes = dup_hash_shapes(@current_hash_shapes) + block_param_names(block).each_with_index do |name, idx| + shape = block_param_shapes_for_call(node)[idx] + @current_hash_shapes[name] = dup_hash_shape(shape) if name && shape + end + node.child_nodes.compact.each { |child| collect_collection_index_facts(child, scope) } + ensure + @current_hash_shapes = old_hash_shapes if old_hash_shapes + end + + def collect_struct_declarations(node, scope) + case node + when Prism::ClassNode, Prism::ModuleNode + child_scope = scope + [const_name(node.constant_path)] + node.child_nodes.compact.each { |child| collect_struct_declarations(child, child_scope) } if node.respond_to?(:child_nodes) + return + when Prism::ConstantWriteNode + if struct_new_call?(node.value) || data_define_call?(node.value) + klass = (scope + [node.name.to_s]).join("::") + fields = struct_fields(node.value) + if fields.any? + rec = { "path" => @rel, "line" => node.location.start_line, "class" => klass, "fields" => fields } + @struct_declarations << rec + @struct_fields_by_name[klass] = fields + self.class.struct_fields_by_name[klass] = fields + @struct_full_by_name[klass] = klass + self.class.struct_full_by_name[klass] = klass + short = klass.split("::").last + unless @struct_fields_by_name.key?(short) + @struct_fields_by_name[short] = fields + @struct_full_by_name[short] = klass + end + unless self.class.struct_fields_by_name.key?(short) + self.class.struct_fields_by_name[short] = fields + self.class.struct_full_by_name[short] = klass + end + end + end + end + node.child_nodes.compact.each { |child| collect_struct_declarations(child, scope) } if node.respond_to?(:child_nodes) + end + + def collect_class_like_constants(node, scope) + case node + when Prism::ClassNode, Prism::ModuleNode + name = const_name(node.constant_path) + full_name = (scope + [name]).join("::") + @class_like_constants.add(full_name) + @class_like_constants.add(name) + child_scope = scope + [name] + node.child_nodes.compact.each { |child| collect_class_like_constants(child, child_scope) } if node.respond_to?(:child_nodes) + return + when Prism::ConstantWriteNode + if struct_new_call?(node.value) || data_define_call?(node.value) + name = node.name.to_s + full_name = (scope + [name]).join("::") + @class_like_constants.add(full_name) + @class_like_constants.add(name) + end + end + node.child_nodes.compact.each { |child| collect_class_like_constants(child, scope) } if node.respond_to?(:child_nodes) + end + + def struct_new_call?(node) + node.is_a?(Prism::CallNode) && + node.name == :new && + node.receiver.is_a?(Prism::ConstantReadNode) && + node.receiver.name == :Struct + end + + def data_define_call?(node) + node.is_a?(Prism::CallNode) && + node.name == :define && + node.receiver.is_a?(Prism::ConstantReadNode) && + node.receiver.name == :Data + end + + def struct_fields(node) + (node.arguments&.arguments || []).filter_map do |arg| + arg.value.to_s if arg.is_a?(Prism::SymbolNode) + end + end + + def const_name(node) + return "" unless node + node.respond_to?(:full_name) ? (node.full_name rescue node.slice) : node.slice + end + + def inspect_struct_constructor(node) + return unless node.name == :new && node.receiver + klass = const_name(node.receiver) + fields = @struct_fields_by_name[klass] || @struct_fields_by_name[klass.split("::").last] || + self.class.struct_fields_by_name[klass] || self.class.struct_fields_by_name[klass.split("::").last] + full_class = @struct_full_by_name[klass] || @struct_full_by_name[klass.split("::").last] || + self.class.struct_full_by_name[klass] || self.class.struct_full_by_name[klass.split("::").last] || klass + return unless fields + args = node.arguments&.arguments || [] + args.each_with_index do |arg, idx| + next if idx >= fields.size || arg.is_a?(Prism::KeywordHashNode) + @struct_field_static << { "path" => @rel, "line" => node.location.start_line, "class" => full_class, + "field" => fields[idx], "type" => expression_type(arg), "expression" => arg.slice } + merge_struct_field_static_type(full_class, fields[idx], expression_type(arg)) + merge_struct_field_hash_shape(full_class, fields[idx], hash_shape_for_value(arg)) + merge_struct_field_array_element_shape(full_class, fields[idx], array_element_shape_for_value(arg)) + end + end + + def inspect_class_constructor_fields(node) + return unless node.name == :new && node.receiver + klass = const_name(node.receiver) + return if klass.empty? || klass == "Struct" + keyword_args = (node.arguments&.arguments || []).grep(Prism::KeywordHashNode) + keyword_args.each do |keywords| + keywords.elements.each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + field = hash_key_name(assoc.key) + next unless field + merge_struct_field_static_type(klass, field, expression_type(assoc.value)) + merge_struct_field_hash_shape(klass, field, hash_shape_for_value(assoc.value)) + merge_struct_field_array_element_shape(klass, field, array_element_shape_for_value(assoc.value)) + end + end + end + + def inspect_array_literal(node) + elements = node.elements || [] + return if elements.size < 2 || elements.any? { |elem| elem.is_a?(Prism::SplatNode) } + types = elements.map { |elem| expression_type(elem) } + known = types.compact + return if known.size != elements.size || known.uniq.size < 2 + @tuple_arrays << { "path" => @rel, "line" => node.location.start_line, "size" => elements.size, + "types" => types, "confidence" => tuple_confidence(types), "code" => node.slice } + end + + def inspect_hash_literal(node) + elements = node.elements || [] + return if elements.empty? + keys = [] + values = [] + value_hash_shapes = {} + value_array_element_shapes = {} + elements.each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key = hash_key_name(assoc.key) + next unless key + keys << key + values << expression_type(assoc.value) + value_hash_shapes[key] = hash_shape_for_value(assoc.value) if hash_shape_for_value(assoc.value) + value_array_element_shapes[key] = array_element_shape_for_value(assoc.value) if array_element_shape_for_value(assoc.value) + end + return if keys.size < 2 || keys.size != elements.size + @hash_shapes << { "path" => @rel, "line" => node.location.start_line, "keys" => keys, + "value_types" => values, "value_hash_shapes" => value_hash_shapes, + "value_array_element_shapes" => value_array_element_shapes, "code" => node.slice } + end + + def inspect_local_container_origin(node) + origin = container_origin_for_value(node.value, name: node.name.to_s) + if origin + @local_container_origins[node.name.to_s] = origin + else + @local_container_origins.delete(node.name.to_s) + end + end + + def inspect_ivar_container_origin(node) + origin = container_origin_for_value(node.value, name: node.name.to_s) + @ivar_container_origins[node.name.to_s] = origin if origin + end + + def container_origin_for_value(value, name:) + return nil unless value + case value + when Prism::ArrayNode + types = Array(value.elements).map { |elem| expression_type(elem) } + { "kind" => "array literal", "name" => name, "path" => @rel, "line" => value.location.start_line, + "code" => value.slice, "array_element_types" => types.compact.uniq.sort } + when Prism::HashNode + key_types = [] + value_types = [] + Array(value.elements).each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key_types << expression_type(assoc.key) + value_types << expression_type(assoc.value) + end + { "kind" => "hash literal", "name" => name, "path" => @rel, "line" => value.location.start_line, + "code" => value.slice, "hash_key_types" => key_types.compact.uniq.sort, + "hash_value_types" => value_types.compact.uniq.sort } + when Prism::LocalVariableReadNode + @local_container_origins[value.name.to_s]&.merge("name" => name, "alias_of" => value.name.to_s) + when Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode + @ivar_container_origins[value.name.to_s]&.merge("name" => name, "alias_of" => value.name.to_s) + when Prism::CallNode + { "kind" => "forwarded return", "name" => name, "path" => @rel, "line" => value.location.start_line, + "code" => value.slice, "callee" => value.name.to_s } + end + end + + def inspect_index_lookup(node, scope) + return unless %i[[] fetch].include?(node.name) && node.receiver + return if sorbet_type_index_syntax?(node.receiver) + args = node.arguments&.arguments || [] + return unless args.size >= 1 + return if node.name == :fetch && args.size > 1 + receiver_type = expression_type(node.receiver) + lookup_type = collection_index_return_type(node, receiver_type) + index_type = expression_type(args.first) + @collection_index_lookups << { "path" => @rel, "line" => node.location.start_line, + "enclosing_scope" => scope.join("::"), "code" => node.slice, "receiver" => node.receiver.slice, + "index" => args.first.slice, "receiver_type" => receiver_type, "index_type" => index_type, + "lookup_type" => lookup_type, "status" => collection_index_status(receiver_type, lookup_type), + "origin" => receiver_collection_origin(node.receiver) } + end + + def inspect_hash_record_blocker(node, scope) + return unless node.receiver + name = node.name.to_s + args = node.arguments&.arguments || [] + if %w[[] fetch].include?(name) + return if name == "fetch" && args.size > 1 + return if args.empty? || hash_key_name(args.first) + origin = hash_record_blocker_origin_for_receiver(node.receiver) + return unless hash_record_blocker_origin?(origin) + @hash_record_blockers << { "path" => @rel, "line" => node.location.start_line, + "enclosing_scope" => scope.join("::"), "kind" => "dynamic_key", "code" => node.slice, + "receiver" => node.receiver.slice, "index" => args.first&.slice, "origin" => origin, + "message" => "dynamic hash-record key prevents struct accessor rewrite" } + elsif %w[[]= merge! update delete clear shift].include?(name) + origin = hash_record_blocker_origin_for_receiver(node.receiver) + return unless hash_record_blocker_origin?(origin) + @hash_record_blockers << { "path" => @rel, "line" => node.location.start_line, + "enclosing_scope" => scope.join("::"), "kind" => "mutation", "code" => node.slice, + "receiver" => node.receiver.slice, "origin" => origin, + "message" => "shape-changing hash-record mutation prevents broad struct rewrite" } + end + end + + def inspect_hash_record_member_call(node, scope) + receiver = node.receiver + return unless receiver.is_a?(Prism::CallNode) + return unless %i[[] fetch].include?(receiver.name) + return if receiver.name == :fetch && (receiver.arguments&.arguments || []).size > 1 + args = receiver.arguments&.arguments || [] + key = hash_key_name(args.first) + return unless key + origin = receiver_collection_origin(receiver.receiver) + return unless hash_record_blocker_origin?(origin) || origin["kind"] == "local hash shape" + @hash_record_member_calls << { "path" => @rel, "line" => node.location.start_line, + "enclosing_scope" => scope.join("::"), "field" => key, "member" => node.name.to_s, + "code" => node.slice, "lookup_code" => receiver.slice, "receiver" => receiver.receiver&.slice, + "origin" => origin } + end + + def hash_record_blocker_origin?(origin) + ["hash literal", "method parameter", "forwarded return", "instance variable", "local hash shape"].include?(origin&.fetch("kind", nil).to_s) + end + + def hash_record_blocker_origin_for_receiver(receiver) + origin = receiver_collection_origin(receiver) + return origin if hash_record_blocker_origin?(origin) + if receiver.is_a?(Prism::LocalVariableReadNode) && @current_hash_shapes[receiver.name.to_s] + { "kind" => "local hash shape", "name" => receiver.name.to_s, "path" => @rel, + "line" => receiver.location.start_line, "shape" => @current_hash_shapes[receiver.name.to_s] } + else + origin + end + end + + def sorbet_type_index_syntax?(receiver) + text = receiver.slice.to_s + text.match?(/\A(?:T::)?(?:Array|Hash|Set|Enumerable)\z/) || text.start_with?("T::") + end + + def collection_index_status(type, lookup_type = nil) + return "typed lookup" if NilKill.useful_type?(lookup_type) && !NilKill.weak_type?(lookup_type) + text = type.to_s + return "unknown receiver type" if text.empty? + return "weak collection receiver" if text.include?("T.untyped") + return "typed collection receiver" if text.match?(/\A(?:Array|Hash|T::Array|T::Hash)\b/) + "non-collection or unresolved receiver" + end + + def receiver_collection_origin(node) + case node + when Prism::LocalVariableReadNode + name = node.name.to_s + origin = @local_container_origins[name] + if origin && origin["kind"] == "method parameter" && @current_hash_shapes[name] + origin.merge("shape" => @current_hash_shapes[name]) + elsif origin + origin + elsif @current_hash_shape_sources[name] + @current_hash_shape_sources[name].merge("receiver" => name, "shape" => @current_hash_shapes[name]) + elsif @current_hash_shapes[name] + { "kind" => "local hash shape", "name" => name, "path" => @rel, + "line" => node.location.start_line, "shape" => @current_hash_shapes[name] } + else + { "kind" => "local variable", "name" => name } + end + when Prism::InstanceVariableReadNode, Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode + @ivar_container_origins[node.name.to_s] || { "kind" => "instance variable", "name" => node.name.to_s } + when Prism::ArrayNode + container_origin_for_value(node, name: "literal") + when Prism::HashNode + container_origin_for_value(node, name: "literal") + when Prism::CallNode + if (shape = hash_shape_for_receiver(node)) + { "kind" => "local hash shape", "name" => node.slice, "path" => @rel, + "line" => node.location.start_line, "shape" => shape } + else + { "kind" => "forwarded return", "callee" => node.name.to_s, "path" => @rel, + "line" => node.location.start_line, "code" => node.slice } + end + else + { "kind" => node.class.name.split("::").last, "code" => node.slice } + end + end + + def inspect_dispatcher(node, record) + param = record["params"].first + return unless param + arms = [] + collect_dispatch_arms(node.body, param["name"], arms) + arms.group_by { |arm| arm["helper"] }.each do |helper, helper_arms| + classes = helper_arms.flat_map { |arm| arm["classes"] }.uniq.sort + next if classes.empty? + type = classes.size == 1 ? classes.first : "T.any(#{classes.join(", ")})" + @dispatcher_inferences << { "path" => @rel, "line" => record["line"], "class" => record["class"], + "kind" => record["kind"], "dispatcher" => record["method"], "helper" => helper, "type" => type, + "classes" => classes } + end + end + + def collect_dispatch_arms(node, param_name, arms) + return unless node + if node.is_a?(Prism::CaseNode) + node.conditions.each do |condition| + next unless condition.is_a?(Prism::WhenNode) + helper = dispatch_helper_call(condition.statements, param_name) + next unless helper + classes = condition.conditions.filter_map { |cond| const_name(cond) } + arms << { "helper" => helper, "classes" => classes } unless classes.empty? + end + end + node.child_nodes.compact.each { |child| collect_dispatch_arms(child, param_name, arms) } if node.respond_to?(:child_nodes) + end + + def dispatch_helper_call(statements, param_name) + body = statements&.body || [] + return nil unless body.size == 1 + call = body.first + return nil unless call.is_a?(Prism::CallNode) + return nil if call.receiver + args = call.arguments&.arguments || [] + return nil unless args.size == 1 + arg = args.first + return nil unless arg.is_a?(Prism::LocalVariableReadNode) && arg.name.to_s == param_name + call.name.to_s + end + + def hash_key_name(node) + case node + when Prism::SymbolNode + node.respond_to?(:value) ? node.value.to_s : node.slice.delete_prefix(":") + when Prism::StringNode + node.respond_to?(:unescaped) ? node.unescaped : node.slice.delete_prefix("\"").delete_prefix("'").delete_suffix("\"").delete_suffix("'") + else + nil + end + end + + def tuple_confidence(types) + constants = types.grep(/\A[A-Z]\w*(?:::[A-Z]\w*)*/) + namespaces = constants.filter_map { |type| type.include?("::") ? type.split("::").first : nil }.uniq + return "review" if namespaces.size == 1 && constants.size == types.size + types.uniq.size == types.size ? "high" : "review" + end + + def collect_ivar_tlet_names(node, scope = []) + case node + when Prism::ClassNode, Prism::ModuleNode + new_scope = scope + [node.constant_path.slice] + node.child_nodes.compact.each { |child| collect_ivar_tlet_names(child, new_scope) } + return + when Prism::InstanceVariableWriteNode + val = node.value + if val.is_a?(Prism::CallNode) && val.name == :let && val.receiver&.slice == "T" + name = node.name.to_s + @ivar_tlet_names.add(name) + type_node = (val.arguments&.arguments || [])[1] + if type_node && !scope.empty? + type_str = type_node.slice + @ivar_tlet_types[[scope.join("::"), name]] = type_str if NilKill.useful_type?(type_str) + end + end + end + node.child_nodes.compact.each { |child| collect_ivar_tlet_names(child, scope) } if node.respond_to?(:child_nodes) + end + + def collect_non_nil_method_returns(node) + if node.is_a?(Prism::DefNode) + sig = sig_above(node.location.start_line) + if sig + ret = NilKill.extract_return_type(sig) + @method_return_types[node.name.to_s] << ret if ret + @non_nil_method_returns << node.name.to_s if non_nil_return_sig?(sig) + end + end + node.child_nodes.compact.each { |child| collect_non_nil_method_returns(child) } if node.respond_to?(:child_nodes) + end + + def non_nil_return_sig?(sig) + match = sig.match(/\.returns\((.+?)\)/) + return false unless match + type = match[1] + !type.include?("T.nilable") && type != "T.untyped" && type != "NilClass" + end + + def scoped_facts(method_record) + old = @non_nil_locals + old_maybe_nil = @maybe_nil_locals + old_param_types = @current_param_types + old_local_types = @current_local_types + old_collection_builders = @current_collection_builders + old_hash_shapes = @current_hash_shapes + old_array_element_shapes = @current_array_element_shapes + old_local_origins = @local_container_origins + old_method_name = @current_method_name + @non_nil_locals = Set.new(method_record["non_nil_params"]) + @maybe_nil_locals = Set.new + @current_param_types = method_record["params"].each_with_object({}) do |param, types| + types[param["name"]] = param["type"] if NilKill.useful_type?(param["type"]) + end + @current_local_types = {} + @current_collection_builders = seed_param_collection_builders(method_record) + @current_hash_shapes = seed_param_hash_shapes(method_record) + @current_array_element_shapes = seed_param_array_element_shapes(method_record) + @current_method_name = method_record["method"] + @local_container_origins = method_record["params"].each_with_object({}) do |param, origins| + origins[param["name"]] = { "kind" => "method parameter", "name" => param["name"], "type" => param["type"], + "path" => method_record["path"], "line" => method_record["line"] } + end + yield + ensure + @non_nil_locals = old + @maybe_nil_locals = old_maybe_nil + @current_param_types = old_param_types + @current_local_types = old_local_types + @current_collection_builders = old_collection_builders + @current_hash_shapes = old_hash_shapes + @current_array_element_shapes = old_array_element_shapes + @local_container_origins = old_local_origins + @current_method_name = old_method_name + end + + def method_record(node, scope) + sig = sig_above(node.location.start_line) + method_params = params(node, sig) + noreturn_candidate = !contains_explicit_return?(node.body) && noreturn_body?(node.body) + # Cross-file propagation: register the method name globally so that + # callers in other files can recognise a transitive raise via + # `noreturn_call?`. Run between the first and second passes of + # `index_sources` in `infer.rb`, which gives one hop of propagation + # per pass. Multi-hop chains need additional passes (driven by + # `propagate_noreturn_until_stable!`). + SourceIndex.register_noreturn_method(node.name) if noreturn_candidate + SourceIndex.register_noreturn_method(node.name) if sig && /returns\(\s*T\.noreturn\s*\)/.match?(sig) + { "path" => @rel, "line" => node.location.start_line, "end_line" => node.location.end_line, "class" => scope.join("::"), + "method" => node.name.to_s, "kind" => node.receiver.is_a?(Prism::SelfNode) ? "class" : "instance", + "has_sig" => !sig.nil?, "sig" => sig, "params" => method_params, "scope" => scope, + "non_nil_params" => non_nil_sig_params(sig), "uses_yield" => uses_yield?(node.body), + "untraceable_params" => untraceable_param_names(node), + "protocols" => param_protocols(node), "noreturn_candidate" => noreturn_candidate } + end + + def contains_explicit_return?(node) + return false unless node + return false if nested_scope_node?(node) + return true if return_node?(node) + node.respond_to?(:child_nodes) && node.child_nodes.compact.any? { |child| contains_explicit_return?(child) } + end + + def noreturn_body?(node) + case node + when nil + false + when Prism::StatementsNode + body = node.body || [] + return false if body.empty? + noreturn_body?(body.last) + when Prism::BeginNode + noreturn_body?(node.statements) + when Prism::IfNode + noreturn_body?(node.statements) && noreturn_body?(node.subsequent) + when Prism::ElseNode + noreturn_body?(node.statements) + when Prism::CaseNode + conditions = node.conditions || [] + return false if conditions.empty? + conditions.all? { |condition| noreturn_body?(condition.respond_to?(:statements) ? condition.statements : nil) } && + noreturn_body?(node.else_clause) + when Prism::RescueNode + noreturn_body?(node.statements) && noreturn_body?(node.subsequent) + when Prism::EnsureNode + noreturn_body?(node.statements) + when Prism::CallNode + noreturn_call?(node) + else + false + end + end + + def noreturn_call?(node) + return true if %i[raise fail exit abort].include?(node.name) + # T.absurd marks unreachable exhaustiveness arms (Sorbet idiom). It + # raises at runtime, so any branch ending in T.absurd is noreturn. + return true if node.name == :absurd && node.receiver&.slice == "T" + return true if SourceIndex.noreturn_methods.include?(node.name.to_s) + known_return_type(node.name.to_s, node: node, allow_rbi: false) == "T.noreturn" + end + + def analyze_return_origin(node, record) + old_param_types = @current_param_types + old_local_types = @current_local_types + old_collection_builders = @current_collection_builders + old_hash_shapes = @current_hash_shapes + old_array_element_shapes = @current_array_element_shapes + old_method_name = @current_method_name + old_class_name = @current_class_name + @current_param_types = record["params"].each_with_object({}) do |param, types| + types[param["name"]] = param["type"] if NilKill.useful_type?(param["type"]) + end + @current_local_types = {} + @current_collection_builders = seed_param_collection_builders(record) + @current_hash_shapes = seed_param_hash_shapes(record) + @current_array_element_shapes = seed_param_array_element_shapes(record) + @current_method_name = record["method"] + @current_class_name = record["class"] if record["class"] && !record["class"].empty? + collect_local_type_facts(node.body) + explicit_expressions = explicit_return_expressions(node.body) + implicit_expr = implicit_return_expression(node.body) + implicit_present = !return_node?(implicit_expr) + expressions = explicit_expressions.dup + expressions << implicit_expr if implicit_present + sources = [] + blockers = [] + expressions.compact.each do |expr| + sources.concat(return_sources_for(expr, blockers)) + end + hash_shape = hash_shape_for_return_expressions(expressions) + array_element_shape = array_element_shape_for_return_expressions(expressions) + if expressions.empty? || sources.empty? + blockers << "no return expression found" + end + type_sources = sources.filter_map { |source| source["type"] } + candidate = NilKill.static_sorbet_type(type_sources) + candidate = "T.untyped" if candidate == "NilClass" && sources.any? { |source| source["kind"] == "call_untyped" || source["kind"] == "unknown" } + useful = NilKill.useful_type?(candidate) + confidence = + if useful && !NilKill.weak_type?(candidate) && blockers.empty? && sources.none? { |source| source["kind"] == "call_untyped" } + "strong" + elsif useful + "weak" + else + "blocked" + end + { "path" => record["path"], "line" => record["line"], "end_line" => record["end_line"], + "class" => record["class"], "method" => record["method"], "kind" => record["kind"], + "implicit" => explicit_expressions.empty?, "return_syntax" => return_syntax(explicit_expressions, implicit_present), + "control_shape" => return_control_shape(explicit_expressions, implicit_expr, implicit_present), + "candidate_type" => useful ? candidate : "T.untyped", + "confidence" => confidence, "sources" => sources, "blockers" => blockers.uniq, + "hash_shape" => hash_shape, "array_element_shape" => array_element_shape } + ensure + @current_param_types = old_param_types + @current_local_types = old_local_types + @current_collection_builders = old_collection_builders + @current_hash_shapes = old_hash_shapes + @current_array_element_shapes = old_array_element_shapes + @current_method_name = old_method_name + @current_class_name = old_class_name + end + + def collect_local_type_facts(node) + return unless node + return if nested_scope_node?(node) + if node.is_a?(Prism::IfNode) + collect_branch_local_type_facts(node) + return + end + update_local_fact(node) if node.is_a?(Prism::LocalVariableWriteNode) + update_collection_builder_call(node) if node.is_a?(Prism::CallNode) + node.child_nodes.compact.each { |child| collect_local_type_facts(child) } if node.respond_to?(:child_nodes) + end + + def collect_branch_local_type_facts(node) + before = @current_local_types.dup + before_builders = dup_collection_builders(@current_collection_builders) + before_shapes = dup_hash_shapes(@current_hash_shapes) + before_array_shapes = dup_hash_shapes(@current_array_element_shapes) + + @current_local_types = before.dup + @current_collection_builders = dup_collection_builders(before_builders) + @current_hash_shapes = dup_hash_shapes(before_shapes) + @current_array_element_shapes = dup_hash_shapes(before_array_shapes) + collect_local_type_facts(node.statements) + then_types = @current_local_types.dup + then_builders = dup_collection_builders(@current_collection_builders) + then_shapes = dup_hash_shapes(@current_hash_shapes) + then_array_shapes = dup_hash_shapes(@current_array_element_shapes) + + @current_local_types = before.dup + @current_collection_builders = dup_collection_builders(before_builders) + @current_hash_shapes = dup_hash_shapes(before_shapes) + @current_array_element_shapes = dup_hash_shapes(before_array_shapes) + collect_local_type_facts(node.subsequent) + else_types = @current_local_types.dup + else_builders = dup_collection_builders(@current_collection_builders) + else_shapes = dup_hash_shapes(@current_hash_shapes) + else_array_shapes = dup_hash_shapes(@current_array_element_shapes) + + @current_local_types = merge_branch_local_types(before, then_types, else_types) + @current_collection_builders = merge_branch_collection_builders(before_builders, then_builders, else_builders) + @current_hash_shapes = merge_branch_hash_shapes(before_shapes, then_shapes, else_shapes) + @current_array_element_shapes = merge_branch_hash_shapes(before_array_shapes, then_array_shapes, else_array_shapes) + end + + def merge_branch_local_types(before, then_types, else_types) + names = (before.keys | then_types.keys | else_types.keys) + names.each_with_object({}) do |name, merged| + if then_types.key?(name) && else_types.key?(name) + type = NilKill.static_sorbet_type([then_types[name], else_types[name]].compact) + merged[name] = NilKill.useful_type?(type) ? type : "T.untyped" + elsif before.key?(name) + merged[name] = before[name] + end + end + end + + def seed_param_collection_builders(record) + record["params"].each_with_object({}) do |param, builders| + type = param["type"].to_s + info = collection_type_info(type) + next unless info + next unless info["element"].to_s.include?("T.untyped") || info["key"].to_s.include?("T.untyped") || info["value"].to_s.include?("T.untyped") + builders[param["name"]] = collection_builder(info["kind"]) + end + end + + def seed_param_hash_shapes(record) + record["params"].each_with_index.each_with_object({}) do |(param, idx), shapes| + shape = inferred_param_hash_shape(record["method"], param["name"], idx) + shapes[param["name"]] = shape if shape && !shape["poisoned"] + end + end + + def seed_param_array_element_shapes(record) + record["params"].each_with_index.each_with_object({}) do |(param, idx), shapes| + shape = inferred_param_array_element_shape(record["method"], param["name"], idx) + shapes[param["name"]] = shape if shape && !shape["poisoned"] + end + end + + def inferred_param_hash_shape(method_name, param_name, idx) + shapes = [ + @inferred_param_hash_shapes[[method_name.to_s, "positional", idx.to_s]], + @inferred_param_hash_shapes[[method_name.to_s, "keyword", param_name.to_s]], + ].compact + return nil if shapes.empty? + shapes.reduce { |acc, shape| merge_hash_record_shapes(acc, shape) } + end + + def inferred_param_array_element_shape(method_name, param_name, idx) + shapes = [ + @inferred_param_array_element_shapes[[method_name.to_s, "positional", idx.to_s]], + @inferred_param_array_element_shapes[[method_name.to_s, "keyword", param_name.to_s]], + ].compact + return nil if shapes.empty? + shapes.reduce { |acc, shape| merge_hash_record_shapes(acc, shape) } + end + + def dup_collection_builders(builders) + builders.transform_values do |builder| + { "kind" => builder["kind"], "types" => Array(builder["types"]).dup, + "key_types" => Array(builder["key_types"]).dup, "value_types" => Array(builder["value_types"]).dup, + "poisoned" => builder["poisoned"] } + end + end + + def collection_builder(kind) + { "kind" => kind, "types" => [], "key_types" => [], "value_types" => [], "poisoned" => false } + end + + def merge_branch_collection_builders(before, then_builders, else_builders) + names = (before.keys | then_builders.keys | else_builders.keys) + names.each_with_object({}) do |name, merged| + if then_builders.key?(name) && else_builders.key?(name) + merged[name] = merge_collection_builders(then_builders[name], else_builders[name]) + elsif before.key?(name) + merged[name] = before[name] + end + end + end + + def merge_collection_builders(left, right) + return collection_builder("unknown").merge("poisoned" => true) unless left["kind"] == right["kind"] + { + "kind" => left["kind"], + "types" => (Array(left["types"]) + Array(right["types"])).uniq, + "key_types" => (Array(left["key_types"]) + Array(right["key_types"])).uniq, + "value_types" => (Array(left["value_types"]) + Array(right["value_types"])).uniq, + "poisoned" => left["poisoned"] || right["poisoned"], + } + end + + def dup_hash_shapes(shapes) + shapes.transform_values { |shape| dup_hash_shape(shape) } + end + + def dup_hash_shape(shape) + HashShapeOps.dup_shape(shape) + end + + def merge_branch_hash_shapes(before, then_shapes, else_shapes) + names = (before.keys | then_shapes.keys | else_shapes.keys) + names.each_with_object({}) do |name, merged| + if then_shapes.key?(name) && else_shapes.key?(name) + merged[name] = merge_hash_record_shapes(then_shapes[name], else_shapes[name]) + elsif before.key?(name) + merged[name] = before[name] + end + end + end + + def merge_hash_record_shapes(left, right) + HashShapeOps.merge_shapes(left, right) + end + + def merge_nested_hash_shape_maps(left, right) + HashShapeOps.merge_nested_shape_maps(left, right) + end + + def hash_shape_for_return_expressions(expressions) + record_expressions = expressions.compact.reject { |expr| nil_return_expression?(expr) } + shapes = record_expressions.filter_map { |expr| hash_shape_for_expression(expr) } + return nil if shapes.empty? || shapes.size != record_expressions.size + shapes.reduce { |acc, shape| merge_hash_record_shapes(acc, shape) } + end + + def hash_shape_for_expression(expr) + case expr + when :bare_return + nil + when Prism::StatementsNode, Prism::BeginNode, Prism::ElseNode, Prism::ParenthesesNode + hash_shape_for_expression(implicit_return_expression(expr)) + else + if return_node?(expr) + args = expr.respond_to?(:arguments) ? expr.arguments : nil + values = args&.arguments || [] + return hash_shape_for_expression(values.first || :bare_return) + end + case expr + when Prism::IfNode + left = hash_shape_for_expression(implicit_return_expression(expr.statements)) + right = expr.subsequent ? hash_shape_for_expression(implicit_return_expression(expr.subsequent)) : nil + return nil unless left && right + merge_hash_record_shapes(left, right) + else + hash_shape_for_value(expr) + end + end + end + + def nil_return_expression?(expr) + return true if expr == :bare_return + if return_node?(expr) + args = expr.respond_to?(:arguments) ? expr.arguments : nil + values = args&.arguments || [] + return true if values.empty? + return nil_return_expression?(values.first) + end + case expr + when Prism::NilNode + true + when Prism::StatementsNode, Prism::BeginNode, Prism::ElseNode, Prism::ParenthesesNode + nil_return_expression?(implicit_return_expression(expr)) + else + false + end + end + + def array_element_shape_for_return_expressions(expressions) + record_expressions = expressions.compact.reject { |expr| nil_return_expression?(expr) } + shapes = record_expressions.filter_map { |expr| array_element_shape_for_expression(expr) } + return nil if shapes.empty? || shapes.size != record_expressions.size + shapes.reduce { |acc, shape| merge_hash_record_shapes(acc, shape) } + end + + def array_element_shape_for_expression(expr) + case expr + when :bare_return + nil + when Prism::StatementsNode, Prism::BeginNode, Prism::ElseNode, Prism::ParenthesesNode + array_element_shape_for_expression(implicit_return_expression(expr)) + else + if return_node?(expr) + args = expr.respond_to?(:arguments) ? expr.arguments : nil + values = args&.arguments || [] + return array_element_shape_for_expression(values.first || :bare_return) + end + case expr + when Prism::IfNode + left = array_element_shape_for_expression(implicit_return_expression(expr.statements)) + right = expr.subsequent ? array_element_shape_for_expression(implicit_return_expression(expr.subsequent)) : nil + return nil unless left && right + merge_hash_record_shapes(left, right) + else + array_element_shape_for_value(expr) + end + end + end + + def update_collection_builder_call(node) + receiver = node.receiver + if receiver.is_a?(Prism::LocalVariableReadNode) && @current_collection_builders.key?(receiver.name.to_s) + update_receiver_collection_builder(node, receiver.name.to_s) + end + poison_escaped_collection_builders(node) + end + + def update_receiver_collection_builder(node, name) + builder = @current_collection_builders[name] + args = node.arguments&.arguments || [] + handled = true + case node.name.to_s + when "<<", "push", "add" + add_collection_type(builder, args.first) + add_array_element_shape(name, args.first) if builder["kind"] == "array" + when "concat" + add_array_collection_types(builder, args.first) + add_array_element_shapes(name, args.first) if builder["kind"] == "array" + when "[]=" + return unless args.size >= 2 + add_hash_collection_types(builder, args[0], args[-1]) + add_hash_shape_key(name, args[0], args[-1]) if builder["kind"] == "hash" + when "merge!" + add_hash_literal_collection_types(builder, args.first) + merge_hash_shape_literal(name, args.first) if builder["kind"] == "hash" + else + handled = false + end + return unless handled + @current_local_types[name] = synthesized_collection_builder_type(builder) + end + + def add_array_element_shape(name, expr) + shape = hash_shape_for_value(expr) + return unless shape && !shape["poisoned"] + current = @current_array_element_shapes[name] + @current_array_element_shapes[name] = current ? merge_hash_record_shapes(current, shape) : dup_hash_shape(shape) + end + + def add_array_element_shapes(name, expr) + shape = array_element_shape_for_value(expr) + return unless shape && !shape["poisoned"] + current = @current_array_element_shapes[name] + @current_array_element_shapes[name] = current ? merge_hash_record_shapes(current, shape) : dup_hash_shape(shape) + end + + def add_hash_shape_key(name, key_expr, value_expr) + key = hash_key_name(key_expr) + type = expression_type(value_expr) + return unless key && (NilKill.useful_type?(type) || type == "NilClass") + shape = @current_hash_shapes[name] ||= { "keys" => {}, "poisoned" => false } + shape["keys"][key] ||= [] + shape["keys"][key] |= [type] + end + + def merge_hash_shape_literal(name, expr) + shape = hash_shape_for_value(expr) + return unless shape && !shape["poisoned"] + current = @current_hash_shapes[name] + @current_hash_shapes[name] = current ? merge_hash_record_shapes(current, shape) : dup_hash_shape(shape) + end + + def poison_escaped_collection_builders(node) + return if node.receiver + return if node.name.to_s == @current_method_name + return if known_return_type(node.name.to_s, node: node, allow_rbi: false) + (node.arguments&.arguments || []).each do |arg| + next unless arg.is_a?(Prism::LocalVariableReadNode) + builder = @current_collection_builders[arg.name.to_s] + next unless builder + builder["poisoned"] = true + @current_local_types.delete(arg.name.to_s) + @current_hash_shapes.delete(arg.name.to_s) + @current_array_element_shapes.delete(arg.name.to_s) + end + (node.arguments&.arguments || []).each do |arg| + next unless arg.is_a?(Prism::LocalVariableReadNode) + next unless @current_hash_shapes.key?(arg.name.to_s) || @current_array_element_shapes.key?(arg.name.to_s) + @current_hash_shapes.delete(arg.name.to_s) + @current_array_element_shapes.delete(arg.name.to_s) + end + end + + def add_collection_type(builder, expr) + return unless builder && expr + type = expression_type(expr) + if NilKill.useful_type?(type) || type == "NilClass" + builder["types"] |= [type] + else + builder["poisoned"] = true + end + end + + def add_array_collection_types(builder, expr) + return unless builder && expr + if expr.is_a?(Prism::ArrayNode) + expr.elements.each { |elem| add_collection_type(builder, elem) } + return + end + type = expression_type(expr) + info = collection_type_info(type) + if info && info["kind"] == "array" && NilKill.useful_type?(info["element"]) + builder["types"] |= [info["element"]] + else + builder["poisoned"] = true + end + end + + def add_hash_collection_types(builder, key_expr, value_expr) + return unless builder + key_type = expression_type(key_expr) + value_type = expression_type(value_expr) + if (NilKill.useful_type?(key_type) || key_type == "NilClass") && (NilKill.useful_type?(value_type) || value_type == "NilClass") + builder["key_types"] |= [key_type] + builder["value_types"] |= [value_type] + else + builder["poisoned"] = true + end + end + + def add_hash_literal_collection_types(builder, expr) + return unless builder && expr + if expr.is_a?(Prism::HashNode) || expr.is_a?(Prism::KeywordHashNode) + expr.elements.each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + add_hash_collection_types(builder, assoc.key, assoc.value) + end + else + builder["poisoned"] = true + end + end + + def builder_has_evidence?(builder) + builder && (!Array(builder["types"]).empty? || !Array(builder["key_types"]).empty? || + !Array(builder["value_types"]).empty? || builder["poisoned"]) + end + + def synthesized_collection_builder_type(builder) + return nil unless builder + return nil if builder["poisoned"] + case builder["kind"] + when "array" + elem = NilKill.static_sorbet_type(builder["types"]) + elem = "T.untyped" unless NilKill.useful_type?(elem) + "T::Array[#{elem}]" + when "hash" + key = NilKill.static_sorbet_type(builder["key_types"]) + value = NilKill.static_sorbet_type(builder["value_types"]) + key = "T.untyped" unless NilKill.useful_type?(key) + value = "T.untyped" unless NilKill.useful_type?(value) + "T::Hash[#{key}, #{value}]" + when "set" + elem = NilKill.static_sorbet_type(builder["types"]) + elem = "T.untyped" unless NilKill.useful_type?(elem) + "T::Set[#{elem}]" + end + end + + def explicit_return_expressions(node) + results = [] + collect_explicit_returns(node, results) + results + end + + def collect_explicit_returns(node, results) + return unless node + return if nested_scope_node?(node) + if return_node?(node) + args = node.respond_to?(:arguments) ? node.arguments : nil + values = args&.arguments || [] + results << (values.first || :bare_return) + return + end + node.child_nodes.compact.each { |child| collect_explicit_returns(child, results) } if node.respond_to?(:child_nodes) + end + + def implicit_return_expression(node) + case node + when Prism::StatementsNode + node.body&.last + when Prism::BeginNode + implicit_return_expression(node.statements) + when Prism::ElseNode + implicit_return_expression(node.statements) + when Prism::ParenthesesNode + implicit_return_expression(node.body) + else + node + end + end + + def return_syntax(explicit_expressions, implicit_present) + explicit = !explicit_expressions.empty? + return "mixed" if explicit && implicit_present + return "explicit" if explicit + "implicit" + end + + def return_control_shape(explicit_expressions, implicit_expr, implicit_present) + return "branching" if explicit_expressions.size > 1 + return "branching" if !explicit_expressions.empty? && implicit_present + return "branching" if explicit_expressions.any? { |expr| branching_return_expression?(expr) } + return "branching" if implicit_present && branching_return_expression?(implicit_expr) + "branchless" + end + + def branching_return_expression?(node) + case node + when nil, :bare_return + false + when Prism::IfNode, Prism::CaseNode, Prism::RescueNode + true + else + node.respond_to?(:child_nodes) && node.child_nodes.compact.any? { |child| branching_return_expression?(child) } + end + end + + def return_sources_for(node, blockers) + return [{ "kind" => "nil", "type" => "NilClass", "line" => nil, "code" => "return" }] if node == :bare_return + return [] unless node + line = node.location.start_line + code = node.slice + + if return_node?(node) + args = node.respond_to?(:arguments) ? node.arguments : nil + values = args&.arguments || [] + return return_sources_for(values.first || :bare_return, blockers) + end + + if node.is_a?(Prism::StatementsNode) || node.is_a?(Prism::BeginNode) || + node.is_a?(Prism::ElseNode) || node.is_a?(Prism::ParenthesesNode) + return return_sources_for(implicit_return_expression(node), blockers) + end + + if node.is_a?(Prism::InstanceVariableReadNode) + ivar_type = ivar_expression_type(node.name.to_s) + if NilKill.useful_type?(ivar_type) + return [{ "kind" => "ivar_typed", "type" => ivar_type, "line" => line, "code" => code }] + end + blockers << "untyped instance variable #{code} at #{@rel}:#{line}" + return [{ "kind" => "ivar_read", "line" => line, "code" => code }] + end + if node.is_a?(Prism::ClassVariableReadNode) || node.is_a?(Prism::GlobalVariableReadNode) + blockers << "untyped instance variable #{code} at #{@rel}:#{line}" + return [{ "kind" => "ivar_read", "line" => line, "code" => code }] + end + + if node.is_a?(Prism::IfNode) + sources = [] + sources.concat(return_sources_for(implicit_return_expression(node.statements), blockers)) + if node.subsequent + sources.concat(return_sources_for(implicit_return_expression(node.subsequent), blockers)) + else + sources << { "kind" => "nil", "type" => "NilClass", "line" => line, "code" => "implicit else" } + end + blockers << "conditional return without exhaustive static branch type at #{@rel}:#{line}" if sources.empty? + return sources + end + + if node.class.name == "Prism::UnlessNode" + sources = [] + sources.concat(return_sources_for(implicit_return_expression(node.statements), blockers)) + if node.respond_to?(:else_clause) && node.else_clause + sources.concat(return_sources_for(implicit_return_expression(node.else_clause), blockers)) + else + sources << { "kind" => "nil", "type" => "NilClass", "line" => line, "code" => "implicit else" } + end + blockers << "unless return without exhaustive static branch type at #{@rel}:#{line}" if sources.empty? + return sources + end + + if node.is_a?(Prism::CaseNode) + sources = [] + node.conditions.each do |condition| + sources.concat(return_sources_for(implicit_return_expression(condition.statements), blockers)) if condition.respond_to?(:statements) + end + sources.concat(return_sources_for(implicit_return_expression(node.else_clause), blockers)) if node.respond_to?(:else_clause) + blockers << "case return without exhaustive static branch type at #{@rel}:#{line}" if sources.empty? + return sources + end + + if node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode) + return [{ "kind" => "nil", "type" => "NilClass", "line" => line, "code" => code }] + end + + if node.is_a?(Prism::CallNode) + callee = node.name.to_s + if assignment_call?(node) + arg = assignment_value_expression(node) + arg_type = expression_type(arg) + if NilKill.useful_type?(arg_type) + return [{ "kind" => "assignment", "callee" => callee, "type" => arg_type, "line" => line, "code" => code }] + end + blockers << "assignment #{callee} has unknown RHS at #{@rel}:#{line}" + return [{ "kind" => "unknown", "line" => line, "code" => code, + "unknown_reasons" => unknown_expression_reasons(arg) }] + end + if node.safe_navigation? + ret = known_return_type(callee, node: node, allow_rbi: rbi_return_candidate?(node)) + if ret && NilKill.useful_type?(ret) + return [{ "kind" => "safe_call", "callee" => callee, "type" => nilable_type(ret), "line" => line, "code" => code, + "stdlib" => rbi_return_source?(node) }] + end + blockers << "safe navigation return may be nil at #{@rel}:#{line}" + return [{ "kind" => "nil", "type" => "NilClass", "line" => line, "code" => code }, + { "kind" => "call_untyped", "callee" => callee, "line" => line, "code" => code }] + end + ret = known_return_type(callee, node: node, allow_rbi: rbi_return_candidate?(node)) + return [{ "kind" => "typed_call", "callee" => callee, "type" => ret, "line" => line, "code" => code, + "stdlib" => rbi_return_source?(node) }] if ret && NilKill.useful_type?(ret) + expr_type = expression_type(node) + return [{ "kind" => "static", "callee" => callee, "type" => expr_type, "line" => line, "code" => code }] if NilKill.useful_type?(expr_type) + blockers << "untyped callee #{callee} at #{@rel}:#{line}" + return [{ "kind" => "call_untyped", "callee" => callee, "line" => line, "code" => code }] + end + + # `@x = v` / `x = v` / `CONST = v` as the return expression: the + # returned value IS the RHS. Recurse so it types exactly like a + # direct return of the RHS (this was ~90% of "NotImplemented"). + if node.is_a?(Prism::InstanceVariableWriteNode) || node.is_a?(Prism::LocalVariableWriteNode) || + node.is_a?(Prism::ClassVariableWriteNode) || node.is_a?(Prism::GlobalVariableWriteNode) || + node.is_a?(Prism::ConstantWriteNode) + return return_sources_for(node.value, blockers) + end + + type = expression_type(node) + return [{ "kind" => type == "NilClass" ? "nil" : "static", "type" => type, "line" => line, "code" => code }] if type + + blockers << "unknown return expression #{node.class.name.split("::").last} at #{@rel}:#{line}" + [{ "kind" => "unknown", "line" => line, "code" => code, "unknown_reasons" => unknown_expression_reasons(node) }] + end + + def assignment_call?(node) + setter_call?(node) || index_assignment_call?(node) + end + + def setter_call?(node) + # Trailing `=` identifies setters (`foo=`) and `[]=`. Exclude + # comparison operators (`==`, `!=`, `<=`, `>=`, `===`) which also + # end with `=` but are method calls returning T::Boolean, not + # assignments. Without this filter, `1 != 2` is misclassified as + # an assignment and returns the RHS type. + return false unless node.is_a?(Prism::CallNode) + name = node.name.to_s + return false unless name.end_with?("=") + return false if %w[== != <= >= ===].include?(name) + (node.arguments&.arguments || []).size == 1 + end + + def index_assignment_call?(node) + node.is_a?(Prism::CallNode) && node.name == :[]= && (node.arguments&.arguments || []).size >= 2 + end + + def assignment_value_expression(node) + args = node.arguments&.arguments || [] + args.last + end + + def return_node?(node) + node.class.name == "Prism::ReturnNode" + end + + def nested_scope_node?(node) + node.is_a?(Prism::DefNode) || node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) + end + + def rbi_return_candidate?(node) + return false unless node.is_a?(Prism::CallNode) + # A call on a global variable receiver (e.g. `$stderr.puts`) is not safe + # to look up via the RBI index: the global is dynamically reassignable + # and the receiver's actual class at runtime can be anything. Treating + # the result as the stdlib's nominal return type leads the proposer to + # narrow signatures that runtime test paths violate. + return false if node.receiver.is_a?(Prism::GlobalVariableReadNode) + node.receiver || %w[! == puts print warn raise].include?(node.name.to_s) + end + + def rbi_return_source?(node) + node.is_a?(Prism::CallNode) && NilKill.rbi_return_type(node.name.to_s, receiver_type_for_call(node)) + end + + def known_return_type(method_name, node: nil, allow_rbi: true) + propagated = propagated_core_return_type(node) + return propagated if NilKill.useful_type?(propagated) + static = @static_return_types[method_name.to_s] + return static if NilKill.useful_type?(static) + types = @method_return_types[method_name].compact.uniq + return types.first if types.size == 1 && NilKill.useful_type?(types.first) + return nil unless allow_rbi + NilKill.rbi_return_type(method_name, receiver_type_for_call(node)) + end + + def propagated_core_return_type(node) + return nil unless node.is_a?(Prism::CallNode) + receiver_type = receiver_type_for_call(node) + case node.name.to_s + when "[]" + collection_index_return_type(node, receiver_type) + when "each", "each_pair", "each_value", "each_key" + collection_receiver_type?(receiver_type) ? receiver_type : nil + when "<<", "push", "concat", "merge!", "add" + collection_receiver_type?(receiver_type) ? receiver_type : nil + when "map" + collection_map_return_type(node, receiver_type) + when "filter_map" + collection_filter_map_return_type(node, receiver_type) + when "compact" + collection_compact_return_type(receiver_type) + when "select", "reject" + collection_receiver_type?(receiver_type) ? receiver_type : nil + when "length", "size" + collection_receiver_type?(receiver_type) || receiver_type == "String" ? "Integer" : nil + when "empty?", "any?", "all?", "none?", "one?", "include?", "key?", "has_key?", "value?", "has_value?" + collection_receiver_type?(receiver_type) || receiver_type == "String" ? "T::Boolean" : nil + when "join" + array_receiver_type?(receiver_type) ? "String" : nil + when "to_s" + node.receiver ? "String" : nil + when "to_i" + node.receiver ? "Integer" : nil + when "to_sym" + node.receiver ? "Symbol" : nil + when "to_a" + collection_receiver_type?(receiver_type) ? receiver_type : nil + when "to_h" + receiver_type.to_s.match?(/\AT::(?:Hash|Array)\b/) ? receiver_type : nil + when "!", "!=", "==", "<", ">", "<=", ">=", "eql?", "equal?", "===", "frozen?", "respond_to?", "kind_of?", "instance_of?" + "T::Boolean" + when "<=>" + node.receiver ? "T.nilable(Integer)" : nil + when "hash" + node.receiver ? "Integer" : nil + when "inspect" + node.receiver ? "String" : nil + when "freeze", "dup", "clone", "itself", "tap" + receiver_type if NilKill.useful_type?(receiver_type) + when "+", "-", "*", "/", "%" + # Receiver-typed for stdlib numerics, String, Array (where defined). + # Skip for unknown receivers so we don't over-claim. + case receiver_type.to_s + when "Integer", "Float", "Rational", "Complex", "String" then receiver_type + else + array_receiver_type?(receiver_type) ? receiver_type : nil + end + else + nil + end + end + + def receiver_type_for_call(node) + return nil unless node.is_a?(Prism::CallNode) + expression_type(node.receiver) + end + + def nilable_type(type) + return type if type == "NilClass" + return type if type.start_with?("T.nilable(") + "T.nilable(#{type})" + end + + def inspect_param_origins(node, scope) + callee = node.name.to_s + args = node.arguments&.arguments || [] + args.each_with_index do |arg, idx| + if arg.is_a?(Prism::KeywordHashNode) + arg.elements.each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key = hash_key_name(assoc.key) + next unless key + @param_origins << param_origin_record(node, assoc.value, callee, :keyword, key, scope) + record_callsite_hash_shape(callee, :keyword, key, assoc.value) + record_callsite_array_element_shape(callee, :keyword, key, assoc.value) + end + else + @param_origins << param_origin_record(node, arg, callee, :positional, idx, scope) + record_callsite_hash_shape(callee, :positional, idx, arg) + record_callsite_array_element_shape(callee, :positional, idx, arg) + end + end + end + + def record_callsite_hash_shape(callee, kind, slot, arg) + shape = hash_shape_for_value(arg) + return unless shape && !shape["poisoned"] + callsite_callee_names(callee).each do |name| + key = [name, kind.to_s, slot.to_s] + @inferred_param_hash_shapes[key] = + if @inferred_param_hash_shapes[key] + merge_hash_record_shapes(@inferred_param_hash_shapes[key], shape) + else + dup_hash_shape(shape) + end + end + end + + def record_callsite_array_element_shape(callee, kind, slot, arg) + shape = array_element_shape_for_value(arg) + return unless shape && !shape["poisoned"] + callsite_callee_names(callee).each do |name| + key = [name, kind.to_s, slot.to_s] + @inferred_param_array_element_shapes[key] = + if @inferred_param_array_element_shapes[key] + merge_hash_record_shapes(@inferred_param_array_element_shapes[key], shape) + else + dup_hash_shape(shape) + end + end + end + + def callsite_callee_names(callee) + name = callee.to_s + name == "new" ? ["new", "initialize"] : [name] + end + + def inspect_attribute_shape_write(node) + return unless node.is_a?(Prism::CallNode) && node.receiver + name = node.name.to_s + return unless name.end_with?("=") && name != "==" + args = node.arguments&.arguments || [] + return unless args.size == 1 + attr = name.delete_suffix("=") + if (shape = hash_shape_for_value(args.first)) + merge_attribute_hash_shape(attr, shape) + end + if (shape = array_element_shape_for_value(args.first)) + merge_attribute_array_element_shape(attr, shape) + end + end + + def merge_attribute_hash_shape(attr, shape) + return unless shape && !shape["poisoned"] + current = self.class.attribute_hash_shapes[attr] + self.class.attribute_hash_shapes[attr] = current ? merge_hash_record_shapes(current, shape) : dup_hash_shape(shape) + end + + def merge_attribute_array_element_shape(attr, shape) + return unless shape && !shape["poisoned"] + current = self.class.attribute_array_element_shapes[attr] + self.class.attribute_array_element_shapes[attr] = current ? merge_hash_record_shapes(current, shape) : dup_hash_shape(shape) + end + + def merge_struct_field_hash_shape(klass, field, shape) + return unless shape && !shape["poisoned"] + key = [klass.to_s, field.to_s] + current = self.class.struct_field_hash_shapes[key] + self.class.struct_field_hash_shapes[key] = current ? merge_hash_record_shapes(current, shape) : dup_hash_shape(shape) + end + + def merge_struct_field_array_element_shape(klass, field, shape) + return unless shape && !shape["poisoned"] + key = [klass.to_s, field.to_s] + current = self.class.struct_field_array_element_shapes[key] + self.class.struct_field_array_element_shapes[key] = current ? merge_hash_record_shapes(current, shape) : dup_hash_shape(shape) + end + + def merge_struct_field_static_type(klass, field, type) + return unless NilKill.useful_type?(type) || type == "NilClass" + key = [klass.to_s, field.to_s] + self.class.struct_field_static_types[key] ||= [] + self.class.struct_field_static_types[key] |= [type] + end + + def param_origin_record(call_node, arg, callee, kind, slot, scope) + type = expression_type(arg) + origin_kind = type ? "static" : "unknown" + source_method = nil + if arg.is_a?(Prism::CallNode) + source_method = arg.name.to_s + ret = known_return_type(source_method, node: arg, allow_rbi: rbi_return_candidate?(arg)) + if ret + type = ret + origin_kind = "typed_return" + elsif NilKill.useful_type?(type) + origin_kind = "typed_return" + else + origin_kind = "untyped_return" + end + elsif arg.is_a?(Prism::LocalVariableReadNode) + origin_kind = "local" + end + { "path" => @rel, "line" => call_node.location.start_line, "enclosing_scope" => scope.join("::"), + "callee" => callee, "arg_kind" => kind.to_s, "slot" => slot.to_s, "origin_kind" => origin_kind, + "receiver" => call_receiver_name(call_node), "source_method" => source_method, "type" => type, "code" => arg.slice, + "hash_shape" => hash_shape_for_value(arg), "array_element_shape" => array_element_shape_for_value(arg), + "unknown_reasons" => origin_kind == "unknown" ? unknown_expression_reasons(arg) : [] } + end + + def call_receiver_name(call_node) + receiver = call_node.receiver + return nil unless receiver + const_name(receiver) + rescue StandardError + receiver.slice.to_s + end + + def unknown_expression_reasons(node) + reasons = Set.new + collect_unknown_expression_reasons(node, reasons) + reasons.to_a.sort + end + + def collect_unknown_expression_reasons(node, reasons) + return unless node + case node + when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode + reasons << "instance variable #{node.name}" + when Prism::ClassVariableReadNode, Prism::ClassVariableWriteNode + reasons << "class variable #{node.name}" + when Prism::GlobalVariableReadNode, Prism::GlobalVariableWriteNode + reasons << "global variable #{node.name}" + when Prism::LocalVariableReadNode + reasons << "local variable #{node.name}" + when Prism::ConstantReadNode, Prism::ConstantPathNode + type = static_expression_type(node) + reasons << (type ? "literal/static expression #{static_expression_reason(type)}" : "operation unresolved constant #{node.slice}") + return + when Prism::ArrayNode + reasons << "struct/array/collection value Array" + return + when Prism::HashNode, Prism::KeywordHashNode + reasons << "struct/array/collection value Hash" + return + when Prism::CallNode + if node.receiver&.slice == "T" && %i[let cast unsafe bind].include?(node.name) + reasons << "literal/static expression explicit #{node.receiver.slice}.#{node.name}" + args = node.arguments&.arguments || [] + reasons << "literal/static expression explicit T.untyped" if args.any? { |arg| arg.slice == "T.untyped" } + return + elsif expression_type(node) + reasons << "literal/static expression #{static_expression_reason(expression_type(node))}" + return + elsif !known_return_type(node.name.to_s, node: node, allow_rbi: rbi_return_candidate?(node)) + reasons << "forwarded return #{node.name}" + collect_unknown_expression_reasons(node.receiver, reasons) + return + end + else + type = static_expression_type(node) + if type + reasons << "literal/static expression #{static_expression_reason(type)}" + return + else + reasons << "operation #{node.class.name.split("::").last}" + end + end + node.child_nodes.compact.each { |child| collect_unknown_expression_reasons(child, reasons) } if node.respond_to?(:child_nodes) + end + + def param_protocols(node) + names = params(node).map { |param| param["name"] }.to_set + protocols = names.each_with_object({}) { |name, hash| hash[name] = { "methods" => Set.new, "aliases" => Set.new, "gaps" => Set.new } } + collect_protocols(node.body, protocols, names) + protocols.transform_values do |data| + { "methods" => data["methods"].to_a.sort, "aliases" => data["aliases"].to_a.sort, "gaps" => data["gaps"].to_a.sort } + end + end + + def collect_protocols(node, protocols, param_names) + return unless node + if node.is_a?(Prism::CallNode) + receiver = node.receiver + if receiver.is_a?(Prism::LocalVariableReadNode) && protocols.key?(receiver.name.to_s) + protocols[receiver.name.to_s]["methods"] << node.name.to_s + end + # Methods called on an instance variable populate the class-scoped + # ivar protocol used by the recursive resolver. Capture covers + # `@x.token` directly as well as `T.must(@x).token` / safe-nav. + if receiver.is_a?(Prism::InstanceVariableReadNode) && @current_class_name + @ivar_protocols[[@current_class_name, receiver.name.to_s]] << node.name.to_s + end + (node.arguments&.arguments || []).each_with_index do |arg, slot| + if arg.is_a?(Prism::LocalVariableReadNode) && protocols.key?(arg.name.to_s) + protocols[arg.name.to_s]["gaps"] << "forwarded to #{node.name} slot #{slot} at #{@rel}:#{node.location.start_line}" + end + end + elsif node.is_a?(Prism::LocalVariableWriteNode) + source = unwrap_alias_source(node.value) + if source && protocols.key?(source) + protocols[source]["aliases"] << "#{node.name} at #{@rel}:#{node.location.start_line}" + end + elsif node.is_a?(Prism::InstanceVariableWriteNode) + source = unwrap_alias_source(node.value) + if source && protocols.key?(source) + protocols[source]["gaps"] << "captured in #{node.name} at #{@rel}:#{node.location.start_line}" + # Record that this ivar receives a value originating from a param + # of the enclosing method. The resolver uses this to decide + # whether to consult @ivar_protocols when expanding a capture gap. + @ivar_param_origins[[@current_class_name, node.name.to_s]] << source if @current_class_name + end + end + node.child_nodes.compact.each { |child| collect_protocols(child, protocols, param_names) } if node.respond_to?(:child_nodes) + end + + def unwrap_alias_source(node) + case node + when Prism::LocalVariableReadNode + node.name.to_s + when Prism::CallNode + if node.receiver&.slice == "T" && %i[must cast let].include?(node.name) + unwrap_alias_source(node.arguments&.arguments&.first) + end + end + end + + def sig_above(line) + (line - 2).downto([line - 6, 0].max) { |idx| return @lines[idx].strip if @lines[idx]&.match?(/\bsig\s*\{/) } + nil + end + + def params(node, sig = sig_above(node.location.start_line)) + p = node.parameters + return [] unless p + sig_types = NilKill.extract_param_entries(sig).to_h + nodes = p.requireds + p.optionals + p.keywords + nodes.filter_map do |n| + next unless n.respond_to?(:name) && n.name + name = n.name.to_s + { "name" => name, "nil_default" => nil_default?(n), "type" => sig_types[name] } + end + end + + # Splat (`*a`), double-splat (`**kw`), and block (`&b`) params: the + # runtime tracer types only positional/keyword named args, so these + # slots can never get runtime evidence. They appear in the Sorbet + # sig string (as `name: T...`) with no `*`/`**`/`&` marker, so the + # sig-text heuristic alone cannot recognise them -- the structural + # truth lives only in the def's Prism parameter list. + def untraceable_param_names(node) + p = node.parameters + return [] unless p + names = [] + names << p.rest.name.to_s if p.rest.respond_to?(:name) && p.rest&.name + kr = p.respond_to?(:keyword_rest) ? p.keyword_rest : nil + names << kr.name.to_s if kr.respond_to?(:name) && kr&.name + names << p.block.name.to_s if p.respond_to?(:block) && p.block.respond_to?(:name) && p.block&.name + names + end + + def non_nil_sig_params(sig) + return [] unless sig + params_match = sig.match(/params\((.*)\)\./) + return [] unless params_match + params_match[1].scan(/\b([a-zA-Z_]\w*):\s*([^,)]+)/).filter_map do |name, type| + next if type.include?("T.nilable") || type == "T.untyped" || type == "NilClass" + name + end + end + + def nil_default?(node) + node.respond_to?(:value) && node.value.is_a?(Prism::NilNode) + end + + def uses_yield?(node) + return false unless node&.respond_to?(:child_nodes) + return true if node.is_a?(Prism::YieldNode) + node.child_nodes.compact.any? { |child| uses_yield?(child) } + end + + def inspect_call(node) + if node.name == :let && node.receiver&.slice == "T" + args = node.arguments&.arguments || [] + @tlet_sites << { "path" => @rel, "line" => node.location.start_line, "tlet" => true, "type" => args[1]&.slice } + elsif node.safe_navigation? && provably_non_nil?(node.receiver) + @dead_nil_checks << { "path" => @rel, "line" => node.location.start_line, "kind" => "safe_nav", + "code" => node.slice, "reason" => "#{node.receiver.slice} is provably non-nil" } + elsif node.name == :nil? && node.receiver && provably_non_nil?(node.receiver) + @dead_nil_checks << { "path" => @rel, "line" => node.location.start_line, "kind" => "nil_check", + "code" => node.slice, "reason" => "#{node.receiver.slice} is provably non-nil; .nil? is always false" } + end + end + + def provably_non_nil?(node) + case node + when Prism::LocalVariableReadNode + name = node.name.to_s + @non_nil_locals.include?(name) && !@maybe_nil_locals.include?(name) + when Prism::CallNode + !node.safe_navigation? && @non_nil_method_returns.include?(node.name.to_s) + when Prism::SelfNode + true + else + !!non_nil_literal?(node) + end + end + + def update_local_fact(node) + name = node.name.to_s + builder = collection_builder_for_assignment(node.value) + hash_shape = hash_shape_for_value(node.value) + array_shape = array_element_shape_for_value(node.value) + if builder + @current_collection_builders[name] = builder + elsif !preserve_collection_builder_assignment?(node.value) + @current_collection_builders.delete(name) + end + if hash_shape + @current_hash_shapes[name] = hash_shape + @current_hash_shape_sources[name] = hash_record_source_for_assignment(node, hash_shape) + elsif preserve_hash_shape_assignment?(node.value) + @current_hash_shapes[name] = dup_hash_shape(@current_hash_shapes[node.value.name.to_s]) + @current_hash_shape_sources[name] = @current_hash_shape_sources[node.value.name.to_s]&.merge("alias" => name) + else + @current_hash_shapes.delete(name) + @current_hash_shape_sources.delete(name) + end + if array_shape + @current_array_element_shapes[name] = array_shape + elsif preserve_array_element_shape_assignment?(node.value) + @current_array_element_shapes[name] = dup_hash_shape(@current_array_element_shapes[node.value.name.to_s]) + else + @current_array_element_shapes.delete(name) + end + type = expression_type(node.value) + if NilKill.useful_type?(type) + @current_local_types[name] = type + elsif builder + @current_local_types[name] = synthesized_collection_builder_type(builder) + else + @current_local_types.delete(name) + end + if non_nil_literal?(node.value) && !@maybe_nil_locals.include?(name) + @non_nil_locals.add(name) + else + @non_nil_locals.delete(name) + @maybe_nil_locals.add(name) + end + end + + def hash_shape_for_value(value) + return nil unless value + case value + when Prism::HashNode, Prism::KeywordHashNode + shape = { "keys" => {}, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } + value.elements.each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key = hash_key_name(assoc.key) + type = expression_type(assoc.value) + if key && (NilKill.useful_type?(type) || type == "NilClass") + shape["keys"][key] ||= [] + shape["keys"][key] |= [type] + if (nested_hash = hash_shape_for_value(assoc.value)) + shape["value_hash_shapes"][key] = nested_hash + end + if (nested_array = array_element_shape_for_value(assoc.value)) + shape["value_array_element_shapes"][key] = nested_array + end + elsif key + shape["keys"][key] ||= [] + shape["keys"][key] |= ["T.untyped"] + elsif !key + shape["poisoned"] = true + end + end + shape + when Prism::LocalVariableReadNode + dup_hash_shape(@current_hash_shapes[value.name.to_s]) + when Prism::CallNode + if assignment_call?(value) + hash_shape_for_value(assignment_value_expression(value)) + elsif value.receiver&.slice == "T" && %i[must cast let].include?(value.name) + hash_shape_for_value(value.arguments&.arguments&.first) + elsif %i[find detect].include?(value.name) + array_element_shape_for_receiver(value.receiver) + elsif %i[first last].include?(value.name) + array_element_shape_for_receiver(value.receiver) + elsif !value.receiver + dup_hash_shape(@static_hash_return_shapes[value.name.to_s]) + else + attribute_hash_shape_for_call(value) + end + when Prism::OrNode + merge_optional_hash_shape(hash_shape_for_value(value.left), hash_shape_for_value(value.right)) + end + end + + def array_element_shape_for_value(value) + return nil unless value + case value + when Prism::ArrayNode + shapes = value.elements.filter_map { |elem| hash_shape_for_value(elem) } + return nil if shapes.empty? + shapes.reduce { |acc, shape| merge_hash_record_shapes(acc, shape) } + when Prism::LocalVariableReadNode + dup_hash_shape(@current_array_element_shapes[value.name.to_s]) + when Prism::CallNode + if assignment_call?(value) + array_element_shape_for_value(assignment_value_expression(value)) + elsif value.receiver&.slice == "T" && %i[must cast let].include?(value.name) + array_element_shape_for_value(value.arguments&.arguments&.first) + elsif %i[map filter_map].include?(value.name) + hash_shape_for_block_return(value) + elsif %i[select reject compact].include?(value.name) + array_element_shape_for_receiver(value.receiver) + elsif !value.receiver + dup_hash_shape(@static_array_element_return_shapes[value.name.to_s]) + elsif value.receiver + attribute_array_element_shape_for_call(value) + end + when Prism::OrNode + merge_optional_hash_shape(array_element_shape_for_value(value.left), array_element_shape_for_value(value.right)) + end + end + + def merge_optional_hash_shape(left, right) + return dup_hash_shape(left) if left && !right + return dup_hash_shape(right) if right && !left + return nil unless left && right + merge_hash_record_shapes(left, right) + end + + def attribute_hash_shape_for_call(node) + return nil unless node.is_a?(Prism::CallNode) + return nil if node.name.to_s.end_with?("=") + if (shape = struct_field_hash_shape_for_call(node)) + return shape + end + dup_hash_shape(self.class.attribute_hash_shapes[node.name.to_s]) + end + + def attribute_array_element_shape_for_call(node) + return nil unless node.is_a?(Prism::CallNode) + return nil if node.name.to_s.end_with?("=") + if (shape = struct_field_array_element_shape_for_call(node)) + return shape + end + dup_hash_shape(self.class.attribute_array_element_shapes[node.name.to_s]) + end + + def hash_shape_for_block_return(call_node) + block = call_node.block + return nil unless block && block.respond_to?(:body) + old_hash_shapes = @current_hash_shapes + @current_hash_shapes = dup_hash_shapes(@current_hash_shapes) + block_param_names(block).each_with_index do |name, idx| + shape = block_param_shapes_for_call(call_node)[idx] + @current_hash_shapes[name] = dup_hash_shape(shape) if name && shape + end + expr = implicit_return_expression(block.body) + shape = hash_shape_for_expression(expr) + if (!shape || Hash(shape["keys"]).empty?) && (literal_shape = hash_shape_for_literal_keys(expr)) + shape = literal_shape + end + shape + ensure + @current_hash_shapes = old_hash_shapes if old_hash_shapes + end + + def hash_shape_for_literal_keys(value) + return nil unless value.is_a?(Prism::HashNode) || value.is_a?(Prism::KeywordHashNode) + shape = { "keys" => {}, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } + value.elements.each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + key = hash_key_name(assoc.key) + if key + type = expression_type(assoc.value) + shape["keys"][key] ||= [] + shape["keys"][key] |= [NilKill.useful_type?(type) || type == "NilClass" ? type : "T.untyped"] + if (nested_hash = hash_shape_for_value(assoc.value)) + shape["value_hash_shapes"][key] = nested_hash + end + if (nested_array = array_element_shape_for_value(assoc.value)) + shape["value_array_element_shapes"][key] = nested_array + end + else + shape["poisoned"] = true + end + end + Hash(shape["keys"]).empty? ? nil : shape + end + + def struct_field_hash_shape_for_call(node) + struct_field_shape_for_call(node, self.class.struct_field_hash_shapes) + end + + def struct_field_array_element_shape_for_call(node) + struct_field_shape_for_call(node, self.class.struct_field_array_element_shapes) + end + + def struct_field_shape_for_call(node, index) + receiver_type = expression_type(node.receiver) + receiver_classes_for_field_shape(receiver_type).each do |klass| + shape = index[[klass, node.name.to_s]] + return dup_hash_shape(shape) if shape + end + if receiver_classes_for_field_shape(receiver_type).empty? + matching = index.select { |(_klass, field), _shape| field == node.name.to_s }.values + return dup_hash_shape(matching.first) if matching.size == 1 + end + nil + end + + def struct_field_static_type_for_call(node) + return nil unless node.is_a?(Prism::CallNode) && node.receiver + receiver_type = expression_type(node.receiver) + types = receiver_classes_for_field_shape(receiver_type).flat_map do |klass| + Array(self.class.struct_field_static_types[[klass, node.name.to_s]]) + end + NilKill.static_sorbet_type(types.uniq) + end + + def receiver_classes_for_field_shape(type) + raw = NilKill.strip_nilable_type(type.to_s) + return [] if raw.empty? || raw == "T.untyped" + if raw.start_with?("T.any(") + return NilKill.split_top_level(NilKill.extract_call_args(raw, "T.any") || "").flat_map { |inner| receiver_classes_for_field_shape(inner) }.uniq + end + [raw, raw.split("::").last].uniq + end + + def collection_builder_for_assignment(value) + return nil unless value + case value + when Prism::ArrayNode + builder = collection_builder("array") + value.elements.each { |elem| add_collection_type(builder, elem) } + builder + when Prism::HashNode + builder = collection_builder("hash") + value.elements.each do |assoc| + next unless assoc.respond_to?(:key) && assoc.respond_to?(:value) + add_hash_collection_types(builder, assoc.key, assoc.value) + end + builder + when Prism::CallNode + if value.name == :new && value.receiver&.slice == "Set" + collection_builder("set") + end + end + end + + def preserve_collection_builder_assignment?(value) + value.is_a?(Prism::LocalVariableReadNode) && @current_collection_builders.key?(value.name.to_s) + end + + def preserve_hash_shape_assignment?(value) + value.is_a?(Prism::LocalVariableReadNode) && @current_hash_shapes.key?(value.name.to_s) + end + + def hash_record_source_for_assignment(node, shape) + value = node.value + if value.is_a?(Prism::HashNode) || value.is_a?(Prism::KeywordHashNode) + { "kind" => "hash literal", "name" => node.name.to_s, "path" => @rel, + "line" => node.location.start_line, "code" => value.slice, "shape" => shape } + else + { "kind" => "local hash shape", "name" => node.name.to_s, "path" => @rel, + "line" => node.location.start_line, "code" => value&.slice, "shape" => shape } + end + end + + def preserve_array_element_shape_assignment?(value) + value.is_a?(Prism::LocalVariableReadNode) && @current_array_element_shapes.key?(value.name.to_s) + end + + def inspect_variable_write(node) + if node.value.is_a?(Prism::CallNode) && node.value.name == :let && node.value.receiver&.slice == "T" + @ivar_tlet_names.add(node.name.to_s) + return + end + return if @ivar_tlet_names.include?(node.name.to_s) + type = static_expression_type(node.value) + return if type == "NilClass" + return unless type + @tlet_sites << { "path" => @rel, "line" => node.location.start_line, "tlet" => false, "name" => node.name.to_s, "candidate_type" => type } + end + + def expression_type(node) + return nil unless node + if return_node?(node) + args = node.respond_to?(:arguments) ? node.arguments : nil + values = args&.arguments || [] + return expression_type(values.first) || "NilClass" + end + if node.is_a?(Prism::CallNode) && node.name == :let && node.receiver&.slice == "T" + return node.arguments&.arguments&.[](1)&.slice + end + if node.is_a?(Prism::CallNode) && node.name == :must && node.receiver&.slice == "T" + return expression_type(node.arguments&.arguments&.first) + end + if node.is_a?(Prism::LocalVariableReadNode) + name = node.name.to_s + builder_type = synthesized_collection_builder_type(@current_collection_builders[name]) + return builder_type if builder_has_evidence?(@current_collection_builders[name]) && NilKill.useful_type?(builder_type) + return "T::Hash[T.untyped, T.untyped]" if @current_hash_shapes[name] + return "T::Array[T::Hash[T.untyped, T.untyped]]" if @current_array_element_shapes[name] + return @current_local_types[name] if NilKill.useful_type?(@current_local_types[name]) + return @current_param_types[name] + end + if node.is_a?(Prism::InstanceVariableReadNode) + return ivar_expression_type(node.name.to_s) + end + if node.is_a?(Prism::ParenthesesNode) + return expression_type(implicit_return_expression(node.body)) + end + if node.is_a?(Prism::StatementsNode) + return expression_type(node.body&.last) + end + if node.is_a?(Prism::ElseNode) + return expression_type(implicit_return_expression(node.statements)) + end + if node.is_a?(Prism::IfNode) + left = expression_type(implicit_return_expression(node.statements)) + right = node.subsequent ? expression_type(implicit_return_expression(node.subsequent)) : "NilClass" + return NilKill.static_sorbet_type([left, right].compact) + end + if node.class.name == "Prism::UnlessNode" + left = expression_type(implicit_return_expression(node.statements)) + right = node.respond_to?(:else_clause) && node.else_clause ? expression_type(implicit_return_expression(node.else_clause)) : "NilClass" + return NilKill.static_sorbet_type([left, right].compact) + end + if node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode) + return "NilClass" + end + if node.is_a?(Prism::OrNode) + left = expression_type(node.left) + right = expression_type(node.right) + non_nil = [left, right].compact.reject { |type| type == "NilClass" } + normalized = non_nil.map { |type| NilKill.strip_nilable_type(type.to_s) }.uniq + return normalized.first if normalized.size == 1 && NilKill.useful_type?(normalized.first) + return non_nil.first if non_nil.size == 1 && NilKill.useful_type?(non_nil.first) + return left if left == right && NilKill.useful_type?(left) + end + if node.is_a?(Prism::CallNode) + if assignment_call?(node) + return expression_type(assignment_value_expression(node)) + end + return "T::Hash[T.untyped, T.untyped]" if hash_shape_for_receiver(node) + return "T::Array[T::Hash[T.untyped, T.untyped]]" if array_element_shape_for_receiver(node) + field_type = struct_field_static_type_for_call(node) + return field_type if NilKill.useful_type?(field_type) + ret = known_return_type(node.name.to_s, node: node, allow_rbi: rbi_return_candidate?(node)) + return ret if NilKill.useful_type?(ret) + end + return "T::Array[T::Hash[T.untyped, T.untyped]]" if array_element_shape_for_value(node) + constant_expression_type(node) || literal_type(node) + end + + def ivar_expression_type(name) + return nil unless @current_class_name + tlet_type = @ivar_tlet_types[[@current_class_name, name]] + return tlet_type if NilKill.useful_type?(tlet_type) + field = name.sub(/\A@/, "") + class_chain = @current_class_name.split("::") + while class_chain.any? + candidate = class_chain.join("::") + rbi_type = SourceIndex.rbi_field_types[[candidate, field]] + return rbi_type if NilKill.useful_type?(rbi_type) + rbi_type_short = SourceIndex.rbi_field_types[[class_chain.last, field]] + return rbi_type_short if NilKill.useful_type?(rbi_type_short) + class_chain.pop + end + nil + end + + def array_receiver_type?(type) + type.to_s.match?(/\A(?:Array|T::Array)\b/) + end + + def hash_receiver_type?(type) + type.to_s.match?(/\A(?:Hash|T::Hash)\b/) + end + + def collection_receiver_type?(type) + array_receiver_type?(type) || hash_receiver_type?(type) || type.to_s.match?(/\A(?:Set|T::Set)\b/) + end + + def collection_index_return_type(node, receiver_type) + args = node.arguments&.arguments || [] + return nil unless args.size == 1 + shape_type = hash_shape_index_return_type(node.receiver, args.first) + return shape_type if NilKill.useful_type?(shape_type) + info = collection_type_info(receiver_type) + return nil unless info + case info["kind"] + when "array" + elem = info["element"] + return nil if elem.to_s.empty? || elem.include?("T.untyped") + index = args.first + if index.class.name == "Prism::RangeNode" + "T::Array[#{elem}]" + elsif expression_type(index) == "Integer" + nilable_type(elem) + end + when "hash" + value = info["value"] + return nil if value.to_s.empty? || value.include?("T.untyped") + nilable_type(value) + end + end + + def hash_shape_index_return_type(receiver, index) + shape = hash_shape_for_receiver(receiver) + return nil unless shape && !shape["poisoned"] + key = hash_key_name(index) + return nil unless key + types = Array(shape.dig("keys", key)) + return nil if types.empty? + value = NilKill.static_sorbet_type(types) + return nil unless NilKill.useful_type?(value) + nilable_type(value) + end + + def hash_shape_for_receiver(receiver) + case receiver + when Prism::LocalVariableReadNode + @current_hash_shapes[receiver.name.to_s] + when Prism::HashNode, Prism::KeywordHashNode + hash_shape_for_value(receiver) + when Prism::CallNode + if receiver.receiver&.slice == "T" && %i[must cast let].include?(receiver.name) + hash_shape_for_receiver(receiver.arguments&.arguments&.first) + elsif %i[first last].include?(receiver.name) + array_element_shape_for_receiver(receiver.receiver) + else + attribute_hash_shape_for_call(receiver) + end + end + end + + def collection_map_return_type(node, receiver_type) + info = collection_type_info(receiver_type) + return nil unless info + return nil unless array_receiver_type?(receiver_type) + block_type = block_return_type(node, block_param_types_for_collection(info), block_param_shapes_for_collection(node, info)) + return nil unless NilKill.useful_type?(block_type) + return nil if block_type.include?("T.untyped") + "T::Array[#{block_type}]" + end + + def collection_filter_map_return_type(node, receiver_type) + info = collection_type_info(receiver_type) + return nil unless info + return nil unless array_receiver_type?(receiver_type) + block_type = block_return_type(node, block_param_types_for_collection(info), block_param_shapes_for_collection(node, info)) + return nil unless NilKill.useful_type?(block_type) + elem = non_nil_type(block_type) + return nil if elem.to_s.empty? || elem.include?("T.untyped") || elem == "NilClass" + "T::Array[#{elem}]" + end + + def collection_compact_return_type(receiver_type) + info = collection_type_info(receiver_type) + return nil unless info && info["kind"] == "array" + elem = non_nil_type(info["element"]) + return nil if elem.to_s.empty? || elem.include?("T.untyped") || elem == "NilClass" + "T::Array[#{elem}]" + end + + def block_return_type(call_node, param_types, param_shapes = []) + block = call_node.block + return nil unless block && block.respond_to?(:body) + old_local_types = @current_local_types + old_hash_shapes = @current_hash_shapes + @current_local_types = @current_local_types.dup + @current_hash_shapes = dup_hash_shapes(@current_hash_shapes) + block_param_names(block).each_with_index do |name, idx| + type = param_types[idx] + @current_local_types[name] = type if name && NilKill.useful_type?(type) + shape = param_shapes[idx] + @current_hash_shapes[name] = dup_hash_shape(shape) if name && shape + end + expression_type(implicit_return_expression(block.body)) + ensure + @current_local_types = old_local_types if old_local_types + @current_hash_shapes = old_hash_shapes if old_hash_shapes + end + + def block_param_names(block) + return [] unless block.respond_to?(:parameters) + params = block.parameters&.parameters + return [] unless params + (params.requireds + params.optionals).filter_map { |param| param.name.to_s if param.respond_to?(:name) && param.name } + end + + def block_param_types_for_collection(info) + case info["kind"] + when "array", "set" + [info["element"]] + when "hash" + [info["key"], info["value"]] + else + [] + end + end + + def block_param_shapes_for_collection(call_node, info) + return [] unless info["kind"] == "array" + shape = array_element_shape_for_receiver(call_node.receiver) + shape ? [shape] : [] + end + + def block_param_shapes_for_call(call_node) + return [] unless %w[each map filter_map select reject find detect any? all? none? one?].include?(call_node.name.to_s) + shape = array_element_shape_for_receiver(call_node.receiver) + shape ? [shape] : [] + end + + def array_element_shape_for_receiver(receiver) + case receiver + when Prism::LocalVariableReadNode + @current_array_element_shapes[receiver.name.to_s] + when Prism::ArrayNode + array_element_shape_for_value(receiver) + when Prism::CallNode + if receiver.receiver&.slice == "T" && %i[must cast let].include?(receiver.name) + array_element_shape_for_receiver(receiver.arguments&.arguments&.first) + elsif %i[select reject compact].include?(receiver.name) + array_element_shape_for_receiver(receiver.receiver) + else + attribute_array_element_shape_for_call(receiver) + end + end + end + + def non_nil_type(type) + raw = type.to_s + return nil if raw.empty? + if raw.start_with?("T.nilable(") + return NilKill.strip_nilable_type(raw) + end + if raw.start_with?("T.any(") + parts = NilKill.split_top_level(NilKill.extract_call_args(raw, "T.any") || "") + parts = parts.reject { |part| part == "NilClass" } + return NilKill.static_sorbet_type(parts) + end + raw + end + + def collection_type_info(type) + raw = NilKill.strip_nilable_type(type.to_s.strip) + return nil if raw.empty? + case raw + when /\A(?:Array|T::Array)(?:\[(.*)\])?\z/ + { "kind" => "array", "element" => $1 } + when /\A(?:Hash|T::Hash)(?:\[(.*)\])?\z/ + args = $1 ? NilKill.split_top_level($1) : [] + { "kind" => "hash", "key" => args[0], "value" => args[1] } + when /\A(?:Set|T::Set)(?:\[(.*)\])?\z/ + { "kind" => "set", "element" => $1 } + end + end + + def constant_expression_type(node) + return nil unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode) + name = node.slice + return nil if name.to_s.empty? + return "T.class_of(#{name})" if CORE_CLASS_CONSTANTS.include?(name.delete_prefix("::")) + return "T.class_of(#{name})" if @class_like_constants.include?(name.delete_prefix("::")) + nil + end + + def static_expression_type(node) + constant_expression_type(node) || literal_type(node) + end + + def static_expression_reason(type) + type.to_s.start_with?("T.class_of(") ? "class constant #{type.delete_prefix("T.class_of(").delete_suffix(")")}" : type + end + + def literal_type(node) + case node + when Prism::StringNode then "String" + when Prism::SymbolNode then "Symbol" + when Prism::IntegerNode then "Integer" + when Prism::FloatNode then "Float" + when Prism::TrueNode, Prism::FalseNode then "T::Boolean" + when Prism::NilNode then "NilClass" + when Prism::RangeNode then "Range" + when Prism::InterpolatedStringNode then "String" + when Prism::ArrayNode then "T::Array[T.untyped]" + when Prism::HashNode then "T::Hash[T.untyped, T.untyped]" + else + node.is_a?(Prism::CallNode) && node.name == :new && node.receiver ? node.receiver.slice : nil + end + end + + def non_nil_literal?(node) + type = static_expression_type(node) + type && type != "NilClass" + end + + end +end diff --git a/gems/nil-kill/lib/nil_kill/source_instrumenter.rb b/gems/nil-kill/lib/nil_kill/source_instrumenter.rb new file mode 100644 index 000000000..37db85e8d --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/source_instrumenter.rb @@ -0,0 +1,305 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class SourceInstrumenter + def initialize + @line_offsets = [] + @method_plans_by_file_line = Hash.new { |h, k| h[k] = {} } + @tracepoint_methods = {} + @trace_plan = File.exist?(TRACE_PLAN_PATH) ? JSON.parse(File.read(TRACE_PLAN_PATH)) : { "methods" => {} } + @trace_plan.fetch("methods", {}).each do |raw_key, plan| + owner, method_id, kind, path, line = raw_key.split("\0", 5) + next if plan && plan["sample"] == false + @method_plans_by_file_line[File.expand_path(path, ROOT)][line.to_i] = { + "class" => owner, + "method" => method_id, + "kind" => kind, + "line" => line.to_i, + "plan" => plan || {}, + "raw_key" => raw_key, + } + end + rescue JSON::ParserError + @trace_plan = { "methods" => {} } + @method_plans_by_file_line ||= Hash.new { |h, k| h[k] = {} } + @tracepoint_methods ||= {} + end + + # Computed at call time, not load time: the spec suite resets + # NilKill::RUNTIME_DIR per example (remove_const/const_set), so a + # load-time constant would point at a stale tmp dir under isolation. + def line_map_path + File.join(NilKill::RUNTIME_DIR, ".nk-linemap.json") + end + + # In-place instrumentation. Snapshot every pristine target file + # into snapshot_dir, then OVERWRITE the real file with its + # instrumented form. There is exactly ONE copy of each file -- at + # its real path -- and it is always wrapped, so EVERY load + # mechanism (require, require_relative, Kernel#load, autoload, + # absolute require, a bare `ruby file.rb` entrypoint, a re-exec, an + # RUBYOPT-armed spawn) loads the wrapped code. "ran" (Ruby + # Coverage) and "recorded" (the injected recorder) become the same + # event in the same file: the collect_ran_untraced reachability gap + # is closed by construction, not patched per load path. + # + # The instrumented->src line map is still required (the wrapper + # injects lines, shifting every later one) and is keyed by the REAL + # ROOT-relative path. Returns the manifest of restored-by rel paths. + def run_in_place(snapshot_dir) + FileUtils.mkdir_p(snapshot_dir) + @line_map = {} + manifest = [] + NilKill.target_files.each do |path| + rel = NilKill.rel(path) + snap = File.join(snapshot_dir, rel) + FileUtils.mkdir_p(File.dirname(snap)) + FileUtils.cp(path, snap) + instrumented, line_map = instrument_file_with_map(path) + File.write(path, instrumented) + @line_map[rel] = line_map if line_map + manifest << rel + end + FileUtils.mkdir_p(File.dirname(line_map_path)) + File.write(line_map_path, JSON.generate(@line_map)) + write_tracepoint_fallback_plan + manifest + end + + # Exact instrumented_line -> src_line, derived from the byte-offset + # edits (not line equality, which drifts past modified lines). + # Every src line start maps to an instrumented byte = src byte + + # net bytes inserted by edits before it; injected lines inherit + # the preceding src line (their tracing belongs to that code). + def instrument_file_with_map(path) + source = File.read(path) + @line_offsets = line_offsets(source) + parsed = Prism.parse(source) + return [source, nil] unless parsed.success? + edits = [] + collect_ivar_assignment_edits(parsed.value, edits) + collect_method_edits(parsed.value, File.expand_path(path, ROOT), edits) + collect_source_ref_edits(parsed.value, edits, File.expand_path(path, ROOT)) + write_tracepoint_fallback_plan + return [source, nil] if edits.empty? + kept = non_overlapping_edits(edits).sort_by { |s, _e, _r| s } + instrumented = apply_edits(source, edits) + src_line_count = source.lines.length + total_instr_lines = instrumented.count("\n") + 1 + # src line -> instrumented line of that line's first byte + instr_line_of_src = [] + delta = 0 # net bytes inserted before the current scan offset + ei = 0 + (1..@line_offsets.length).each do |s| + src_byte = @line_offsets[s - 1] + while ei < kept.length && kept[ei][0] <= src_byte + so, eo, rep = kept[ei] + delta += rep.b.bytesize - (eo - so) + ei += 1 + end + instr_byte = src_byte + delta + instr_line_of_src[s] = instrumented.byteslice(0, instr_byte).to_s.count("\n") + 1 + end + map = [] + s = 1 + (1..total_instr_lines).each do |i| + s += 1 while s + 1 <= @line_offsets.length && instr_line_of_src[s + 1] && instr_line_of_src[s + 1] <= i + map[i] = s > src_line_count ? src_line_count : s + end + [instrumented, map] + end + + def instrument_file(path) + source = File.read(path) + @line_offsets = line_offsets(source) + parsed = Prism.parse(source) + return source unless parsed.success? + edits = [] + collect_ivar_assignment_edits(parsed.value, edits) + collect_method_edits(parsed.value, File.expand_path(path, ROOT), edits) + collect_source_ref_edits(parsed.value, edits, File.expand_path(path, ROOT)) + write_tracepoint_fallback_plan + return source if edits.empty? + apply_edits(source, edits) + end + + def line_offsets(source) + offsets = [0] + source.each_line.with_index do |line, idx| + offsets[idx + 1] = offsets[idx] + line.bytesize + end + offsets + end + + def collect_method_edits(node, path, edits) + case node + when Prism::DefNode + plan = @method_plans_by_file_line[path][node.location.start_line] + return unless plan + # Endless defs (`def f = expr`) have no `end` keyword to anchor + # the suffix; fall back to TracePoint. One-line classic defs + # (`def f; ...; end`) DO have an `end` and are wrappable -- the + # suffix anchors on end_keyword_loc, not the end line start. + ek = node.end_keyword_loc + # Punt ONLY shapes the inline wrapper genuinely cannot express: + # endless defs (no `end` to anchor the suffix) and `ensure`. + # - A `return` inside an iterator / `proc` block is a NON-LOCAL + # method return: rewritten to `throw __nil_kill_return_tag`, + # stays source-wrapped. + # - A `return` LOCAL to a lambda returns from the lambda, not + # the method, so it never reaches the wrapper's catch -- the + # method is still safely wrapped; we only must NOT rewrite + # that return (collect_return_edits skips lambda scopes). + # The TracePoint fallback is unreliable in the real + # multi-process collect; inline wrappers are not. + ek = nil if ek && ek.length.zero? + if ek.nil? || contains_ensure?(node.body) + @tracepoint_methods[plan.fetch("raw_key")] = plan.fetch("plan") + return + end + insert_method_wrapper(node, plan, edits) + collect_return_edits(node.body, plan, edits) + return + end + node.child_nodes.compact.each { |child| collect_method_edits(child, path, edits) } if node.respond_to?(:child_nodes) + end + + def insert_method_wrapper(node, plan, edits) + start_line = node.location.start_line + end_line = node.location.end_line + body_indent = " " + header_end = method_header_end_offset(node) + # Anchor the suffix immediately before the def's `end` keyword. + # Works for one-line defs too (where the end-line start would + # point at the `def`, not before `end`). + end_anchor = node.end_keyword_loc.start_offset + params_expr = source_params_expr(plan) + call = "NilKillRuntimeTrace.record_source_method_call(#{plan["class"].inspect}, #{plan["method"].inspect}, #{plan["kind"].inspect}, __FILE__, #{plan["line"]}, #{params_expr})" + raise_call = "NilKillRuntimeTrace.record_source_method_raise(#{plan["class"].inspect}, #{plan["method"].inspect}, #{plan["kind"].inspect}, __FILE__, #{plan["line"]}, __nil_kill_error)" + ret = "NilKillRuntimeTrace.record_source_method_return(#{plan["class"].inspect}, #{plan["method"].inspect}, #{plan["kind"].inspect}, __FILE__, #{plan["line"]}, __nil_kill_result)" + prefix = "\n#{body_indent}__nil_kill_return_tag = Object.new\n#{body_indent}#{call}\n#{body_indent}begin\n#{body_indent} catch(__nil_kill_return_tag) do\n#{body_indent} __nil_kill_result = begin\n" + suffix = "#{body_indent} end\n#{body_indent} #{ret}\n#{body_indent} end\n#{body_indent}rescue Exception => __nil_kill_error\n#{body_indent} #{raise_call}\n#{body_indent} raise\n#{body_indent}end\n" + edits << [header_end, header_end, prefix] + edits << [end_anchor, end_anchor, suffix] + end + + # Under in-place instrumentation the file IS at its real src path, + # so __FILE__ and __dir__ already resolve correctly with NO rewrite + # (the parallel-tree problem -- and its SourceFileNode/__dir__ + # rewrites -- are gone). Only __LINE__ still needs literalising: + # the injected wrapper shifts every later line, so a raw __LINE__ + # would yield the instrumented line, not the src line. + def collect_source_ref_edits(node, edits, _real_file = nil) + if node.is_a?(Prism::SourceLineNode) + edits << [node.location.start_offset, node.location.end_offset, node.location.start_line.to_s] + end + node.child_nodes.compact.each { |child| collect_source_ref_edits(child, edits, _real_file) } if node.respond_to?(:child_nodes) + end + + def method_header_end_offset(node) + loc = node.rparen_loc || node.parameters&.location || node.name_loc || node.location + loc.start_offset + loc.length + end + + def contains_ensure?(node) + return false unless node + return false if node.is_a?(Prism::DefNode) || node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) + return true if node.is_a?(Prism::EnsureNode) + node.respond_to?(:child_nodes) && node.child_nodes.compact.any? { |child| contains_ensure?(child) } + end + + + def collect_return_edits(node, plan, edits) + return unless node + case node + when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::LambdaNode + # New scope: a `return` here belongs to it, not this method. + # We DO recurse into BlockNode now so non-local `return`s inside + # `do..end` / `{}` iterator and `proc` blocks are rewritten to a + # tagged throw -- equivalent to the non-local method return, and + # the value flows to record_source_method_return. Lambda scopes + # are excluded here and below (lambda-local return is left as-is + # -- it does not escape the wrapper). + return + when Prism::ReturnNode + args = node.arguments&.arguments || [] + expr = + if args.empty? + "nil" + elsif args.size == 1 + args.first.slice + else + args.map(&:slice).join(", ") + end + replacement = "throw __nil_kill_return_tag, NilKillRuntimeTrace.record_source_method_return(#{plan["class"].inspect}, #{plan["method"].inspect}, #{plan["kind"].inspect}, __FILE__, #{plan["line"]}, (#{expr}))" + edits << [node.location.start_offset, node.location.end_offset, replacement] + return + end + # `lambda { ... }` body is lambda-scoped: do NOT rewrite its local + # returns. Skip the whole call subtree; the caller's recursion + # still walks siblings. + return if node.is_a?(Prism::CallNode) && node.name == :lambda && node.block.is_a?(Prism::BlockNode) + node.child_nodes.compact.each { |child| collect_return_edits(child, plan, edits) } if node.respond_to?(:child_nodes) + end + + def source_params_expr(plan) + params = plan.fetch("plan", {}).fetch("params", {}) + names = params.select { |_name, sample| sample }.keys + return "{}" if names.empty? + "{ #{names.map { |name| "#{name.inspect} => #{name}" }.join(", ")} }" + end + + def start_line_offset(line) + @line_offsets[line - 1] || 0 + end + + def end_line_offset(line) + @line_offsets[line] ? @line_offsets[line] - 1 : @line_offsets.last.to_i + end + + def collect_ivar_assignment_edits(node, edits) + case node + when Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode + value = node.value + if value&.location + name = node.name.to_s + rhs = value.slice + replacement = "NilKillRuntimeTrace.record_ivar_assignment(self, #{name.inspect}, (#{rhs}), __FILE__, __LINE__)" + edits << [value.location.start_offset, value.location.end_offset, replacement] + end + end + node.child_nodes.compact.each { |child| collect_ivar_assignment_edits(child, edits) } if node.respond_to?(:child_nodes) + end + + def apply_edits(source, edits) + bytes = source.b + edits = non_overlapping_edits(edits) + edits.sort_by { |start_offset, _end_offset, _replacement| -start_offset }.each do |start_offset, end_offset, replacement| + bytes = bytes.byteslice(0, start_offset) + replacement.b + bytes.byteslice(end_offset..).to_s + end + bytes + end + + def write_tracepoint_fallback_plan + return if @tracepoint_methods.empty? + @trace_plan["tracepoint_methods"] = @tracepoint_methods + FileUtils.mkdir_p(File.dirname(TRACE_PLAN_PATH)) + File.write(TRACE_PLAN_PATH, JSON.pretty_generate(@trace_plan)) + end + + def non_overlapping_edits(edits) + kept = [] + edits.sort_by { |start_offset, end_offset, _replacement| [start_offset, -(end_offset - start_offset)] }.each do |edit| + start_offset, end_offset, = edit + if start_offset == end_offset + kept << edit + next + end + next if kept.any? { |kept_start, kept_end, _| start_offset >= kept_start && end_offset <= kept_end } + kept << edit + end + kept + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/spec_dependency_index.rb b/gems/nil-kill/lib/nil_kill/spec_dependency_index.rb new file mode 100644 index 000000000..0fa322154 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/spec_dependency_index.rb @@ -0,0 +1,105 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + # Maps src files <-> spec files via `require_relative` graph traversal, + # so the verified loop only runs specs that transitively load the files + # an action touches. Cuts per-verify wall time from ~30s (full `prspec + # spec/`) to a few seconds for most action batches. + # + # Resolves `require_relative "path"` literally against the requiring + # file's directory. Ignores plain `require "..."` (stdlib + gems) and + # dynamic requires. This is sufficient for codebases that use + # `require_relative` for all project-internal links, which is the + # convention here (184 require_relative vs 85 bare-require in src/). + class SpecDependencyIndex + class << self + def instance + @instance ||= build + end + + def reset! + @instance = nil + end + + def build + index = new + index.scan! + index + end + end + + def initialize + @requires_by_file = {} + @required_by = Hash.new { |h, k| h[k] = Set.new } + end + + def scan! + NilKill.usage_scan_files.each do |path| + @requires_by_file[path] = extract_requires(path) + end + @requires_by_file.each do |source, targets| + targets.each { |target| @required_by[target] << source } + end + self + end + + # Given a list of paths (relative or absolute), return all spec files + # (absolute paths) whose require_relative chain transitively reaches + # any of the input paths. The input paths themselves are seeded into + # the visited set so a spec that directly requires `src/X.rb` is + # included when X is the input. + def specs_depending_on(paths) + visited = Set.new + queue = [] + Array(paths).each do |p| + abs = File.expand_path(p, ROOT) + next if visited.include?(abs) + visited << abs + queue << abs + end + until queue.empty? + current = queue.shift + @required_by[current].each do |dependent| + next if visited.include?(dependent) + visited << dependent + queue << dependent + end + end + visited.select { |p| spec_file?(p) }.sort + end + + private + + def spec_file?(path) + path.end_with?("_spec.rb") && + (path.include?("#{File::SEPARATOR}spec#{File::SEPARATOR}") || + path.end_with?("#{File::SEPARATOR}spec.rb")) + end + + def extract_requires(path) + parsed = Prism.parse_file(path) + return [] unless parsed.success? + targets = [] + walk(parsed.value, File.dirname(path), targets) + targets + end + + def walk(node, source_dir, out) + return unless node + if node.is_a?(Prism::CallNode) && node.name == :require_relative + arg = node.arguments&.arguments&.first + if arg.is_a?(Prism::StringNode) + raw = arg.unescaped.to_s + unless raw.empty? + resolved = File.expand_path(raw, source_dir) + resolved += ".rb" unless resolved.end_with?(".rb") + out << resolved if File.file?(resolved) + end + end + end + return unless node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |child| walk(child, source_dir, out) } + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/store.rb b/gems/nil-kill/lib/nil_kill/store.rb new file mode 100644 index 000000000..d2a07c158 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/store.rb @@ -0,0 +1,52 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class Store + attr_reader :methods, :tlets, :facts, :diagnostics, :actions + + def initialize + @methods = {} + @tlets = {} + @facts = { "files" => {}, "unsigned_methods" => [], "existing_sigs" => [], "tlet_sites" => [], "dead_nil_checks" => [], + "struct_declarations" => [], "struct_field_static" => [], "tuple_arrays" => [], "hash_shapes" => [], + "collection_index_lookups" => [], "hash_record_blockers" => [], + "hash_record_member_calls" => [], + "collection_runtime" => [], "ivar_runtime" => [], "collect_coverage" => {}, + "type_normalizers" => [], "dispatcher_inferences" => [], "return_origins" => [], "param_origins" => [], + "flow_graph" => nil } + @diagnostics = { "sorbet_errors" => [], "nil_origins" => [], "sorbet_feedback" => [] } + @actions = [] + end + + def method_record(key) + @methods[key.join("\0")] ||= { + "key" => key, "calls" => 0, "ok_calls" => 0, "raised_calls" => 0, + "params_by_name" => {}, "params_ok" => {}, "params_raised" => {}, "param_elem" => {}, "param_kv" => {}, + "param_elem_shapes" => {}, "param_kv_shapes" => {}, + "param_sites" => {}, "param_sites_ok" => {}, "param_sites_raised" => {}, + "param_traces" => {}, "param_traces_ok" => {}, "param_traces_raised" => {}, + "returns" => [], "return_elem" => [], "return_kv" => [[], []], + "return_elem_shapes" => [], "return_kv_shapes" => [[], []], "raised" => [], + "source" => nil, "has_sig" => false, + } + end + + def to_h + { "version" => 1, "generated_at" => Time.now.utc.iso8601, "target_dirs" => NilKill.target_dirs.map { |d| NilKill.rel(d) }, + "target_exclude_dirs" => NilKill.target_exclude_dirs.map { |d| NilKill.rel(d) }, + "methods" => @methods.values, "tlets" => @tlets.values, "facts" => @facts, + "diagnostics" => @diagnostics, "actions" => @actions } + end + + def write + FileUtils.mkdir_p(TMP_DIR) + File.write(EVIDENCE_PATH, JSON.pretty_generate(to_h)) + end + + def self.read + abort "missing #{NilKill.rel(EVIDENCE_PATH)}; run `bundle exec tools/nil-kill infer` first" unless File.exist?(EVIDENCE_PATH) + JSON.parse(File.read(EVIDENCE_PATH)) + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/struct_rbi.rb b/gems/nil-kill/lib/nil_kill/struct_rbi.rb new file mode 100644 index 000000000..fd082470d --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/struct_rbi.rb @@ -0,0 +1,204 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class StructRBI + def initialize(argv) + @output = option_value(argv, "--output") + @include_existing = argv.include?("--include-existing-rbi") + @complete = argv.include?("--complete") + @validate = argv.include?("--validate") + @max_validate_iterations = (option_value(argv, "--validate-max-iters") || "20").to_i + @blocklist = Set.new + @evidence = Store.read + end + + def run + if @validate + run_with_validation + else + rbi = generate + write_or_print(rbi) + end + end + + # Iteratively generate and srb-tc-validate the RBI, pruning sigs that + # cause srb tc errors until the file is clean. Each failing run extracts + # the method names from "Got originating from\n... .METHOD" + # blocks in the srb tc output and adds them to a blocklist. The + # subsequent generation drops sigs for those field names across all + # struct classes, falling back to T.untyped (the safe default that + # matches the prior Sorbet behaviour). + # + # Bounded by --validate-max-iters (default 20) to prevent runaway loops + # on degenerate inputs. + def run_with_validation + raise "struct-rbi --validate requires --output PATH" unless @output + path = File.expand_path(@output, ROOT) + original = File.read(path) if File.file?(path) + iter = 0 + loop do + iter += 1 + rbi = generate + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, rbi) + out, err, status = Open3.capture3({ "SRB_YES" => "1", "NO_COLOR" => "1" }, "bundle", "exec", "srb", "tc") + combined = out + err + if status.success? + puts "wrote #{NilKill.rel(path)} (validated clean in #{iter} iter(s); blocklist: #{@blocklist.size})" + puts "blocklist: #{@blocklist.to_a.sort.join(", ")}" if @blocklist.any? + return + end + new_methods = extract_offending_methods(combined) - @blocklist + if new_methods.empty? || iter >= @max_validate_iterations + warn "struct-rbi --validate could not converge after #{iter} iter(s). Reverting." + warn combined.lines.grep(/^src\/.*: /).first(10).join + File.write(path, original) if original + File.delete(path) if !original && File.file?(path) + raise "struct-rbi --validate failed" + end + @blocklist |= new_methods + warn "iter #{iter}: dropping #{new_methods.size} offending method(s): #{new_methods.to_a.sort.first(8).join(", ")}#{new_methods.size > 8 ? ", ..." : ""}" + end + end + + # Parse srb tc error output for the `Got originating from` blocks + # and pull the trailing `receiver.method` calls out. The method name is + # what we blocklist -- aggressive (drops the sig across all classes + # defining that method), but unambiguous and progressive. + def extract_offending_methods(srb_output) + methods = Set.new + # 1. "Got `T` originating from:" blocks -- the original signal. + srb_output.scan(/Got `[^`]+` originating from:\s*\n((?:(?: \s*[^\n]+)\n)+)/).each do |block_lines| + block_lines.first.scan(/\.([a-z_][a-zA-Z0-9_]*)[?!]?\b/).each { |m| methods << m[0] } + end + # 2. Other srb tc shapes that the iterative loop must also be able + # to blocklist or it can never converge (7002 argument type, + # 7004 not-enough-args, 7046 always-false comparison, 7005 + # result type). For each error, scan the highlighted source + # line(s) `NNNN | code` for `.field` accessor chains -- the + # field whose freshly-emitted RBI sig is wrong -- plus the + # `for argument`/`result type of method` names. A name that + # isn't actually a struct field is a harmless no-op at regen + # (no sig is dropped for it). + srb_output.each_line do |line| + if line =~ /^\s*\d+ \|/ + line.scan(/\.([a-z_][a-zA-Z0-9_]*)[?!]?\b/).each { |m| methods << m[0] } + end + line.scan(/for argument `([a-z_]\w*)`/).each { |m| methods << m[0] } + line.scan(/result type of method `([a-z_]\w*[?!]?)`/).each { |m| methods << m[0].sub(/[?!]\z/, "") } + end + methods + end + + def write_or_print(rbi) + if @output + path = File.expand_path(@output, ROOT) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, rbi) + puts "wrote #{NilKill.rel(path)}" + else + puts rbi + end + end + + def generate + facts = @evidence["facts"] + candidates = Report.new.struct_field_candidates(Array(facts["struct_field_runtime"]), Array(facts["struct_field_static"])) + return generate_complete(facts, candidates) if @complete + existing = @include_existing ? Set.new : existing_rbi_slots + grouped = Hash.new { |h, k| h[k] = [] } + candidates.each do |candidate| + next if candidate["type"] == "T.untyped" + slot = [candidate["class"], candidate["field"]] + next if existing.include?(slot) + grouped[candidate["class"]] << candidate + end + lines = [ + "# typed: true", + "# frozen_string_literal: true", + "", + "# AUTO-GENERATED by bundle exec tools/nil-kill struct-rbi.", + "# Re-run nil-kill infer/collect before regenerating.", + "", + ] + grouped.keys.sort.each do |klass| + lines << "class #{klass}" + grouped[klass].sort_by { |candidate| candidate["field"] }.each do |candidate| + lines << " sig { returns(#{candidate["type"]}) }" + lines << " def #{candidate["field"]}; end" + end + lines << "end" + lines << "" + end + lines << "# No new struct field candidates." if grouped.empty? + lines.join("\n") + end + + def generate_complete(facts, candidates) + @blocklist ||= Set.new + candidate_types = candidates.each_with_object({}) { |candidate, hash| hash[[candidate["class"], candidate["field"]]] = candidate["type"] } + existing_types = existing_rbi_types + lines = [ + "# typed: true", + "# frozen_string_literal: true", + "", + "# AUTO-GENERATED by bundle exec tools/nil-kill struct-rbi --complete.", + "# Re-run nil-kill infer/collect before regenerating.", + "", + ] + Array(facts["struct_declarations"]).sort_by { |decl| [decl["class"], decl["line"].to_i] }.each do |decl| + fields = Array(decl["fields"]) + next if fields.empty? + lines << "class #{decl["class"]}" + fields.each do |field| + type = if @blocklist.include?(field.to_s) + # Validation pruned this method name globally. Fall back to + # T.untyped to match the pre-RBI behaviour Sorbet had before. + existing_types[[decl["class"], field]] && !@blocklist.include?(field.to_s) ? existing_types[[decl["class"], field]] : "T.untyped" + else + candidate_types[[decl["class"], field]] || existing_types[[decl["class"], field]] || "T.untyped" + end + lines << " sig { returns(#{type}) }" + lines << " def #{field}; end" + end + lines << "end" + lines << "" + end + lines.join("\n") + end + + def existing_rbi_slots + slots = Set.new + existing_rbi_types.each_key { |slot| slots << slot } + slots + end + + def existing_rbi_types + types = {} + Dir.glob(File.join(ROOT, "sorbet", "rbi", "**", "*.rbi")).each do |path| + klass = nil + pending_type = nil + File.readlines(path).each do |line| + if line =~ /^\s*class\s+([A-Z]\S*)/ + klass = $1 + elsif klass && line =~ /^\s*sig\s*\{\s*returns\((.+)\)\s*\}/ + pending_type = $1.strip + elsif klass && line =~ /^\s*def\s+([a-zA-Z_]\w*)\b/ + types[[klass, $1]] = pending_type || "T.untyped" + pending_type = nil + elsif line =~ /^\s*end\s*$/ + klass = nil + pending_type = nil + end + end + end + types + end + + def option_value(argv, flag) + idx = argv.index(flag) + idx ? argv[idx + 1] : nil + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/trace_plan.rb b/gems/nil-kill/lib/nil_kill/trace_plan.rb new file mode 100644 index 000000000..da89b4a66 --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/trace_plan.rb @@ -0,0 +1,79 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + class TracePlan + def self.write(path = TRACE_PLAN_PATH) + new.write(path) + end + + def initialize + @methods = {} + @tlets = {} + @struct_fields = {} + end + + def write(path) + NilKill.target_files.each do |file| + idx = SourceIndex.new(file) + idx.methods.each { |method| add_method(method) } + idx.tlet_sites.each { |site| add_tlet(site) } + idx.struct_declarations.each { |decl| add_struct_decl(decl) } + idx.struct_field_static.each { |field| add_struct_static(field) } + end + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate({ + "version" => 1, + "generated_at" => Time.now.utc.iso8601, + "target_dirs" => NilKill.target_dirs.map { |dir| File.expand_path(dir, ROOT) }.sort, + "target_exclude_dirs" => NilKill.target_exclude_dirs.map { |dir| File.expand_path(dir, ROOT) }.sort, + "methods" => @methods, + "tlets" => @tlets, + "struct_fields" => @struct_fields, + })) + end + + private + + def add_method(method) + abs = File.expand_path(method["path"], ROOT) + key = [method["class"], method["method"], method["kind"], abs, method["line"]].join("\0") + param_types = NilKill.extract_param_entries(method["sig"]).to_h + params = {} + method["params"].each do |param| + name = param["name"].to_s + type = param_types[name] || param["type"] + params[name] = !NilKill.strong_trace_type?(type) + end + return_type = NilKill.extract_return_type(method["sig"]) + sample_return = !method["sig"].to_s.include?(".void") && !NilKill.strong_trace_type?(return_type) + @methods[key] = { + "frame" => true, + "params" => params, + "return" => sample_return, + "sample" => params.values.any? || sample_return, + } + end + + def add_tlet(site) + return unless site["tlet"] + type = site["type"] + return if NilKill.strong_trace_type?(type) + @tlets[[File.expand_path(site["path"], ROOT), site["line"]].join("\0")] = true + end + + def add_struct_decl(decl) + decl.fetch("fields", []).each do |field| + @struct_fields[[decl["class"], field.to_s].join("\0")] = true + end + end + + def add_struct_static(field) + klass = field["class"].to_s + name = field["field"].to_s + type = field["type"] || field["candidate_type"] + key = [klass, name].join("\0") + @struct_fields[key] = !NilKill.strong_trace_type?(type) + end + end +end diff --git a/gems/nil-kill/lib/nil_kill/util.rb b/gems/nil-kill/lib/nil_kill/util.rb new file mode 100644 index 000000000..569c6cada --- /dev/null +++ b/gems/nil-kill/lib/nil_kill/util.rb @@ -0,0 +1,417 @@ +# typed: false +# frozen_string_literal: true + +module NilKill + HIGH = "high" + REVIEW = "review" + GAP = "gap" + MAX_UNION_TYPES = 3 + MAX_SHAPE_UNION_TYPES = MAX_UNION_TYPES + MAX_SHAPE_TYPE_LENGTH = 240 + CORE_CLASS_CONSTANTS = Set.new(%w[ + Array BasicObject Class Complex Encoding Enumerator Exception FalseClass Fiber Float Hash Integer Module NilClass + Numeric Object Proc Range Rational Regexp String Struct Symbol Thread Time TrueClass + ]).freeze + + module_function + + def rel(path) + Pathname.new(path).relative_path_from(Pathname.new(ROOT)).to_s + rescue StandardError + path.to_s + end + + # ---- In-place instrumentation lifecycle ----------------------------- + # `collect` wraps the real src/ in place (one copy, at the real path, + # always instrumented -> every load mechanism / subprocess / re-exec + # loads instrumented code). The pristine tree is snapshotted and + # restored automatically, including after a crash, via this sentinel. + + INPLACE_SENTINEL_NAME = ".nk-inplace-active.json" + + # Computed at call time (the spec suite resets RUNTIME_DIR per example). + def inplace_sentinel_path + File.join(RUNTIME_DIR, INPLACE_SENTINEL_NAME) + end + + # Written BEFORE the first src byte is overwritten and deleted LAST + # after restore, with the FULL candidate file list (not a post-wrap + # manifest) so a crash mid-wrap is still fully healed. Its presence + # at the top of any nil-kill run means a collect was instrumenting + # src and did not finish -> heal before anything else touches src. + def write_inplace_sentinel!(snapshot_dir, files) + FileUtils.mkdir_p(File.dirname(inplace_sentinel_path)) + File.write(inplace_sentinel_path, + JSON.generate("snapshot_dir" => snapshot_dir, "files" => Array(files), "pid" => Process.pid)) + end + + # Idempotent. Restores every snapshotted file to its pristine bytes + # (atomic per file: sibling tmp + rename), then deletes the sentinel. + # Files not yet wrapped at crash time have no snapshot and are + # already pristine -> skipped. Safe to call from an ensure, a signal + # trap, AND the next process's startup: whoever runs first heals, the + # rest no-op (sentinel gone). + def restore_inplace_snapshot! + sentinel = inplace_sentinel_path + return false unless File.file?(sentinel) + meta = begin + JSON.parse(File.read(sentinel)) + rescue StandardError + nil + end + return false unless meta + snapshot_dir = meta["snapshot_dir"].to_s + Array(meta["files"]).each do |relp| + snap = File.join(snapshot_dir, relp) + dest = File.expand_path(relp, ROOT) + next unless File.file?(snap) + tmp = "#{dest}.nk-restore-#{Process.pid}" + begin + File.binwrite(tmp, File.binread(snap)) + File.rename(tmp, dest) + rescue StandardError + File.delete(tmp) if File.file?(tmp) + end + end + File.delete(sentinel) if File.file?(sentinel) + true + rescue StandardError + false + end + + # Self-heal: a prior collect that crashed mid-instrumentation left + # the sentinel on disk and src/ holding wrapped copies. Restore + # before any subcommand reads src. Loud -- a stale wrapped tree would + # otherwise silently poison infer/report/loop. + def ensure_src_restored! + return unless File.file?(inplace_sentinel_path) + warn "nil-kill: a previous `collect` left src/ instrumented (crash?). Restoring pristine sources..." + restore_inplace_snapshot! + end + + def target_dirs + ENV.fetch("NIL_KILL_TARGETS", "src").split(File::PATH_SEPARATOR).map { |path| File.expand_path(path, ROOT) } + end + + def target_exclude_dirs + ENV.fetch("NIL_KILL_EXCLUDE_TARGETS", "").split(File::PATH_SEPARATOR).reject(&:empty?).map { |path| File.expand_path(path, ROOT) } + end + + def target_files + target_dirs.flat_map { |dir| File.directory?(dir) ? Dir.glob(File.join(dir, "**", "*.rb")) : [dir] } + .select { |p| File.file?(p) && !target_excluded?(p) } + .sort + end + + # Every .rb file the usage-detection passes (e.g. `unused_return_methods`) + # should walk to decide whether a method is actually called. Has to include + # spec/, tools/, etc., not just `target_dirs`: a method only used by a spec + # is still used; narrowing it to `void` would replace its return value with + # a Void marker at runtime and break callers. + # + # Scoping rule: when `NIL_KILL_TARGETS` is set explicitly (the spec-suite + # `isolated_env` pattern, or any narrow-scope production run), respect that + # scope -- usage scanning is bounded by the same world as type inference. + # Otherwise fall back to a broad host-project scan that excludes vendored + # gems and tmp/build output. + PROJECT_RUBY_EXCLUDES = %w[vendor tmp gems/tmp gems/nil-kill/vendor gems/nil-kill/tmp .bundle].freeze + + def usage_scan_files + return target_files if ENV.key?("NIL_KILL_TARGETS") + excluded_prefixes = PROJECT_RUBY_EXCLUDES.map { |p| File.expand_path(p, ROOT) + File::SEPARATOR } + Dir.glob(File.join(ROOT, "**", "*.rb")) + .reject { |p| excluded_prefixes.any? { |prefix| p.start_with?(prefix) } } + .select { |p| File.file?(p) } + .sort + end + + def target_path?(path) + abs = File.expand_path(path, ROOT) + target_dirs.any? { |dir| abs == dir || abs.start_with?(dir + File::SEPARATOR) } && !target_excluded?(abs) + end + + def target_excluded?(path) + abs = File.expand_path(path, ROOT) + target_exclude_dirs.any? { |dir| abs == dir || abs.start_with?(dir + File::SEPARATOR) } + end + + def sorbet_type(classes, allow_nilable: true) + classes = Array(classes).compact.reject(&:empty?) + return "T.untyped" if classes.empty? + has_nil = classes.include?("NilClass") + others = classes.reject { |c| c == "NilClass" || c.include?("#") || c.start_with?("Sorbet::Private::") } + return "T.untyped" if others.empty? + base = + if others.all? { |c| c == "TrueClass" || c == "FalseClass" } + "T::Boolean" + elsif others.size == 1 + others.first + elsif ENV.fetch("NIL_KILL_UNION_POLICY", "any") == "any" && others.size <= MAX_UNION_TYPES + "T.any(#{others.sort.join(", ")})" + else + "T.untyped" + end + return "T.untyped" if base == "T.untyped" + has_nil && allow_nilable ? "T.nilable(#{base})" : base + end + + def useful_type?(type) + !type.to_s.empty? && type != "T.untyped" + end + + # Strip parametric stdlib container wrappers so a class-keyed lookup can + # match the bare class name. T::Array[Foo] -> "Array", T::Hash[K, V] -> + # "Hash", etc. Returns nil for non-container types so callers can detect + # "no stripping applies" without doing the regex themselves. + def strip_to_stdlib_owner(type) + case type.to_s + when /\AT::Array\b/ then "Array" + when /\AT::Hash\b/ then "Hash" + when /\AT::Set\b/ then "Set" + when /\AT::Enumerable\b/ then "Enumerable" + when /\AT::Range\b/ then "Range" + when /\AT::Enumerator\b/ then "Enumerator" + else nil + end + end + + def weak_type?(type) + type.to_s.include?("T.untyped") || + type.to_s.match?(/\AT::(?:Array|Hash|Enumerable|Set)\b.*\[T\.untyped/) + end + + def strong_trace_type?(type) + useful_type?(type) && !weak_type?(type) + end + + def static_sorbet_type(types) + types = Array(types).compact.reject(&:empty?) + return "T.untyped" if types.empty? + has_nil = false + others = [] + types.each do |type| + if type == "NilClass" + has_nil = true + elsif type.start_with?("T.nilable(") && type.end_with?(")") + has_nil = true + others << type[10..-2] + else + others << normalize_static_sorbet_type(type) + end + end + others = others.uniq.sort + if others.include?("T.noreturn") + return has_nil ? "NilClass" : "T.noreturn" if others == ["T.noreturn"] + others.delete("T.noreturn") + end + return "NilClass" if others.empty? && has_nil + return "T.untyped" if others.empty? + base = + if others.all? { |type| type == "TrueClass" || type == "FalseClass" || type == "T::Boolean" } + "T::Boolean" + elsif others.size == 1 + others.first + elsif ENV.fetch("NIL_KILL_UNION_POLICY", "untyped") == "any" && others.size <= MAX_UNION_TYPES + "T.any(#{others.join(", ")})" + else + "T.untyped" + end + return "T.untyped" if base == "T.untyped" + has_nil ? "T.nilable(#{base})" : base + end + + def normalize_static_sorbet_type(type) + case type.to_s + when "Array" then "T::Array[T.untyped]" + when "Hash" then "T::Hash[T.untyped, T.untyped]" + when "Set" then "T::Set[T.untyped]" + else type.to_s + end + end + + def extract_call_args(source, name) + idx = source.to_s.index("#{name}(") + return nil unless idx + start = idx + name.length + 1 + depth = 1 + i = start + while i < source.length + case source[i] + when "(" then depth += 1 + when ")" + depth -= 1 + return source[start...i] if depth.zero? + end + i += 1 + end + nil + end + + def split_top_level(source) + parts = [] + start = 0 + depth = 0 + source.to_s.each_char.with_index do |char, idx| + case char + when "(", "[", "{" + depth += 1 + when ")", "]", "}" + depth -= 1 if depth.positive? + when "," + if depth.zero? + parts << source[start...idx].strip + start = idx + 1 + end + end + end + parts << source[start..].to_s.strip + parts.reject(&:empty?) + end + + def broad_union_type?(type, max: MAX_UNION_TYPES) + source = type.to_s + idx = 0 + total = 0 + while (start = source.index("T.any(", idx)) + args_start = start + "T.any(".length + depth = 1 + i = args_start + while i < source.length + case source[i] + when "(" + depth += 1 + when ")" + depth -= 1 + break if depth.zero? + end + i += 1 + end + return true if depth.positive? + size = split_top_level(source[args_start...i]).size + return true if size > max + total += size + return true if total > max + idx = start + 1 + end + false + end + + def extract_param_entries(sig) + params = extract_call_args(sig, "params") + return [] unless params + split_top_level(params).filter_map do |entry| + name, type = entry.split(/:\s*/, 2) + next unless name && type + [name.strip, type.strip] + end + end + + def extract_return_type(sig) + extract_call_args(sig, "returns") + end + + def strip_nilable_type(type) + type = type.to_s.strip + return type unless type.start_with?("T.nilable(") + extract_call_args(type, "T.nilable") || type + end + + def conservative_element_type(classes) + classes = Array(classes).compact.reject(&:empty?) + has_nil = classes.include?("NilClass") + others = classes.reject { |c| c == "NilClass" || c.include?("#") || c.start_with?("Sorbet::Private::") } + return nil if others.empty? + return "T::Boolean" if others.sort == %w[FalseClass TrueClass] + return nil unless others.size == 1 + klass = others.first + return nil if klass.start_with?("AST::") || klass.start_with?("MIR::") + has_nil ? "T.nilable(#{klass})" : klass + end + + def parse_shape(shape) + shape.is_a?(String) ? JSON.parse(shape) : shape + rescue JSON::ParserError + { "kind" => "class", "name" => shape.to_s } + end + + def shape_type(shape) + shape = parse_shape(shape) + return nil unless shape.is_a?(Hash) + case shape["kind"] + when "class" + klass = shape["name"].to_s + return nil if klass.empty? || klass == "T.untyped" || klass.include?("#") || klass.start_with?("Sorbet::Private::") + return nil if klass.start_with?("AST::") || klass.start_with?("MIR::") + klass + when "array" + elem = shape_union_type(shape["elements"]) + elem ? "T::Array[#{elem}]" : nil + when "set" + elem = shape_union_type(shape["elements"]) + elem ? "T::Set[#{elem}]" : nil + when "hash" + key = shape_union_type(shape["keys"]) + value = shape_union_type(shape["values"]) + key && value ? "T::Hash[#{key}, #{value}]" : nil + end + end + + def shape_union_type(shapes) + parsed_shapes = Array(shapes).filter_map do |shape| + parsed = parse_shape(shape) + parsed if parsed.is_a?(Hash) + end + return nil if parsed_shapes.empty? + + kinds = parsed_shapes.map { |shape| shape["kind"] }.uniq + if kinds.one? + case kinds.first + when "array" + elem = shape_union_type(parsed_shapes.flat_map { |shape| Array(shape["elements"]) }) + return elem ? "T::Array[#{elem}]" : nil + when "set" + elem = shape_union_type(parsed_shapes.flat_map { |shape| Array(shape["elements"]) }) + return elem ? "T::Set[#{elem}]" : nil + when "hash" + key = shape_union_type(parsed_shapes.flat_map { |shape| Array(shape["keys"]) }) + value = shape_union_type(parsed_shapes.flat_map { |shape| Array(shape["values"]) }) + value = "T.untyped" if value.to_s.include?("T.any(") + return key && value ? "T::Hash[#{key}, #{value}]" : nil + end + end + + types = parsed_shapes.filter_map { |shape| shape_type(shape) }.uniq.sort + has_nil = types.delete("NilClass") + return nil if types.empty? + return "T.untyped" if types.size > MAX_SHAPE_UNION_TYPES + type = types == %w[FalseClass TrueClass] ? "T::Boolean" : (types.one? ? types.first : "T.any(#{types.join(", ")})") + type = "T.nilable(#{type})" if has_nil + return "T.untyped" if type.length > MAX_SHAPE_TYPE_LENGTH + return "T.untyped" if broad_union_type?(type) + type + end + + def acceptable_shape_candidate?(type) + type.to_s.length <= MAX_SHAPE_TYPE_LENGTH && !broad_union_type?(type) + end + + def confidence(calls) + calls.to_i >= ENV.fetch("NIL_KILL_MIN_CALLS", "20").to_i ? HIGH : REVIEW + end + + def rbi_return_type(method_name, receiver_type = nil) + rbi_return_index.return_type(method_name, receiver_type) + end + + def rbi_return_index + @rbi_return_index ||= RbiReturnIndex.build + end + + def display_union(classes, allow_nilable: true) + classes = Array(classes).compact.reject(&:empty?) + has_nil = classes.include?("NilClass") + others = classes.reject { |c| c == "NilClass" || c.include?("#") || c.start_with?("Sorbet::Private::") } + base = others.size == 1 ? others.first : "T.any(#{others.sort.join(", ")})" + has_nil && allow_nilable ? "T.nilable(#{base})" : base + end +end diff --git a/tools/nil-kill/z3_solver.rb b/gems/nil-kill/lib/nil_kill/z3_solver.rb similarity index 70% rename from tools/nil-kill/z3_solver.rb rename to gems/nil-kill/lib/nil_kill/z3_solver.rb index 01bd03ed5..fbbaa90a2 100644 --- a/tools/nil-kill/z3_solver.rb +++ b/gems/nil-kill/lib/nil_kill/z3_solver.rb @@ -106,6 +106,25 @@ def infer_unobserved_params(evidence) new_actions end + # A5: fast static/Z3-adjacent action preflight. This catches classes of + # bad type rewrites before the guarded loop pays for Sorbet bisection. + # Z3 still handles subtype batch consistency; these checks reject proposed + # type strings or local source shapes that are not valid Sorbet contracts. + def preflight_rejection(action) + type = action.dig("data", "type").to_s + return nil if type.empty? + + return "candidate union exceeds cutoff" if NilKill.broad_union_type?(type) + return "candidate uses bare generic collection type" if bare_collection_type?(type) + return "array candidate conflicts with tuple-like return shape" if tuple_like_array_return?(action, type) + return "hash candidate collapses per-key symbol shape" if heterogeneous_symbol_hash_shape?(action, type) + return "container candidate conflicts with receiver protocol use" if container_protocol_mismatch?(action, type) + + nil + rescue StandardError => e + "preflight analysis failed: #{e.class}: #{e.message}" + end + # A4: Returns true if the receiver IS provably non-nil (allow the action). # Returns false if the receiver might be nil (block the action). # Covers both remove_dead_safe_nav ("foo&.bar") and @@ -172,6 +191,165 @@ def provably_dead_safe_nav?(action) private + # ---------- A5 preflight helpers ---------- + + def bare_collection_type?(type) + return true if type.match?(/\A(?:Array|Hash|Set)\z/) + type.scan(/(? 1 && element_types.size != 1 + end + + def heterogeneous_symbol_hash_shape?(action, type) + return false unless type.include?("T::Hash[Symbol, T.any(") || type.include?("T::Hash[Symbol, T.nilable(T.any(") + def_node = action_def_node(action) + return false unless def_node + + if action["kind"] == "narrow_generic_param" || action["kind"] == "fix_sig_param" + name = action.dig("data", "name").to_s + return false if name.empty? + return symbol_index_keys(def_node, name).size > 1 + end + + returned_hash_literals(def_node).any? { |hash| heterogeneous_hash_literal?(hash) } + end + + def returned_hash_literals(def_node) + hashes = [] + walk = lambda do |node| + return unless node + if node.is_a?(Prism::ReturnNode) + Array(node.arguments&.arguments).each { |arg| hashes << arg if arg.is_a?(Prism::HashNode) } + end + node.child_nodes.compact.each { |child| walk.call(child) } if node.respond_to?(:child_nodes) + end + walk.call(def_node.body) + hashes + end + + def heterogeneous_hash_literal?(node) + value_types = node.elements.filter_map do |assoc| + next unless assoc.is_a?(Prism::AssocNode) + literal_type(assoc.value) || static_constant_type(assoc.value) + end.uniq + value_types.size > 1 + end + + def symbol_index_keys(def_node, receiver_name) + keys = Set.new + walk = lambda do |node| + return unless node + if node.is_a?(Prism::CallNode) && node.name == :[] && node.receiver&.slice == receiver_name + arg = node.arguments&.arguments&.first + keys << arg.value.to_s if arg.is_a?(Prism::SymbolNode) + end + node.child_nodes.compact.each { |child| walk.call(child) } if node.respond_to?(:child_nodes) + end + walk.call(def_node.body) + keys + end + + def container_protocol_mismatch?(action, type) + return false unless action["kind"] == "narrow_generic_param" || action["kind"] == "fix_sig_param" + root = root_container_type(type) + return false unless root + name = action.dig("data", "name").to_s + return false if name.empty? + def_node = action_def_node(action) + return false unless def_node + + protocol_calls(def_node, name).any? do |call| + !container_supports_protocol?(root, call) + end + end + + def root_container_type(type) + case type + when /\AT::Hash\b/, /\AHash\b/ then "Hash" + when /\AT::Array\b/, /\AArray\b/ then "Array" + when /\AT::Set\b/, /\ASet\b/ then "Set" + end + end + + def protocol_calls(def_node, receiver_name) + calls = Set.new + walk = lambda do |node| + return unless node + if node.is_a?(Prism::CallNode) + receiver = node.receiver + if receiver&.slice == receiver_name + calls << node.name.to_s + elsif receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver&.slice == receiver_name + calls << "class.#{node.name}" + end + end + node.child_nodes.compact.each { |child| walk.call(child) } if node.respond_to?(:child_nodes) + end + walk.call(def_node.body) + calls + end + + def container_supports_protocol?(root, call) + allowed = { + "Array" => %w[[] []= each each_with_index empty? first last length size map filter select reject push << class name], + "Hash" => %w[[] []= each each_pair keys values empty? length size merge merge! fetch dig class name], + "Set" => %w[add << each include? empty? length size merge class name], + }.fetch(root, []) + allowed.include?(call) + end + + def action_def_node(action) + path = File.join(ROOT, action["path"].to_s) + return nil unless File.file?(path) + parsed = Prism.parse_file(path) + return nil unless parsed.success? + target_line = action["line"].to_i + find_def_node(parsed.value, target_line) + end + + def find_def_node(node, target_line) + return nil unless node + if node.is_a?(Prism::DefNode) + start_line = node.location.start_line + end_line = node.location.end_line + return node if target_line >= start_line && target_line <= end_line + end + return nil unless node.respond_to?(:child_nodes) + node.child_nodes.compact.filter_map { |child| find_def_node(child, target_line) }.first + end + + def static_constant_type(node) + return nil unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode) + "T.class_of(#{node.slice})" + end + # ---------- constraint collection ---------- def collect_constraints(actions) diff --git a/gems/nil-kill/nil-kill.gemspec b/gems/nil-kill/nil-kill.gemspec new file mode 100644 index 000000000..dff7674b2 --- /dev/null +++ b/gems/nil-kill/nil-kill.gemspec @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = "nil-kill" + spec.version = "0.1.0" + spec.summary = "Runtime and static evidence tooling for tightening Sorbet nilability." + spec.authors = ["Clear contributors"] + spec.license = "MIT" + spec.required_ruby_version = ">= 3.2" + + spec.files = Dir.glob("{exe,lib}/**/*", base: __dir__).select { |path| File.file?(File.join(__dir__, path)) } + spec.bindir = "exe" + spec.executables = ["nil-kill"] + spec.require_paths = ["lib"] + + spec.add_dependency "prism", ">= 1.6" + spec.add_dependency "parlour" + spec.add_dependency "rbs-trace" + spec.add_dependency "sorbet-runtime" + + spec.add_development_dependency "parallel_rspec" + spec.add_development_dependency "rspec" + spec.add_development_dependency "ruby-prof" + spec.add_development_dependency "simplecov" + spec.add_development_dependency "simplecov-cobertura" + spec.add_development_dependency "stackprof" + spec.add_development_dependency "vernier" +end diff --git a/gems/nil-kill/report.md b/gems/nil-kill/report.md new file mode 100644 index 000000000..258160bbe --- /dev/null +++ b/gems/nil-kill/report.md @@ -0,0 +1,3764 @@ +# Nil Kill Report + +- Target dirs: src +- Methods indexed: 2328 +- Runtime-observed methods: 1162 +- Missing sigs: 111 +- Existing sigs: 2217 +- Existing/candidate `T.let` sites: 686 +- Sorbet errors captured: 0 + +## Project Prioritization +- [Nil Source Fixes (158)](#nil-source-fixes-158): 148 action item(s), 158 `T.nilable` slot(s); top source affects 6 slot(s), 726 source calls +- [Union / `T.any` Candidates (504)](#union-tany-candidates-504): 472 action item(s), 504 union slot(s); top source affects 3 slot(s), 936745 source calls +- [Hash Record Struct Candidates (Shapes + Pressure)](#hash-record-struct-candidates-shapes-pressure): 238 struct candidate(s), 513 pressure record(s); top candidate BodyRecord has pressure 119; 63 pressure record(s) without a literal shape cluster + +## Hygiene Overview + +### Type Soundness + +| Slot category | Total | Strong | Weak | Untyped | Nilable | +|---|---|---|---|---|---| +| Param inputs | 2852 | 1957 (68.6%) | 22 (0.8%) | 873 (30.6%) | 184 (6.5%) | +| Returns | 1620 | 1331 (82.2%) | 16 (1.0%) | 273 (16.9%) | 360 (22.2%) | +| Struct/class fields & ivars | 1483 | 560 (37.8%) | 19 (1.3%) | 904 (61.0%) | 93 (6.3%) | +| Arrays/Sets/Hashmaps | 1195 | 346 (29.0%) | 849 (71.0%) | 0 (0.0%) | 324 (27.1%) | + +Total = Strong + Weak + Untyped. Nilable is a cross-cut sub-count (a `T.nilable(String)` slot is Strong and Nilable, not a fourth bucket). Collection-typed slots (`T::Array[...]` etc.) are counted only in the Arrays/Sets/Hashmaps row, so the four categories are mutually exclusive. The Param/Returns/Struct Untyped columns equal the per-row denominators in the Untyped Cause Breakdown below. + +### Untyped Cause Breakdown + +| Slot category | Refused/Pending | PropagationGap | WeakEvidence | Heterogeneous | NoEvidence | +|---|---|---|---|---|---| +| Param inputs (873 untyped) | 260 (29.8%) | 114 (13.1%) | 184 (21.1%) | 211 (24.2%) | 104 (11.9%) | +| Returns (273 untyped) | 113 (41.4%) | 10 (3.7%) | 80 (29.3%) | 66 (24.2%) | 4 (1.5%) | +| Struct/class fields & ivars (904 untyped) | 604 (66.8%) | 52 (5.8%) | 6 (0.7%) | 37 (4.1%) | 205 (22.7%) | +| Arrays/Sets/Hashmaps (669 untyped) | 143 (21.4%) | 28 (4.2%) | 90 (13.5%) | 249 (37.2%) | 159 (23.8%) | + +- **Refused/Pending**: type IS determinable from local evidence (single observed runtime type, void/unused, boolean pair) -- untyped only because the fix is unapplied or conservatively refused +- **PropagationGap**: type is determinable elsewhere but needs cross-method/whole-program flow (forwarded return, ivar-from-param capture, callee untyped-but-resolvable, coherent collection needing the typed-collection rewrite) +- **WeakEvidence**: a type is known but only weakly (T::Array[`T.untyped`], a union wider than policy) -- the weak-collection / union-policy axis +- **Heterogeneous**: slot legitimately holds many unrelated types/shapes (AST/MIR node grab-bags, dynamic dispatch) -- `T.untyped` is the correct type +- **NoEvidence**: never observed at runtime AND no static expression/callsite to infer from -- needs a test or a hand-written sig + +Actionable by more nil-kill work: PropagationGap (and the policy half of WeakEvidence). Inherent (correct `T.untyped` or needs human/tests): Heterogeneous + NoEvidence. Refused/Pending is resolvable today but unapplied or conservatively declined. + +### Union Decomplexity +- Each entry is a canonical origin contract (an accessor like `.type_info`, a hash key like `[:type]`, an ivar, a call) and the TOTAL `is_a?(Type)` guards that collapse if that one contract is given a concrete type. Guards are aggregated across every method that reads the contract. Producer types come from runtime evidence for that contract; `unattributed` = no runtime trace yet for it. +- 59 guards collapse | `.type_info` (accessor) across 38 method(s) -> via @type_info assignments (runtime) {NilClass, Type}: tighten that contract + - methods: `FunctionAnalysis#resolve_call`, `EscapeAnalysis#tag_transitive_provenance!`, `FunctionAnalysis#verify_function_signature!`, `CleanupClassifier#classify_struct_cleanup_fields`, `CleanupClassifier#walk_if_bind_bindings`, `CleanupClassifier#walk_match_as_bindings`, +32 more + - guards at: src/annotator-helpers/function_analysis.rb:243, src/annotator-helpers/function_analysis.rb:247, src/annotator-helpers/function_analysis.rb:249, src/mir/escape_analysis.rb:673, src/mir/escape_analysis.rb:676 +- 38 guards collapse | `.full_type` (accessor) across 29 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `GenericAnalysis#propagate_collection_metadata!`, `CapabilityHelper#declare_capability_scope!`, `CapabilityHelper#validate_capability`, `FunctionAnalysis#verify_function_signature!`, `MIRLowering#lower_binary_op`, `SemanticAnnotator#promote_pipe_to_observable_dest!`, +23 more + - guards at: src/annotator-helpers/generic_analysis.rb:580, src/annotator-helpers/generic_analysis.rb:590, src/annotator-helpers/generic_analysis.rb:597, src/annotator-helpers/capabilities.rb:771, src/annotator-helpers/capabilities.rb:789 +- 28 guards collapse | `.type` (accessor) across 23 method(s) -> via @type assignments (runtime) {Type, Symbol, NilClass, T.nilable(Type), FunctionSignature, String}: tighten that contract + - methods: `CapabilityHelper#_unified_capture_walk`, `SemanticAnnotator#finalize_decl_node!`, `SemanticAnnotator#promote_pipe_to_observable_dest!`, `CapabilityHelper#_bg_walk`, `EscapeAnalysis#e2_stamp_symbol_via_return_ident!`, `EscapeAnalysis#per_fn_scan!`, +17 more + - guards at: src/annotator-helpers/capabilities.rb:1130, src/annotator-helpers/capabilities.rb:1141, src/annotator.rb:2712, src/annotator.rb:2763, src/annotator.rb:2639 +- 22 guards collapse | `.return_type` (accessor) across 17 method(s) -> via @return_type assignments (runtime) {T.nilable(Type), Type, Symbol, Hash, Proc}: tighten that contract + - methods: `ReentranceBridge#emit_mutual_thunk_unsupported!`, `ReentranceBridge#validate_not_logical_return!`, `SemanticAnnotator#visit_FunctionDef`, `CapabilityHelper#visit_post_clauses!`, `EffectTracker#enforce_fallible_returns!`, `EscapeAnalysis#e2_carry_return_vars`, +11 more + - guards at: src/annotator-helpers/reentrance.rb:441, src/annotator-helpers/reentrance.rb:479, src/annotator-helpers/reentrance.rb:162, src/annotator-helpers/reentrance.rb:164, src/annotator.rb:674 +- 11 guards collapse | `:type` (hash-key) across 9 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#lower_lambda`, `EscapeAnalysis#param_accepts_caller_sync?`, `EscapeAnalysis#param_sync_was_declared?`, `EscapeAnalysis#per_fn_scan!`, `FunctionAnalysis#atomic_cell_to_atomic_param?`, `FunctionAnalysis#verify_function_signature!`, +3 more + - guards at: src/mir/mir_lowering.rb:2264, src/mir/mir_lowering.rb:2265, src/mir/escape_analysis.rb:814, src/mir/escape_analysis.rb:808, src/mir/escape_analysis.rb:365 +- 9 guards collapse | `param `final_type` (AST::Locatable#finalize_storage!)` (param) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `AST::Locatable#finalize_storage!` + - guards at: src/ast/ast.rb:404, src/ast/ast.rb:407, src/ast/ast.rb:412 +- 5 guards collapse | `` (hash-key) across 4 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `CleanupClassifier#walk_match_as_bindings`, `MIRLowering#lower_union_variant_lit`, `SemanticAnnotator#annotate_struct_pattern!`, `SemanticAnnotator#visit_MatchStatement` + - guards at: src/mir/promotion_plan.rb:564, src/mir/mir_lowering.rb:5490, src/annotator.rb:1401, src/annotator.rb:1465, src/annotator.rb:1664 +- 3 guards collapse | `local `ti` (EscapeAnalysis#per_fn_scan!)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `EscapeAnalysis#per_fn_scan!` + - guards at: src/mir/escape_analysis.rb:238, src/mir/escape_analysis.rb:327, src/mir/escape_analysis.rb:374 +- 3 guards collapse | `:resolved_type` (hash-key) across 2 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#lower_with_block`, `MIRLowering#lower_polymorphic_universal` + - guards at: src/mir/mir_lowering.rb:2761, src/mir/mir_lowering.rb:2823, src/mir/mir_lowering.rb:3193 +- 3 guards collapse | `.wrapped_type` (accessor) across 3 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `CleanupClassifier#walk_if_bind_bindings`, `CleanupClassifier#walk_while_bind_bindings`, `SemanticAnnotator#visit_IfBind` + - guards at: src/mir/promotion_plan.rb:626, src/mir/promotion_plan.rb:601, src/annotator.rb:1325 +- 2 guards collapse | `param `ti` (MIRLowering#build_drop_entry!)` (param) across 1 method(s) -> always `Type`: collapse, all 2 die + - methods: `MIRLowering#build_drop_entry!` + - guards at: src/mir/mir_lowering.rb:851, src/mir/mir_lowering.rb:852 +- 2 guards collapse | `param `other_type` (Type#accepts_fn_type?)` (param) across 1 method(s) -> always `Type`: collapse, all 2 die + - methods: `Type#accepts_fn_type?` + - guards at: src/ast/type.rb:1745, src/ast/type.rb:1746 +- 2 guards collapse | `param `expected_type` (SemanticAnnotator#ensure_owned_value!)` (param) across 1 method(s) -> 50.0% `T.nilable(Type)` + 1 outlier producer(s) + - methods: `SemanticAnnotator#ensure_owned_value!` + - guards at: src/annotator.rb:4232, src/annotator.rb:4236 + - outlier producer `Type` at src/annotator.rb:3606 `expected_type` +- 2 guards collapse | `local `ti` (BorrowChecker#_collect_share_moves)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `BorrowChecker#_collect_share_moves` + - guards at: src/mir/control_flow.rb:1973, src/mir/control_flow.rb:1974 +- 2 guards collapse | `local `source_type` (CapabilityHelper#declare_capability_scope!)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `CapabilityHelper#declare_capability_scope!` + - guards at: src/annotator-helpers/capabilities.rb:830, src/annotator-helpers/capabilities.rb:859 +- 2 guards collapse | `local `field_ti` (CleanupClassifier#stamp_field_pre_cleanups!)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `CleanupClassifier#stamp_field_pre_cleanups!` + - guards at: src/mir/promotion_plan.rb:323, src/mir/promotion_plan.rb:324 +- 2 guards collapse | `local `decl_ti` (EscapeAnalysis#e2_loop_carry_names!)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `EscapeAnalysis#e2_loop_carry_names!` + - guards at: src/mir/escape_analysis.rb:544, src/mir/escape_analysis.rb:545 +- 2 guards collapse | `local `outer_ti` (EscapeAnalysis#e2_loop_carry_names!)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `EscapeAnalysis#e2_loop_carry_names!` + - guards at: src/mir/escape_analysis.rb:558, src/mir/escape_analysis.rb:559 +- 2 guards collapse | `local `ti` (EscapeAnalysis#e3_mark_carry_expr!)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `EscapeAnalysis#e3_mark_carry_expr!` + - guards at: src/mir/escape_analysis.rb:906, src/mir/escape_analysis.rb:912 +- 2 guards collapse | `local `ct` (FsmTransform#collect_body_locals)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `FsmTransform#collect_body_locals` + - guards at: src/mir/fsm_transform.rb:186, src/mir/fsm_transform.rb:188 +- 2 guards collapse | `local `ct` (FsmTransform::RecursiveSplitter#emit_for_each_fragment)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `FsmTransform::RecursiveSplitter#emit_for_each_fragment` + - guards at: src/mir/fsm_transform/recursive_splitter.rb:532, src/mir/fsm_transform/recursive_splitter.rb:545 +- 2 guards collapse | `local `ti` (LoopFrameAnalysis#promote_value_to_heap!)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `LoopFrameAnalysis#promote_value_to_heap!` + - guards at: src/mir/control_flow.rb:1564, src/mir/control_flow.rb:1565 +- 2 guards collapse | `param `ti` (MIRLowering#bare_zig_type)` (param) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#bare_zig_type` + - guards at: src/mir/mir_lowering.rb:7415, src/mir/mir_lowering.rb:7590 +- 2 guards collapse | `local `ti` (MIRLowering#container_borrow_expr?)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#container_borrow_expr?` + - guards at: src/mir/mir_lowering.rb:273, src/mir/mir_lowering.rb:274 +- 2 guards collapse | `param `type` (MIRLowering#generic_type_arg_zig)` (param) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#generic_type_arg_zig` + - guards at: src/mir/mir_lowering.rb:5915, src/mir/mir_lowering.rb:5918 +- 2 guards collapse | `local `field_ti` (MIRLowering#lower_assignment)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#lower_assignment` + - guards at: src/mir/mir_lowering.rb:6349, src/mir/mir_lowering.rb:6350 +- 2 guards collapse | `local `root_ti` (MIRLowering#lower_assignment)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#lower_assignment` + - guards at: src/mir/mir_lowering.rb:6360, src/mir/mir_lowering.rb:6361 +- 2 guards collapse | `.resolved_type` (accessor) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#lower_match` + - guards at: src/mir/mir_lowering.rb:7008, src/mir/mir_lowering.rb:7012 +- 2 guards collapse | `local `p` (SemanticAnnotator#visit_MatchStatement)` (local) across 1 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `SemanticAnnotator#visit_MatchStatement` + - guards at: src/annotator.rb:1573, src/annotator.rb:1638 +- 2 guards collapse | `.element_type` (accessor) across 2 method(s) -> producers unattributed (no runtime trace for this contract yet) + - methods: `MIRLowering#build_drop_entry!`, `MIRLowering#lower_for_each` + - guards at: src/mir/mir_lowering.rb:876, src/mir/mir_lowering.rb:6934 + +### Node-Union Alias Candidates +- Heterogeneous param slots whose every observed class is in ONE namespace. Each namespace below collapses to a single `T.type_alias` (e.g. `AstNode = T.type_alias { T.any(AST::...) }`); applying it types every listed param at once. `classes` = distinct node types observed at that slot (small = a precise sub-union; large = the full node grab-bag). +- 161 of 207 Heterogeneous params (78%) collapse to 3 alias(es). +- `AstNode` (AST::*): 134 param slot(s) + - src/backends/string_concat_rewriter.rb:27 `StringConcatRewriter#rewrite_in_node!` param `node` (82 node types) + - src/backends/string_concat_rewriter.rb:45 `StringConcatRewriter#rewrite_children!` param `node` (82 node types) + - src/backends/string_concat_rewriter.rb:78 `StringConcatRewriter#string_concat?` param `node` (82 node types) + - src/ast/ast.rb:162 `AST#_expr_each_concurrent_capture` param `node` (81 node types) + - src/mir/escape_analysis.rb:441 `EscapeAnalysis#e2_walk_calls_in_expr` param `node` (79 node types) + - src/annotator.rb:345 `SemanticAnnotator#visit` param `node` (68 node types) + - src/mir/control_flow.rb:234 `FunctionCFG#stmt_can_fail?` param `node` (65 node types) + - src/backends/pipeline_rewriter.rb:34 `PipelineRewriter#rewrite!` param `node` (59 node types) + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` param `node` (59 node types) + - src/mir/control_flow.rb:1140 `UseAfterMoveChecker#check_reads_in_expr` param `node` (55 node types) + - src/mir/control_flow.rb:1984 `BorrowChecker#walk_for_was_moved` param `node` (53 node types) + - src/mir/control_flow.rb:900 `OwnershipDataflow#walk_expr` param `node` (52 node types) + - src/mir/control_flow.rb:945 `OwnershipDataflow#walk_expr_skip_copy` param `node` (52 node types) + - src/mir/mir_pass.rb:744 `MIRPass#walk_consumed` param `node` (40 node types) + - src/ast/ast.rb:67 `AST#_bg_visit_recursive` param `node` (33 node types) + - src/ast/ast.rb:124 `AST#_expr_each_bg_block_shallow` param `expr` (32 node types) + - src/ast/ast.rb:85 `AST#_expr_each_bg_block_recursive` param `expr` (32 node types) + - src/annotator.rb:3031 `SemanticAnnotator#track_union_alias` param `value_node` (31 node types) + - src/ast/ast.rb:107 `AST#each_bg_block_in_stmt` param `stmt` (29 node types) + - src/mir/mir_pass.rb:335 `MIRPass#recurse_branches!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:399 `MIRPass#insert_suppress_cleanup!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:422 `MIRPass#insert_bg_give_suppress!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:446 `MIRPass#insert_bg_resource_suppress!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:589 `MIRPass#insert_bg_escape_promote!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:630 `MIRPass#insert_or_fallback_dupe!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:641 `MIRPass#find_or_rescue_in_value` param `stmt` (29 node types) + - src/mir/mir_pass.rb:796 `MIRPass#stamp_reassign_cleanup!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:813 `MIRPass#stamp_match_as_cleanup!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:850 `MIRPass#stamp_while_bind_cleanup!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:862 `MIRPass#stamp_if_bind_cleanup!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:881 `MIRPass#insert_container_promote!` param `stmt` (29 node types) + - src/mir/mir_pass.rb:696 `MIRPass#collect_consumed_names` param `stmt` (28 node types) + - src/mir/control_flow.rb:1887 `BorrowChecker#check_binding_moves` param `expr` (27 node types) + - src/mir/control_flow.rb:1914 `BorrowChecker#collect_moved_names` param `node` (27 node types) + - src/mir/control_flow.rb:1921 `BorrowChecker#_collect_moves` param `node` (27 node types) + - src/mir/control_flow.rb:698 `OwnershipDataflow#collect_binding_moves` param `node` (27 node types) + - src/mir/control_flow.rb:707 `OwnershipDataflow#collect_ownership_transfers` param `node` (27 node types) + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` param `stmt` (25 node types) + - src/mir/control_flow.rb:1287 `LoopFrameAnalysis#walk_stmt!` param `stmt` (25 node types) + - src/mir/control_flow.rb:1791 `BorrowChecker#check_stmt` param `stmt` (25 node types) + - src/mir/control_flow.rb:604 `OwnershipDataflow#transfer_stmt` param `stmt` (25 node types) + - src/mir/mir_lowering.rb:219 `MIRLowering#hoist_alloc` param `ast_node` (24 node types) + - src/mir/mir_lowering.rb:7276 `MIRLowering#call_union_return_needs_hoist?` param `ast_node` (22 node types) + - src/mir/escape_analysis.rb:126 `EscapeAnalysis#return_expr_is_heap?` param `val` (21 node types) + - src/annotator-helpers/function_analysis.rb:961 `FunctionAnalysis#return_is_borrow?` param `node` (20 node types) + - src/annotator-helpers/function_analysis.rb:532 `FunctionAnalysis#atomic_cell_to_bare_value_param?` param `arg_node` (19 node types) + - src/annotator-helpers/function_analysis.rb:547 `FunctionAnalysis#atomic_cell_to_atomic_param?` param `arg_node` (19 node types) + - src/annotator-helpers/function_analysis.rb:591 `FunctionAnalysis#verify_param_lifetime!` param `arg_node` (19 node types) + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` param `node` (19 node types) + - src/ast/parser.rb:1910 `Parser#parse_suffixes` param `lhs` (18 node types) + - src/mir/control_flow.rb:801 `OwnershipDataflow#collect_share_transfers_in` param `node` (17 node types) + - src/mir/control_flow.rb:1959 `BorrowChecker#_collect_was_moved` param `node` (16 node types) + - src/mir/control_flow.rb:772 `OwnershipDataflow#collect_explicit_in` param `node` (16 node types) + - src/mir/control_flow.rb:837 `OwnershipDataflow#_walk_bg_captures_in_expr` param `expr` (16 node types) + - src/annotator-helpers/generic_analysis.rb:698 `GenericAnalysis#bg_exit_frame_string?` param `expr` (14 node types) + - src/ast/ast.rb:40 `AST#wrapped_children` param `expr` (14 node types) + - src/ast/parser.rb:1743 `Parser#parse_binary_op` param `lhs` (14 node types) + - src/backends/pipeline_host.rb:375 `PipelineHost#copy_type_info` param `src` (14 node types) + - src/backends/pipeline_host.rb:375 `PipelineHost#copy_type_info` param `dst` (14 node types) + - src/mir/escape_analysis.rb:393 `EscapeAnalysis#e2_promote_frame_concats!` param `node` (13 node types) + - src/mir/fsm_transform/recursive_splitter.rb:380 `FsmTransform::RecursiveSplitter#stmt_unsupported_suspend?` param `stmt` (13 node types) + - src/mir/mir_lowering.rb:7289 `MIRLowering#universal_poly_arg_needs_addr?` param `arg_node` (13 node types) + - src/backends/pipeline_rewriter.rb:407 `PipelineRewriter#build_init` param `terminal` (11 node types) + - src/backends/pipeline_rewriter.rb:503 `PipelineRewriter#build_recursive_body` param `terminal` (11 node types) + - src/backends/pipeline_rewriter.rb:601 `PipelineRewriter#build_terminal_action` param `terminal` (11 node types) + - src/mir/mir_lowering.rb:238 `MIRLowering#hoist_owned_value_temp` param `ast_node` (11 node types) + - src/mir/mir_lowering.rb:254 `MIRLowering#owned_value_temp_needs_cleanup?` param `ast_node` (11 node types) + - src/mir/mir_lowering.rb:286 `MIRLowering#copy_container_borrow_if_needed` param `ast_node` (11 node types) + - src/annotator.rb:5990 `SemanticAnnotator#lifetime_sources_for_value` param `val_node` (10 node types) + - src/backends/pipeline_rewriter.rb:792 `PipelineRewriter#replace_placeholder` param `node` (10 node types) + - src/mir/control_flow.rb:1442 `LoopFrameAnalysis#expr_references_var?` param `expr` (10 node types) + - src/mir/escape_analysis.rb:885 `EscapeAnalysis#e3_top_level_exprs` param `stmt` (10 node types) + - src/mir/mir_lowering.rb:4956 `MIRLowering#unit_variant_access` param `node` (10 node types) + - src/annotator.rb:5724 `SemanticAnnotator#finalize_scope` param `node` (9 node types) + - src/backends/pipeline_rewriter.rb:718 `PipelineRewriter#build_final_result` param `terminal` (9 node types) + - src/mir/mir_lowering.rb:667 `MIRLowering#alloc_for_node` param `node` (9 node types) + - src/mir/mir_lowering.rb:7596 `MIRLowering#should_dupe_borrowed_union?` param `val_node` (9 node types) + - src/backends/pipeline_host.rb:170 `PipelineHost#visit_mir` param `node` (8 node types) + - src/backends/pipeline_host.rb:2316 `PipelineHost#lower_binding_fold` param `fold` (8 node types) + - src/backends/pipeline_host.rb:2845 `PipelineHost#lower_range_fold_observable_default` param `fold_op` (8 node types) + - src/backends/pipeline_host.rb:758 `PipelineHost#build_soa_scalar_fold_block` param `fold_node` (8 node types) + - src/backends/string_concat_rewriter.rb:86 `StringConcatRewriter#collect_parts` param `node` (8 node types) + - src/mir/control_flow.rb:1561 `LoopFrameAnalysis#promote_value_to_heap!` param `node` (8 node types) + - src/mir/mir_lowering.rb:5694 `MIRLowering#type_info_for` param `ast_node` (8 node types) + - src/mir/mir_pass.rb:548 `MIRPass#bg_exit_needs_string_dupe?` param `expr` (8 node types) + - src/ast/parser.rb:2022 `Parser#extract_paren_bindings` param `node` (7 node types) + - src/ast/source_error.rb:81 `ErrorHelper#note!` param `node_or_token` (7 node types) + - src/backends/pipeline_rewriter.rb:264 `PipelineRewriter#binding_source?` param `node` (7 node types) + - src/backends/pipeline_rewriter.rb:756 `PipelineRewriter#needs_transpiler_pipeline?` param `source` (7 node types) + - src/mir/control_flow.rb:786 `OwnershipDataflow#collect_explicit_moves` param `node` (7 node types) + - src/mir/mir_lowering.rb:299 `MIRLowering#hoist_cleanup_entry` param `ast_node` (7 node types) + - src/annotator.rb:1290 `SemanticAnnotator#ifbind_source_root` param `expr` (6 node types) + - src/backends/pipeline_host.rb:111 `PipelineHost#visit` param `node` (6 node types) + - src/mir/fsm_transform.rb:257 `FsmTransform#suspend_value?` param `value` (6 node types) + - src/mir/mir_lowering.rb:756 `MIRLowering#extract_root_var_name` param `node` (6 node types) + - src/tools/migration_suggester_helpers.rb:106 `MigrationSuggesterHelpers#classify_uses!` param `node` (6 node types) + - src/backends/pipeline_host.rb:196 `PipelineHost#ast_node_uses_placeholder?` param `node` (5 node types) + - src/mir/fsm_transform/recursive_splitter.rb:400 `FsmTransform::RecursiveSplitter#emit_pivot` param `stmt` (5 node types) + - src/mir/thunk_transform/recursive_splitter.rb:257 `ThunkTransform::RecursiveSplitter#direct_self_call` param `node` (5 node types) + - src/annotator.rb:6519 `SemanticAnnotator#og_declare` param `node` (4 node types) + - src/ast/parser.rb:3920 `Parser#deep_clone_node` param `node` (4 node types) + - src/ast/scope.rb:24 `Scope#declare` param `reg` (4 node types) + - src/backends/pipeline_rewriter.rb:307 `PipelineRewriter#fuse_pipeline` param `source` (4 node types) + - src/mir/control_flow.rb:1314 `LoopFrameAnalysis#process_loop!` param `loop_node` (4 node types) + - src/mir/control_flow.rb:595 `OwnershipDataflow#make_owner_entry` param `node` (4 node types) + - src/mir/promotion_plan.rb:645 `CleanupClassifier#classify_binding` param `node` (4 node types) + - src/mir/promotion_plan.rb:693 `CleanupClassifier#classify_resource` param `node` (4 node types) + - src/mir/promotion_plan.rb:756 `CleanupClassifier#classify_array_struct_strings` param `node` (4 node types) + - src/mir/promotion_plan.rb:817 `CleanupClassifier#classify_heap_provenance` param `node` (4 node types) + - src/mir/promotion_plan.rb:853 `CleanupClassifier#classify_heap_struct_plain` param `node` (4 node types) + - src/mir/thunk_transform/emit.rb:133 `ThunkTransform::Emit#render_expr` param `ast_expr` (4 node types) + - src/mir/thunk_transform/recursive_splitter.rb:214 `ThunkTransform::RecursiveSplitter#match_base_case` param `stmt` (4 node types) + - src/annotator-helpers/generic_analysis.rb:32 `GenericAnalysis#validate_type_param_list!` param `node` (3 node types) + - src/annotator.rb:5460 `SemanticAnnotator#handle_assign_move` param `node` (3 node types) + - src/annotator.rb:5530 `SemanticAnnotator#handle_assign_borrow` param `node` (3 node types) + - src/annotator.rb:5603 `SemanticAnnotator#verify_unrestricted!` param `node` (3 node types) + - src/annotator.rb:5643 `SemanticAnnotator#promote_to_expr_if!` param `parent_node` (3 node types) + - src/annotator.rb:5821 `SemanticAnnotator#mark_chain_needs_mut_ref!` param `node` (3 node types) + - src/mir/mir_lowering.rb:716 `MIRLowering#resolve_alloc_sym` param `node` (3 node types) + - src/annotator-helpers/capabilities.rb:362 `CapabilityHelper#record_predicate_call_site!` param `node` (2 node types) + - src/annotator-helpers/effects.rb:1000 `EffectTracker#validate_tight_body!` param `loop_node` (2 node types) + - src/annotator-helpers/function_analysis.rb:11 `FunctionAnalysis#analyze_routine` param `node` (2 node types) + - src/annotator-helpers/function_analysis.rb:89 `FunctionAnalysis#resolve_call` param `node` (2 node types) + - src/annotator-helpers/test_annotation.rb:72 `TestAnnotation#visit_test_lets` param `node` (2 node types) + - src/annotator-helpers/test_annotation.rb:93 `TestAnnotation#visit_test_hook_bodies` param `node` (2 node types) + - src/annotator.rb:2063 `SemanticAnnotator#resolve_error_registration!` param `node` (2 node types) + - src/annotator.rb:2690 `SemanticAnnotator#finalize_decl_node!` param `node` (2 node types) + - src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` param `node` (2 node types) + - src/annotator.rb:5682 `SemanticAnnotator#promote_to_expr_match!` param `parent_node` (2 node types) + - src/annotator.rb:6032 `SemanticAnnotator#stamp_bg_handle_lifetime!` param `decl_node` (2 node types) + - src/mir/mir_lowering.rb:1902 `MIRLowering#lower_intrinsic` param `node` (2 node types) + - src/mir/mir_lowering.rb:4658 `MIRLowering#fiber_string_promotes` param `node` (2 node types) + - src/mir/mir_lowering.rb:716 `MIRLowering#resolve_alloc_sym` param `target_node` (2 node types) + - src/mir/mir_lowering.rb:7264 `MIRLowering#call_heap_provenance?` param `node` (2 node types) +- `MirNode` (MIR::*): 23 param slot(s) + - src/mir/mir_checker.rb:340 `MIRChecker#walk_mir_node` param `node` (51 node types) + - src/mir/mir_checker.rb:810 `MIRChecker#check_stmt_for_unhoisted` param `node` (48 node types) + - src/mir/mir_lowering.rb:7477 `MIRLowering#emit_expr` param `node` (38 node types) + - src/mir/mir_lowering.rb:180 `MIRLowering#mir_allocates?` param `node` (35 node types) + - src/mir/mir_lowering.rb:6204 `MIRLowering#owned_return_transfer_binding?` param `init` (31 node types) + - src/mir/mir_lowering.rb:782 `MIRLowering#mir_cast` param `mir_node` (28 node types) + - src/mir/mir_lowering.rb:7276 `MIRLowering#call_union_return_needs_hoist?` param `expr` (25 node types) + - src/mir/mir_checker.rb:749 `MIRChecker#expr_has_frame_alloc?` param `expr` (19 node types) + - src/mir/mir_lowering.rb:238 `MIRLowering#hoist_owned_value_temp` param `expr` (19 node types) + - src/mir/mir_lowering.rb:286 `MIRLowering#copy_container_borrow_if_needed` param `expr` (16 node types) + - src/mir/mir_checker.rb:383 `MIRChecker#scan_expr_for_hpt_leak!` param `node` (14 node types) + - src/mir/mir_lowering.rb:299 `MIRLowering#hoist_cleanup_entry` param `mir` (12 node types) + - src/mir/mir_lowering.rb:7496 `MIRLowering#strip_try` param `mir_node` (10 node types) + - src/mir/fsm_lowering.rb:194 `FsmLowering#wrap_step_as_stmt` param `mir` (8 node types) + - src/mir/mir_emitter.rb:368 `MIREmitter#emit_flow_stmt` param `stmt` (8 node types) + - src/mir/mir_lowering.rb:7489 `MIRLowering#try_catch_with_provenance` param `catch_body` (6 node types) + - src/mir/mir_lowering.rb:7434 `MIRLowering#direct_index_get` param `index` (4 node types) + - src/mir/mir_lowering.rb:7489 `MIRLowering#try_catch_with_provenance` param `fallback` (4 node types) + - src/backends/pipeline_host.rb:551 `PipelineHost#mat_append` param `value_expr` (2 node types) + - src/mir/mir_emitter.rb:216 `MIREmitter#sharded_map_template` param `node` (2 node types) + - src/mir/mir_emitter.rb:223 `MIREmitter#sharded_map_substitute_common` param `node` (2 node types) + - src/mir/mir_emitter.rb:889 `MIREmitter#emit_batch_window_emit` param `node` (2 node types) + - src/mir/mir_lowering.rb:155 `MIRLowering#with_pending` param `node` (2 node types) +- `FsmOpsNode` (FsmOps::*): 4 param slot(s) + - src/mir/fsm_ops.rb:281 `FsmOps::Emitter#emit_expr` param `expr` (11 node types) + - src/mir/fsm_ops.rb:442 `FsmOps::Lowerer#lower_expr` param `expr` (10 node types) + - src/mir/fsm_ops.rb:394 `FsmOps::Lowerer#lower_stmt` param `op` (8 node types) + - src/mir/fsm_ops.rb:241 `FsmOps::Emitter#emit_stmt` param `op` (7 node types) + +### Untyped Evidence Gaps +- The residual NoEvidence, by category x WHY, then listed with locations. Each is a triage candidate (dead code / missing test / should-be-void / untraceable arg), not a classifier defect. + +| | unseen | arg untraced | only nil | discarded return | collection no elements | struct unobserved | Total | +|---|---|---|---|---|---|---|---| +| Params | 40 | 52 | 12 | 0 | 0 | 0 | 104 | +| Returns | 4 | 0 | 0 | 0 | 0 | 0 | 4 | +| Struct/ivar | 0 | 0 | 0 | 0 | 0 | 23 | 23 | +| Collections | 0 | 0 | 0 | 0 | 159 | 0 | 159 | +| **Total** | 44 | 52 | 12 | 0 | 159 | 23 | 290 | +- `unseen`: Not reached by the collect workload (a superset of every suite) and no runtime record -- genuinely dead/unreachable, or a real missing test. Investigate or delete. +- `arg untraced`: Block / kwarg / splat arg -- the tracer types only positional named args (these are ~always Proc; low value) +- `only nil`: Only ever nil at runtime -- likely unused / optional-dead; verify it is reachable with a real value +- `discarded return`: Return value never consumed -- likely should be `sig { ... .void }` +- `collection no elements`: Collection never observed holding an element -- only-empty, or built/consumed off any instrumented path +- `struct unobserved`: Struct/class field never observed assigned during collect -- the tracer signal for fields is struct_field_runtime/ivar_runtime, not line coverage, so the method-oriented coverage split does not apply. Either the class is never constructed by the workload, or the field is always left at its default. +- 44 unseen + - src/annotator-helpers/capabilities.rb:1364 `CapabilityAudit#audit_mark_bg_captures` param `body_exprs` + - src/annotator-helpers/capabilities.rb:1364 `CapabilityAudit#audit_mark_bg_captures` param `is_parallel` + - src/annotator-helpers/fixable_helpers.rb:1224 `FixableHelper#build_decl_cap_replace_fix` param `name` + - src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!` param `node` + - src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!` param `tok` + - src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!` return + - src/annotator-helpers/fixable_helpers.rb:806 `FixableHelper#emit_cap_field_needs_with!` param `name` + - src/annotator-helpers/fixable_helpers.rb:935 `FixableHelper#emit_with_read_needs_write_lock!` param `var_node` + - src/annotator-helpers/fixable_helpers.rb:935 `FixableHelper#emit_with_read_needs_write_lock!` return + - src/annotator-helpers/pipe_analysis.rb:1162 `PipeAnalysis#pre_scan_node_for_sharded` param `node` + - src/annotator-helpers/pipe_analysis.rb:1222 `PipeAnalysis#auto_detect_sharded_access` param `conc` + - src/annotator-helpers/pipe_analysis.rb:1222 `PipeAnalysis#auto_detect_sharded_access` param `smooth_node` + - src/annotator-helpers/pipe_analysis.rb:1272 `PipeAnalysis#walk_for_sharded_access` param `nodes` + - src/annotator-helpers/pipe_analysis.rb:1306 `PipeAnalysis#walk_for_sharded_getindex` param `results` + - src/annotator-helpers/test_annotation.rb:110 `TestAnnotation#visit_AssertRaises` param `node` + - src/annotator.rb:3912 `SemanticAnnotator#visit_Placeholder` param `node` + - src/annotator.rb:4368 `SemanticAnnotator#visit_Give` param `node` + - src/annotator.rb:5969 `SemanticAnnotator#lifetime_violation_for_store` param `dest_depth` + - src/annotator.rb:5969 `SemanticAnnotator#lifetime_violation_for_store` param `val_node` + - src/annotator.rb:6287 `SemanticAnnotator#contains_self_call?` param `fn_name` + - src/ast/ast.rb:263 `AST::Locatable#collection_return=` param `val` + - src/ast/schemas.rb:43 `Schemas::ResourceSchema#initialize` param `as_type` + - src/ast/schemas.rb:43 `Schemas::ResourceSchema#initialize` param `close_zig` + - src/ast/schemas.rb:43 `Schemas::ResourceSchema#initialize` param `extern_module` + - src/ast/schemas.rb:43 `Schemas::ResourceSchema#initialize` param `fields` + - src/ast/schemas.rb:43 `Schemas::ResourceSchema#initialize` param `static_methods` + - src/ast/schemas.rb:43 `Schemas::ResourceSchema#initialize` param `type_params` + - src/backends/pipeline_host.rb:2622 `PipelineHost#bc_for_iter_range` param `range_lit` + - src/backends/pipeline_host.rb:3476 `PipelineHost#extract_concurrent_error_policy_for_bc` param `expr` + - src/backends/pipeline_host.rb:3787 `PipelineHost#lower_bc_concurrent_select_prune` param `inner_expr` + - src/backends/pipeline_host.rb:3814 `PipelineHost#lower_bc_concurrent_where_prune` param `inner_expr` + - src/backends/pipeline_rewriter.rb:765 `PipelineRewriter#patch_chain_source!` param `new_source` + - src/backends/pipeline_rewriter.rb:765 `PipelineRewriter#patch_chain_source!` return + - src/backends/transpiler.rb:48 `ZigTranspiler#collect_bg_blocks` param `node` + - src/backends/transpiler.rb:48 `ZigTranspiler#collect_bg_blocks` return + - src/mir/control_flow.rb:1493 `LoopFrameAnalysis#rhs_references_any?` param `names` + - src/mir/fsm_transform/recursive_splitter.rb:425 `FsmTransform::RecursiveSplitter#emit_suspend` param `builder` + - src/mir/fsm_transform/recursive_splitter.rb:425 `FsmTransform::RecursiveSplitter#emit_suspend` param `susp_tail` + - src/mir/fsm_transform/segments.rb:237 `FsmTransform::Segments#stmt_unsupported?` param `stmt` + - src/mir/fsm_transform/segments.rb:264 `FsmTransform::Segments#contains_suspend_anywhere?` param `stmts` + - src/mir/fsm_transform/segments.rb:331 `FsmTransform::Segments#suspending_call?` param `expr` + - src/mir/mir_emitter.rb:1486 `MIREmitter#emit_has_field` param `node` + - src/mir/mir_emitter.rb:243 `MIREmitter#emit_raw_bc_as_zig` param `node` + - src/mir/mir_lowering.rb:1682 `MIRLowering#infer_catch_value_allocator` param `expr` +- 52 arg untraced + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` param `block` + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` param `block` + - src/annotator-helpers/capabilities.rb:57 `Capabilities#validate!` param `error_handler` + - src/annotator-helpers/fixable_helpers.rb:1251 `FixableHelper#emit_with_cap_mismatch!` param `kw` + - src/annotator-helpers/fixable_helpers.rb:740 `FixableHelper#emit_match_partial_fix!` param `kwargs` + - src/annotator-helpers/pipe_analysis.rb:103 `PipeAnalysis#mark_observable_terminal!` param `type_kwargs` + - src/annotator-helpers/pipe_analysis.rb:1801 `PipeAnalysis#with_soa_tracking` param `blk` + - src/annotator-helpers/pipe_analysis.rb:84 `PipeAnalysis#lift_to_observable_if_terminal!` param `type_kwargs` + - src/annotator.rb:1079 `SemanticAnnotator#walk_ast` param `block` + - src/ast/ast.rb:107 `AST#each_bg_block_in_stmt` param `block` + - src/ast/ast.rb:124 `AST#_expr_each_bg_block_shallow` param `block` + - src/ast/ast.rb:147 `AST#each_capture_analysis` param `block` + - src/ast/ast.rb:162 `AST#_expr_each_concurrent_capture` param `block` + - src/ast/ast.rb:18 `AST#walk_body` param `visitor` + - src/ast/ast.rb:376 `AST::Locatable#finalize_storage!` param `schema_lookup` + - src/ast/ast.rb:60 `AST#each_bg_block` param `block` + - src/ast/ast.rb:67 `AST#_bg_visit_recursive` param `block` + - src/ast/ast.rb:85 `AST#_expr_each_bg_block_recursive` param `block` + - src/ast/diagnostic_registry.rb:2538 `DiagnosticRegistry#format` param `kwargs` + - src/ast/parser.rb:26 `Parser#stmt` param `block` + - src/ast/parser.rb:42 `Parser#primary` param `block` + - src/ast/parser.rb:57 `Parser#suffix` param `block` + - src/ast/scope.rb:324 `ScopeHelper#with_new_scope` param `blk` + - src/ast/source_error.rb:31 `ErrorHelper#error!` param `kwargs` + - src/ast/type.rb:1334 `Type#slot_size` param `lookup_block` + - src/ast/type.rb:1381 `Type#copyable?` param `lookup_block` + - src/ast/type.rb:1413 `Type#bg_capture_is_value_copy?` param `lookup_block` + - src/ast/type.rb:1440 `Type#implicitly_copyable?` param `lookup_block` + - src/backends/pipeline_host.rb:76 `PipelineHost#with_optional_named_binding` param `blk` + - src/backends/pipeline_host.rb:84 `PipelineHost#with_named_binding` param `blk` + - src/backends/pipeline_host.rb:98 `PipelineHost#with_fiber_capture_map` param `blk` + - src/lsp/document_store.rb:82 `LSP::DocumentStore#each` param `block` + - src/mir/concurrency_checks.rb:185 `ConcurrencyChecks#walk_with_blocks` param `blk` + - src/mir/concurrency_checks.rb:199 `ConcurrencyChecks#walk_scope_no_nested_with` param `blk` + - src/mir/concurrency_checks.rb:222 `ConcurrencyChecks#walk_scope_for_nested_with` param `blk` + - src/mir/control_flow.rb:1629 `LoopFrameAnalysis#scan_direct` param `block` + - src/mir/control_flow.rb:1657 `LoopFrameAnalysis#walk_all_nodes` param `block` + - src/mir/control_flow.rb:1984 `BorrowChecker#walk_for_was_moved` param `block` + - src/mir/control_flow.rb:900 `OwnershipDataflow#walk_expr` param `block` + - src/mir/control_flow.rb:945 `OwnershipDataflow#walk_expr_skip_copy` param `block` + - src/mir/escape_analysis.rb:436 `EscapeAnalysis#e2_walk_calls` param `blk` + - src/mir/escape_analysis.rb:441 `EscapeAnalysis#e2_walk_calls_in_expr` param `blk` + - src/mir/escape_analysis.rb:792 `EscapeAnalysis#unify_caller_attr` param `project` + - src/mir/fsm_transform/liveness.rb:240 `FsmTransform::Liveness#walk_idents` param `block` + - src/mir/fsm_transform/recursive_splitter.rb:78 `FsmTransform::RecursiveSplitter::Builder#with_alias_overrides` param `blk` + - src/mir/mir_checker.rb:334 `MIRChecker#walk_mir` param `block` + - src/mir/mir_checker.rb:340 `MIRChecker#walk_mir_node` param `block` + - src/mir/mir_checker.rb:931 `MIRChecker#each_sub_expr` param `blk` + - src/mir/mir_lowering.rb:7552 `MIRLowering#with_fiber_capture_map` param `blk` + - src/mir/promotion_plan.rb:687 `CleanupClassifier#entry` param `extra` + - ... +2 more +- 12 only nil + - src/annotator.rb:6530 `SemanticAnnotator#og_set_moved` param `consumer_param_type` + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` param `shard_hash` + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` param `shard_idx` + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` param `shard_key` + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` param `shard_map` + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` param `soa` + - src/backends/pipeline_host.rb:104 `PipelineHost#task_config_zig` param `computed_tier` + - src/mir/control_flow.rb:1039 `UseAfterMoveChecker#check` param `can_fail_fns` + - src/mir/mir_checker.rb:79 `MIRChecker#initialize` param `fn_name` + - src/tools/migration_suggester_helpers.rb:170 `MigrationSuggesterHelpers#rhs_uses_alias_only_for_field_get?` param `field_name` + - src/tools/stack_verifier.rb:334 `StackVerifier#deepest_path_cost` param `fn_nodes` + - src/tools/stack_verifier.rb:384 `StackVerifier#compute_main_optimal_tier` param `fn_nodes` +- 159 collection no elements + - src/annotator-helpers/auto_inference.rb:50 `T.let` `` + - src/annotator-helpers/auto_inference.rb:58 `T.let` `` + - src/annotator-helpers/auto_inference.rb:691 `T.let` `` + - src/annotator-helpers/capabilities.rb:130 `T.let` `` + - src/annotator-helpers/capabilities.rb:27 `T.let` `` + - src/annotator-helpers/capabilities.rb:34 `Capabilities#errors_for` return + - src/annotator-helpers/function_context.rb:27 `T.let` `` + - src/annotator-helpers/function_signature.rb:66 `FunctionSignature#initialize` param `owner_type_params` + - src/annotator-helpers/generic_analysis.rb:291 `T.let` `` + - src/annotator-helpers/lock_helper.rb:381 `LockHelper#verify_handler_reachability!` param `types_with_self` + - src/annotator-helpers/lock_helper.rb:44 `T.let` `` + - src/annotator-helpers/lock_helper.rb:45 `T.let` `` + - src/annotator-helpers/lock_helper.rb:46 `T.let` `` + - src/annotator-helpers/lock_helper.rb:49 `T.let` `` + - src/annotator-helpers/lock_helper.rb:53 `T.let` `` + - src/annotator-helpers/lock_helper.rb:54 `T.let` `` + - src/annotator-helpers/pipe_analysis.rb:1139 `PipeAnalysis#collect_sharded_names` param `names` + - src/annotator-helpers/pipe_analysis.rb:1228 `T.let` `` + - src/annotator-helpers/pipe_analysis.rb:133 `T.let` `` + - src/annotator.rb:103 `T.let` `` + - src/annotator.rb:104 `T.let` `` + - src/annotator.rb:105 `T.let` `` + - src/annotator.rb:107 `T.let` `` + - src/annotator.rb:1099 `T.let` `` + - src/annotator.rb:111 `T.let` `` + - src/annotator.rb:112 `T.let` `` + - src/annotator.rb:113 `T.let` `` + - src/annotator.rb:114 `T.let` `` + - src/annotator.rb:125 `T.let` `` + - src/annotator.rb:126 `T.let` `` + - src/annotator.rb:128 `T.let` `` + - src/annotator.rb:132 `T.let` `` + - src/annotator.rb:134 `T.let` `` + - src/annotator.rb:135 `T.let` `` + - src/annotator.rb:2304 `SemanticAnnotator#collect_implicit_type_params` param `explicit` + - src/annotator.rb:276 `SemanticAnnotator#flush_deferred_with_validations!` return + - src/annotator.rb:4175 `SemanticAnnotator#visit_MoveNode` return + - src/annotator.rb:6311 `T.let` `` + - src/annotator.rb:6528 `SemanticAnnotator#og_move` return + - src/annotator.rb:6530 `SemanticAnnotator#og_set_moved` return + - src/annotator.rb:96 `T.let` `` + - src/annotator.rb:97 `T.let` `` + - src/ast/ast.rb:1308 `T.let` `` + - src/ast/ast.rb:1321 `T.let` `` + - src/ast/ast.rb:1340 `T.let` `` + - src/ast/ast.rb:553 `AST::FunctionDef.captures` + - src/ast/ast.rb:664 `AST::StructDef.type_params` + - src/ast/ast.rb:703 `AST#lazy_fields` return + - src/ast/ast.rb:822 `AST::MethodCall.args` + - src/ast/diagnostic_registry.rb:2538 `DiagnosticRegistry#format` param `args` + - ... +109 more +- 23 struct unobserved + - `OwnershipGraph::Node` (src/mir/ownership_graph.rb:21): 4 field(s) -- move_action, move_col, move_consumer_param_type, move_line + - `MIR::RawBc` (src/mir/mir.rb:1741): 3 field(s) -- args, stdlib_def, template + - `AST::Cast` (src/ast/ast.rb:856): 2 field(s) -- target, value + - `AST::MatchStatement` (src/ast/ast.rb:1174): 2 field(s) -- exhaustive, takes + - `MIR::FsmTailCondSkip` (src/mir/mir.rb:804): 2 field(s) -- cond_zig, skip_step + - `MIR::HasField` (src/mir/mir.rb:1628): 2 field(s) -- expr, field + - `MIR::InlineZig` (src/mir/mir.rb:1702): 2 field(s) -- allocs, target_var + - `MIR::TryOrPanic` (src/mir/mir.rb:270): 2 field(s) -- expr, panic_msg + - `AST::Copy` (src/ast/ast.rb:971): 1 field(s) -- value + - `AST::Require` (src/ast/ast.rb:872): 1 field(s) -- path + - `MIR::TryCatch` (src/mir/mir.rb:1513): 1 field(s) -- heap_provenance + - `MIR::UnionVariantGet` (src/mir/mir.rb:1550): 1 field(s) -- object + +### Signature Slot Evidence +- primary reason: the single strongest current explanation for why this weak/untyped signature slot has not been safely strengthened +- evidence count: runtime observations plus static callsite/origin records feeding the slot +- candidate action: an existing nil-kill action that could rewrite this slot, if one exists + +#### Param Slot Evidence +- blocked: unknown callsite expression: 306 slot(s); weak 0, untyped 306; evidence 2956 + - src/annotator.rb:250 `SemanticAnnotator#program_has_auto?` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/annotator.rb:151 node; src/annotator.rb:258 c; src/annotator.rb:260 v; protocol hint strong d ...; evidence 119 + - src/backends/string_concat_rewriter.rb:45 `StringConcatRewriter#rewrite_children!` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/backends/pipeline_rewriter.rb:44 node; src/backends/string_concat_rew ...; evidence 84 + - src/backends/string_concat_rewriter.rb:78 `StringConcatRewriter#string_concat?` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/backends/string_concat_rewriter.rb:31 node; src/backends/string_concat_r ...; evidence 84 + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/backends/pipeline_rewriter.rb:44 node; src/backends/string_concat_rewriter.rb: ...; evidence 61 + - src/mir/mir_checker.rb:908 `MIRChecker#allocating_expr?` expr; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/mir/mir_checker.rb:890 expr; src/mir/mir_checker.rb:898 expr; protocol hint strong direct proto ...; evidence 40 + - src/annotator.rb:6088 `SemanticAnnotator#collect_bg_sources_walk` v; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/annotator.rb:6083 v; src/annotator.rb:6090 x; src/annotator.rb:6091 x; protocol hint weak ...; evidence 36 + - src/ast/ast.rb:107 `AST#each_bg_block_in_stmt` stmt; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/mir/mir_pass.rb:229 stmt; src/mir/mir_pass.rb:295 stmt; src/mir/mir_pass.rb:428 stmt; protocol hint stron ...; evidence 35 + - src/ast/ast.rb:67 `AST#_bg_visit_recursive` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; src/ast/ast.rb:63 n; protocol hint strong direct protocol #args, #child_bodies, #object, #value; evidence 34 +- candidate: runtime-only param observation: 260 slot(s); weak 0, untyped 260; evidence 962 + - src/mir/fsm_transform/recursive_splitter.rb:153 `FsmTransform::RecursiveSplitter#split` body; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; src/annotator-helpers/function_analysis.rb:614 "."; src/annotator-helpe ...; evidence 46 + - src/mir/thunk_transform/recursive_splitter.rb:88 `ThunkTransform::RecursiveSplitter#split` body; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; src/annotator-helpers/function_analysis.rb:614 "."; src/annotator-he ...; evidence 46 + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` placeholder; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; src/backends/pipeline_host.rb:180 placeholder; src/backends/pipeline_hos ...; evidence 40 + - src/mir/fsm_transform.rb:236 `FsmTransform#contains_suspend_anywhere?` stmts; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; src/mir/fsm_transform.rb:220 s.do_branch; src/mir/fsm_transform.rb:223 s.body; src/mir/ ...; evidence 34 + - src/mir/fsm_transform/recursive_splitter.rb:294 `FsmTransform::RecursiveSplitter#contains_suspend_anywhere?` stmts; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; src/mir/fsm_transform.rb:220 s.do_branch; src/mir ...; evidence 34 + - src/ast/source_error.rb:121 `ErrorHelper#fixable!` raise_in_collector; `T.untyped`; boolean pair; T::Boolean candidate; untyped literal/static expression; src/annotator-helpers/capabilities.rb:300 false; src/annotator-helpers/fixable_helpers.rb ...; evidence 25 + - src/mir/fsm_wrapper_emitter.rb:571 `FsmWrapperEmitter#empty?` s; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; src/mir/fsm_wrapper_emitter.rb:96 s.captures_decl_zig; src/mir/fsm_wrapper_emitter.rb:119 step.rt_su ...; evidence 19 + - src/mir/fsm_wrapper_emitter.rb:580 `FsmWrapperEmitter#indent_block` text; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; src/mir/fsm_wrapper_emitter.rb:115 l; src/mir/fsm_wrapper_emitter.rb:198 render_dispatch(s. ...; evidence 18 +- blocked: forwarded return argument: 187 slot(s); weak 0, untyped 187; evidence 5595 + - src/ast/source_error.rb:31 `ErrorHelper#error!` node_or_token; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/annotator-helpers/capabilities.rb:133 var_node; src/annotator-helpers/capabilities.rb:267 node; s ...; evidence 471 + - src/ast/ast.rb:308 `AST::Locatable#full_type=` val; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/annotator-helpers/function_analysis.rb:140 :Type; src/annotator-helpers/function_analysis.rb:163 substituted ...; evidence 288 + - src/mir/mir_lowering.rb:376 `MIRLowering#lower` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/backends/pipeline_host.rb:163 substituted; src/backends/pipeline_host.rb:172 substituted; src/backends/pip ...; evidence 246 + - src/mir/mir_emitter.rb:43 `MIREmitter#emit` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/backends/importer.rb:213 item; src/backends/importer.rb:214 item; src/backends/pipeline_host.rb:164 mir_node; ...; evidence 228 + - src/annotator.rb:345 `SemanticAnnotator#visit` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/annotator-helpers/capabilities.rb:458 gcap[:guard_expr]; src/annotator-helpers/capabilities.rb:522 expr; sr ...; evidence 205 + - src/backends/pipeline_host.rb:111 `PipelineHost#visit` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/annotator-helpers/capabilities.rb:458 gcap[:guard_expr]; src/annotator-helpers/capabilities.rb:522 ...; evidence 143 + - src/mir/escape_analysis.rb:441 `EscapeAnalysis#e2_walk_calls_in_expr` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/mir/escape_analysis.rb:437 stmt; src/mir/escape_analysis.rb:446 a; src/mir/escape_an ...; evidence 98 + - src/mir/mir_lowering.rb:7477 `MIRLowering#emit_expr` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; src/mir/fsm_lowering.rb:339 lower(clause[:message]); src/mir/fsm_ops.rb:244 op.value; src/mir/fsm_ops.rb: ...; evidence 98 +- weak declared type: array element evidence needed: 172 slot(s); weak 172, untyped 0; evidence 950 + - src/mir/fsm_transform/recursive_splitter.rb:125 `FsmTransform::RecursiveSplitter::Builder#push` stmts; T::Array[`T.untyped`]; weak declared type: array element evidence needed; untyped forwarded return; src/annotator-helpers/lock_helper.rb:298 ...; evidence 43 + - src/mir/fsm_ops.rb:179 `FsmOps::DSL#call` args; T::Array[`T.untyped`]; weak declared type: array element evidence needed; untyped forwarded return; src/annotator-helpers/capabilities.rb:60 errs.first; src/annotator-helpers/method_analysis.rb:84 ...; evidence 35 + - src/mir/mir_lowering.rb:511 `MIRLowering#lower_body` stmts; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped forwarded return; src/backends/pipeline_host.rb:182 substituted; src/mir/mir_lowering.rb:55 ...; evidence 34 + - src/ast/ast.rb:18 `AST#walk_body` body; T::Array[`T.untyped`]; weak declared type: array element evidence needed; untyped forwarded return; src/annotator-helpers/with_match_check.rb:57 fn.body; src/annotator-helpers/with_match_check.rb:248 fn.b ...; evidence 33 + - src/ast/parser.rb:42 `Parser#primary` pattern; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped struct/array/collection value; src/ast/parser.rb:202 ['CAST', '(', :expression, 'AS', :type_annotation, ...; evidence 31 + - src/mir/mir_lowering.rb:7397 `MIRLowering#emit_builtin` args; T::Array[`T.untyped`]; weak declared type: array element evidence needed; untyped struct/array/collection value; src/mir/mir_lowering.rb:997 [MIR::Ident.new(de[:zig_type]), alloc_ref ...; evidence 29 + - src/annotator-helpers/fixable_helpers.rb:139 `FixableHelper#emit_typo_suggestion!` candidates; T::Array[`T.untyped`]; weak declared type: array element evidence needed; untyped forwarded return; src/annotator-helpers/capabilities.rb:329 [own_al ...; evidence 26 + - src/annotator.rb:1095 `SemanticAnnotator#visit_stmts` stmts; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped forwarded return; src/annotator-helpers/function_analysis.rb:30 body; src/annotator-helper ...; evidence 22 +- weak declared type: hash key/value evidence needed: 146 slot(s); weak 146, untyped 0; evidence 400 + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` name_map; T::Hash[String, T::Hash[`T.untyped`, `T.untyped`]]; weak declared type: hash key/value evidence needed; untyped struct/array/collection value; src/annotator-helpe ...; evidence 11 + - src/annotator-helpers/generic_analysis.rb:356 `GenericAnalysis#apply_type_subst` subst; T::Hash[Symbol, `T.untyped`]; weak declared type: hash key/value evidence needed; untyped struct/array/collection value; src/annotator-helpers/generic_analy ...; evidence 11 + - src/ast/schemas.rb:125 `Schemas#as_union_schema` schema; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped unknown expression; src/mir/control_flow.rb:626 schema; src/mir/promotion_plan.rb:9 ...; evidence 11 + - src/mir/control_flow.rb:584 `OwnershipDataflow#mark_moved!` state; T::Hash[String, `T.untyped`]; weak declared type: hash key/value evidence needed; untyped struct/array/collection value; src/mir/control_flow.rb:608 state; src/mir/control_flow. ...; evidence 11 + - src/mir/mir_pass.rb:226 `MIRPass#walk_for_bg_captures` bindings; T::Hash[String, T::Hash[Symbol, `T.untyped`]]; weak declared type: hash key/value evidence needed; untyped struct/array/collection value; src/mir/mir_pass.rb:222 bindings; src/mir ...; evidence 11 + - src/mir/promotion_plan.rb:416 `CleanupClassifier#walk_expression_bg_bodies` bindings; T::Hash[String, T::Hash[Symbol, `T.untyped`]]; weak declared type: hash key/value evidence needed; untyped struct/array/collection value; src/mir/promotion_pl ...; evidence 9 + - src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` name_to_slot; T::Hash[String, T::Array[`T.untyped`]]; weak declared type: hash key/value evidence needed; untyped struct/array/collection value; src/annotator-h ...; evidence 8 + - src/annotator-helpers/lock_helper.rb:164 `LockHelper#lock_identity_of` cap; T::Hash[Symbol, `T.untyped`]; weak declared type: hash key/value evidence needed; untyped struct/array/collection value; src/annotator-helpers/lock_helper.rb:77 cap; sr ...; evidence 7 +- blocked: no static callsite evidence: 90 slot(s); weak 0, untyped 90; evidence 54 + - src/ast/symbol_entry.rb:151 `SymbolEntry#initialize` reg; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none observed; analysis gaps: captured in @reg ...; evidence 7 + - src/ast/type.rb:199 `Type#initialize` raw_input; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none observed; analysis gaps: aliases seen other at src ...; evidence 5 + - src/annotator-helpers/function_signature.rb:66 `FunctionSignature#initialize` return_type; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none observed ...; evidence 4 + - src/ast/symbol_entry.rb:151 `SymbolEntry#initialize` type; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none observed; analysis gaps: captured in @ty ...; evidence 4 + - src/annotator-helpers/function_signature.rb:66 `FunctionSignature#initialize` return_lifetime; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none obse ...; evidence 3 + - src/ast/fixable_error.rb:99 `FixableFinding#initialize` token; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none observed; analysis gaps: captured in ...; evidence 3 + - src/ast/source_error.rb:157 `SourceError#initialize` token; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none observed; analysis gaps: captured in @t ...; evidence 3 + - src/ast/symbol_entry.rb:151 `SymbolEntry#initialize` mutable; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; no static callsite origin; protocol hint direct protocol: none observed; analysis gaps: captured in ...; evidence 3 +- weak declared type: union: 18 slot(s); weak 18, untyped 0; evidence 42 + - src/tools/lint_fix_rewriter.rb:253 `LintFixRewriter#to_type` t; T.any(Symbol, Type); weak declared type: union; untyped forwarded return; src/annotator-helpers/union.rb:74 rp[:type]; src/annotator-helpers/union.rb:75 sig.params[i][:type]; src ...; evidence 8 + - src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!` loop_node; T.any(AST::WhileLoop, AST::ForRange); weak declared type: union; untyped unknown expression; src/annotator-helpers/effects.rb:1004 loop_node; src/annotator- ...; evidence 6 + - src/mir/mir_lowering.rb:5957 `MIRLowering#compose_capability_wrap` inner_mir; T.any(MIR::ContainerInit, MIR::StructInit); weak declared type: union; untyped unknown expression; src/mir/mir_lowering.rb:2428 inner; src/mir/mir_lowering.rb:6058 ...; evidence 6 + - src/annotator.rb:4800 `SemanticAnnotator#retryable_with_fallible_body_error!` sources; T.nilable(T.any(T::Array[`T.untyped`], T::Array[`T.untyped`])); weak declared type: union; untyped unknown expression; src/annotator.rb:4568 fallible_sources; ...; evidence 3 + - src/mir/mir_pass.rb:388 `MIRPass#bg_inner_bindings` bg_node; T.any(AST::BgBlock, AST::BgStreamBlock); weak declared type: union; untyped unknown expression; src/mir/mir_pass.rb:361 stmt; src/mir/mir_pass.rb:371 val; src/mir/mir_pass.rb:376 a; evidence 3 + - src/annotator-helpers/fixable_helpers.rb:197 `FixableHelper#variant_anchor_from_unionlit` node; T.any(AST::StructLit, AST::UnionVariantLit); weak declared type: union; untyped unknown expression; src/annotator-helpers/union.rb:153 node; src/a ...; evidence 2 + - src/annotator-helpers/fixable_helpers.rb:358 `FixableHelper#emit_use_of_moved_in_loop_error!` node; T.any(AST::WhileBindLoop, AST::WhileLoop); weak declared type: union; untyped unknown expression; src/annotator.rb:1912 node; src/annotator.rb ...; evidence 2 + - src/mir/mir_lowering.rb:4085 `MIRLowering#enforce_bg_capture_strategies!` node; T.any(AST::BgBlock, AST::BgStreamBlock); weak declared type: union; untyped unknown expression; src/mir/mir_lowering.rb:3814 node; src/mir/mir_lowering.rb:4171 no ...; evidence 2 +- blocked: runtime union policy: 15 slot(s); weak 0, untyped 15; evidence 7503 + - src/mir/ownership_graph.rb:293 OwnershipGraph#[] path; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped instance variable; src/annotator-helpers/auto_inference.rb:45 String; src/annotator-helpers/auto_inference.rb:50 `T.untyped`; s ...; evidence 5217 + - src/ast/type.rb:355 Type#== other; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped instance variable; src/annotator-helpers/auto_inference.rb:440 b; src/annotator-helpers/auto_inference.rb:444 b_sym; src/annotator-helpers/auto_i ...; evidence 1691 + - src/ast/source_error.rb:31 `ErrorHelper#error!` code_or_message; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; src/annotator-helpers/capabilities.rb:133 :WITH_CAP_BAD_TARGET; src/annotator-helpers/capa ...; evidence 408 + - src/ast/scope.rb:24 `Scope#declare` reg; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; src/annotator-helpers/capabilities.rb:575 nil; src/annotator-helpers/capabilities.rb:775 nil; src/annotator-helper ...; evidence 67 + - src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; src/annotator-helpers/effects.rb:1004 s; src/annotator-helpers/effects.rb: ...; evidence 40 + - src/annotator.rb:6519 `SemanticAnnotator#og_declare` node; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; src/annotator-helpers/capabilities.rb:777 nil; src/annotator-helpers/capabilities.rb:795 nil; sr ...; evidence 22 + - src/backends/pipeline_rewriter.rb:307 `PipelineRewriter#fuse_pipeline` terminal; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; src/backends/pipeline_rewriter.rb:184 terminal; src/backends/pipeline_rewr ...; evidence 14 + - src/backends/pipeline_host.rb:3924 `PipelineHost#build_bounded_concurrent_callback` return_type; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; src/backends/pipeline_host.rb:4034 result_t; src/backends/ ...; evidence 13 +- blocked: collection/hash argument evidence: 12 slot(s); weak 0, untyped 12; evidence 127 + - src/mir/control_flow.rb:1657 `LoopFrameAnalysis#walk_all_nodes` nodes; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; src/mir/control_flow.rb:1668 child; src/mir/control_flow.rb:1671 node; src/mir/c ...; evidence 79 + - src/annotator-helpers/pipe_analysis.rb:1801 `PipeAnalysis#with_soa_tracking` item_type; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; src/annotator-helpers/pipe_analysis.rb:282 item_type; src/annot ...; evidence 12 + - src/mir/fsm_ops.rb:575 `FsmOps#walk` block; `T.untyped`; slot not observed: source index did not model this param shape; untyped struct/array/collection value; src/annotator-helpers/auto_inference.rb:550 name_map; src/annotator-helpers/auto_inf ...; evidence 10 + - src/mir/thunk_transform/recursive_splitter.rb:194 `ThunkTransform::RecursiveSplitter#contains_any_call?` names_set; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; src/mir/thunk_transform/recursive_s ...; evidence 6 + - src/annotator-helpers/fixable_helpers.rb:59 `FixableHelper#closest_name` candidates; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; src/annotator-helpers/fixable_helpers.rb:103 candidates; src/annot ...; evidence 5 + - src/backends/transpiler.rb:48 `ZigTranspiler#collect_bg_blocks` result; `T.untyped`; slot not observed: method was not hit; untyped struct/array/collection value; src/backends/transpiler.rb:51 result; src/backends/transpiler.rb:54 result; src/b ...; evidence 4 + - src/annotator-helpers/pipe_analysis.rb:1306 `PipeAnalysis#walk_for_sharded_getindex` nodes; `T.untyped`; slot not observed: method was not hit; untyped struct/array/collection value; src/annotator-helpers/pipe_analysis.rb:1290 [node.value]; src ...; evidence 3 + - src/annotator-helpers/pipe_analysis.rb:1778 `PipeAnalysis#check_soa_opportunity!` item_type; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; src/annotator-helpers/pipe_analysis.rb:1805 item_type; pro ...; evidence 3 +- weak declared type: collection element evidence needed: 10 slot(s); weak 10, untyped 0; evidence 24 + - src/annotator-helpers/pipe_analysis.rb:1139 `PipeAnalysis#collect_sharded_names` names; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped struct/array/collection value; src/annotator-helpers/pipe_analysis.rb:1 ...; evidence 4 + - src/annotator-helpers/fixable_helpers.rb:1427 `FixableHelper#build_auto_op_evidence_block` ops; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped unknown expression; src/annotator-helpers/fixable_helpers.rb:15 ...; evidence 3 + - src/annotator-helpers/with_match_check.rb:348 `WithMatchCheck#expand_snapshotted` family_set; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped forwarded return; src/annotator-helpers/with_match_check.rb:337 s ...; evidence 3 + - src/annotator-helpers/lock_helper.rb:381 `LockHelper#verify_handler_reachability!` types_with_self; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped unknown expression; src/annotator-helpers/lock_helper.rb:37 ...; evidence 2 + - src/annotator-helpers/test_annotation.rb:160 `TestAnnotation#validate_strict_io!` stubbed_fns; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped struct/array/collection value; src/annotator-helpers/test_annota ...; evidence 2 + - src/annotator.rb:1064 `SemanticAnnotator#collect_pipe_input_types` types; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped unknown expression; src/annotator.rb:794 snap_types; candidate action narrow_generic_ ...; evidence 2 + - src/annotator.rb:6411 `SemanticAnnotator#find_mutual_max_depth_callee` call_names; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped struct/array/collection value; src/annotator.rb:6361 call_names; candidate a ...; evidence 2 + - src/mir/control_flow.rb:1967 `BorrowChecker#_collect_share_moves` names; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped struct/array/collection value; src/mir/control_flow.rb:1945 names; candidate action na ...; evidence 2 +- weak declared type: nested `T.untyped`: 6 slot(s); weak 6, untyped 0; evidence 0 + - src/annotator.rb:65 `SemanticAnnotator#with_conditional_context` blk; `T.proc`.returns(`T.untyped`); weak declared type: nested `T.untyped`; untyped unknown expression; no static callsite origin; evidence 0 + - src/ast/parser.rb:3905 `Parser#parse_comma_seq` blk; `T.proc`.returns(`T.untyped`); weak declared type: nested `T.untyped`; untyped unknown expression; no static callsite origin; evidence 0 + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` blk; `T.proc`.returns(`T.untyped`); weak declared type: nested `T.untyped`; untyped unknown expression; no static callsite origin; evidence 0 + - src/backends/pipeline_host.rb:458 `PipelineHost#lower_pipeline_block` blk; `T.proc`.params(items_ident: String, label: String).returns(T::Array[`T.untyped`]); weak declared type: nested `T.untyped`; untyped unknown expression; no static callsite or ...; evidence 0 + - src/mir/mir_lowering.rb:112 `MIRLowering#lower_scoped` blk; `T.proc`.returns(`T.untyped`); weak declared type: nested `T.untyped`; untyped unknown expression; no static callsite origin; evidence 0 + - src/mir/mir_lowering.rb:140 `MIRLowering#lower_head` blk; `T.proc`.returns(`T.untyped`); weak declared type: nested `T.untyped`; untyped unknown expression; no static callsite origin; evidence 0 +- candidate: static callsite backflow: 1 slot(s); weak 0, untyped 1; evidence 4 + - src/backends/pipeline_rewriter.rb:765 `PipelineRewriter#patch_chain_source!` node; `T.untyped`; slot not observed: method was not hit; untyped unknown expression; src/backends/pipeline_rewriter.rb:131 node; src/backends/pipeline_rewriter.rb:150 ...; evidence 4 +- nil only observed: 1 slot(s); weak 0, untyped 1; evidence 4 + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` acc; `T.untyped`; nil only observed; untyped literal/static expression; src/backends/pipeline_host.rb:1322 "acc"; src/backends/pipeline_host.rb:2944 curr_var; src/b ...; evidence 4 +- slot not observed: method was not hit: 1 slot(s); weak 0, untyped 1; evidence 1 + - src/annotator-helpers/fixable_helpers.rb:1224 `FixableHelper#build_decl_cap_replace_fix` old_sigil; `T.untyped`; slot not observed: method was not hit; untyped literal/static expression; src/annotator-helpers/fixable_helpers.rb:939 '@locked'; evidence 1 + +#### Return Slot Evidence +- weak declared type: array element evidence needed: 219 slot(s); weak 219, untyped 0; evidence 716 + - src/annotator-helpers/capabilities.rb:126 `CapabilityHelper#validate_capability` return; T.nilable(T::Array[T::Hash[`T.untyped`, `T.untyped`]]); weak declared type: array element evidence needed; untyped forwarded return; call_untyped @deferred_w ...; evidence 24 + - src/backends/pipeline_rewriter.rb:601 `PipelineRewriter#build_terminal_action` return; T::Array[`T.untyped`]; weak declared type: array element evidence needed; untyped forwarded return; static [assign]; static [if_stmt]; static [AST::Assignmen ...; evidence 14 + - src/mir/fsm_transform/segments.rb:162 `FsmTransform::Segments#split_while_loop_next` return; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped struct/array/collection value; nil nil; nil nil; nil nil; evidence 12 + - src/backends/pipeline_host.rb:2316 `PipelineHost#lower_binding_fold` return; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped struct/array/collection value; static [init, bc_wrap_stages(stages, placeh ...; evidence 10 + - src/annotator-helpers/auto_inference.rb:188 `AutoConstraintCollector#record_local` return; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped literal/static expression; nil return; nil return; nil retur ...; evidence 9 + - src/annotator-helpers/auto_inference.rb:228 `AutoConstraintCollector#record_reassignment_sources` return; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped forwarded return; nil return; nil return; nil ...; evidence 9 + - src/annotator-helpers/auto_inference.rb:615 `ShapeEvidenceCollector#record_method_call` return; T.nilable(T::Array[`T.untyped`]); weak declared type: array element evidence needed; untyped forwarded return; nil return; nil return; call_untyped ...; evidence 9 + - src/ast/type.rb:932 `Type#resolve_resource_close` return; T::Array[`T.untyped`]; weak declared type: array element evidence needed; untyped struct/array/collection value; static [false, nil]; static [true, "{0}.deinit(rt.heapAlloc())"]; static ...; evidence 9 +- weak declared type: hash key/value evidence needed: 107 slot(s); weak 107, untyped 0; evidence 393 + - src/mir/mir_lowering.rb:299 `MIRLowering#hoist_cleanup_entry` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; static { kind: :heap_string, alloc: :he ...; evidence 18 + - src/mir/promotion_plan.rb:732 `CleanupClassifier#classify_collection` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; nil nil; typed_call entry(:fixe ...; evidence 11 + - src/mir/promotion_plan.rb:817 `CleanupClassifier#classify_heap_provenance` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; nil nil; nil nil; nil nil; evidence 10 + - src/mir/promotion_plan.rb:496 `CleanupClassifier#takes_param_base_entry` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; typed_call entry(:resource, ...; evidence 9 + - src/mir/thunk_transform/recursive_splitter.rb:214 `ThunkTransform::RecursiveSplitter#match_base_case` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; ...; evidence 9 + - src/backends/pipeline_host.rb:2169 `PipelineHost#unwrap_binding_unnest_chain` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; nil nil; nil nil; nil n ...; evidence 8 + - src/mir/fsm_lowering.rb:325 `FsmLowering#emit_fsm_lock_error_arm_split` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; nil nil; nil nil; static { bo ...; evidence 8 + - src/mir/promotion_plan.rb:853 `CleanupClassifier#classify_heap_struct_plain` return; T.nilable(T::Hash[`T.untyped`, `T.untyped`]); weak declared type: hash key/value evidence needed; untyped struct/array/collection value; nil nil; nil nil; nil ni ...; evidence 8 +- blocked: forwarded return chain: 101 slot(s); weak 0, untyped 101; evidence 1454 + - src/backends/string_concat_rewriter.rb:45 `StringConcatRewriter#rewrite_children!` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; typed_call node.body.map!.with_index { |s, _| rewrite_in_node!(s) }; unkn ...; evidence 82 + - src/mir/mir_lowering.rb:376 `MIRLowering#lower` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; unknown cast_node; call_untyped case node # --- Top-level --- when AST::Program then lower_program(node) # - ...; evidence 79 + - src/annotator-helpers/effects.rb:671 `EffectTracker#scan_suspend_points` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; call_untyped node.each { |n| scan_suspend_points(n, fn_node, points) }; call_untype ...; evidence 72 + - src/ast/parser.rb:1879 `Parser#parse_unary` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; static AST::UnaryOp.new(op_token, AST::OP_TO_OP_CODE[v], right); static AST::CallSiteOverride.new(sigil_tok, `T.m` ...; evidence 63 + - src/ast/parser.rb:2451 `Parser#parse_primary` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; call_untyped instance_exec(&rule); call_untyped parse_unary(); call_untyped parse_suffixes(lit); evidence 63 + - src/backends/pipeline_rewriter.rb:34 `PipelineRewriter#rewrite!` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; unknown node; call_untyped rewrite_pipeline(node); unknown node; evidence 62 + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; typed_call node.statements.map! { |s| rewrite!(s) }; call_untyped node.body.map! { ...; evidence 60 + - src/ast/parser.rb:684 `Parser#parse_statement` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped forwarded return; unknown result; call_untyped instance_exec(&rule); unknown expr; evidence 42 +- candidate: runtime-only return observation: 70 slot(s); weak 0, untyped 70; evidence 212 + - src/ast/type.rb:793 `Type#fsm_foreach_descriptor` return; `T.untyped`; single observed type; narrow candidate; untyped struct/array/collection value; static { kind: :pool_indexed, var_zig_type: element_type&.zig_type || "anyopaque" }; static { ...; evidence 8 + - src/mir/fsm_transform/emit.rb:296 `FsmTransform::Emit#build_recursive` return; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; nil nil; nil nil; nil nil; candidate action fix_sig_return (review); evidence 7 + - src/mir/fsm_transform/recursive_splitter.rb:202 `FsmTransform::RecursiveSplitter#emit_stmts` return; `T.untyped`; single observed type; narrow candidate; untyped literal/static expression; static after_idx; typed_call builder.push(stmts, Segmen ...; evidence 6 + - src/ast/source_error.rb:121 `ErrorHelper#fixable!` return; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; nil return; call_untyped $stderr.puts "#{tag} #{message}#{loc}"; typed_call raise err_class.new(token, mes ...; evidence 5 + - src/ast/type.rb:1634 `Type#from_node` return; `T.untyped`; single observed type; narrow candidate; untyped literal/static expression; nil nil; nil nil; unknown t; candidate action fix_sig_return (review); evidence 5 + - src/mir/escape_analysis.rb:873 `EscapeAnalysis#e3_find_decl` return; `T.untyped`; single observed type; narrow candidate; untyped literal/static expression; nil nil; unknown node; unknown node; candidate action fix_sig_return (review); evidence 5 + - src/mir/fsm_transform.rb:60 `FsmTransform#transform` return; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; nil nil; nil nil; call_untyped Emit.build_recursive( ctx.merge(extra_ctx_fields: ext_ctx, recursive_prom ...; evidence 5 + - src/annotator.rb:3101 `SemanticAnnotator#chain_root_name` return; `T.untyped`; single observed type; narrow candidate; untyped forwarded return; call_untyped curr.name; nil nil; candidate action fix_sig_return (review); evidence 4 +- blocked: runtime union policy: 39 slot(s); weak 0, untyped 39; evidence 301 + - src/ast/parser.rb:2972 `Parser#parse_concurrent_inner_op` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static AST::SelectOp.new(previous, expr); static AST::WhereOp.new(previous, expr); typed_ ...; evidence 17 + - src/mir/mir_lowering.rb:4677 `MIRLowering#lower_literal` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static MIR::Lit.new("\"#{escaped}\""); static MIR::Lit.new(node.value.to_i.to_s); static M ...; evidence 16 + - src/mir/fsm_ops.rb:394 `FsmOps::Lowerer#lower_stmt` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static MIR::Set.new(state_ref(op.field), lower_expr(op.value), false); static MIR::Let.new(op.n ...; evidence 15 + - src/mir/fsm_transform/recursive_splitter.rb:820 `FsmTransform::RecursiveSplitter#remap_tail` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static tail; static Segments::Goto.new(mapping.fetch(t ...; evidence 14 + - src/mir/mir_lowering.rb:4720 `MIRLowering#lower_identifier` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static MIR::FnRef.new(zig_safe_name(node.name)); static MIR::Ident.new(rc_map[node.name ...; evidence 14 + - src/mir/mir_lowering.rb:5225 `MIRLowering#lower_get_field` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static MIR::StructInit.new(node.target.name, [{ name: node.field.to_s, value: `MIR::Lit.n` ...; evidence 13 + - src/mir/mir_lowering.rb:6371 `MIRLowering#lower_indexed_assignment` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static MIR::Set.new(MIR::IndexGet.new(target, idx), val); static MIR::Set.new(M ...; evidence 13 + - src/mir/capture_strategy.rb:143 `CaptureStrategy#classify` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; static MoveInto.new(zig_t, name, name); static FreshHeapCopy.new(zig_t, name, alloc_sym) ...; evidence 12 +- blocked: unknown return expression: 27 slot(s); weak 0, untyped 27; evidence 414 + - src/backends/string_concat_rewriter.rb:27 `StringConcatRewriter#rewrite_in_node!` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; unknown node; unknown concat; unknown node; evidence 86 + - src/ast/parser.rb:1707 `Parser#parse_expression` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; unknown lhs; evidence 61 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; unknown node; unknown new_id; unknown new_id; evidence 40 + - src/mir/mir_lowering.rb:219 `MIRLowering#hoist_alloc` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; unknown expr; static MIR::Ident.new(name); evidence 27 + - src/ast/parser.rb:1910 `Parser#parse_suffixes` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped unknown expression; unknown lhs; evidence 20 + - src/mir/mir_lowering.rb:238 `MIRLowering#hoist_owned_value_temp` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; unknown expr; static MIR::Ident.new(name); evidence 20 + - src/mir/mir_lowering.rb:5126 `MIRLowering#lower_or_rescue` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; unknown left; static MIR::TryExpr.new(strip_try(left)); unknown left; evidence 19 + - src/mir/fsm_transform/emit.rb:209 `FsmTransform::Emit#build_dispatch_tail` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped literal/static expression; unknown tail; static MIR::FsmTailDone.new(nil); static MIR::FsmTailJump. ...; evidence 16 +- candidate: void return: 21 slot(s); weak 0, untyped 21; evidence 129 + - src/annotator-helpers/pipe_analysis.rb:171 `PipeAnalysis#analyze_higher_order_op` return; `T.untyped`; void candidate; return value appears unused; untyped literal/static expression; typed_call analyze_select_family_op(node); typed_call analyze ...; evidence 29 + - src/mir/control_flow.rb:604 `OwnershipDataflow#transfer_stmt` return; `T.untyped`; void candidate; return value appears unused; untyped struct/array/collection value; assignment state[stmt.name.to_s] = make_owner_entry(stmt); assignment state[s ...; evidence 16 + - src/annotator.rb:3347 `SemanticAnnotator#visit_GetField` return; `T.untyped`; void candidate; return value appears unused; untyped forwarded return; nil return; nil return; typed_call_inferred error!(node, :ENUM_FIELD_ACCESS, enum: type); candi ...; evidence 10 + - src/annotator.rb:3876 `SemanticAnnotator#visit_BinaryOp` return; `T.untyped`; void candidate; return value appears unused; untyped forwarded return; call_untyped visit_Smooth(node); typed_call visit_BindVar(node); typed_call visit_OrRescue(node ...; evidence 10 + - src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` return; `T.untyped`; void candidate; return value appears unused; untyped forwarded return; nil return; nil return; nil return; candidate action fix_sig_return (review); evidence 9 + - src/ast/scope.rb:207 `Scope#mark_read` return; `T.untyped`; void candidate; return value appears unused; untyped forwarded return; nil return; nil entry.reg&.tap { |r| r.var_used = true if r.respond_to?(:var_used=) }; call_untyped entry.reg&.ta ...; evidence 8 + - src/annotator.rb:3484 `SemanticAnnotator#visit_UnaryOp` return; `T.untyped`; void candidate; return value appears unused; untyped struct/array/collection value; assignment node.full_type = :Bool; assignment node.full_type = node.right.full_type ...; evidence 5 + - src/annotator.rb:361 `SemanticAnnotator#visit_Program` return; `T.untyped`; void candidate; return value appears unused; untyped struct/array/collection value; assignment node.full_type = node.statements.last.full_type; assignment node.full_typ ...; evidence 4 +- weak declared type: union: 19 slot(s); weak 19, untyped 0; evidence 95 + - src/mir/mir_checker.rb:340 `MIRChecker#walk_mir_node` return; T.nilable(T::Array[T::Hash[Symbol, T.any(String, Symbol)]]); weak declared type: union; untyped struct/array/collection value; nil return; typed_call walk_mir(node.body, &block); t ...; evidence 27 + - src/backends/pipeline_host.rb:1882 `PipelineHost#lower_each` return; T.nilable(T.any(MIR::ForStmt, MIR::ScopeBlock)); weak declared type: union; untyped literal/static expression; typed_call lower_sharded_each(site, each_op); static MIR::Scop ...; evidence 10 + - src/mir/mir_lowering.rb:3235 `MIRLowering#guard_fail_flow_body` return; T.nilable(T.any(T::Array[`T.untyped`], T::Array[`T.untyped`])); weak declared type: union; untyped struct/array/collection value; static []; static []; static [MIR::ReturnStm ...; evidence 8 + - src/mir/mir_pass.rb:940 `MIRPass#insert_promotion!` return; T.nilable(T.any(T::Hash[`T.untyped`, `T.untyped`], Symbol, T::Hash[`T.untyped`, `T.untyped`])); weak declared type: union; untyped struct/array/collection value; nil return; assignment ret_n ...; evidence 7 + - src/mir/promotion_plan.rb:38 `PromotionClassifier#classify` return; T::Hash[Symbol, T.any(T::Array[T::Hash[Symbol, String]], T::Set[Integer])]; weak declared type: union; untyped struct/array/collection value; static {}; static {}; static {}; evidence 7 + - src/annotator.rb:3734 `SemanticAnnotator#visit_ListLit` return; T.nilable(T.any(Symbol, Type)); weak declared type: union; untyped literal/static expression; nil return; nil return; nil return; evidence 5 + - src/mir/control_flow.rb:542 `OwnershipDataflow#join_entry` return; T.any(OwnershipDataflow::OwnerEntry, Symbol); weak declared type: union; untyped literal/static expression; static T.must(b); static a; static OwnerEntry.new(state: joined_sta ...; evidence 5 + - src/mir/mir_lowering.rb:4113 `MIRLowering#lower_bg_stream_block` return; T.any(MIR::BgBlock, MIR::BlockExpr, MIR::InlineBc, MIR::StreamSpawn); weak declared type: union; untyped literal/static expression; static MIR::StreamSpawn.new(captures_ ...; evidence 4 +- blocked: collection/field return evidence: 11 slot(s); weak 0, untyped 11; evidence 63 + - src/mir/control_flow.rb:1791 `BorrowChecker#check_stmt` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; typed_call handle_with_block(stmt); typed_call check_binding_moves(stmt.value, stmt.tok ...; evidence 16 + - src/annotator-helpers/generic_analysis.rb:325 `GenericAnalysis#extract_type_bindings!` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; unknown subst[p_res] = actual_binding; typed_call param_ ...; evidence 7 + - src/mir/mir_lowering.rb:968 `MIRLowering#lower_union_def` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; typed_call helper_structs + [generic_fn]; unknown generic_fn; typed_call helper_struc ...; evidence 7 + - src/mir/test_lowering.rb:435 `TestLowering#lower_stub_decl` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; static MIR::Let.new(stub_var, val, false, nil, nil); static MIR::Let.new(cap_name, ...; evidence 7 + - src/annotator-helpers/pipe_analysis.rb:116 `PipeAnalysis#finite_stream_element_type` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; typed_call range_element_type(node); static node.type_info ...; evidence 5 + - src/mir/mir_lowering.rb:1127 `MIRLowering#lower_function_def` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; static [build_post_inner_fn(node, params_mir, return_type_str, prologue, body_mir ...; evidence 5 + - src/mir/mir_lowering.rb:4399 `MIRLowering#lower_require` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; static [raw, *helper_fns]; static MIR::Import.new(node.namespace || node.path, "#{node ...; evidence 5 + - src/mir/mir_lowering.rb:5993 `MIRLowering#lower_var_decl` return; `T.untyped`; runtime union; kept `T.untyped` by policy; untyped struct/array/collection value; static [MIR::AllocMark.new(safe_name, mir_alloc, node.type_info), let_node, MIR::Clea ...; evidence 5 +- weak declared type: collection element evidence needed: 9 slot(s); weak 9, untyped 0; evidence 24 + - src/annotator-helpers/with_match_check.rb:387 `WithMatchCheck#warn_polymorphic_unhandled_errors!` return; T.nilable(T::Set[`T.untyped`]); weak declared type: collection element evidence needed; untyped struct/array/collection value; nil return; ...; evidence 4 + - src/mir/ownership_graph.rb:113 `OwnershipGraph#mark_moved` return; T.nilable(T::Set[`T.untyped`]); weak declared type: collection element evidence needed; untyped literal/static expression; nil return; typed_call invalidate(path, source); evidence 4 + - src/annotator-helpers/with_match_check.rb:348 `WithMatchCheck#expand_snapshotted` return; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped struct/array/collection value; static family_set; static out; candida ...; evidence 3 + - src/mir/concurrency_checks.rb:230 `ConcurrencyChecks#collect_held_params` return; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped struct/array/collection value; static `Set.new`; static out; candidate action n ...; evidence 3 + - src/annotator.rb:356 `SemanticAnnotator#outer_scope_vars` return; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped struct/array/collection value; typed_call @scope_stack.flat_map { |s| s.locals.keys }.to_set; ...; evidence 2 + - src/annotator.rb:4175 `SemanticAnnotator#visit_MoveNode` return; T.nilable(T::Set[`T.untyped`]); weak declared type: collection element evidence needed; untyped literal/static expression; typed_call og_set_moved(node.value.name, at_token: node. ...; evidence 2 + - src/annotator.rb:6528 `SemanticAnnotator#og_move` return; T::Set[`T.untyped`]; weak declared type: collection element evidence needed; untyped unknown expression; typed_call_inferred @og.transfer(from, to, at_token: at_token, action: action); evidence 2 + - src/annotator.rb:6530 `SemanticAnnotator#og_set_moved` return; T.nilable(T::Set[`T.untyped`]); weak declared type: collection element evidence needed; untyped forwarded return; call_untyped @og.mark_moved(name, at_token: at_token, action: actio ...; evidence 2 +- nil only observed: 2 slot(s); weak 0, untyped 2; evidence 6 + - src/ast/schemas.rb:136 `Schemas#as_resource_schema` return; `T.untyped`; nil only observed; untyped literal/static expression; static schema; nil nil; static ResourceSchema.new( close_zig: schema[:close_zig], static_methods: schema[:static_meth ...; evidence 4 + - src/ast/fixable_error.rb:140 `FixCollector#disable!` return; `T.untyped`; nil only observed; untyped literal/static expression; nil nil; candidate action fix_sig_return (review); evidence 2 +- blocked: instance variable return: 1 slot(s); weak 0, untyped 1; evidence 1 + - src/ast/type.rb:604 `Type#location` return; `T.untyped`; slot not observed: method was not hit; untyped instance variable; ivar_read @provenance; evidence 1 +- slot not observed: method hit but return was not captured: 1 slot(s); weak 0, untyped 1; evidence 1 + - src/ast/source_error.rb:31 `ErrorHelper#error!` return; `T.untyped`; slot not observed: method hit but return was not captured; untyped literal/static expression; typed_call raise err_class.new(token, message, T.cast(T.unsafe(self).instance_var ...; evidence 1 + +### Return Hygiene +- control shape: whether the method return is branchless or depends on branching control flow +- return syntax: whether the method uses implicit return, explicit `return`, or a mix +- return value usage: whether static callsites use this method's return value, forward it, or ignore it +- return source kind: the kind of expression that produces the return value +- fixability: the report's estimate of whether the return is already addressed, directly fixable, cascading, or needs more evidence +- row percent: share of all return slots; strength percents: share within that row +- Return slots indexed: 2217 +- Return slot strength: strong 1590 (71.7%); weak 354 (16.0%); untyped 273 (12.3%); nilable 579 (26.1%) + +#### Control Shape + +- branching: total 1117 (50.4%) of all returns; strong 706 (63.2%); weak 217 (19.4%); untyped 194 (17.4%); nilable 431 (38.6%) within row +- branchless: total 1100 (49.6%) of all returns; strong 884 (80.4%); weak 137 (12.5%); untyped 79 (7.2%); nilable 148 (13.5%) within row + +#### Return Syntax + +- implicit: total 1447 (65.3%) of all returns; strong 1086 (75.1%); weak 211 (14.6%); untyped 150 (10.4%); nilable 249 (17.2%) within row +- mixed: total 765 (34.5%) of all returns; strong 502 (65.6%); weak 143 (18.7%); untyped 120 (15.7%); nilable 329 (43.0%) within row +- explicit: total 5 (0.2%) of all returns; strong 2 (40.0%); weak 0 (0.0%); untyped 3 (60.0%); nilable 1 (20.0%) within row + +#### Return Value Usage + +- used as value: total 1585 (71.5%) of all returns; strong 1127 (71.1%); weak 225 (14.2%); untyped 233 (14.7%); nilable 382 (24.1%) within row +- ambiguous method name: total 220 (9.9%) of all returns; strong 164 (74.5%); weak 39 (17.7%); untyped 17 (7.7%); nilable 25 (11.4%) within row +- unused statement-only: total 183 (8.3%) of all returns; strong 85 (46.4%); weak 82 (44.8%); untyped 16 (8.7%); nilable 121 (66.1%) within row +- declared void: total 119 (5.4%) of all returns; strong 119 (100.0%); weak 0 (0.0%); untyped 0 (0.0%); nilable 0 (0.0%) within row +- no static callsites found: total 104 (4.7%) of all returns; strong 89 (85.6%); weak 8 (7.7%); untyped 7 (6.7%); nilable 49 (47.1%) within row +- declared noreturn: total 3 (0.1%) of all returns; strong 3 (100.0%); weak 0 (0.0%); untyped 0 (0.0%); nilable 0 (0.0%) within row +- unused via return-forwarding: total 3 (0.1%) of all returns; strong 3 (100.0%); weak 0 (0.0%); untyped 0 (0.0%); nilable 2 (66.7%) within row + +#### Return Source Kind + +- collection lookup: total 801 (36.1%) of all returns; strong 430 (53.7%); weak 282 (35.2%); untyped 89 (11.1%); nilable 240 (30.0%) within row +- literal/static: total 509 (23.0%) of all returns; strong 483 (94.9%); weak 9 (1.8%); untyped 17 (3.3%); nilable 74 (14.5%) within row +- implicit/direct forwarded return: total 255 (11.5%) of all returns; strong 158 (62.0%); weak 23 (9.0%); untyped 74 (29.0%); nilable 64 (25.1%) within row +- Ruby stdlib call: total 159 (7.2%) of all returns; strong 156 (98.1%); weak 0 (0.0%); untyped 3 (1.9%); nilable 20 (12.6%) within row +- mixed/direct forwarded return: total 150 (6.8%) of all returns; strong 78 (52.0%); weak 23 (15.3%); untyped 49 (32.7%); nilable 60 (40.0%) within row +- mixed sources: total 127 (5.7%) of all returns; strong 99 (78.0%); weak 3 (2.4%); untyped 25 (19.7%); nilable 46 (36.2%) within row +- unknown source: total 126 (5.7%) of all returns; strong 102 (81.0%); weak 13 (10.3%); untyped 11 (8.7%); nilable 21 (16.7%) within row +- mutation/setter assignment: total 81 (3.7%) of all returns; strong 80 (98.8%); weak 0 (0.0%); untyped 1 (1.2%); nilable 54 (66.7%) within row +- struct/class field or instance variable: total 6 (0.3%) of all returns; strong 4 (66.7%); weak 1 (16.7%); untyped 1 (16.7%); nilable 0 (0.0%) within row +- explicit/direct forwarded return: total 3 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 3 (100.0%); nilable 0 (0.0%) within row + +#### Fixability + +- addressed: strong: total 1468 (66.2%) of all returns; strong 1468 (100.0%); weak 0 (0.0%); untyped 0 (0.0%); nilable 412 (28.1%) within row +- addressed: weak: total 354 (16.0%) of all returns; strong 0 (0.0%); weak 354 (100.0%); untyped 0 (0.0%); nilable 167 (47.2%) within row +- addressed: void: total 119 (5.4%) of all returns; strong 119 (100.0%); weak 0 (0.0%); untyped 0 (0.0%); nilable 0 (0.0%) within row +- cascade: forwarded return: total 41 (1.8%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 41 (100.0%); nilable 0 (0.0%) within row +- review action: void from runtime_void: total 21 (0.9%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 21 (100.0%); nilable 0 (0.0%) within row +- needs collection/field evidence: total 20 (0.9%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 20 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Array, Hash)) from review: total 12 (0.5%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 12 (100.0%); nilable 0 (0.0%) within row +- review action: T::Boolean from review: total 10 (0.5%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 10 (100.0%); nilable 0 (0.0%) within row +- manual review: total 9 (0.4%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 9 (100.0%); nilable 0 (0.0%) within row +- review action: String from review: total 9 (0.4%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 9 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T::Boolean) from review: total 9 (0.4%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 9 (100.0%); nilable 0 (0.0%) within row +- review action: Type from review: total 9 (0.4%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 9 (100.0%); nilable 0 (0.0%) within row +- review action: Integer from review: total 6 (0.3%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 6 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(Symbol, Type) from review: total 6 (0.3%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 6 (100.0%); nilable 0 (0.0%) within row +- review action: Array from review: total 5 (0.2%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 5 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(String) from review: total 5 (0.2%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 5 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Array, Hash, Set)) from review: total 5 (0.2%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 5 (100.0%); nilable 0 (0.0%) within row +- addressed: noreturn: total 3 (0.1%) of all returns; strong 3 (100.0%); weak 0 (0.0%); untyped 0 (0.0%); nilable 0 (0.0%) within row +- review action: T.nilable(Array) from review: total 3 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 3 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(Hash) from review: total 3 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 3 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Array, Set)) from review: total 3 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 3 (100.0%); nilable 0 (0.0%) within row +- missing action: no singular static/RBI candidate: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: Hash from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::IfBind, AST::IfStatement) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(Array, MIR::Let) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(FalseClass, Lexer::Token) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::BlockExpr, MIR::IfStmt) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::BlockExpr, MIR::InlineZig, MIR::ScopeBlock) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(Symbol) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Hash, Schemas::StructSchema)) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(String, Symbol)) from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T::Array[T::Hash[Symbol, `T.untyped`]] from review: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- review action: T::Hash[String, LSP::DocumentStore::Document] from static_return_origin: total 2 (0.1%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 2 (100.0%); nilable 0 (0.0%) within row +- auto-fixable: String: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: AST::VarDecl from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: Lexer::Token from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: SymbolEntry from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::BatchWindowOp, AST::WindowOp) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::BgBlock, AST::BgStreamBlock) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::BinaryOp, AST::GetField, AST::Identifier) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::BinaryOp, AST::RangeLit) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::BlockExpr, AST::ForEach) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::CapabilityWrap, AST::Literal, AST::MethodCall) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::CopyNode, AST::Identifier, AST::StructLit) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::ExternFnDecl, AST::ExternStructDecl) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::ForEach, AST::ForRange) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::ForEach, AST::ForRange, AST::WhileLoop) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(AST::WhileBindLoop, AST::WhileLoop) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(Array, Hash) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(Array, MIR::FnDef) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(Array, MIR::FnDef, MIR::UnionTypeDef) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(Array, String) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(FalseClass, Lexer::Token, TrueClass) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(IO, StringIO) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(Lexer::Token, Symbol, TrueClass) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::BinOp, MIR::FieldGet, MIR::Ident) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::BlockExpr, MIR::HeapCreate, MIR::StructInit) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::BlockExpr, MIR::IfChain, MIR::SwitchStmt) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::BlockExpr, MIR::ScopeBlock) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::BlockExpr, MIR::StructInit) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::Call, MIR::Cast, MIR::InlineZig) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::Call, MIR::InlineZig) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::Call, MIR::Lit) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::CapWrap, MIR::RcRetain, MIR::SharePromote) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::Cast, MIR::Ident) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::Cast, MIR::Lit) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::FnDef, MIR::StructDef) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::ForStmt, MIR::InlineZig) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::ForStmt, MIR::ScopeBlock, MIR::WhileStmt) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::Import, MIR::Noop) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::Import, MIR::RawZig) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::InlineBc, MIR::InlineZig) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::InlineZig, MIR::Lit) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::InlineZig, MIR::MethodCall) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::PolymorphicMutate, MIR::PolymorphicMutateFlow) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::ReturnStmt, MIR::ScopeBlock) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::ScopeBlock, MIR::Set) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.any(MIR::SnapshotMultiTxn, MIR::SnapshotTransaction) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(FunctionContext) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(MIR::SuspendDescriptor) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(SymbolEntry) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(AST::Assignment, AST::BindExpr)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Array, OwnershipDataflow::OwnerEntry)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Array, Symbol, Type)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(FsmTransform::Segments::IoSuspend, FsmTransform::Segments::NextSuspend)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(FunctionSignature, Symbol)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(IO, StringIO, Thread)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Integer, Symbol, Type)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(LSP::Analyzer::Result, String)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(LSP::Analyzer::SyntheticFinding, StubFinding)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(MIR::BlockExpr, MIR::Call, MIR::Ident)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(MIR::BlockExpr, String)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Module, Symbol, Type)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T.any(Symbol, Type)) from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T.nilable(T::Hash[String, LSP::DocumentStore::Document]) from static_return_origin: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: `T.noreturn` from noreturn_body: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: `T.noreturn` from static_return_origin: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Array[Integer] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Array[T::Array[`T.untyped`]] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Array[Type] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Hash[String, OwnershipDataflow::OwnerEntry] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Hash[Symbol, `T.untyped`] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Hash[Symbol, T::Array[`T.untyped`]] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Hash[Symbol, T::Hash[Symbol, `T.untyped`]] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Hash[Symbol, T::Hash[Symbol, T::Hash[Symbol, Integer]]] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- review action: T::Set[String] from review: total 1 (0.0%) of all returns; strong 0 (0.0%); weak 0 (0.0%); untyped 1 (100.0%); nilable 0 (0.0%) within row +- Easily addressable/addressed returns: 1944 (99.9%) + +#### Top Return Hygiene Actions + +- src/tools/pprof.rb:181 `Pprof::Profile#encode`: auto-fixable: String; used as value; literal/static +- src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls`: cascade: forwarded return; used as value; mixed/direct forwarded return +- src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk`: cascade: forwarded return; ambiguous method name; mixed/direct forwarded return +- src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls`: cascade: forwarded return; used as value; mixed/direct forwarded return +- src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops`: cascade: forwarded return; used as value; mixed/direct forwarded return +- src/annotator-helpers/effects.rb:671 `EffectTracker#scan_suspend_points`: cascade: forwarded return; used as value; implicit/direct forwarded return +- src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!`: cascade: forwarded return; used as value; mixed/direct forwarded return +- src/annotator-helpers/pipe_analysis.rb:171 `PipeAnalysis#analyze_higher_order_op`: cascade: forwarded return; unused statement-only; implicit/direct forwarded return +- src/annotator.rb:345 `SemanticAnnotator#visit`: cascade: forwarded return; ambiguous method name; mixed/direct forwarded return +- src/ast/parser.rb:500 `Parser#run_action`: cascade: forwarded return; used as value; explicit/direct forwarded return +- src/ast/parser.rb:684 `Parser#parse_statement`: cascade: forwarded return; used as value; mixed/direct forwarded return +- src/ast/parser.rb:908 `Parser#parse_visibility_decl`: cascade: forwarded return; used as value; implicit/direct forwarded return +- src/ast/parser.rb:1786 `Parser#parse_or_rescue`: cascade: forwarded return; used as value; implicit/direct forwarded return +- src/ast/parser.rb:1925 `Parser#parse_var_id`: cascade: forwarded return; used as value; explicit/direct forwarded return +- src/ast/parser.rb:2451 `Parser#parse_primary`: cascade: forwarded return; used as value; mixed/direct forwarded return +- src/ast/parser.rb:2499 `Parser#parse_lit`: cascade: forwarded return; used as value; explicit/direct forwarded return +- src/ast/parser.rb:2573 `Parser#parse_sigil_construct`: cascade: forwarded return; used as value; mixed/direct forwarded return +- src/ast/parser.rb:2972 `Parser#parse_concurrent_inner_op`: cascade: forwarded return; used as value; implicit/direct forwarded return +- src/ast/parser.rb:3920 `Parser#deep_clone_node`: cascade: forwarded return; used as value; implicit/direct forwarded return +- src/ast/scope.rb:184 `Scope#resolve_type`: cascade: forwarded return; used as value; implicit/direct forwarded return + + +## Review Actions (2124) + +### Nil Source Fixes (158) +- src/backends/pipeline_generator.rb:28: affects 6 of 158 nil source fixes; source calls 726 + - src/backends/pipeline_generator.rb:28 acc; top source src/backends/pipeline_generator.rb:28; source calls 121 + - src/backends/pipeline_generator.rb:28 shard_hash; top source src/backends/pipeline_generator.rb:28; source calls 121 + - src/backends/pipeline_generator.rb:28 shard_idx; top source src/backends/pipeline_generator.rb:28; source calls 121 + - src/backends/pipeline_generator.rb:28 shard_key; top source src/backends/pipeline_generator.rb:28; source calls 121 + - src/backends/pipeline_generator.rb:28 shard_map; top source src/backends/pipeline_generator.rb:28; source calls 121 + - src/backends/pipeline_generator.rb:28 soa; top source src/backends/pipeline_generator.rb:28; source calls 121 +- src/tools/doctor.rb:1213: affects 3 of 158 nil source fixes; source calls 65 + - src/tools/doctor.rb:1213 llc_miss_rate; top source src/tools/doctor.rb:1213; source calls 24 + - src/tools/doctor.rb:1213 resolved; top source src/tools/doctor.rb:1213; source calls 24 + - src/tools/doctor.rb:1213 sites; candidate Array; auto-default []; top source src/tools/doctor.rb:1213; source calls 17 +- src/annotator-helpers/function_signature.rb:66: affects 2 of 158 nil source fixes; source calls 49904 + - src/annotator-helpers/function_signature.rb:66 owner_type_params; candidate Array; auto-default []; top source src/annotator-helpers/function_signature.rb:66; source calls 31522 + - src/annotator-helpers/function_signature.rb:66 return_lifetime; candidate T.any(Array, String); top source src/annotator-helpers/function_signature.rb:66; source calls 18382 +- src/mir/mir_pass.rb:23: affects 2 of 158 nil source fixes; source calls 1897 + - src/mir/mir_pass.rb:23 promo; candidate Hash; auto-default {}; top source src/mir/mir_pass.rb:23; source calls 1869 + - src/mir/mir_pass.rb:23 bindings; candidate Hash; auto-default {}; top source src/mir/mir_pass.rb:23; source calls 28 +- src/lsp/hover.rb:91: affects 2 of 158 nil source fixes; source calls 11 + - src/lsp/hover.rb:91 entry; candidate Hash; auto-default {}; top source src/lsp/hover.rb:91; source calls 6 + - src/lsp/hover.rb:91 example; candidate Hash; auto-default {}; top source src/lsp/hover.rb:91; source calls 5 +- src/ast/symbol_entry.rb:151: affects 1 of 158 nil source fix; source calls 470595 + - src/ast/symbol_entry.rb:151 reg; top source src/ast/symbol_entry.rb:151; source calls 470595 +- src/ast/scope.rb:24: affects 1 of 158 nil source fix; source calls 470577 + - src/ast/scope.rb:24 reg; top source src/ast/scope.rb:24; source calls 470577 +- src/annotator.rb:250: affects 1 of 158 nil source fix; source calls 68841 + - src/annotator.rb:250 node; top source src/annotator.rb:250; source calls 68841 +- src/tools/lint_fix_rewriter.rb:211: affects 1 of 158 nil source fix; source calls 33492 + - src/tools/lint_fix_rewriter.rb:211 n; top source src/tools/lint_fix_rewriter.rb:211; source calls 33492 +- src/ast/parser.rb:42: affects 1 of 158 nil source fix; source calls 32769 + - src/ast/parser.rb:42 pattern; candidate Array; auto-default []; top source src/ast/parser.rb:42; source calls 32769 +- src/ast/parser.rb:26: affects 1 of 158 nil source fix; source calls 30783 + - src/ast/parser.rb:26 pattern; candidate Array; auto-default []; top source src/ast/parser.rb:26; source calls 30783 +- src/tools/predicate_rewriter.rb:118: affects 1 of 158 nil source fix; source calls 23669 + - src/tools/predicate_rewriter.rb:118 n; top source src/tools/predicate_rewriter.rb:118; source calls 23669 +- src/ast/ast.rb:273: affects 1 of 158 nil source fix; source calls 23540 + - src/ast/ast.rb:273 val; candidate String; auto-default ""; top source src/ast/ast.rb:273; source calls 23540 +- src/tools/method_rewriter.rb:138: affects 1 of 158 nil source fix; source calls 23463 + - src/tools/method_rewriter.rb:138 node; top source src/tools/method_rewriter.rb:138; source calls 23463 +- src/tools/predicate_rewriter.rb:103: affects 1 of 158 nil source fix; source calls 23355 + - src/tools/predicate_rewriter.rb:103 node; top source src/tools/predicate_rewriter.rb:103; source calls 23355 +- src/tools/lint_fix_rewriter.rb:197: affects 1 of 158 nil source fix; source calls 16745 + - src/tools/lint_fix_rewriter.rb:197 node; top source src/tools/lint_fix_rewriter.rb:197; source calls 16745 +- src/tools/lint_fix_rewriter.rb:68: affects 1 of 158 nil source fix; source calls 16745 + - src/tools/lint_fix_rewriter.rb:68 node; top source src/tools/lint_fix_rewriter.rb:68; source calls 16745 +- src/tools/lint_fix_rewriter.rb:89: affects 1 of 158 nil source fix; source calls 16745 + - src/tools/lint_fix_rewriter.rb:89 node; top source src/tools/lint_fix_rewriter.rb:89; source calls 16745 +- src/annotator-helpers/effects.rb:671: affects 1 of 158 nil source fix; source calls 15143 + - src/annotator-helpers/effects.rb:671 node; top source src/annotator-helpers/effects.rb:671; source calls 15143 +- src/tools/method_rewriter.rb:65: affects 1 of 158 nil source fix; source calls 14143 + - src/tools/method_rewriter.rb:65 node; top source src/tools/method_rewriter.rb:65; source calls 14143 +- ... 128 more source group(s) + +### Union / `T.any` Candidates (504) +- src/ast/symbol_entry.rb:151: affects 3 of 504 union candidates; source calls 936745 + - src/ast/symbol_entry.rb:151 mutable; observed FalseClass, Lexer::Token, TrueClass; src/ast/symbol_entry.rb:151; source calls 481297 + - src/ast/symbol_entry.rb:151 type; observed FunctionSignature, String, Symbol, Type; src/ast/symbol_entry.rb:151; source calls 455440 + - src/ast/symbol_entry.rb:151 reg; observed AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl, String, Symbol; src/ast/symbol_entry.rb:151; source calls 8 +- src/mir/thunk_transform/emit.rb:266: affects 3 of 504 union candidates; source calls 29 + - src/mir/thunk_transform/emit.rb:266 lowering; observed FakeThunkLowering, MIRLowering; src/mir/thunk_transform/emit.rb:266; source calls 21 + - src/mir/thunk_transform/emit.rb:266 _mtp; observed OpenStruct, ThunkTransform::RecursiveSplitter::MutualThunkPlan; src/mir/thunk_transform/emit.rb:266; source calls 4 + - src/mir/thunk_transform/emit.rb:266 cf; observed AST::FunctionDef, OpenStruct; src/mir/thunk_transform/emit.rb:266; source calls 4 +- src/mir/mir_lowering.rb:7489: affects 3 of 504 union candidates; source calls 0 + - src/mir/mir_lowering.rb:7489 catch_body; observed MIR::BlockExpr, MIR::Ident, MIR::Lit, MIR::ScopeBlock, MIR::StructInit, MIR::UnaryOp; no source callsite + - src/mir/mir_lowering.rb:7489 fallback; observed MIR::BlockExpr, MIR::Lit, MIR::StructInit, MIR::UnaryOp; no source callsite + - src/mir/mir_lowering.rb:7489 left; observed MIR::Call, MIR::Ident, MIR::InlineZig; no source callsite +- src/ast/scope.rb:24: affects 2 of 504 union candidates; source calls 936688 + - src/ast/scope.rb:24 is_mutable; observed FalseClass, Lexer::Token, TrueClass; src/ast/scope.rb:24; source calls 481279 + - src/ast/scope.rb:24 type; observed FunctionSignature, String, Symbol, Type; src/ast/scope.rb:24; source calls 455409 +- src/tools/lint_fix_rewriter.rb:68: affects 2 of 504 union candidates; source calls 674686 + - src/tools/lint_fix_rewriter.rb:68 in_bg; observed FalseClass, TrueClass; src/tools/lint_fix_rewriter.rb:68; source calls 520350 + - src/tools/lint_fix_rewriter.rb:68 node; observed AST::AllOp, AST::AnyOp, AST::Assert, AST::Assignment, AST::AverageOp, AST::BatchWindowOp, AST::BenchmarkStmt, AST::BgBlock, ...; src/tools/lint_fix_rewriter.rb:68; source calls 154336 +- src/ast/type.rb:2297: affects 2 of 504 union candidates; source calls 60855 + - src/ast/type.rb:2297 source_type; observed Symbol, Type; src/ast/type.rb:2297; source calls 31337 + - src/ast/type.rb:2297 target_type; observed Symbol, Type; src/ast/type.rb:2297; source calls 29518 +- src/annotator-helpers/function_signature.rb:66: affects 2 of 504 union candidates; source calls 36101 + - src/annotator-helpers/function_signature.rb:66 return_type; observed Hash, Proc, Symbol, Type; src/annotator-helpers/function_signature.rb:66; source calls 22920 + - src/annotator-helpers/function_signature.rb:66 return_lifetime; observed Array, String; src/annotator-helpers/function_signature.rb:66; source calls 13181 +- src/annotator-helpers/function_analysis.rb:11: affects 2 of 504 union candidates; source calls 18647 + - src/annotator-helpers/function_analysis.rb:11 body; observed AST::BinaryOp, AST::Identifier, AST::Literal, Array; src/annotator-helpers/function_analysis.rb:11; source calls 9360 + - src/annotator-helpers/function_analysis.rb:11 declared_return; observed Symbol, Type; src/annotator-helpers/function_analysis.rb:11; source calls 9287 +- src/mir/thunk_transform/recursive_splitter.rb:194: affects 2 of 504 union candidates; source calls 1955 + - src/mir/thunk_transform/recursive_splitter.rb:194 names_set; observed Array, Set; src/mir/thunk_transform/recursive_splitter.rb:194; source calls 1455 + - src/mir/thunk_transform/recursive_splitter.rb:194 node; observed AST::BinaryOp, AST::FuncCall, AST::Identifier, AST::Literal, Array, FalseClass, Integer, Lexer::Token, ...; src/mir/thunk_transform/recursive_splitter.rb:194; source calls 500 +- src/ast/source_error.rb:31: affects 2 of 504 union candidates; source calls 965 + - src/ast/source_error.rb:31 code_or_message; observed String, Symbol; src/ast/source_error.rb:31; source calls 960 + - src/ast/source_error.rb:31 node_or_token; observed AST::AllOp, AST::AnyOp, AST::Assert, AST::Assignment, AST::AverageOp, AST::BgBlock, AST::BgStreamBlock, AST::BinaryOp, ...; src/ast/source_error.rb:31; source calls 5 +- src/annotator-helpers/fixable_helpers.rb:59: affects 2 of 504 union candidates; source calls 231 + - src/annotator-helpers/fixable_helpers.rb:59 input; observed String, Symbol; src/annotator-helpers/fixable_helpers.rb:59; source calls 117 + - src/annotator-helpers/fixable_helpers.rb:59 candidates; observed Array, Set; src/annotator-helpers/fixable_helpers.rb:59; source calls 114 +- src/annotator-helpers/generic_analysis.rb:426: affects 2 of 504 union candidates; source calls 36 + - src/annotator-helpers/generic_analysis.rb:426 left; observed Symbol, Type; src/annotator-helpers/generic_analysis.rb:426; source calls 18 + - src/annotator-helpers/generic_analysis.rb:426 right; observed Symbol, Type; src/annotator-helpers/generic_analysis.rb:426; source calls 18 +- src/mir/thunk_transform/emit.rb:147: affects 2 of 504 union candidates; source calls 24 + - src/mir/thunk_transform/emit.rb:147 _lowering; observed FakeThunkLowering, MIRLowering; src/mir/thunk_transform/emit.rb:147; source calls 19 + - src/mir/thunk_transform/emit.rb:147 fn_node; observed AST::FunctionDef, OpenStruct; src/mir/thunk_transform/emit.rb:147; source calls 5 +- src/annotator-helpers/auto_inference.rb:439: affects 2 of 504 union candidates; source calls 19 + - src/annotator-helpers/auto_inference.rb:439 a; observed Symbol, Type; src/annotator-helpers/auto_inference.rb:439; source calls 11 + - src/annotator-helpers/auto_inference.rb:439 b; observed Symbol, Type; src/annotator-helpers/auto_inference.rb:439; source calls 8 +- src/mir/thunk_transform/emit.rb:79: affects 2 of 504 union candidates; source calls 15 + - src/mir/thunk_transform/emit.rb:79 lowering; observed FakeThunkLowering, MIRLowering; src/mir/thunk_transform/emit.rb:79; source calls 10 + - src/mir/thunk_transform/emit.rb:79 fn_node; observed AST::FunctionDef, OpenStruct; src/mir/thunk_transform/emit.rb:79; source calls 5 +- src/mir/thunk_transform/emit.rb:223: affects 2 of 504 union candidates; source calls 11 + - src/mir/thunk_transform/emit.rb:223 lowering; observed FakeThunkLowering, MIRLowering; src/mir/thunk_transform/emit.rb:223; source calls 9 + - src/mir/thunk_transform/emit.rb:223 fn_node; observed AST::FunctionDef, OpenStruct; src/mir/thunk_transform/emit.rb:223; source calls 2 +- src/backends/pipeline_host.rb:3050: affects 2 of 504 union candidates; source calls 0 + - src/backends/pipeline_host.rb:3050 fold_op; observed AST::AllOp, AST::AnyOp, AST::AverageOp, AST::CountOp, AST::FindOp, AST::MaxOp, AST::MinOp, AST::SumOp; no source callsite + - src/backends/pipeline_host.rb:3050 range_lit; observed AST::Identifier, AST::RangeLit; no source callsite +- src/backends/pipeline_host.rb:375: affects 2 of 504 union candidates; source calls 0 + - src/backends/pipeline_host.rb:375 dst; observed AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::FuncCall, AST::GetField, AST::GetIndex, AST::HashLit, ...; no source callsite + - src/backends/pipeline_host.rb:375 src; observed AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::FuncCall, AST::GetField, AST::GetIndex, AST::HashLit, ...; no source callsite +- src/backends/pipeline_host.rb:4396: affects 2 of 504 union candidates; source calls 0 + - src/backends/pipeline_host.rb:4396 inner; observed AST::AverageOp, AST::MaxOp, AST::MinOp, AST::SumOp; no source callsite + - src/backends/pipeline_host.rb:4396 lhs; observed AST::Identifier, AST::MethodCall, AST::RangeLit; no source callsite +- src/backends/pipeline_host.rb:691: affects 2 of 504 union candidates; source calls 0 + - src/backends/pipeline_host.rb:691 expr_node; observed AST::BinaryOp, AST::GetField, AST::Identifier; no source callsite + - src/backends/pipeline_host.rb:691 list_node; observed AST::BinaryOp, AST::Identifier; no source callsite +- ... 452 more source group(s) + +### Missing Sigs Needing Manual Review (94) +- src/backends/pipeline_host.rb:1574 add_sig: [downgraded from high by sorbet pre-validate] add missing sig +- src/mir/mir_lowering.rb:7414 add_sig: add missing sig +- src/tools/atomic_escape_suggester.rb:25 add_sig: add missing sig +- src/tools/atomic_escape_suggester.rb:53 add_sig: add missing sig +- src/tools/atomic_migration_suggester.rb:53 add_sig: add missing sig +- src/tools/atomic_migration_suggester.rb:60 add_sig: add missing sig +- src/tools/atomic_migration_suggester.rb:103 add_sig: add missing sig +- src/tools/atomic_migration_suggester.rb:122 add_sig: add missing sig +- src/tools/atomic_migration_suggester.rb:128 add_sig: add missing sig +- src/tools/atomic_migration_suggester.rb:162 add_sig: add missing sig +- src/tools/atomic_ptr_migration_suggester.rb:41 add_sig: add missing sig +- src/tools/atomic_ptr_migration_suggester.rb:47 add_sig: add missing sig +- src/tools/atomic_ptr_migration_suggester.rb:84 add_sig: add missing sig +- src/tools/atomic_ptr_migration_suggester.rb:115 add_sig: add missing sig +- src/tools/atomic_ptr_migration_suggester.rb:121 add_sig: add missing sig +- src/tools/completions.rb:31 add_sig: add missing sig +- src/tools/completions.rb:44 add_sig: add missing sig +- src/tools/completions.rb:92 add_sig: add missing sig +- src/tools/completions.rb:127 add_sig: add missing sig +- src/tools/doctor.rb:73 add_sig: add missing sig +- ... 74 more + +### Other Review Actions (1368) +- src/backends/pipeline_rewriter.rb:765 fix_sig_param: static callsites prove param node is AST::BinaryOp; 4 static callsite(s) agree +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipDataflow::OwnerEntry#allocator` as Symbol (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipDataflow::OwnerEntry#needs_cleanup` as T::Boolean (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipDataflow::OwnerEntry#state` as Symbol (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `AST::FuncCall#name` as String (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipGraph::Node#kind` as Symbol (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipGraph::Node#line` as Integer (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipGraph::Node#path` as String (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipGraph::Node#scope_depth` as Integer (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipGraph::Node#state` as Symbol (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `AST::MethodCall#name` as String (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `BinaryOpResult#type` as Type (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipDataflow::DataflowStep#consumed` as Set (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `OwnershipDataflow::DataflowStep#state` as Hash (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `AST::StructLit#fields` as T.any(Hash, T::Hash[`T.untyped`, `T.untyped`]) (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `MIR::Call#args` as T.any(Array, T::Array[`T.untyped`]) (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `MIR::Call#callee` as String (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `FsmOps::CallExpr#args` as T.any(Array, T::Array[`T.untyped`]) (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `FsmOps::AssignField#value` as T.any(FsmOps::AllocExpr, FsmOps::CallExpr) (struct field RBI) +- sorbet/rbi/ast-struct-fields.rbi:1 add_struct_field_sig: type `MIR::Param#name` as String (struct field RBI) +- ... 1348 more +## High-Confidence Actions (49) +- src/backends/transpiler.rb:65 narrow_generic_param: narrow generic param pkg_paths from T::Hash[`T.untyped`, `T.untyped`] to T::Hash[String, String] + - method: `ZigTranspiler#transpile` + - current: sig { params(cheat_code: String, source_dir: String, pkg_paths: T::Hash[`T.untyped`, `T.untyped`], use_c_allocator: T::Boolean, use_debug_allocator: T::Boolean, test_mode: T::Boolean, strict_test: T::Boolean, exact_tiers: T.nilable(T::Hash[`T.untyped`, `T.untyped`]), main_tier: T.nilable(Symbol), default_stack: T.nilable(String)).returns(T.nilable(String)) } + - evidence: observed T::Hash[String, String] +- src/backends/transpiler.rb:75 narrow_generic_param: narrow generic param pkg_paths from T::Hash[`T.untyped`, `T.untyped`] to T::Hash[String, String] + - method: `ZigTranspiler#transpile_mir` + - current: sig { params(cheat_code: String, source_dir: String, pkg_paths: T::Hash[`T.untyped`, `T.untyped`], use_c_allocator: T::Boolean, use_debug_allocator: T::Boolean, test_mode: T::Boolean, strict_test: T::Boolean, exact_tiers: T.nilable(T::Hash[`T.untyped`, `T.untyped`]), main_tier: T.nilable(Symbol), default_stack: T.nilable(String)).returns(T.nilable(String)) } + - evidence: observed T::Hash[String, String] +- src/backends/importer.rb:31 narrow_generic_param: narrow generic param pkg_paths from T::Hash[`T.untyped`, `T.untyped`] to T::Hash[String, String] + - method: `ModuleImporter#initialize` + - current: sig { params(base_dir: String, pkg_paths: T::Hash[`T.untyped`, `T.untyped`], use_mir: T::Boolean, stdlib_root: String).void } + - evidence: observed T::Hash[String, String] +- src/annotator-helpers/with_match_check.rb:387 narrow_generic_return: narrow generic return from T.nilable(T::Set[`T.untyped`]) to T.nilable(T::Set[String]) + - method: `WithMatchCheck#warn_polymorphic_unhandled_errors!` + - current: sig { params(node: AST::WithBlock, bound_params: T::Set[String], requires_map: T::Hash[String, T::Set[Symbol]], policy_handlers: T::Array[T::Hash[Symbol, `T.untyped`]], warn_handler: Proc).returns(T.nilable(T::Set[`T.untyped`])) } + - evidence: observed T.nilable(T::Set[String]) +- src/mir/concurrency_checks.rb:136 narrow_generic_return: narrow generic return from T::Set[`T.untyped`] to T::Set[String] + - method: `ConcurrencyChecks#lock_holding_names` + - current: sig { params(with_block: `T.untyped`).returns(T::Set[`T.untyped`]) } + - evidence: observed T::Set[String] +- src/mir/concurrency_checks.rb:230 narrow_generic_return: narrow generic return from T::Set[`T.untyped`] to T::Set[String] + - method: `ConcurrencyChecks#collect_held_params` + - current: sig { params(with_block: `T.untyped`, fn: `T.untyped`).returns(T::Set[`T.untyped`]) } + - evidence: observed T::Set[String] +- src/tools/atomic_migration_suggester.rb:174 add_sig: add missing sig + - method: `AtomicMigrationSuggester#field_get_of?` + - proposed: sig { params(node: T.any(AST::GetField, AST::Literal), alias_name: String, field_name: String).returns(T::Boolean) } +- src/annotator.rb:356 narrow_generic_return: narrow generic return from T::Set[`T.untyped`] to T::Set[String] + - method: `SemanticAnnotator#outer_scope_vars` + - current: sig { returns(T::Set[`T.untyped`]) } + - evidence: observed T::Set[String] +- src/tools/lint_fix_rewriter.rb:165 add_sig: add missing sig + - method: `LintFixRewriter#mutable_unused_finding?` + - proposed: sig { params(finding: FixableFinding).returns(T::Boolean) } +- src/tools/lint_fix_rewriter.rb:184 add_sig: add missing sig + - method: `LintFixRewriter#edit_from_span` + - proposed: sig { params(span: Span, replacement: String).returns(Hash) } +- src/ast/parser.rb:4084 narrow_generic_return: narrow generic return from T::Array[`T.untyped`] to T::Array[String] + - method: `Parser#parse_when_tags` + - current: sig { returns(T::Array[`T.untyped`]) } + - evidence: observed T::Array[String] +- src/annotator.rb:1064 narrow_generic_param: narrow generic param types from T::Set[`T.untyped`] to T::Set[String] + - method: `SemanticAnnotator#collect_pipe_input_types` + - current: sig { params(body: T::Array[`T.untyped`], types: T::Set[`T.untyped`]).returns(T::Array[`T.untyped`]) } + - evidence: observed T::Set[String] +- src/tools/pprof_converter.rb:401 add_sig: add missing sig + - method: `PprofConverter#parse_addr` + - proposed: sig { params(s: String).returns(Integer) } +- src/tools/pprof.rb:181 fix_sig_return: existing sig return is `T.untyped`; static return origins suggest String + - method: `Pprof::Profile#encode` + - current: sig { params(location_ids: Array, values: Array, labels: Hash).returns(`T.untyped`) } + - proposed: change return to String + - evidence: static candidate String +- src/tools/pprof.rb:42 add_sig: add missing sig + - method: `Pprof::Wire#field_varint` + - proposed: sig { params(field: Integer, n: Integer).returns(String) } +- src/tools/doctor.rb:654 add_sig: add missing sig + - method: `Doctor#section_locks` + - proposed: sig { params(profile_dir: String).returns(NilClass) } +- src/tools/doctor.rb:903 add_sig: add missing sig + - method: `Doctor#section_mvcc` + - proposed: sig { params(profile_dir: String).returns(NilClass) } +- src/tools/doctor.rb:1095 add_sig: add missing sig + - method: `Doctor#section_atomic_escape` + - proposed: sig { params(profile_dir: String).returns(NilClass) } +- src/tools/doctor.rb:1139 add_sig: add missing sig + - method: `Doctor#section_syscalls` + - proposed: sig { params(profile_dir: String).returns(NilClass) } +- src/tools/doctor.rb:1155 add_sig: add missing sig + - method: `Doctor#section_hardware` + - proposed: sig { params(profile_dir: String).returns(NilClass) } +- src/tools/doctor.rb:1651 add_sig: add missing sig + - method: `Doctor#bytes_pretty` + - proposed: sig { params(n: Integer).returns(String) } +- src/tools/predicate_rewriter.rb:412 add_sig: add missing sig + - method: `PredicateRewriter#expression_terminator_op?` + - proposed: sig { params(source: String, j: Integer).returns(T::Boolean) } +- src/tools/predicate_rewriter.rb:303 add_sig: add missing sig + - method: `PredicateRewriter#literal_source_length` + - proposed: sig { params(node: AST::Literal, source: String, lit_off: Integer).returns(Integer) } +- src/tools/predicate_rewriter.rb:283 add_sig: add missing sig + - method: `PredicateRewriter#expand_paren_wrap` + - proposed: sig { params(source: String, lhs_start: Integer, lhs_end: Integer).returns(Array) } +- src/tools/predicate_rewriter.rb:426 add_sig: add missing sig + - method: `PredicateRewriter#receiver_source_for_method_call` + - proposed: sig { params(call: AST::MethodCall, source: String).returns(String) } +- src/tools/predicate_rewriter.rb:445 add_sig: add missing sig + - method: `PredicateRewriter#paren_if_needed` + - proposed: sig { params(text: String).returns(String) } +- src/tools/predicate_rewriter.rb:475 add_sig: add missing sig + - method: `PredicateRewriter#apply_edits` + - proposed: sig { params(source: String, edits: Array).returns(String) } +- src/annotator-helpers/auto_inference.rb:355 narrow_tlet: narrow existing `T.let` to Proc + - proposed: change `T.let` type to Proc + - evidence: observed Proc +- src/annotator-helpers/effects.rb:117 narrow_tlet: narrow existing `T.let` to T.nilable(Integer) + - proposed: change `T.let` type to T.nilable(Integer) + - evidence: observed T.nilable(Integer) +- src/annotator-helpers/fixable_helpers.rb:430 narrow_tlet: narrow existing `T.let` to T.nilable(String) + - proposed: change `T.let` type to T.nilable(String) + - evidence: observed T.nilable(String) +- src/annotator.rb:3102 narrow_tlet: narrow existing `T.let` to T.any(AST::GetField, AST::GetIndex, AST::Identifier) + - proposed: change `T.let` type to T.any(AST::GetField, AST::GetIndex, AST::Identifier) + - evidence: observed T.any(AST::GetField, AST::GetIndex, AST::Identifier) +- src/annotator.rb:3697 narrow_tlet: narrow existing `T.let` to T.nilable(AST::CopyNode) + - proposed: change `T.let` type to T.nilable(AST::CopyNode) + - evidence: observed T.nilable(AST::CopyNode) +- src/annotator.rb:5422 narrow_tlet: narrow existing `T.let` to T.any(AST::CopyNode, AST::Identifier, AST::StructLit) + - proposed: change `T.let` type to T.any(AST::CopyNode, AST::Identifier, AST::StructLit) + - evidence: observed T.any(AST::CopyNode, AST::Identifier, AST::StructLit) +- src/ast/ast.rb:216 narrow_tlet: narrow existing `T.let` to T.nilable(T.any(String, Symbol)) + - method: `AST::Locatable#zig_pattern` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T.any(String, Symbol)) + - evidence: observed T.nilable(T.any(String, Symbol)) +- src/ast/ast.rb:218 narrow_tlet: narrow existing `T.let` to T.nilable(T.any(String, Symbol)) + - method: `AST::Locatable#zig_pattern=` + - current: sig { params(val: `T.untyped`).returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T.any(String, Symbol)) + - evidence: observed T.nilable(T.any(String, Symbol)) +- src/ast/ast.rb:231 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#mutates_receiver` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:236 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#was_moved` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:241 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#container_borrow` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:246 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#needs_mut_ref` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:256 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#needs_heap_create` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:271 narrow_tlet: narrow existing `T.let` to T.nilable(String) + - method: `AST::Locatable#resource_close_zig` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(String) + - evidence: observed T.nilable(String) +- src/ast/ast.rb:273 narrow_tlet: narrow existing `T.let` to T.nilable(String) + - method: `AST::Locatable#resource_close_zig=` + - current: sig { params(val: `T.untyped`).returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(String) + - evidence: observed T.nilable(String) +- src/ast/ast.rb:276 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#can_fail` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:291 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#var_used` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:293 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#var_used=` + - current: sig { params(val: `T.untyped`).returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:296 narrow_tlet: narrow existing `T.let` to T.nilable(T::Boolean) + - method: `AST::Locatable#var_mutated` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T::Boolean) + - evidence: observed T.nilable(T::Boolean) +- src/ast/ast.rb:301 narrow_tlet: narrow existing `T.let` to T.nilable(SymbolEntry) + - method: `AST::Locatable#symbol` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(SymbolEntry) + - evidence: observed T.nilable(SymbolEntry) +- src/backends/pipeline_host.rb:2175 narrow_tlet: narrow existing `T.let` to T.any(AST::BinaryOp, AST::Identifier) + - proposed: change `T.let` type to T.any(AST::BinaryOp, AST::Identifier) + - evidence: observed T.any(AST::BinaryOp, AST::Identifier) +- src/lsp/document_store.rb:27 narrow_tlet: narrow existing `T.let` to T.nilable(T.any(LSP::Analyzer::Result, String)) + - method: `LSP::DocumentStore#cached_findings` + - current: sig { returns(`T.untyped`) } + - proposed: change `T.let` type to T.nilable(T.any(LSP::Analyzer::Result, String)) + - evidence: observed T.nilable(T.any(LSP::Analyzer::Result, String)) + +## Gap Actions (0) +- none + +## Untyped Slots +- bucket: runtime-observation state for the current `T.untyped` slot, such as unobserved, nil-only, single-type, or runtime union +- source category: static origin category explaining where the untyped value appears to come from +- unknown expression cause: parser/indexer reason the report could not classify the expression more precisely + +### Param `T.untyped` Buckets +- runtime union; kept `T.untyped` by policy: 476 + - 3 slots: src/annotator-helpers/function_analysis.rb:11 `FunctionAnalysis#analyze_routine` node; 9415 call(s); observed AST::FunctionDef, AST::LambdaLit; direct protocol: none observed; analysis gaps: forwarded to declare_and_verify_params slo ... + - 3 slots: src/ast/scope.rb:24 `Scope#declare` reg; 490381 call(s); observed AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl, NilClass; direct protocol: none observed + - 3 slots: src/ast/symbol_entry.rb:151 `SymbolEntry#initialize` reg; 490412 call(s); observed AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl, NilClass, String, Symbol; direct protocol: none observed; analysis gaps: captured in @reg ... + - 3 slots: src/mir/mir_lowering.rb:7489 `MIRLowering#try_catch_with_provenance` left; 151 call(s); observed MIR::Call, MIR::Ident, MIR::InlineZig; direct protocol: none observed; analysis gaps: forwarded to mir_allocates? slot 0 at src/mir/mir_ ... + - 3 slots: src/mir/thunk_transform/emit.rb:266 `ThunkTransform::Emit#build_mutual_arm` cf; 25 call(s); observed AST::FunctionDef, OpenStruct; strong direct protocol #mutual_thunk_plan, #name; analysis gaps: forwarded to find_cycle_member slot 0 ... + - 2 slots: src/annotator-helpers/auto_inference.rb:228 `AutoConstraintCollector#record_reassignment_sources` entry; 8 call(s); observed Array, Hash; weak direct protocol #[]; analysis gaps: forwarded to [] slot 0 at src/annotator-helpers/auto_i ... + - 2 slots: src/annotator-helpers/auto_inference.rb:439 `AutoUnifier#types_equal?` a; 13 call(s); observed Symbol, Type; medium direct protocol #==, #resolved + - 2 slots: src/annotator-helpers/capabilities.rb:1325 `CapabilityAudit#record_capability_binding` node; 19769 call(s); observed AST::BindExpr, AST::VarDecl; medium direct protocol #token; other potential options, not exhaustive: AST::AllOp, AST ... +- single observed type; narrow candidate: 257 + - 7 slots: src/mir/fsm_transform/recursive_splitter.rb:568 `FsmTransform::RecursiveSplitter#emit_for_each_iterator` for_stmt; 2 call(s); observed AST::ForEach + - 6 slots: src/mir/fsm_transform/recursive_splitter.rb:616 `FsmTransform::RecursiveSplitter#emit_for_each_indexed` for_stmt; 1 call(s); observed AST::ForEach + - 5 slots: src/mir/fsm_transform/emit.rb:697 `FsmTransform::Emit#expand_lock_segment` spec; 215 call(s); observed Hash + - 5 slots: src/mir/fsm_transform/recursive_splitter.rb:649 `FsmTransform::RecursiveSplitter#emit_for_each_pool` for_stmt; 1 call(s); observed AST::ForEach + - 4 slots: src/lsp/code_actions.rb:60 `LSP::CodeActions#build_action` fix; 11 call(s); observed Fix + - 4 slots: src/mir/concurrency_checks.rb:32 `ConcurrencyChecks#check_all!` fn_nodes; 4702 call(s); observed Hash + - 4 slots: src/mir/fsm_transform/emit.rb:296 `FsmTransform::Emit#build_recursive` ctx; 727 call(s); observed Hash + - 4 slots: src/mir/fsm_transform/emit.rb:649 `FsmTransform::Emit#check_fsm_cleanup_invariant!` seg_codes; 734 call(s); observed Array +- slot not observed: method was not hit: 83 + - 6 slots: src/ast/schemas.rb:43 `Schemas::ResourceSchema#initialize` close_zig; 0 call(s); observed no observed runtime type + - 2 slots: src/annotator-helpers/capabilities.rb:1364 `CapabilityAudit#audit_mark_bg_captures` body_exprs; 0 call(s); observed no observed runtime type + - 2 slots: src/annotator-helpers/fixable_helpers.rb:1224 `FixableHelper#build_decl_cap_replace_fix` name; 0 call(s); observed no observed runtime type + - 2 slots: src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!` node; 0 call(s); observed no observed runtime type + - 2 slots: src/annotator-helpers/fixable_helpers.rb:935 `FixableHelper#emit_with_read_needs_write_lock!` name; 0 call(s); observed no observed runtime type + - 2 slots: src/annotator-helpers/pipe_analysis.rb:1120 `PipeAnalysis#emit_multi_map_warning` conc; 0 call(s); observed no observed runtime type + - 2 slots: src/annotator-helpers/pipe_analysis.rb:1189 `PipeAnalysis#analyze_auto_shard_each_op` smooth_node; 0 call(s); observed no observed runtime type + - 2 slots: src/annotator-helpers/pipe_analysis.rb:1222 `PipeAnalysis#auto_detect_sharded_access` smooth_node; 0 call(s); observed no observed runtime type +- slot not observed: source index did not model this param shape: 39 + - 1 slot: src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` block; 1570 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` block; 1409 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/capabilities.rb:57 `Capabilities#validate!` error_handler; 19769 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/fixable_helpers.rb:1251 `FixableHelper#emit_with_cap_mismatch!` kw; 9 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/fixable_helpers.rb:740 `FixableHelper#emit_match_partial_fix!` kwargs; 12 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/pipe_analysis.rb:1801 `PipeAnalysis#with_soa_tracking` blk; 124 call(s); observed no observed runtime type + - 1 slot: src/annotator.rb:1079 `SemanticAnnotator#walk_ast` block; 2364 call(s); observed no observed runtime type + - 1 slot: src/ast/ast.rb:107 `AST#each_bg_block_in_stmt` block; 107323 call(s); observed no observed runtime type +- nil only observed: 15 + - 6 slots: src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` acc; 121 call(s); observed NilClass + - 1 slot: src/annotator.rb:6530 `SemanticAnnotator#og_set_moved` consumer_param_type; 258 call(s); observed NilClass + - 1 slot: src/backends/pipeline_host.rb:104 `PipelineHost#task_config_zig` computed_tier; 198 call(s); observed NilClass + - 1 slot: src/mir/control_flow.rb:1039 `UseAfterMoveChecker#check` can_fail_fns; 12 call(s); observed NilClass + - 1 slot: src/mir/mir_checker.rb:253 `MIRChecker#check_fsm_structure!` source; 6 call(s); observed NilClass + - 1 slot: src/mir/mir_checker.rb:79 `MIRChecker#initialize` fn_name; 1484 call(s); observed NilClass + - 1 slot: src/mir/mir_lowering.rb:673 `MIRLowering#alloc_expr` _rt_name; 30 call(s); observed NilClass + - 1 slot: src/tools/migration_suggester_helpers.rb:170 `MigrationSuggesterHelpers#rhs_uses_alias_only_for_field_get?` field_name; 9 call(s); observed NilClass +- boolean pair; T::Boolean candidate: 3 + - 1 slot: src/ast/ast.rb:293 `AST::Locatable#var_used=` val; 39715 call(s); observed FalseClass, NilClass, TrueClass + - 1 slot: src/ast/diagnostic_examples.rb:154 `DiagnosticExamples#extract_first_heredoc_in_it` expecting_raise; 4264 call(s); observed FalseClass, TrueClass + - 1 slot: src/ast/source_error.rb:121 `ErrorHelper#fixable!` raise_in_collector; 1245 call(s); observed FalseClass, TrueClass + +### Return `T.untyped` Buckets +- runtime union; kept `T.untyped` by policy: 150 + - 1 slot: src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` return; 5726 call(s); observed Array, Hash, NilClass + - 1 slot: src/annotator-helpers/auto_inference.rb:429 `AutoUnifier#widen_byte_array_to_string` return; 41 call(s); observed Symbol, Type + - 1 slot: src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` return; 1570 call(s); observed AST::Assert, AST::Assignment, AST::BinaryOp, AST::FuncCall, AST::GetIndex, AST::HashLit, AST::Identifier, AST::Li ... + - 1 slot: src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` return; 776 call(s); observed AST::BindExpr, AST::HashLit, AST::Identifier, AST::ListLit, AST::Literal, AST::ReturnNode, AST::VarDecl, Array, ... + - 1 slot: src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` return; 1409 call(s); observed AST::Assert, AST::Assignment, AST::BinaryOp, AST::FuncCall, AST::GetIndex, AST::HashLit, AST::Identifier, AST: ... + - 1 slot: src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` return; 1307 call(s); observed AST::Assert, AST::Assignment, AST::BindExpr, AST::FuncCall, AST::GetIndex, AST::HashLit, AST::Identifier, AST::ListLit, ... + - 1 slot: src/annotator-helpers/capabilities.rb:640 `CapabilityHelper#acquire_capability!` return; 2105 call(s); observed Array, Hash + - 1 slot: src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!` return; 15032 call(s); observed AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::CapabilityWrap, AST::EachOp, AST::ForRange, AST::GetField, AST::GetIndex, . ... +- single observed type; narrow candidate: 68 + - 1 slot: src/annotator.rb:3101 `SemanticAnnotator#chain_root_name` return; 2212 call(s); observed NilClass, String + - 1 slot: src/annotator.rb:56 `SemanticAnnotator#current_fn_ctx` return; 405908 call(s); observed FunctionContext, NilClass + - 1 slot: src/annotator.rb:6005 `SemanticAnnotator#dest_scope_depth_for_target` return; 2 call(s); observed Integer + - 1 slot: src/annotator.rb:6201 `SemanticAnnotator#root_variable_name` return; 473 call(s); observed String + - 1 slot: src/annotator.rb:6411 `SemanticAnnotator#find_mutual_max_depth_callee` return; 1 call(s); observed String + - 1 slot: src/annotator.rb:972 `SemanticAnnotator#synthesize_clause_from_policy` return; 63 call(s); observed Hash + - 1 slot: src/ast/ast.rb:221 `AST::Locatable#matched_stdlib_def` return; 29381 call(s); observed Hash, NilClass + - 1 slot: src/ast/ast.rb:231 `AST::Locatable#mutates_receiver` return; 9941 call(s); observed NilClass, TrueClass +- void candidate; return value appears unused: 21 + - 1 slot: src/annotator-helpers/pipe_analysis.rb:171 `PipeAnalysis#analyze_higher_order_op` return; 2550 call(s); observed Integer, NilClass, Symbol, SymbolEntry, Type + - 1 slot: src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` return; 6383 call(s); observed Module, NilClass, Symbol, Type + - 1 slot: src/annotator.rb:3347 `SemanticAnnotator#visit_GetField` return; 10813 call(s); observed NilClass, Symbol, Type + - 1 slot: src/annotator.rb:3484 `SemanticAnnotator#visit_UnaryOp` return; 749 call(s); observed Symbol, Type + - 1 slot: src/annotator.rb:361 `SemanticAnnotator#visit_Program` return; 5753 call(s); observed Symbol, Type + - 1 slot: src/annotator.rb:3837 `SemanticAnnotator#visit_Literal` return; 50736 call(s); observed Symbol, Type + - 1 slot: src/annotator.rb:3876 `SemanticAnnotator#visit_BinaryOp` return; 28239 call(s); observed Integer, NilClass, Symbol, Type + - 1 slot: src/annotator.rb:65 `SemanticAnnotator#with_conditional_context` return; 0 call(s); observed Array, NilClass +- nil only observed: 14 + - 1 slot: src/annotator-helpers/fixable_helpers.rb:1033 `FixableHelper#emit_with_restrict_immutable_error!` return; 10 call(s); observed NilClass + - 1 slot: src/annotator-helpers/fixable_helpers.rb:1458 `FixableHelper#emit_auto_resolved_finding!` return; 18 call(s); observed NilClass + - 1 slot: src/annotator-helpers/fixable_helpers.rb:1482 `FixableHelper#emit_auto_shape_resolved_finding!` return; 7 call(s); observed NilClass + - 1 slot: src/annotator-helpers/fixable_helpers.rb:1524 `FixableHelper#emit_auto_ambiguity_finding!` return; 4 call(s); observed NilClass + - 1 slot: src/annotator-helpers/fixable_helpers.rb:1555 `FixableHelper#emit_auto_unresolved_finding!` return; 9 call(s); observed NilClass + - 1 slot: src/annotator-helpers/fixable_helpers.rb:740 `FixableHelper#emit_match_partial_fix!` return; 12 call(s); observed NilClass + - 1 slot: src/annotator-helpers/fixable_helpers.rb:767 `FixableHelper#emit_return_borrowed_no_copy_error!` return; 9 call(s); observed NilClass + - 1 slot: src/annotator-helpers/reentrance.rb:403 `ReentranceBridge#emit_mutual_thunk_unsupported!` return; 9 call(s); observed NilClass +- slot not observed: method was not hit: 10 + - 1 slot: src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!` return; 0 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/fixable_helpers.rb:935 `FixableHelper#emit_with_read_needs_write_lock!` return; 0 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/pipe_analysis.rb:1272 `PipeAnalysis#walk_for_sharded_access` return; 0 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/pipe_analysis.rb:1306 `PipeAnalysis#walk_for_sharded_getindex` return; 0 call(s); observed no observed runtime type + - 1 slot: src/ast/type.rb:604 `Type#location` return; 0 call(s); observed no observed runtime type + - 1 slot: src/backends/pipeline_host.rb:84 `PipelineHost#with_named_binding` return; 0 call(s); observed MIR::BlockExpr, NilClass, String + - 1 slot: src/backends/pipeline_rewriter.rb:765 `PipelineRewriter#patch_chain_source!` return; 0 call(s); observed no observed runtime type + - 1 slot: src/backends/transpiler.rb:48 `ZigTranspiler#collect_bg_blocks` return; 0 call(s); observed no observed runtime type +- slot not observed: method hit but return was not captured: 8 + - 1 slot: src/annotator-helpers/fixable_helpers.rb:1251 `FixableHelper#emit_with_cap_mismatch!` return; 9 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/fixable_helpers.rb:848 `FixableHelper#emit_with_guard_all_bindings_need_as!` return; 2 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/fixable_helpers.rb:883 `FixableHelper#emit_with_guard_mutable_mutated!` return; 8 call(s); observed no observed runtime type + - 1 slot: src/annotator-helpers/fixable_helpers.rb:993 `FixableHelper#emit_with_materialized_needs_tense!` return; 3 call(s); observed no observed runtime type + - 1 slot: src/ast/parser.rb:587 `Parser#emit_consume_error_with_fix` return; 45 call(s); observed no observed runtime type + - 1 slot: src/ast/parser.rb:606 `Parser#emit_syntax_insert_end_of_line!` return; 12 call(s); observed no observed runtime type + - 1 slot: src/ast/parser.rb:629 `Parser#emit_syntax_insert_before_token!` return; 4 call(s); observed no observed runtime type + - 1 slot: src/ast/source_error.rb:31 `ErrorHelper#error!` return; 962 call(s); observed no observed runtime type +- boolean pair; T::Boolean candidate: 2 + - 1 slot: src/ast/ast.rb:291 `AST::Locatable#var_used` return; 17653 call(s); observed FalseClass, NilClass, TrueClass + - 1 slot: src/ast/ast.rb:293 `AST::Locatable#var_used=` return; 39715 call(s); observed FalseClass, NilClass, TrueClass + +### Param `T.untyped` Source Categories +- untyped unknown expression: 566 + - src/annotator-helpers/auto_inference.rb:135 `AutoConstraintCollector#record_constraint` node; src/annotator-helpers/auto_inference.rb:118 node + - src/annotator-helpers/auto_inference.rb:188 `AutoConstraintCollector#record_local` decl_node; src/annotator-helpers/auto_inference.rb:142 node + - src/annotator-helpers/auto_inference.rb:228 `AutoConstraintCollector#record_reassignment_sources` entry; src/annotator-helpers/auto_inference.rb:216 entry + - src/annotator-helpers/auto_inference.rb:278 `AutoConstraintCollector#register_list_shape_slot` decl_node; src/annotator-helpers/auto_inference.rb:194 decl_node + - src/annotator-helpers/auto_inference.rb:289 `AutoConstraintCollector#register_map_shape_slots` decl_node; src/annotator-helpers/auto_inference.rb:197 decl_node + - src/annotator-helpers/auto_inference.rb:439 `AutoUnifier#types_equal?` a; src/annotator-helpers/auto_inference.rb:422 existing + - src/annotator-helpers/auto_inference.rb:439 `AutoUnifier#types_equal?` b; src/annotator-helpers/auto_inference.rb:422 t + - src/annotator-helpers/auto_inference.rb:458 `AutoUnifier#stamp_slot!` type; src/annotator-helpers/auto_inference.rb:375 type +- untyped forwarded return: 241 + - src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` node; src/annotator-helpers/auto_inference.rb:67 program_node; src/annotator-helpers/auto_inference.rb:114 c; src/annotator-helpers/auto_inference.rb:116 v + - src/annotator-helpers/auto_inference.rb:228 `AutoConstraintCollector#record_reassignment_sources` rhs; src/annotator-helpers/auto_inference.rb:216 decl_node.value + - src/annotator-helpers/auto_inference.rb:267 `AutoConstraintCollector#empty_list_lit?` node; src/annotator-helpers/auto_inference.rb:193 decl_node.value + - src/annotator-helpers/auto_inference.rb:273 `AutoConstraintCollector#empty_hash_lit?` node; src/annotator-helpers/auto_inference.rb:196 decl_node.value + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` node; src/annotator-helpers/auto_inference.rb:557 fn.body; src/annotator-helpers/auto_inference.rb:573 node.value; src/annotator-helpers/auto_inference. ... + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` node; src/annotator-helpers/auto_inference.rb:67 program_node; src/annotator-helpers/auto_inference.rb:114 c; src/annotator-helpers/auto_inference.rb:116 v + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` node; src/annotator-helpers/auto_inference.rb:719 fn.body; src/annotator-helpers/auto_inference.rb:733 node.value; src/annotator-helpers/auto_inferen ... + - src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` node; src/annotator-helpers/auto_inference.rb:705 fn.body; src/annotator-helpers/auto_inference.rb:756 node.left; src/annotator-helpers/auto_inference.rb:757 ... +- untyped struct/array/collection value: 34 + - src/annotator-helpers/fixable_helpers.rb:59 `FixableHelper#closest_name` candidates; src/annotator-helpers/fixable_helpers.rb:103 candidates; src/annotator-helpers/fixable_helpers.rb:142 candidates; src/annotator-helpers/fixable_helpers.rb:21 ... + - src/annotator-helpers/fixable_helpers.rb:848 `FixableHelper#emit_with_guard_all_bindings_need_as!` missing_caps; src/annotator-helpers/capabilities.rb:445 missing_alias + - src/annotator-helpers/fixable_helpers.rb:883 `FixableHelper#emit_with_guard_mutable_mutated!` names; src/annotator-helpers/capabilities.rb:621 mutated + - src/annotator-helpers/pipe_analysis.rb:1272 `PipeAnalysis#walk_for_sharded_access` results; src/annotator-helpers/pipe_analysis.rb:1229 sharded_accesses; src/annotator-helpers/pipe_analysis.rb:1298 results + - src/annotator-helpers/pipe_analysis.rb:1306 `PipeAnalysis#walk_for_sharded_getindex` nodes; src/annotator-helpers/pipe_analysis.rb:1290 [node.value]; src/annotator-helpers/pipe_analysis.rb:1320 val; src/annotator-helpers/pipe_analysis.rb:1322 ... + - src/annotator-helpers/pipe_analysis.rb:1778 `PipeAnalysis#check_soa_opportunity!` item_type; src/annotator-helpers/pipe_analysis.rb:1805 item_type + - src/annotator-helpers/pipe_analysis.rb:1801 `PipeAnalysis#with_soa_tracking` item_type; src/annotator-helpers/pipe_analysis.rb:282 item_type; src/annotator-helpers/pipe_analysis.rb:820 item_type; src/annotator-helpers/pipe_analysis.rb:999 ite ... + - src/ast/diagnostic_examples.rb:81 `DiagnosticExamples#scan_file` out; src/ast/diagnostic_examples.rb:73 out +- untyped literal/static expression: 25 + - src/annotator-helpers/capabilities.rb:907 `CapabilityHelper#capability_alias_type` type; src/annotator-helpers/capabilities.rb:818 cap[:resolved_type] || cap[:old_scope]&.resolve_type(var_name) || :Any; src/annotator-helpers/capabilities.rb:8 ... + - src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!` node; src/annotator-helpers/effects.rb:1004 s; src/annotator-helpers/effects.rb:1015 n; src/annotator-helpers/effects.rb:1026 a + - src/annotator-helpers/fixable_helpers.rb:1224 `FixableHelper#build_decl_cap_replace_fix` old_sigil; src/annotator-helpers/fixable_helpers.rb:939 '@locked' + - src/annotator-helpers/function_analysis.rb:11 `FunctionAnalysis#analyze_routine` declared_return; src/annotator.rb:640 :Any; src/annotator.rb:696 declared_return + - src/annotator.rb:6519 `SemanticAnnotator#og_declare` node; src/annotator-helpers/capabilities.rb:777 nil; src/annotator-helpers/capabilities.rb:795 nil; src/annotator-helpers/capabilities.rb:823 nil + - src/ast/ast.rb:228 `AST::Locatable#stdlib_allocates=` val; src/annotator-helpers/method_analysis.rb:117 true; src/annotator.rb:2372 true; src/annotator.rb:2575 true + - src/ast/ast.rb:233 `AST::Locatable#mutates_receiver=` val; src/annotator-helpers/method_analysis.rb:118 true; src/annotator.rb:2373 true; src/annotator.rb:2576 true + - src/ast/ast.rb:238 `AST::Locatable#was_moved=` val; src/annotator-helpers/function_analysis.rb:406 true; src/annotator-helpers/function_analysis.rb:407 true; src/annotator-helpers/function_analysis.rb:412 true +- untyped instance variable: 7 + - src/ast/ast.rb:278 `AST::Locatable#can_fail=` val; src/annotator-helpers/effects.rb:455 (can_fail[name] == true); src/annotator-helpers/effects.rb:591 true; src/annotator-helpers/function_signature.rb:56 fn.can_fail + - src/ast/type.rb:355 Type#== other; src/annotator-helpers/auto_inference.rb:440 b; src/annotator-helpers/auto_inference.rb:444 b_sym; src/annotator-helpers/auto_inference.rb:495 :map_key + - src/lsp/rpc.rb:32 `LSP::RPC#read_message` io; src/lsp/server.rb:54 @stdin + - src/lsp/rpc.rb:53 `LSP::RPC#write_message` io; src/lsp/server.rb:129 @stdout + - src/mir/concurrency_checks.rb:32 `ConcurrencyChecks#check_all!` fn_nodes; src/annotator.rb:186 @fn_nodes + - src/mir/effect_inference.rb:21 `EffectInference#analyze!` fn_nodes; src/annotator.rb:164 @fn_nodes; src/mir/mir_pass.rb:85 @fn_nodes; src/mir/mir_pass.rb:95 @fn_nodes + - src/mir/ownership_graph.rb:293 OwnershipGraph#[] path; src/annotator-helpers/auto_inference.rb:45 String; src/annotator-helpers/auto_inference.rb:50 `T.untyped`; src/annotator-helpers/auto_inference.rb:58 `T.untyped` + +### Return `T.untyped` Source Categories +- untyped forwarded return: 159 + - src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` + - src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` + - src/annotator-helpers/capabilities.rb:640 `CapabilityHelper#acquire_capability!` + - src/annotator-helpers/effects.rb:671 `EffectTracker#scan_suspend_points` + - src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!` +- untyped literal/static expression: 81 + - src/annotator-helpers/auto_inference.rb:429 `AutoUnifier#widen_byte_array_to_string` + - src/annotator-helpers/pipe_analysis.rb:171 `PipeAnalysis#analyze_higher_order_op` + - src/annotator.rb:6411 `SemanticAnnotator#find_mutual_max_depth_callee` + - src/ast/ast.rb:329 `AST::Locatable#coerced_type` + - src/ast/diagnostic_examples.rb:130 `DiagnosticExamples#find_block_end` + - src/ast/fixable_error.rb:140 `FixCollector#disable!` + - src/ast/parser.rb:675 `Parser#match!` + - src/ast/parser.rb:702 `Parser#try_parse_bind_or_assign` +- untyped struct/array/collection value: 20 + - src/annotator-helpers/generic_analysis.rb:325 `GenericAnalysis#extract_type_bindings!` + - src/annotator-helpers/pipe_analysis.rb:116 `PipeAnalysis#finite_stream_element_type` + - src/annotator-helpers/pipe_analysis.rb:1272 `PipeAnalysis#walk_for_sharded_access` + - src/annotator-helpers/pipe_analysis.rb:1306 `PipeAnalysis#walk_for_sharded_getindex` + - src/annotator.rb:361 `SemanticAnnotator#visit_Program` + - src/annotator.rb:3484 `SemanticAnnotator#visit_UnaryOp` + - src/ast/parser.rb:3905 `Parser#parse_comma_seq` + - src/ast/type.rb:793 `Type#fsm_foreach_descriptor` +- untyped unknown expression: 12 + - src/annotator.rb:3837 `SemanticAnnotator#visit_Literal` + - src/annotator.rb:5421 `SemanticAnnotator#get_root_object` + - src/ast/diagnostic_examples.rb:58 `DiagnosticExamples#all` + - src/ast/parser.rb:1707 `Parser#parse_expression` + - src/ast/parser.rb:1910 `Parser#parse_suffixes` + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` + - src/backends/pipeline_rewriter.rb:765 `PipelineRewriter#patch_chain_source!` + - src/backends/string_concat_rewriter.rb:27 `StringConcatRewriter#rewrite_in_node!` +- untyped instance variable: 1 + - src/ast/type.rb:604 `Type#location` + +### Param Unknown Expression Causes +- unknown expression with multiple unknown types: 8 + - src/annotator-helpers/function_analysis.rb:740 full_type=(0) param[:type].to_sym rescue param[:type] + - src/annotator-helpers/lock_helper.rb:423 error!(0) sel[:token] || node + - src/annotator-helpers/lock_helper.rb:455 error!(0) anchor || @current_fn_node || @program_node + - src/annotator.rb:2773 check_prefixed_int_range!(1) node.value.coerced_type || final_type + - src/annotator.rb:2856 og_declare(2) node.type_info || final_type + - src/annotator.rb:3747 full_type=(0) :"~#{inner_types.first}[#{node.items.size}]" + - src/annotator.rb:5143 full_type=(0) :"~?#{elem_syms.first}[]" + - src/mir/fsm_transform/suspend_resolvers.rb:35 resolve_next(susp_idx) susp_idx || (seg.index + 1) +- unknown local variable item_type: 8 + - src/annotator-helpers/pipe_analysis.rb:303 full_type=(0) :"HashMap<#{item_type}[]>" + - src/annotator-helpers/pipe_analysis.rb:307 full_type=(0) :"#{item_type}[]" + - src/annotator-helpers/pipe_analysis.rb:362 declare(2) :"#{item_type}[]" + - src/annotator-helpers/pipe_analysis.rb:440 declare(2) :"#{item_type}[]" + - src/annotator-helpers/pipe_analysis.rb:611 full_type=(0) :"#{item_type}[]" + - src/annotator-helpers/pipe_analysis.rb:911 full_type=(0) :"?#{item_type}" + - src/annotator-helpers/pipe_analysis.rb:1623 full_type=(0) case node.right.op when AST::SelectOp :"#{node.right.op.expression.full_type}[]" when AST::WhereOp :"#{item_type}[]" end + - src/annotator-helpers/pipe_analysis.rb:1686 full_type=(0) case node.right.op when AST::SelectOp then :"#{node.right.op.expression.full_type}[]" when AST::WhereOp then :"#{item_type}[]" end +- unknown operation unresolved constant SymbolEntry: 8 + - src/annotator.rb:2872 [](0) SymbolEntry + - src/annotator.rb:5989 [](0) SymbolEntry + - src/annotator.rb:5991 [](0) SymbolEntry + - src/annotator.rb:6031 [](0) SymbolEntry + - src/annotator.rb:6075 [](0) SymbolEntry + - src/annotator.rb:6087 [](0) SymbolEntry + - src/annotator.rb:6097 [](0) SymbolEntry + - src/annotator.rb:6115 [](0) SymbolEntry +- unknown operation SelfNode: 6 + - src/annotator-helpers/reentrance.rb:126 split(2) self + - src/annotator-helpers/reentrance.rb:376 split_mutual(3) self + - src/annotator.rb:735 split(2) self + - src/mir/mir_lowering.rb:1396 build_trampoline(1) self + - src/mir/mir_lowering.rb:1398 build_mutual_trampoline(1) self + - src/mir/mir_lowering.rb:4015 transform(2) self +- unknown operation RegularExpressionNode: 4 + - src/annotator-helpers/fixable_helpers.rb:815 [](0) /\A\s*/ + - src/ast/diagnostic_examples.rb:187 [](0) /\A( *)/ + - src/lsp/diagnostics.rb:162 split(0) /(%\{[^}]+\})/ + - src/tools/doctor.rb:452 split(0) /\t/ +- unknown local variable elem_sym: 3 + - src/annotator.rb:5372 full_type=(0) :"?#{elem_sym}" + - src/annotator.rb:5385 full_type=(0) :"?#{elem_sym}" + - src/annotator.rb:5390 full_type=(0) :"?#{elem_sym}" +- unknown operation unresolved constant T::Boolean: 2 + - src/annotator-helpers/effects.rb:1054 [](0) T::Boolean + - src/annotator-helpers/effects.rb:1140 [](0) T::Boolean +- unknown local variable expr_type: 2 + - src/annotator-helpers/pipe_analysis.rb:368 full_type=(0) :"#{expr_type}[]" + - src/annotator-helpers/pipe_analysis.rb:445 full_type=(0) :"#{expr_type}[]" +- unknown operation unresolved constant HEAP_STRING_TYPE: 2 + - src/ast/type.rb:170 ==(0) HEAP_STRING_TYPE + - src/ast/type.rb:170 ==(0) HEAP_STRING_TYPE +- unknown operation unresolved constant UNINIT: 2 + - src/mir/control_flow.rb:565 ==(0) UNINIT + - src/mir/control_flow.rb:566 ==(0) UNINIT +- unknown operation unresolved constant FsmTransform::Segments::Segment: 2 + - src/mir/fsm_transform/recursive_splitter.rb:132 [](0) FsmTransform::Segments::Segment + - src/mir/fsm_transform/recursive_splitter.rb:152 [](0) FsmTransform::Segments::Segment +- unknown instance variable @fn_nodes: 2 + - src/mir/mir_pass.rb:85 analyze!(0) @fn_nodes + - src/mir/mir_pass.rb:95 analyze!(0) @fn_nodes +- unknown operation unresolved constant SUSPENDS: 1 + - src/annotator-helpers/effects.rb:138 ==(0) SUSPENDS +- unknown operation unresolved constant SUSPENDS_LOOP: 1 + - src/annotator-helpers/effects.rb:336 ==(0) SUSPENDS_LOOP +- unknown operation unresolved constant SUSPENDS_CONDITIONAL: 1 + - src/annotator-helpers/effects.rb:337 ==(0) SUSPENDS_CONDITIONAL +- unknown instance variable @can_fail: 1 + - src/annotator-helpers/function_signature.rb:105 can_fail=(0) @can_fail +- unknown operation unresolved constant Type: 1 + - src/annotator-helpers/generic_analysis.rb:70 [](0) Type +- unknown local variable join_type_name: 1 + - src/annotator-helpers/pipe_analysis.rb:503 full_type=(0) :"#{join_type_name}[]" +- unknown local variable nested_element_type: 1 + - src/annotator-helpers/pipe_analysis.rb:645 full_type=(0) :"#{nested_element_type}[]" +- unknown operation unresolved constant Edit: 1 + - src/annotator-helpers/reentrance.rb:512 [](0) Edit +- unknown operation unresolved constant Type::STRING_TYPE: 1 + - src/annotator.rb:300 declare(2) Type::STRING_TYPE +- unknown operation unresolved constant OwnershipGraph::Edge: 1 + - src/annotator.rb:3030 [](0) OwnershipGraph::Edge +- unknown local variable element: 1 + - src/annotator.rb:3467 full_type=(0) :"#{element}[]" +- unknown local variable first_val_type: 1 + - src/annotator.rb:3526 full_type=(0) :"HashMap<#{first_val_type}>" +- unknown local variable base_type: 1 + - src/annotator.rb:3790 full_type=(0) :"#{base_type}[#{node.items.size}]" +- unknown forwarded return error!: 1 + - src/annotator.rb:3838 full_type=(0) case node.type when :NUMBER then :Float64 when :INT64 then :Int64 when :STRING # provenance auto-inferred from location: :rodata in Type constructor if node.storage == :stack Type.new(:"Byte[#{node.value. ... +- unknown operation OrNode: 1 + - src/annotator.rb:5189 full_type=(0) node.expr.full_type || :Void +- unknown local variable last_type: 1 + - src/annotator.rb:5224 full_type=(0) :"~#{last_type}" +- unknown operation unresolved constant AST::Identifier: 1 + - src/annotator.rb:6055 [](0) AST::Identifier +- unknown operation unresolved constant Lexer::Token: 1 + - src/ast/parser.rb:61 [](0) Lexer::Token +- unknown operation unresolved constant Fix: 1 + - src/ast/source_error.rb:120 [](0) Fix +- unknown operation unresolved constant FixableFinding: 1 + - src/ast/syntax_typo_scanner.rb:123 [](0) FixableFinding +- unknown instance variable @observable_terminal: 1 + - src/ast/type.rb:1198 [](0) @observable_terminal +- unknown global variable $0: 1 + - src/backends/transpiler.rb:279 ==(0) $0 +- unknown instance variable @stdin: 1 + - src/lsp/server.rb:54 read_message(0) @stdin +- unknown instance variable @stdout: 1 + - src/lsp/server.rb:129 write_message(0) @stdout +- unknown forwarded return lookup_type_schema: 1 + - src/mir/alloc.rb:50 resolve_resource_close(0) ->(name) { lookup_type_schema(name) } +- unknown local variable name: 1 + - src/mir/fsm_transform/liveness.rb:108 [](0) :"#{name}__type" +- unknown operation unresolved constant MIR::Let: 1 + - src/mir/mir_checker.rb:187 [](0) MIR::Let + +### Return Unknown Expression Causes +- unknown local variable node: 13 + - src/ast/parser.rb:2499 `Parser#parse_lit` node + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` node + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` node + - src/backends/pipeline_rewriter.rb:34 `PipelineRewriter#rewrite!` node + - src/backends/pipeline_rewriter.rb:34 `PipelineRewriter#rewrite!` node + - src/backends/pipeline_rewriter.rb:774 `PipelineRewriter#replace_named_placeholder` node + - src/backends/pipeline_rewriter.rb:792 `PipelineRewriter#replace_placeholder` node + - src/backends/string_concat_rewriter.rb:27 `StringConcatRewriter#rewrite_in_node!` node +- unknown local variable expr: 8 + - src/ast/ast.rb:124 `AST#_expr_each_bg_block_shallow` yield expr + - src/ast/parser.rb:684 `Parser#parse_statement` expr + - src/ast/parser.rb:3836 `Parser#parse_bg_body_stmt` expr + - src/mir/mir_lowering.rb:219 `MIRLowering#hoist_alloc` expr + - src/mir/mir_lowering.rb:238 `MIRLowering#hoist_owned_value_temp` expr + - src/mir/mir_lowering.rb:286 `MIRLowering#copy_container_borrow_if_needed` expr + - src/mir/mir_lowering.rb:286 `MIRLowering#copy_container_borrow_if_needed` expr + - src/mir/mir_lowering.rb:286 `MIRLowering#copy_container_borrow_if_needed` expr +- unknown local variable result: 8 + - src/ast/parser.rb:684 `Parser#parse_statement` result + - src/ast/parser.rb:3836 `Parser#parse_bg_body_stmt` result + - src/mir/fsm_transform/emit.rb:831 `FsmTransform::Emit#build_segment_descriptor` result + - src/mir/mir_lowering.rb:112 `MIRLowering#lower_scoped` result + - src/mir/mir_lowering.rb:5398 `MIRLowering#lower_struct_lit` result + - src/mir/mir_lowering.rb:6225 `MIRLowering#lower_bind_expr` result + - src/mir/mir_lowering.rb:6989 `MIRLowering#lower_match` result + - src/mir/mir_lowering.rb:7552 `MIRLowering#with_fiber_capture_map` result +- unknown forwarded return rewrite!: 6 + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` node.value = rewrite!(node.value) + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` node.value = rewrite!(node.value) + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` node.value = rewrite!(node.value) + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` node.right = rewrite!(node.right) + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` node.right = rewrite!(node.right) + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` node.result = rewrite!(node.result) +- unknown local variable left: 6 + - src/mir/mir_lowering.rb:5126 `MIRLowering#lower_or_rescue` left + - src/mir/mir_lowering.rb:5126 `MIRLowering#lower_or_rescue` left + - src/mir/mir_lowering.rb:5126 `MIRLowering#lower_or_rescue` left + - src/mir/mir_lowering.rb:5126 `MIRLowering#lower_or_rescue` left + - src/mir/mir_lowering.rb:5126 `MIRLowering#lower_or_rescue` left + - src/mir/mir_lowering.rb:5126 `MIRLowering#lower_or_rescue` left +- unknown operation InstanceVariableOrWriteNode: 5 + - src/ast/diagnostic_examples.rb:58 `DiagnosticExamples#all` @all ||= load! + - src/ast/type.rb:995 `Type#value_type` @value_type_obj ||= T.let(Type.new(@value_type_raw || :Any), T.nilable(Type)) + - src/ast/type.rb:1030 `Type#wrapped_type` @wrapped_type_obj ||= T.let(Type.new(@wrapped_type_raw || :Any), T.nilable(Type)) + - src/ast/type.rb:1042 `Type#payload_type` @payload_type_obj ||= T.let(Type.new(@payload_type_raw || :Any), T.nilable(Type)) + - src/ast/type.rb:1215 `Type#tense_type` @tense_type_obj ||= T.let(Type.new(@tense_type_raw || :Void), T.nilable(Type)) +- unknown local variable new_id: 5 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_id + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_id + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_id + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_id + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_id +- unknown forwarded return rewrite_in_node!: 4 + - src/backends/string_concat_rewriter.rb:45 `StringConcatRewriter#rewrite_children!` node.value = rewrite_in_node!(node.value) + - src/backends/string_concat_rewriter.rb:45 `StringConcatRewriter#rewrite_children!` node.value = rewrite_in_node!(node.value) + - src/backends/string_concat_rewriter.rb:45 `StringConcatRewriter#rewrite_children!` node.value = rewrite_in_node!(node.value) + - src/backends/string_concat_rewriter.rb:45 `StringConcatRewriter#rewrite_children!` node.right = rewrite_in_node!(node.right) +- unknown expression with multiple unknown types: 2 + - src/ast/ast.rb:162 `AST#_expr_each_concurrent_capture` yield node.capture_analysis + - src/ast/type.rb:1014 `Type#generic_args` @generic_args_obj ||= @generic_args_raw.map { |a| Type.new(a) } +- unknown local variable lhs: 2 + - src/ast/parser.rb:1707 `Parser#parse_expression` lhs + - src/ast/parser.rb:1910 `Parser#parse_suffixes` lhs +- unknown local variable lit: 2 + - src/ast/parser.rb:2499 `Parser#parse_lit` lit + - src/ast/parser.rb:2499 `Parser#parse_lit` lit +- unknown local variable t: 2 + - src/ast/type.rb:1634 `Type#from_node` t + - src/mir/mir_lowering.rb:7589 `MIRLowering#bare_zig_type` t +- unknown local variable new_node: 2 + - src/backends/pipeline_rewriter.rb:774 `PipelineRewriter#replace_named_placeholder` new_node + - src/backends/pipeline_rewriter.rb:792 `PipelineRewriter#replace_placeholder` new_node +- unknown local variable mir: 2 + - src/mir/fsm_lowering.rb:194 `FsmLowering#wrap_step_as_stmt` mir + - src/mir/mir_lowering.rb:2088 `MIRLowering#lower_extern_arg` mir +- unknown local variable intercept: 2 + - src/mir/mir_lowering.rb:1697 `MIRLowering#lower_func_call` intercept + - src/mir/mir_lowering.rb:1814 `MIRLowering#lower_method_call` intercept +- unknown local variable iz: 2 + - src/mir/mir_lowering.rb:2303 `MIRLowering#lower_list_lit` iz + - src/mir/mir_lowering.rb:4273 `MIRLowering#lower_next_expr` iz +- unknown local variable cmp_node: 2 + - src/mir/mir_lowering.rb:4792 `MIRLowering#lower_binary_op` cmp_node + - src/mir/mir_lowering.rb:4792 `MIRLowering#lower_binary_op` cmp_node +- unknown local variable out: 2 + - src/mir/thunk_transform/emit.rb:186 `ThunkTransform::Emit#qualify_params` out + - src/mir/thunk_transform/emit.rb:308 `ThunkTransform::Emit#qualify_with_f` out +- unknown local variable actual_binding: 1 + - src/annotator-helpers/generic_analysis.rb:325 `GenericAnalysis#extract_type_bindings!` subst[p_res] = actual_binding +- unknown local variable target_type: 1 + - src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` node.value.coerced_type = target_type +- unknown local variable field_type: 1 + - src/annotator.rb:3347 `SemanticAnnotator#visit_GetField` node.full_type = field_type +- unknown forwarded return error!: 1 + - src/annotator.rb:3837 `SemanticAnnotator#visit_Literal` node.full_type = case node.type when :NUMBER then :Float64 when :INT64 then :Int64 when :STRING # provenance auto-inferred from location: :rodata in Type constructor if node.storage == : ... +- unknown local variable curr: 1 + - src/annotator.rb:5421 `SemanticAnnotator#get_root_object` curr +- unknown local variable name: 1 + - src/annotator.rb:6411 `SemanticAnnotator#find_mutual_max_depth_callee` name +- unknown local variable stmt: 1 + - src/ast/ast.rb:107 `AST#each_bg_block_in_stmt` yield stmt +- unknown local variable k: 1 + - src/ast/diagnostic_examples.rb:130 `DiagnosticExamples#find_block_end` k +- unknown local variable bind: 1 + - src/ast/parser.rb:702 `Parser#try_parse_bind_or_assign` bind +- unknown local variable asgn: 1 + - src/ast/parser.rb:702 `Parser#try_parse_bind_or_assign` asgn +- unknown local variable schema: 1 + - src/ast/scope.rb:300 `ScopeHelper#lookup_type_schema` schema +- unknown forwarded return capacity: 1 + - src/ast/type.rb:1316 `Type#stream_capacity` optional_stream_shape_type&.capacity || tense_type.capacity +- unknown operation RescueModifierNode: 1 + - src/ast/type.rb:1634 `Type#from_node` Type.new(t) rescue nil +- unknown local variable new_call: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_call +- unknown local variable new_mc: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_mc +- unknown local variable new_bin: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_bin +- unknown local variable new_gi: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_gi +- unknown local variable new_gf: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_gf +- unknown local variable new_ia: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_ia +- unknown local variable new_bind: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_bind +- unknown local variable new_assign: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_assign +- unknown local variable new_uo: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_uo +- unknown local variable new_with: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_with +- unknown local variable new_sl: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_sl +- unknown local variable new_hl: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_hl +- unknown local variable new_assert: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_assert +- unknown local variable new_if: 1 + - src/backends/pipeline_host.rb:219 `PipelineHost#substitute_placeholders` new_if +- unknown local variable expr_mir: 1 + - src/backends/pipeline_host.rb:2436 `PipelineHost#numeric_fold_expr_typed` expr_mir +- unknown local variable call: 1 + - src/backends/pipeline_rewriter.rb:105 `PipelineRewriter#rewrite_pipeline` call +- unknown local variable op: 1 + - src/backends/pipeline_rewriter.rb:105 `PipelineRewriter#rewrite_pipeline` op +- unknown local variable wrapper: 1 + - src/backends/pipeline_rewriter.rb:307 `PipelineRewriter#fuse_pipeline` wrapper +- unknown local variable new_source: 1 + - src/backends/pipeline_rewriter.rb:765 `PipelineRewriter#patch_chain_source!` cursor.left = new_source +- unknown local variable concat: 1 + - src/backends/string_concat_rewriter.rb:27 `StringConcatRewriter#rewrite_in_node!` concat +- unknown local variable diags: 1 + - src/lsp/diagnostics.rb:58 `LSP::Diagnostics#from_result` diags +- unknown local variable strict: 1 + - src/lsp/hover.rb:63 `LSP::Hover#find_overlapping` strict +- unknown local variable tail: 1 + - src/mir/fsm_transform/emit.rb:209 `FsmTransform::Emit#build_dispatch_tail` tail +- unknown local variable pivot_entry: 1 + - src/mir/fsm_transform/recursive_splitter.rb:202 `FsmTransform::RecursiveSplitter#emit_stmts` pivot_entry +- unknown local variable cast_node: 1 + - src/mir/mir_lowering.rb:376 `MIRLowering#lower` cast_node +- unknown local variable generic_fn: 1 + - src/mir/mir_lowering.rb:968 `MIRLowering#lower_union_def` generic_fn +- unknown local variable union_node: 1 + - src/mir/mir_lowering.rb:968 `MIRLowering#lower_union_def` union_node +- unknown local variable items: 1 + - src/mir/mir_lowering.rb:1095 `MIRLowering#lower_extern_struct` items +- unknown local variable len_expr: 1 + - src/mir/mir_lowering.rb:1902 `MIRLowering#lower_intrinsic` len_expr +- unknown local variable inner: 1 + - src/mir/mir_lowering.rb:2385 `MIRLowering#lower_hash_lit` inner +- unknown local variable wrapped: 1 + - src/mir/mir_lowering.rb:2385 `MIRLowering#lower_hash_lit` wrapped +- unknown local variable raw: 1 + - src/mir/mir_lowering.rb:4399 `MIRLowering#lower_require` raw +- unknown local variable mir_node: 1 + - src/mir/mir_lowering.rb:7496 `MIRLowering#strip_try` mir_node + +## Nilability Pressure By Root Callsite +- pressure: how many review actions are attributed to the same source location +- root callsite: the caller/source location where nil entered one or more typed slots +- src/backends/pipeline_generator.rb:28 priority 9.46; affects `T.nilable` in 6 signature slot(s), 726 observed call(s) + - src/backends/pipeline_generator.rb:28 acc + - src/backends/pipeline_generator.rb:28 soa + - src/backends/pipeline_generator.rb:28 shard_map + - src/backends/pipeline_generator.rb:28 shard_idx + - src/backends/pipeline_generator.rb:28 shard_key +- src/ast/symbol_entry.rb:151 priority 6.67; affects `T.nilable` in 1 signature slot(s), 470595 observed call(s) + - src/ast/symbol_entry.rb:151 reg +- src/ast/scope.rb:24 priority 6.67; affects `T.nilable` in 1 signature slot(s), 470577 observed call(s) + - src/ast/scope.rb:24 reg +- src/mir/mir_pass.rb:23 priority 6.05; affects `T.nilable` in 2 signature slot(s), 1897 observed call(s) + - src/mir/mir_pass.rb:23 bindings (candidate Hash; default {}) + - src/mir/mir_pass.rb:23 promo (candidate Hash; default {}) +- src/annotator.rb:250 priority 5.84; affects `T.nilable` in 1 signature slot(s), 68841 observed call(s) + - src/annotator.rb:250 node +- src/tools/lint_fix_rewriter.rb:211 priority 5.52; affects `T.nilable` in 1 signature slot(s), 33492 observed call(s) + - src/tools/lint_fix_rewriter.rb:211 n +- src/tools/predicate_rewriter.rb:118 priority 5.37; affects `T.nilable` in 1 signature slot(s), 23669 observed call(s) + - src/tools/predicate_rewriter.rb:118 n +- src/ast/ast.rb:273 priority 5.37; affects `T.nilable` in 1 signature slot(s), 23540 observed call(s) + - src/ast/ast.rb:273 val (candidate String; default "") +- src/tools/method_rewriter.rb:138 priority 5.37; affects `T.nilable` in 1 signature slot(s), 23463 observed call(s) + - src/tools/method_rewriter.rb:138 node +- src/tools/predicate_rewriter.rb:103 priority 5.37; affects `T.nilable` in 1 signature slot(s), 23355 observed call(s) + - src/tools/predicate_rewriter.rb:103 node +- src/annotator-helpers/function_signature.rb:66 priority 5.26; affects `T.nilable` in 1 signature slot(s), 18382 observed call(s) + - src/annotator-helpers/function_signature.rb:66 return_lifetime (candidate T.any(Array, String)) +- src/tools/lint_fix_rewriter.rb:68 priority 5.22; affects `T.nilable` in 1 signature slot(s), 16745 observed call(s) + - src/tools/lint_fix_rewriter.rb:68 node +- src/tools/lint_fix_rewriter.rb:89 priority 5.22; affects `T.nilable` in 1 signature slot(s), 16745 observed call(s) + - src/tools/lint_fix_rewriter.rb:89 node +- src/tools/lint_fix_rewriter.rb:197 priority 5.22; affects `T.nilable` in 1 signature slot(s), 16745 observed call(s) + - src/tools/lint_fix_rewriter.rb:197 node +- src/annotator-helpers/effects.rb:671 priority 5.18; affects `T.nilable` in 1 signature slot(s), 15143 observed call(s) + - src/annotator-helpers/effects.rb:671 node +- src/tools/method_rewriter.rb:65 priority 5.15; affects `T.nilable` in 1 signature slot(s), 14143 observed call(s) + - src/tools/method_rewriter.rb:65 node +- src/ast/type.rb:1610 priority 5.10; affects `T.nilable` in 1 signature slot(s), 12536 observed call(s) + - src/ast/type.rb:1610 vt (candidate T.any(Hash, Type)) +- src/annotator.rb:6555 priority 5.01; affects `T.nilable` in 1 signature slot(s), 10133 observed call(s) + - src/annotator.rb:6555 consumer_param_type (candidate T.any(Symbol, Type)) +- src/ast/ast.rb:346 priority 4.99; affects `T.nilable` in 1 signature slot(s), 9822 observed call(s) + - src/ast/ast.rb:346 declared_type (candidate T.any(Symbol, Type)) +- src/annotator.rb:6519 priority 4.97; affects `T.nilable` in 1 signature slot(s), 9433 observed call(s) + - src/annotator.rb:6519 node +- src/mir/mir_pass.rb:652 priority 4.91; affects `T.nilable` in 1 signature slot(s), 8152 observed call(s) + - src/mir/mir_pass.rb:652 expr +- src/ast/schemas.rb:108 priority 4.89; affects `T.nilable` in 1 signature slot(s), 7847 observed call(s) + - src/ast/schemas.rb:108 schema (candidate T.any(Hash, Schemas::StructSchema)) +- src/tools/doctor.rb:1213 priority 4.88; affects `T.nilable` in 3 signature slot(s), 65 observed call(s) + - src/tools/doctor.rb:1213 sites (candidate Array; default []) + - src/tools/doctor.rb:1213 resolved + - src/tools/doctor.rb:1213 llc_miss_rate +- src/ast/ast.rb:85 priority 4.84; affects `T.nilable` in 1 signature slot(s), 6904 observed call(s) + - src/ast/ast.rb:85 expr +- src/ast/ast.rb:124 priority 4.80; affects `T.nilable` in 1 signature slot(s), 6372 observed call(s) + - src/ast/ast.rb:124 expr +- src/annotator.rb:6088 priority 4.79; affects `T.nilable` in 1 signature slot(s), 6159 observed call(s) + - src/annotator.rb:6088 v +- src/mir/test_lowering.rb:364 priority 4.64; affects `T.nilable` in 1 signature slot(s), 4374 observed call(s) + - src/mir/test_lowering.rb:364 receiver +- src/annotator.rb:6256 priority 4.63; affects `T.nilable` in 1 signature slot(s), 4246 observed call(s) + - src/annotator.rb:6256 node +- src/annotator.rb:6272 priority 4.61; affects `T.nilable` in 1 signature slot(s), 4098 observed call(s) + - src/annotator.rb:6272 node +- src/ast/ast.rb:162 priority 4.57; affects `T.nilable` in 1 signature slot(s), 3714 observed call(s) + - src/ast/ast.rb:162 node +- src/mir/escape_analysis.rb:441 priority 4.51; affects `T.nilable` in 1 signature slot(s), 3200 observed call(s) + - src/mir/escape_analysis.rb:441 node +- src/mir/mir_lowering.rb:716 priority 4.41; affects `T.nilable` in 1 signature slot(s), 2559 observed call(s) + - src/mir/mir_lowering.rb:716 target_node (candidate T.any(AST::GetField, AST::Identifier)) +- src/ast/ast.rb:293 priority 4.37; affects `T.nilable` in 1 signature slot(s), 2339 observed call(s) + - src/ast/ast.rb:293 val (candidate T::Boolean) +- src/mir/fsm_wrapper_emitter.rb:239 priority 4.35; affects `T.nilable` in 1 signature slot(s), 2253 observed call(s) + - src/mir/fsm_wrapper_emitter.rb:239 cleanups (candidate Array; default []) +- src/mir/fsm_wrapper_emitter.rb:571 priority 4.35; affects `T.nilable` in 1 signature slot(s), 2240 observed call(s) + - src/mir/fsm_wrapper_emitter.rb:571 s (candidate String; default "") +- src/mir/ownership_graph.rb:323 priority 4.35; affects `T.nilable` in 1 signature slot(s), 2231 observed call(s) + - src/mir/ownership_graph.rb:323 consumer_param_type (candidate T.any(Symbol, Type)) +- src/mir/ownership_graph.rb:113 priority 4.33; affects `T.nilable` in 1 signature slot(s), 2141 observed call(s) + - src/mir/ownership_graph.rb:113 consumer_param_type (candidate T.any(Symbol, Type)) +- src/mir/mir_checker.rb:79 priority 4.17; affects `T.nilable` in 1 signature slot(s), 1484 observed call(s) + - src/mir/mir_checker.rb:79 fn_name +- src/mir/control_flow.rb:698 priority 4.14; affects `T.nilable` in 1 signature slot(s), 1369 observed call(s) + - src/mir/control_flow.rb:698 node +- src/mir/control_flow.rb:1887 priority 3.99; affects `T.nilable` in 1 signature slot(s), 979 observed call(s) + - src/mir/control_flow.rb:1887 expr +- src/mir/control_flow.rb:1140 priority 3.98; affects `T.nilable` in 1 signature slot(s), 964 observed call(s) + - src/mir/control_flow.rb:1140 node +- src/ast/type.rb:199 priority 3.98; affects `T.nilable` in 1 signature slot(s), 960 observed call(s) + - src/ast/type.rb:199 raw_input +- src/backends/transpiler.rb:160 priority 3.92; affects `T.nilable` in 1 signature slot(s), 837 observed call(s) + - src/backends/transpiler.rb:160 override (candidate Symbol) +- src/ast/ast.rb:308 priority 3.91; affects `T.nilable` in 1 signature slot(s), 815 observed call(s) + - src/ast/ast.rb:308 val (candidate T.any(FunctionSignature, Symbol, Type)) +- src/mir/mir_checker.rb:874 priority 3.84; affects `T.nilable` in 1 signature slot(s), 689 observed call(s) + - src/mir/mir_checker.rb:874 expr +- src/mir/fsm_transform/liveness.rb:240 priority 3.76; affects `T.nilable` in 1 signature slot(s), 569 observed call(s) + - src/mir/fsm_transform/liveness.rb:240 node +- src/mir/mir_pass.rb:38 priority 3.71; affects `T.nilable` in 1 signature slot(s), 513 observed call(s) + - src/mir/mir_pass.rb:38 promo (candidate Hash; default {}) +- src/annotator-helpers/auto_inference.rb:108 priority 3.69; affects `T.nilable` in 1 signature slot(s), 486 observed call(s) + - src/annotator-helpers/auto_inference.rb:108 node +- src/tools/predicate_rewriter.rb:62 priority 3.50; affects `T.nilable` in 1 signature slot(s), 314 observed call(s) + - src/tools/predicate_rewriter.rb:62 node +- src/annotator.rb:6530 priority 3.41; affects `T.nilable` in 1 signature slot(s), 258 observed call(s) + - src/annotator.rb:6530 consumer_param_type + +## Union Pressure Downgraded To `T.untyped` +- downgrade: a slot observed with multiple runtime types was kept as `T.untyped` instead of emitted as `T.any(...)` +- why it happens: `T.any(...)` is risky when the runtime sample may not include every type that can reach the slot +Changing these to T.any(...) can be dangerous unless you are certain the runtime sample includes every type that can reach the slot. Static analysis can separately look for other types that could be passed without breaking the function. +- src/ast/symbol_entry.rb:151 priority 12.11; affects `T.any` in 3 signature slot(s), 980350 observed call(s) + - src/ast/symbol_entry.rb:151 reg (observed AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl, String, ...) + - src/ast/symbol_entry.rb:151 type (observed FunctionSignature, String, Symbol, Type) + - src/ast/symbol_entry.rb:151 mutable (observed FalseClass, Lexer::Token, TrueClass) +- src/ast/scope.rb:24 priority 9.89; affects `T.any` in 2 signature slot(s), 980275 observed call(s) + - src/ast/scope.rb:24 type (observed FunctionSignature, String, Symbol, Type) + - src/ast/scope.rb:24 is_mutable (observed FalseClass, Lexer::Token, TrueClass) +- src/ast/type.rb:2297 priority 8.34; affects `T.any` in 2 signature slot(s), 79310 observed call(s) + - src/ast/type.rb:2297 source_type (observed Symbol, Type) + - src/ast/type.rb:2297 target_type (observed Symbol, Type) +- src/annotator-helpers/function_signature.rb:66 priority 7.99; affects `T.any` in 2 signature slot(s), 44816 observed call(s) + - src/annotator-helpers/function_signature.rb:66 return_type (observed Hash, Proc, Symbol, Type) + - src/annotator-helpers/function_signature.rb:66 return_lifetime (observed Array, String) +- src/annotator-helpers/function_analysis.rb:11 priority 7.46; affects `T.any` in 2 signature slot(s), 18775 observed call(s) + - src/annotator-helpers/function_analysis.rb:11 body (observed AST::BinaryOp, AST::Identifier, AST::Literal, Array) + - src/annotator-helpers/function_analysis.rb:11 declared_return (observed Symbol, Type) +- src/annotator.rb:250 priority 7.20; affects `T.any` in 1 signature slot(s), 1580368 observed call(s) + - src/annotator.rb:250 node (observed AST::AllOp, AST::AnyOp, AST::Assert, AST::Assignment, AST::AverageOp, ...) +- src/ast/lexer.rb:299 priority 7.14; affects `T.any` in 1 signature slot(s), 1374997 observed call(s) + - src/ast/lexer.rb:299 val (observed Float, Integer, String) +- src/ast/type.rb:199 priority 6.78; affects `T.any` in 1 signature slot(s), 602843 observed call(s) + - src/ast/type.rb:199 raw_input (observed FunctionSignature, String, Symbol, Type) +- src/annotator-helpers/effects.rb:671 priority 6.65; affects `T.any` in 1 signature slot(s), 443835 observed call(s) + - src/annotator-helpers/effects.rb:671 node (observed AST::AllOp, AST::AnyOp, AST::Assert, AST::Assignment, AST::AverageOp, ...) +- src/ast/ast.rb:308 priority 6.44; affects `T.any` in 1 signature slot(s), 274951 observed call(s) + - src/ast/ast.rb:308 val (observed FunctionSignature, Symbol, Type) +- src/ast/type.rb:355 priority 6.30; affects `T.any` in 1 signature slot(s), 198783 observed call(s) + - src/ast/type.rb:355 other (observed Symbol, Type) +- src/mir/control_flow.rb:1657 priority 6.26; affects `T.any` in 1 signature slot(s), 183436 observed call(s) + - src/mir/control_flow.rb:1657 nodes (observed AST::AllOp, AST::AnyOp, AST::AverageOp, AST::BatchWindowOp, AST::BgBlock, ...) +- src/mir/thunk_transform/recursive_splitter.rb:194 priority 6.23; affects `T.any` in 2 signature slot(s), 2527 observed call(s) + - src/mir/thunk_transform/recursive_splitter.rb:194 node (observed AST::BinaryOp, AST::FuncCall, AST::Identifier, AST::Literal, Array, ...) + - src/mir/thunk_transform/recursive_splitter.rb:194 names_set (observed Array, Set) +- src/annotator.rb:6256 priority 5.96; affects `T.any` in 1 signature slot(s), 90338 observed call(s) + - src/annotator.rb:6256 node (observed AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::CallSiteOverride, AST::ConcurrentOp, ...) +- src/annotator.rb:6272 priority 5.92; affects `T.any` in 1 signature slot(s), 82706 observed call(s) + - src/annotator.rb:6272 node (observed AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::CallSiteOverride, AST::CopyNode, ...) +- src/ast/type.rb:2292 priority 5.90; affects `T.any` in 1 signature slot(s), 79462 observed call(s) + - src/ast/type.rb:2292 input (observed Symbol, Type) +- src/annotator.rb:6088 priority 5.82; affects `T.any` in 1 signature slot(s), 66242 observed call(s) + - src/annotator.rb:6088 v (observed AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BreakNode, ...) +- src/ast/source_error.rb:31 priority 5.64; affects `T.any` in 2 signature slot(s), 967 observed call(s) + - src/ast/source_error.rb:31 node_or_token (observed AST::AllOp, AST::AnyOp, AST::Assert, AST::Assignment, AST::AverageOp, ...) + - src/ast/source_error.rb:31 code_or_message (observed String, Symbol) +- src/ast/ast.rb:60 priority 5.59; affects `T.any` in 1 signature slot(s), 38889 observed call(s) + - src/ast/ast.rb:60 body (observed AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, ...) +- src/ast/ast.rb:293 priority 5.57; affects `T.any` in 1 signature slot(s), 37376 observed call(s) + - src/ast/ast.rb:293 val (observed FalseClass, TrueClass) +- src/ast/type.rb:2305 priority 5.53; affects `T.any` in 1 signature slot(s), 33969 observed call(s) + - src/ast/type.rb:2305 effective_type (observed FunctionSignature, Symbol, Type) +- src/backends/zig_type_mapper.rb:38 priority 5.49; affects `T.any` in 1 signature slot(s), 31200 observed call(s) + - src/backends/zig_type_mapper.rb:38 type (observed FunctionSignature, String, Symbol, Type) +- src/annotator.rb:6519 priority 5.47; affects `T.any` in 1 signature slot(s), 29237 observed call(s) + - src/annotator.rb:6519 type_info (observed Symbol, Type) +- src/annotator.rb:6616 priority 5.47; affects `T.any` in 1 signature slot(s), 29237 observed call(s) + - src/annotator.rb:6616 type_info (observed Symbol, Type) +- src/ast/scope.rb:122 priority 5.43; affects `T.any` in 1 signature slot(s), 26984 observed call(s) + - src/ast/scope.rb:122 schema (observed Hash, Schemas::StructSchema) +- src/annotator.rb:5830 priority 5.38; affects `T.any` in 1 signature slot(s), 24148 observed call(s) + - src/annotator.rb:5830 node (observed AST::BgBlock, AST::BinaryOp, AST::CapabilityWrap, AST::CopyNode, AST::FuncCall, ...) +- src/annotator-helpers/generic_analysis.rb:71 priority 5.38; affects `T.any` in 1 signature slot(s), 24050 observed call(s) + - src/annotator-helpers/generic_analysis.rb:71 type_obj (observed Symbol, Type) +- src/annotator-helpers/generic_analysis.rb:538 priority 5.30; affects `T.any` in 1 signature slot(s), 19769 observed call(s) + - src/annotator-helpers/generic_analysis.rb:538 final_type (observed Symbol, Type) +- src/mir/alloc.rb:29 priority 5.30; affects `T.any` in 1 signature slot(s), 19769 observed call(s) + - src/mir/alloc.rb:29 final_type (observed Symbol, Type) +- src/ast/ast.rb:376 priority 5.30; affects `T.any` in 1 signature slot(s), 19769 observed call(s) + - src/ast/ast.rb:376 final_type (observed Symbol, Type) +- src/annotator-helpers/generic_analysis.rb:568 priority 5.30; affects `T.any` in 1 signature slot(s), 19769 observed call(s) + - src/annotator-helpers/generic_analysis.rb:568 final_type (observed Symbol, Type) +- src/mir/alloc.rb:45 priority 5.30; affects `T.any` in 1 signature slot(s), 19769 observed call(s) + - src/mir/alloc.rb:45 final_type (observed Symbol, Type) +- src/annotator-helpers/capabilities.rb:1325 priority 5.30; affects `T.any` in 1 signature slot(s), 19769 observed call(s) + - src/annotator-helpers/capabilities.rb:1325 final_type (observed Symbol, Type) +- src/ast/ast.rb:321 priority 5.26; affects `T.any` in 1 signature slot(s), 18262 observed call(s) + - src/ast/ast.rb:321 val (observed Symbol, Type) +- src/annotator.rb:2304 priority 5.25; affects `T.any` in 1 signature slot(s), 17722 observed call(s) + - src/annotator.rb:2304 type (observed Symbol, Type) +- src/mir/ownership_graph.rb:293 priority 5.16; affects `T.any` in 1 signature slot(s), 14475 observed call(s) + - src/mir/ownership_graph.rb:293 path (observed AST::GetField, AST::GetIndex, String) +- src/ast/type.rb:1610 priority 5.10; affects `T.any` in 1 signature slot(s), 12562 observed call(s) + - src/ast/type.rb:1610 vt (observed Hash, Type) +- src/ast/ast.rb:346 priority 5.07; affects `T.any` in 1 signature slot(s), 11790 observed call(s) + - src/ast/ast.rb:346 declared_type (observed Symbol, Type) +- src/ast/ast.rb:218 priority 5.05; affects `T.any` in 1 signature slot(s), 11102 observed call(s) + - src/ast/ast.rb:218 val (observed String, Symbol) +- src/annotator-helpers/effects.rb:1008 priority 5.04; affects `T.any` in 1 signature slot(s), 10882 observed call(s) + - src/annotator-helpers/effects.rb:1008 node (observed AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::CapabilityWrap, AST::EachOp, ...) +- src/annotator.rb:4218 priority 5.02; affects `T.any` in 1 signature slot(s), 10378 observed call(s) + - src/annotator.rb:4218 expected_type (observed Symbol, Type) +- src/annotator-helpers/function_context.rb:16 priority 4.97; affects `T.any` in 1 signature slot(s), 9387 observed call(s) + - src/annotator-helpers/function_context.rb:16 return_type (observed Symbol, Type) +- src/annotator-helpers/function_analysis.rb:887 priority 4.93; affects `T.any` in 1 signature slot(s), 8606 observed call(s) + - src/annotator-helpers/function_analysis.rb:887 return_type (observed Symbol, Type) +- src/mir/fsm_transform/liveness.rb:240 priority 4.93; affects `T.any` in 1 signature slot(s), 8605 observed call(s) + - src/mir/fsm_transform/liveness.rb:240 node (observed AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, ...) +- src/mir/effect_set.rb:43 priority 4.92; affects `T.any` in 1 signature slot(s), 8320 observed call(s) + - src/mir/effect_set.rb:43 effects (observed Array, Set) +- src/ast/schemas.rb:108 priority 4.89; affects `T.any` in 1 signature slot(s), 7817 observed call(s) + - src/ast/schemas.rb:108 schema (observed Hash, Schemas::StructSchema) +- src/annotator.rb:3278 priority 4.81; affects `T.any` in 1 signature slot(s), 6383 observed call(s) + - src/annotator.rb:3278 target_type (observed Symbol, Type) +- src/annotator-helpers/fixable_helpers.rb:59 priority 4.78; affects `T.any` in 2 signature slot(s), 238 observed call(s) + - src/annotator-helpers/fixable_helpers.rb:59 input (observed String, Symbol) + - src/annotator-helpers/fixable_helpers.rb:59 candidates (observed Array, Set) +- src/mir/mir_lowering.rb:782 priority 4.74; affects `T.any` in 1 signature slot(s), 5547 observed call(s) + - src/mir/mir_lowering.rb:782 to_type (observed FunctionSignature, Symbol) +- src/mir/mir_lowering.rb:698 priority 4.74; affects `T.any` in 1 signature slot(s), 5535 observed call(s) + - src/mir/mir_lowering.rb:698 spec (observed Hash, Symbol) + +## `T.any` Downgrades By Signature +- signature downgrade: an individual param or return slot where union evidence exists but the report kept the current `T.untyped` signature +- src/mir/fsm_ops.rb:141 value: observed FsmOps::AllocExpr, FsmOps::CallExpr; kept as `T.untyped` +- src/mir/fsm_ops.rb:177 expr: observed FsmOps::ArgRef, FsmOps::CallExpr; kept as `T.untyped` +- src/ast/type.rb:199 raw_input: observed FunctionSignature, String, Symbol, Type; kept as `T.untyped` +- src/ast/lexer.rb:299 val: observed Float, Integer, String; kept as `T.untyped` +- src/ast/parser.rb:1910 lhs: observed AST::BinaryOp, AST::CapabilityWrap, AST::CloneNode, AST::CopyNode, AST::FuncCall, AST::GetField, AST::GetIndex, AST::HashLit, AST::Identifier, AST::ListLit, AST::Literal, AST::MethodCall, AST::NextExpr, AST::RangeLit, AST::StaticCall, AST::StructLit, AST::UnaryOp, AST::UnionVariantLit; kept as `T.untyped` +- src/ast/scope.rb:24 reg: observed AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl; kept as `T.untyped` +- src/ast/scope.rb:24 type: observed FunctionSignature, String, Symbol, Type; kept as `T.untyped` +- src/ast/scope.rb:24 is_mutable: observed FalseClass, Lexer::Token, TrueClass; kept as `T.untyped` +- src/ast/symbol_entry.rb:151 reg: observed AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl, String, Symbol; kept as `T.untyped` +- src/ast/symbol_entry.rb:151 type: observed FunctionSignature, String, Symbol, Type; kept as `T.untyped` +- src/ast/symbol_entry.rb:151 mutable: observed FalseClass, Lexer::Token, TrueClass; kept as `T.untyped` +- src/ast/scope.rb:122 schema: observed Hash, Schemas::StructSchema; kept as `T.untyped` +- src/annotator.rb:345 node: observed AST::Assert, AST::Assignment, AST::BenchmarkStmt, AST::BgBlock, AST::BgStreamBlock, AST::BinaryOp, AST::BindExpr, AST::BlockExpr, AST::BreakNode, AST::CallSiteOverride, AST::CapabilityWrap, AST::Cast, AST::CloneNode, AST::ContinueNode, AST::CopyNode, AST::DoBlock, AST::EnumDef, AST::ExternStructDecl, AST::ForEach, AST::ForRange, AST::FreezeNode, AST::FuncCall, AST::FunctionDef, AST::GetField, AST::GetIndex, AST::HashLit, AST::Identifier, AST::IfBind, AST::IfStatement, AST::LambdaLit, AST::LinkNode, AST::ListLit, AST::Literal, AST::MatchStatement, AST::MethodCall, AST::MoveNode, AST::NextExpr, AST::OptionalUnwrap, AST::OrBreak, AST::OrExit, AST::OrPass, AST::OrPrune, AST::OrRaise, AST::PassStmt, AST::ProfileStmt, AST::Program, AST::Raise, AST::RangeLit, AST::ResolveNode, AST::ReturnNode, AST::ShareNode, AST::Slice, AST::SmashStmt, AST::StaticCall, AST::StructDef, AST::StructLit, AST::StubDecl, AST::SyncPolicyDecl, AST::TestBlock, AST::ThenChain, AST::UnaryOp, AST::UnionDef, AST::UnionVariantLit, AST::VarDecl, AST::WhileBindLoop, AST::WhileLoop, AST::WithBlock, AST::YieldExpr; kept as `T.untyped` +- src/ast/ast.rb:308 val: observed FunctionSignature, Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/function_signature.rb:66 return_type: observed Hash, Proc, Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/function_signature.rb:66 return_lifetime: observed Array, String; kept as `T.untyped` +- src/annotator.rb:2304 type: observed Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/function_context.rb:16 return_type: observed Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/function_analysis.rb:11 node: observed AST::FunctionDef, AST::LambdaLit; kept as `T.untyped` +- src/annotator-helpers/function_analysis.rb:11 body: observed AST::BinaryOp, AST::Identifier, AST::Literal, Array; kept as `T.untyped` +- src/annotator-helpers/function_analysis.rb:11 declared_return: observed Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/function_analysis.rb:814 node: observed AST::FunctionDef, AST::LambdaLit; kept as `T.untyped` +- src/annotator-helpers/function_analysis.rb:719 node: observed AST::FunctionDef, AST::LambdaLit; kept as `T.untyped` +- src/annotator-helpers/function_analysis.rb:854 node: observed AST::FunctionDef, AST::LambdaLit; kept as `T.untyped` +- src/annotator.rb:2690 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator.rb:5603 node: observed AST::Assignment, AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator.rb:5830 node: observed AST::BgBlock, AST::BinaryOp, AST::CapabilityWrap, AST::CopyNode, AST::FuncCall, AST::GetField, AST::GetIndex, AST::Identifier, AST::LambdaLit, AST::LinkNode, AST::Literal, AST::MethodCall, AST::MoveNode, AST::NextExpr, AST::RangeLit, AST::ShareNode, AST::StructLit, AST::UnaryOp, AST::UnionVariantLit, String; kept as `T.untyped` +- src/annotator.rb:5460 node: observed AST::Assignment, AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator.rb:5530 node: observed AST::Assignment, AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator-helpers/generic_analysis.rb:523 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator.rb:2636 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/ast/ast.rb:346 declared_type: observed Symbol, Type; kept as `T.untyped` +- src/ast/type.rb:2305 node: observed AST::BgBlock, AST::BgStreamBlock, AST::BinaryOp, AST::BlockExpr, AST::CapabilityWrap, AST::Cast, AST::CloneNode, AST::CopyNode, AST::FreezeNode, AST::FuncCall, AST::GetField, AST::GetIndex, AST::HashLit, AST::Identifier, AST::IfStatement, AST::LambdaLit, AST::LinkNode, AST::ListLit, AST::Literal, AST::MatchStatement, AST::MethodCall, AST::MoveNode, AST::NextExpr, AST::RangeLit, AST::ResolveNode, AST::ShareNode, AST::Slice, AST::StaticCall, AST::StructLit, AST::UnaryOp, AST::UnionVariantLit; kept as `T.untyped` +- src/ast/type.rb:2305 effective_type: observed FunctionSignature, Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/generic_analysis.rb:538 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator-helpers/generic_analysis.rb:538 final_type: observed Symbol, Type; kept as `T.untyped` +- src/mir/alloc.rb:29 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/mir/alloc.rb:29 final_type: observed Symbol, Type; kept as `T.untyped` +- src/ast/ast.rb:376 final_type: observed Symbol, Type; kept as `T.untyped` +- src/mir/alloc.rb:16 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator-helpers/generic_analysis.rb:568 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator-helpers/generic_analysis.rb:568 final_type: observed Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/generic_analysis.rb:615 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator-helpers/generic_analysis.rb:682 expr: observed AST::Assignment, AST::BgBlock, AST::BgStreamBlock, AST::BinaryOp, AST::BindExpr, AST::BlockExpr, AST::CapabilityWrap, AST::Cast, AST::CloneNode, AST::CopyNode, AST::ForRange, AST::FreezeNode, AST::FuncCall, AST::GetField, AST::GetIndex, AST::HashLit, AST::Identifier, AST::IfStatement, AST::LambdaLit, AST::LinkNode, AST::ListLit, AST::Literal, AST::MatchStatement, AST::MethodCall, AST::MoveNode, AST::NextExpr, AST::RangeLit, AST::ResolveNode, AST::ShareNode, AST::Slice, AST::StaticCall, AST::StructLit, AST::ThenChain, AST::UnaryOp, AST::UnionVariantLit, AST::WhileLoop, AST::WithBlock; kept as `T.untyped` +- src/annotator.rb:6486 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/mir/alloc.rb:45 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/mir/alloc.rb:45 final_type: observed Symbol, Type; kept as `T.untyped` +- src/annotator-helpers/capabilities.rb:57 node: observed AST::BindExpr, AST::VarDecl; kept as `T.untyped` +- src/annotator.rb:6519 node: observed AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl; kept as `T.untyped` +- src/annotator.rb:6519 type_info: observed Symbol, Type; kept as `T.untyped` + +## Return Origin Pressure +- origin: the expression or forwarded callee that currently determines a method's return type +- pressure: how many untyped returns could be improved by fixing the same origin +- cascading return fix: a return annotation that can unlock other forwarded-return annotations after it becomes typed +- blocked: 217 +- weak: 42 +- strong: 14 + +Top root return blockers: +- untyped callee let; affects 30 return(s); 30 source occurrence(s) + - src/ast/ast.rb:216 `AST::Locatable#zig_pattern` + - src/ast/ast.rb:218 `AST::Locatable#zig_pattern=` + - src/ast/ast.rb:221 `AST::Locatable#matched_stdlib_def` + - src/ast/ast.rb:223 `AST::Locatable#matched_stdlib_def=` +- untyped callee each; affects 21 return(s); 32 source occurrence(s); suggestion review as receiver-returning iterator; callers probably want explicit return value + - src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` +- untyped callee fixable!; affects 16 return(s); 16 source occurrence(s) + - src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!` + - src/annotator-helpers/fixable_helpers.rb:740 `FixableHelper#emit_match_partial_fix!` + - src/annotator-helpers/fixable_helpers.rb:767 `FixableHelper#emit_return_borrowed_no_copy_error!` + - src/annotator-helpers/fixable_helpers.rb:848 `FixableHelper#emit_with_guard_all_bindings_need_as!` +- untyped callee each_value; affects 13 return(s); 13 source occurrence(s); suggestion review as receiver-returning iterator; callers probably want explicit return value + - src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` +- untyped callee each_pair; affects 10 return(s); 12 source occurrence(s); suggestion review as receiver-returning iterator; callers probably want explicit return value + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` + - src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` +- untyped callee []; affects 9 return(s); 10 source occurrence(s); suggestion review as nilable lookup or replace with fetch/typed accessor + - src/annotator.rb:5554 `SemanticAnnotator#resolve_borrow_source` + - src/annotator.rb:5554 `SemanticAnnotator#resolve_borrow_source` + - src/ast/parser.rb:114 `Parser#peek_at` + - src/ast/scope.rb:130 `Scope#resolve_type_definition` +- untyped callee call; affects 6 return(s); 7 source occurrence(s) + - src/annotator.rb:65 `SemanticAnnotator#with_conditional_context` + - src/annotator.rb:65 `SemanticAnnotator#with_conditional_context` + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` + - src/backends/pipeline_host.rb:76 `PipelineHost#with_optional_named_binding` +- untyped callee parse_suffixes; affects 5 return(s); 5 source occurrence(s) + - src/ast/parser.rb:466 `Parser#parse_literal` + - src/ast/parser.rb:1925 `Parser#parse_var_id` + - src/ast/parser.rb:2451 `Parser#parse_primary` + - src/ast/parser.rb:2499 `Parser#parse_lit` +- untyped callee send; affects 3 return(s); 3 source occurrence(s) + - src/annotator.rb:345 `SemanticAnnotator#visit` + - src/ast/parser.rb:500 `Parser#run_action` + - src/mir/thunk_transform/emit.rb:133 `ThunkTransform::Emit#render_expr` +- untyped callee name; affects 3 return(s); 3 source occurrence(s) + - src/annotator.rb:3101 `SemanticAnnotator#chain_root_name` + - src/annotator.rb:6201 `SemanticAnnotator#root_variable_name` + - src/mir/concurrency_checks.rb:242 `ConcurrencyChecks#cap_var_name` +- untyped callee instance_exec; affects 3 return(s); 3 source occurrence(s) + - src/ast/parser.rb:684 `Parser#parse_statement` + - src/ast/parser.rb:2451 `Parser#parse_primary` + - src/ast/parser.rb:3836 `Parser#parse_bg_body_stmt` +- untyped callee lower; affects 3 return(s); 3 source occurrence(s) + - src/backends/pipeline_host.rb:170 `PipelineHost#visit_mir` + - src/mir/mir_lowering.rb:164 `MIRLowering#descend` + - src/mir/mir_lowering.rb:4792 `MIRLowering#lower_binary_op` +- untyped callee first; affects 3 return(s); 3 source occurrence(s) + - src/lsp/hover.rb:63 `LSP::Hover#find_overlapping` + - src/mir/fsm_transform/recursive_splitter.rb:734 `FsmTransform::RecursiveSplitter#emit_with_fragment` + - src/mir/mir_lowering.rb:1095 `MIRLowering#lower_extern_struct` +- untyped callee each_bg_block_in_stmt; affects 3 return(s); 3 source occurrence(s); suggestion void candidate: return is only forwarded into other returns, never used as a value + - src/mir/mir_pass.rb:422 `MIRPass#insert_bg_give_suppress!` + - src/mir/mir_pass.rb:446 `MIRPass#insert_bg_resource_suppress!` + - src/mir/mir_pass.rb:589 `MIRPass#insert_bg_escape_promote!` +- untyped callee check_reads_in_expr; affects 2 return(s); 15 source occurrence(s) + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` +- untyped callee map!; affects 2 return(s); 12 source occurrence(s) + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` + - src/backends/pipeline_rewriter.rb:57 `PipelineRewriter#rewrite_children!` +- untyped callee walk_expr; affects 2 return(s); 12 source occurrence(s) + - src/mir/control_flow.rb:801 `OwnershipDataflow#collect_share_transfers_in` + - src/mir/control_flow.rb:900 `OwnershipDataflow#walk_expr` + - src/mir/control_flow.rb:900 `OwnershipDataflow#walk_expr` + - src/mir/control_flow.rb:900 `OwnershipDataflow#walk_expr` +- untyped callee walk_for_was_moved; affects 2 return(s); 8 source occurrence(s) + - src/mir/control_flow.rb:1959 `BorrowChecker#_collect_was_moved` + - src/mir/control_flow.rb:1984 `BorrowChecker#walk_for_was_moved` + - src/mir/control_flow.rb:1984 `BorrowChecker#walk_for_was_moved` + - src/mir/control_flow.rb:1984 `BorrowChecker#walk_for_was_moved` +- untyped callee parse_primary; affects 2 return(s); 3 source occurrence(s) + - src/ast/parser.rb:1786 `Parser#parse_or_rescue` + - src/ast/parser.rb:1786 `Parser#parse_or_rescue` + - src/ast/parser.rb:1879 `Parser#parse_unary` +- untyped callee emit_typo_suggestion!; affects 2 return(s); 2 source occurrence(s) + - src/annotator.rb:3347 `SemanticAnnotator#visit_GetField` + - src/ast/parser.rb:3097 `Parser#apply_capability!` +- untyped callee loop; affects 2 return(s); 2 source occurrence(s) + - src/ast/lexer.rb:170 `Lexer#read_interpolated_string` + - src/lsp/server.rb:51 `LSP::Server#run` +- untyped callee tap; affects 2 return(s); 2 source occurrence(s) + - src/ast/scope.rb:207 `Scope#mark_read` + - src/mir/mir_lowering.rb:376 `MIRLowering#lower` +- untyped callee rewrite_pipeline; affects 2 return(s); 2 source occurrence(s); suggestion void candidate: return is only forwarded into other returns, never used as a value + - src/backends/pipeline_rewriter.rb:34 `PipelineRewriter#rewrite!` + - src/backends/pipeline_rewriter.rb:105 `PipelineRewriter#rewrite_pipeline` +- untyped callee lower_intrinsic; affects 2 return(s); 2 source occurrence(s) + - src/mir/mir_lowering.rb:1697 `MIRLowering#lower_func_call` + - src/mir/mir_lowering.rb:1814 `MIRLowering#lower_method_call` +- untyped callee expr; affects 2 return(s); 2 source occurrence(s) + - src/mir/mir_lowering.rb:2088 `MIRLowering#lower_extern_arg` + - src/mir/mir_lowering.rb:7496 `MIRLowering#strip_try` +- untyped callee walk_expr_skip_copy; affects 1 return(s); 10 source occurrence(s) + - src/mir/control_flow.rb:945 `OwnershipDataflow#walk_expr_skip_copy` + - src/mir/control_flow.rb:945 `OwnershipDataflow#walk_expr_skip_copy` + - src/mir/control_flow.rb:945 `OwnershipDataflow#walk_expr_skip_copy` + - src/mir/control_flow.rb:945 `OwnershipDataflow#walk_expr_skip_copy` +- untyped callee e2_walk_calls_in_expr; affects 1 return(s); 10 source occurrence(s) + - src/mir/escape_analysis.rb:441 `EscapeAnalysis#e2_walk_calls_in_expr` + - src/mir/escape_analysis.rb:441 `EscapeAnalysis#e2_walk_calls_in_expr` + - src/mir/escape_analysis.rb:441 `EscapeAnalysis#e2_walk_calls_in_expr` + - src/mir/escape_analysis.rb:441 `EscapeAnalysis#e2_walk_calls_in_expr` +- untyped callee with_optional_named_binding; affects 1 return(s); 6 source occurrence(s); suggestion void candidate: return is only forwarded into other returns, never used as a value + - src/backends/pipeline_host.rb:3306 `PipelineHost#lower_concurrent` + - src/backends/pipeline_host.rb:3306 `PipelineHost#lower_concurrent` + - src/backends/pipeline_host.rb:3306 `PipelineHost#lower_concurrent` + - src/backends/pipeline_host.rb:3306 `PipelineHost#lower_concurrent` +- nil return at src/mir/fsm_transform/liveness.rb:240; affects 1 return(s); 6 source occurrence(s) + - src/mir/fsm_transform/liveness.rb:240 `FsmTransform::Liveness#walk_idents` + - src/mir/fsm_transform/liveness.rb:240 `FsmTransform::Liveness#walk_idents` + - src/mir/fsm_transform/liveness.rb:240 `FsmTransform::Liveness#walk_idents` + - src/mir/fsm_transform/liveness.rb:240 `FsmTransform::Liveness#walk_idents` +- nil return at src/annotator.rb:3278; affects 1 return(s); 3 source occurrence(s) + - src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` + - src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` + - src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` + +Top cascading return fixes: +- unknown expression at src/annotator.rb:3838; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/annotator.rb:3837 `SemanticAnnotator#visit_Literal` +- nil return at src/ast/fixable_error.rb:141; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/ast/fixable_error.rb:140 `FixCollector#disable!` +- nil return at src/ast/schemas.rb:138; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/ast/schemas.rb:136 `Schemas#as_resource_schema` +- nil return at src/ast/type.rb:815; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/ast/type.rb:793 `Type#fsm_foreach_descriptor` +- nil return at src/ast/type.rb:1283; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/ast/type.rb:1282 `Type#open_stream_element_type` +- nil return at src/ast/type.rb:1299; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/ast/type.rb:1298 `Type#inf_stream_element_type` +- nil return at src/ast/type.rb:1306; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/ast/type.rb:1305 `Type#stream_element_type` +- unknown expression at src/backends/pipeline_rewriter.rb:770; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/backends/pipeline_rewriter.rb:765 `PipelineRewriter#patch_chain_source!` +- nil return at src/mir/control_flow.rb:1808; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/mir/control_flow.rb:1791 `BorrowChecker#check_stmt` +- nil return at src/mir/mir_lowering.rb:5695; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/mir/mir_lowering.rb:5694 `MIRLowering#type_info_for` +- nil return at src/mir/test_lowering.rb:368; may unlock 1 return(s) (1 direct, 0 cascading), 0 possible param flow(s) + - src/mir/test_lowering.rb:364 `TestLowering#stub_intercept_for` + +Forwarded return blocker pressure: +- let: unresolved forwarded callee; affects 30 return(s), 0 possible param flow(s) + - src/ast/ast.rb:216 `AST::Locatable#zig_pattern` + - src/ast/ast.rb:218 `AST::Locatable#zig_pattern=` + - src/ast/ast.rb:221 `AST::Locatable#matched_stdlib_def` + - src/ast/ast.rb:223 `AST::Locatable#matched_stdlib_def=` +- each: typed signature T::Hash[String, LSP::DocumentStore::Document]; affects 21 return(s), 0 possible param flow(s) + - src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` +- fixable!: callee return still untyped; affects 16 return(s), 0 possible param flow(s) + - src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!` + - src/annotator-helpers/fixable_helpers.rb:740 `FixableHelper#emit_match_partial_fix!` + - src/annotator-helpers/fixable_helpers.rb:767 `FixableHelper#emit_return_borrowed_no_copy_error!` + - src/annotator-helpers/fixable_helpers.rb:848 `FixableHelper#emit_with_guard_all_bindings_need_as!` +- each_value: unresolved forwarded callee; affects 13 return(s), 0 possible param flow(s) + - src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` +- each_pair: unresolved forwarded callee; affects 10 return(s), 0 possible param flow(s) + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` + - src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` +- []: typed signature T.nilable(OwnershipGraph::Node); affects 9 return(s), 2134 possible param flow(s) + - src/annotator.rb:5554 `SemanticAnnotator#resolve_borrow_source` + - src/annotator.rb:5554 `SemanticAnnotator#resolve_borrow_source` + - src/ast/parser.rb:114 `Parser#peek_at` + - src/ast/scope.rb:130 `Scope#resolve_type_definition` +- call: typed signature FsmOps::CallExpr; affects 6 return(s), 15 possible param flow(s) + - src/annotator.rb:65 `SemanticAnnotator#with_conditional_context` + - src/annotator.rb:65 `SemanticAnnotator#with_conditional_context` + - src/backends/pipeline_generator.rb:28 `PipelineGenerator#with_pipeline_context` + - src/backends/pipeline_host.rb:76 `PipelineHost#with_optional_named_binding` +- parse_suffixes: callee return still untyped; affects 5 return(s), 0 possible param flow(s) + - src/ast/parser.rb:466 `Parser#parse_literal` + - src/ast/parser.rb:1925 `Parser#parse_var_id` + - src/ast/parser.rb:2451 `Parser#parse_primary` + - src/ast/parser.rb:2499 `Parser#parse_lit` +- name: ambiguous method name; affects 3 return(s), 349 possible param flow(s) + - src/annotator.rb:3101 `SemanticAnnotator#chain_root_name` + - src/annotator.rb:6201 `SemanticAnnotator#root_variable_name` + - src/mir/concurrency_checks.rb:242 `ConcurrencyChecks#cap_var_name` +- lower: callee return still untyped; affects 3 return(s), 49 possible param flow(s) + - src/backends/pipeline_host.rb:170 `PipelineHost#visit_mir` + - src/mir/mir_lowering.rb:164 `MIRLowering#descend` + - src/mir/mir_lowering.rb:4792 `MIRLowering#lower_binary_op` +- first: unresolved forwarded callee; affects 3 return(s), 13 possible param flow(s) + - src/lsp/hover.rb:63 `LSP::Hover#find_overlapping` + - src/mir/fsm_transform/recursive_splitter.rb:734 `FsmTransform::RecursiveSplitter#emit_with_fragment` + - src/mir/mir_lowering.rb:1095 `MIRLowering#lower_extern_struct` +- send: unresolved forwarded callee; affects 3 return(s), 1 possible param flow(s) + - src/annotator.rb:345 `SemanticAnnotator#visit` + - src/ast/parser.rb:500 `Parser#run_action` + - src/mir/thunk_transform/emit.rb:133 `ThunkTransform::Emit#render_expr` +- each_bg_block_in_stmt: callee return still untyped; affects 3 return(s), 0 possible param flow(s) + - src/mir/mir_pass.rb:422 `MIRPass#insert_bg_give_suppress!` + - src/mir/mir_pass.rb:446 `MIRPass#insert_bg_resource_suppress!` + - src/mir/mir_pass.rb:589 `MIRPass#insert_bg_escape_promote!` +- instance_exec: unresolved forwarded callee; affects 3 return(s), 0 possible param flow(s) + - src/ast/parser.rb:684 `Parser#parse_statement` + - src/ast/parser.rb:2451 `Parser#parse_primary` + - src/ast/parser.rb:3836 `Parser#parse_bg_body_stmt` +- expr: unresolved forwarded callee; affects 2 return(s), 45 possible param flow(s) + - src/mir/mir_lowering.rb:2088 `MIRLowering#lower_extern_arg` + - src/mir/mir_lowering.rb:7496 `MIRLowering#strip_try` +- tap: unresolved forwarded callee; affects 2 return(s), 1 possible param flow(s) + - src/ast/scope.rb:207 `Scope#mark_read` + - src/mir/mir_lowering.rb:376 `MIRLowering#lower` +- check_reads_in_expr: callee return still untyped; affects 2 return(s), 0 possible param flow(s) + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` + - src/mir/control_flow.rb:1050 `UseAfterMoveChecker#check_stmt_reads` +- emit_typo_suggestion!: typed signature NilClass; affects 2 return(s), 0 possible param flow(s) + - src/annotator.rb:3347 `SemanticAnnotator#visit_GetField` + - src/ast/parser.rb:3097 `Parser#apply_capability!` +- loop: unresolved forwarded callee; affects 2 return(s), 0 possible param flow(s) + - src/ast/lexer.rb:170 `Lexer#read_interpolated_string` + - src/lsp/server.rb:51 `LSP::Server#run` +- lower_intrinsic: callee return still untyped; affects 2 return(s), 0 possible param flow(s) + - src/mir/mir_lowering.rb:1697 `MIRLowering#lower_func_call` + - src/mir/mir_lowering.rb:1814 `MIRLowering#lower_method_call` + +High-impact root return actions: +- untyped callee each: review as receiver-returning iterator; callers probably want explicit return value; may unblock 21 return(s) +- untyped callee each_value: review as receiver-returning iterator; callers probably want explicit return value; may unblock 13 return(s) +- untyped callee each_pair: review as receiver-returning iterator; callers probably want explicit return value; may unblock 10 return(s) +- untyped callee []: review as nilable lookup or replace with fetch/typed accessor; may unblock 9 return(s) +- untyped callee each_bg_block_in_stmt: void candidate: return is only forwarded into other returns, never used as a value; may unblock 3 return(s) +- untyped callee rewrite_pipeline: void candidate: return is only forwarded into other returns, never used as a value; may unblock 2 return(s) +- untyped callee with_optional_named_binding: void candidate: return is only forwarded into other returns, never used as a value; may unblock 1 return(s) +- untyped callee with_pending: void candidate: return is only forwarded into other returns, never used as a value; may unblock 1 return(s) +- untyped callee each at src/annotator-helpers/auto_inference.rb:114: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each_value at src/annotator-helpers/auto_inference.rb:116: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each at src/annotator-helpers/auto_inference.rb:577: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each_value at src/annotator-helpers/auto_inference.rb:579: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each_pair at src/annotator-helpers/auto_inference.rb:582: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each at src/annotator-helpers/auto_inference.rb:595: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each at src/annotator-helpers/auto_inference.rb:602: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each_value at src/annotator-helpers/auto_inference.rb:604: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each_pair at src/annotator-helpers/auto_inference.rb:607: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each at src/annotator-helpers/auto_inference.rb:737: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each_value at src/annotator-helpers/auto_inference.rb:739: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) +- untyped callee each_pair at src/annotator-helpers/auto_inference.rb:742: review as receiver-returning iterator; callers probably want explicit return value; may unblock 1 return(s) + +Blocked return examples: +- src/annotator-helpers/auto_inference.rb:429 `AutoUnifier#widen_byte_array_to_string`: no blocker recorded +- src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls`: untyped callee walk_for_shape_decls at src/annotator-helpers/auto_inference.rb:573 +- src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk`: untyped callee each at src/annotator-helpers/auto_inference.rb:595 +- src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls`: untyped callee walk_for_local_decls at src/annotator-helpers/auto_inference.rb:733 +- src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops`: untyped callee walk_binops at src/annotator-helpers/auto_inference.rb:757 +- src/annotator-helpers/effects.rb:671 `EffectTracker#scan_suspend_points`: untyped callee each at src/annotator-helpers/effects.rb:677 +- src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!`: untyped callee each at src/annotator-helpers/effects.rb:1015 +- src/annotator-helpers/fixable_helpers.rb:528 `FixableHelper#emit_overflow_suffix_fix!`: untyped callee fixable! at src/annotator-helpers/fixable_helpers.rb:538 +- src/annotator-helpers/fixable_helpers.rb:1458 `FixableHelper#emit_auto_resolved_finding!`: untyped callee fixable! at src/annotator-helpers/fixable_helpers.rb:1466 +- src/annotator-helpers/fixable_helpers.rb:1482 `FixableHelper#emit_auto_shape_resolved_finding!`: untyped callee fixable! at src/annotator-helpers/fixable_helpers.rb:1489 +- src/annotator-helpers/fixable_helpers.rb:1524 `FixableHelper#emit_auto_ambiguity_finding!`: untyped callee fixable! at src/annotator-helpers/fixable_helpers.rb:1543 +- src/annotator-helpers/fixable_helpers.rb:1555 `FixableHelper#emit_auto_unresolved_finding!`: untyped callee fixable! at src/annotator-helpers/fixable_helpers.rb:1576 + +## Input Param Origin Backflow +- origin: the caller-side expression passed into a parameter slot +- backflow: tracing weak or untyped parameter pressure backward from the callee slot to the caller expression that supplied it +- return-to-param flow: a method return value that is later passed into another method's parameter +- Origins indexed: 49185 +- static: 21509 +- local: 11199 +- untyped_return: 7523 +- unknown: 4857 +- typed_return: 4097 + +Return-to-param flows: +- untyped: 2526 flow(s); src/annotator-helpers/auto_inference.rb:45 -> [](1); src/annotator-helpers/auto_inference.rb:50 -> [](0); src/annotator-helpers/auto_inference.rb:50 -> [](1); src/annotator-helpers/auto_inference.rb:58 -> [](0) +- []: 2194 flow(s); src/annotator-helpers/auto_inference.rb:45 -> params(fn_nodes); src/annotator-helpers/auto_inference.rb:50 -> let(1); src/annotator-helpers/auto_inference.rb:58 -> nilable(0); src/annotator-helpers/auto_inference.rb:64 -> returns(0) +- new: 1330 flow(s); src/annotator-helpers/auto_inference.rb:81 -> []=(1); src/annotator-helpers/auto_inference.rb:88 -> []=(1); src/annotator-helpers/auto_inference.rb:376 -> []=(1); src/annotator-helpers/auto_inference.rb:383 -> []=(1) +- nilable: 894 flow(s); src/annotator-helpers/auto_inference.rb:58 -> let(1); src/annotator-helpers/auto_inference.rb:97 -> params(t); src/annotator-helpers/auto_inference.rb:107 -> params(current_fn); src/annotator-helpers/auto_inference.rb:134 -> returns(0) +- name: 351 flow(s); src/annotator-helpers/auto_inference.rb:150 -> [](0); src/annotator-helpers/auto_inference.rb:209 -> []=(0); src/annotator-helpers/auto_inference.rb:214 -> [](0); src/annotator-helpers/auto_inference.rb:285 -> []=(0) +- value: 250 flow(s); src/annotator-helpers/auto_inference.rb:169 -> <<(0); src/annotator-helpers/auto_inference.rb:193 -> empty_list_lit?(0); src/annotator-helpers/auto_inference.rb:196 -> empty_hash_lit?(0); src/annotator-helpers/auto_inference.rb:207 -> <<(0) +- body: 201 flow(s); src/annotator-helpers/auto_inference.rb:550 -> walk(0); src/annotator-helpers/auto_inference.rb:557 -> walk_for_shape_decls(0); src/annotator-helpers/auto_inference.rb:705 -> walk_binops(0); src/annotator-helpers/auto_inference.rb:719 -> walk_for_local_decls(0) +- to_s: 171 flow(s); src/annotator-helpers/capabilities.rb:560 -> [](0); src/annotator-helpers/capabilities.rb:1242 -> [](0); src/annotator-helpers/capabilities.rb:1292 -> [](0); src/annotator-helpers/capabilities.rb:1295 -> [](0) +- length: 152 flow(s); src/annotator-helpers/capabilities.rb:293 -> new(length); src/annotator-helpers/fixable_helpers.rb:36 -> new(length); src/annotator-helpers/fixable_helpers.rb:110 -> new(length); src/annotator-helpers/fixable_helpers.rb:149 -> new(length) +- must: 144 flow(s); src/annotator-helpers/auto_inference.rb:140 -> record_return(1); src/annotator-helpers/effects.rb:1119 -> fixable!(message); src/annotator-helpers/fixable_helpers.rb:619 -> fixable!(message); src/annotator-helpers/fixable_helpers.rb:636 -> fixable!(message) +- left: 115 flow(s); src/annotator-helpers/auto_inference.rb:756 -> walk_binops(0); src/annotator-helpers/generic_analysis.rb:668 -> find_container_source(0); src/annotator-helpers/generic_analysis.rb:689 -> has_heap_promoted_call?(0); src/annotator-helpers/pipe_analysis.rb:20 -> visit(0) +- resolved: 113 flow(s); src/annotator-helpers/capabilities.rb:196 -> emit_with_materialized_needs_tense!(2); src/annotator-helpers/function_analysis.rb:456 -> error!(expected); src/annotator-helpers/function_analysis.rb:456 -> error!(actual); src/annotator-helpers/function_analysis.rb:478 -> ==(0) +- token: 111 flow(s); src/annotator-helpers/capabilities.rb:329 -> emit_typo_suggestion!(0); src/annotator-helpers/capabilities.rb:338 -> emit_typo_suggestion!(0); src/annotator-helpers/capabilities.rb:348 -> emit_typo_suggestion!(0); src/annotator-helpers/capabilities.rb:724 -> new(0) +- expression: 88 flow(s); src/annotator-helpers/pipe_analysis.rb:283 -> visit(0); src/annotator-helpers/pipe_analysis.rb:335 -> visit(0); src/annotator-helpers/pipe_analysis.rb:363 -> visit(0); src/annotator-helpers/pipe_analysis.rb:441 -> visit(0) +- full_type: 87 flow(s); src/annotator-helpers/capabilities.rb:105 -> must(0); src/annotator-helpers/capabilities.rb:120 -> must(0); src/annotator-helpers/capabilities.rb:644 -> []=(1); src/annotator-helpers/function_analysis.rb:183 -> error_union_type=(0) +- freeze: 77 flow(s); src/annotator-helpers/capabilities.rb:19 -> let(0); src/annotator-helpers/capabilities.rb:27 -> let(0); src/annotator-helpers/effects.rb:66 -> let(0); src/annotator-helpers/effects.rb:68 -> let(0) +- +: 75 flow(s); src/annotator-helpers/fixable_helpers.rb:62 -> let(0); src/annotator-helpers/fixable_helpers.rb:83 -> [](0); src/annotator-helpers/fixable_helpers.rb:190 -> anchor_at(1); src/annotator-helpers/fixable_helpers.rb:566 -> [](0) +- right: 71 flow(s); src/annotator-helpers/auto_inference.rb:757 -> walk_binops(0); src/annotator-helpers/pipe_analysis.rb:22 -> higher_order_list_op?(0); src/annotator-helpers/pipe_analysis.rb:287 -> error!(0); src/annotator-helpers/pipe_analysis.rb:339 -> error!(0) +- to_sym: 61 flow(s); src/annotator-helpers/function_analysis.rb:139 -> <<(0); src/annotator-helpers/function_analysis.rb:910 -> lookup_type_schema(0); src/annotator-helpers/generic_analysis.rb:658 -> lookup_type_schema(0); src/annotator-helpers/method_analysis.rb:88 -> send(1) +- target: 60 flow(s); src/annotator-helpers/capabilities.rb:724 -> new(1); src/annotator-helpers/generic_analysis.rb:648 -> root_variable_name(0); src/annotator-helpers/generic_analysis.rb:664 -> root_variable_name(0); src/annotator-helpers/generic_analysis.rb:672 -> find_container_source(0) + +## Foreign Scalar Inputs Into Object-Typed Params +This ranks caller origins where `String`/`Symbol` values flow into params that also receive object instances. It skips `src/tools` origins unless `NIL_KILL_FOREIGN_INCLUDE_TOOLS=1`. +- src/annotator.rb:250 `def program_has_auto?(node)`; 774565 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:250 `SemanticAnnotator#program_has_auto?` node: String, Symbol into AST::AllOp, AST::AnyOp, AST::Assert, AST::Assignment, AST::AverageOp (774565); trace src/annotator.rb:250 +- src/ast/type.rb:199 `def initialize(raw_input, ownership: nil, sync: nil, layout: nil, location: nil, collection: nil, shard_count: nil, stripe_count: nil, observable: nil, observab`; 480142 foreign scalar call(s), affects 1 slot(s) + - src/ast/type.rb:199 `Type#initialize` raw_input: String, Symbol into FunctionSignature, Type (480142); trace src/ast/type.rb:199 +- src/ast/symbol_entry.rb:151 `def initialize(reg:, type:, mutable:, storage:, sync: nil, layout: nil, rebindable: false,`; 455558 foreign scalar call(s), affects 2 slot(s) + - src/ast/symbol_entry.rb:151 `SymbolEntry#initialize` type: String, Symbol into FunctionSignature, Type (455545); trace src/ast/symbol_entry.rb:151 + - src/ast/symbol_entry.rb:151 `SymbolEntry#initialize` reg: String, Symbol into AST::BindExpr, AST::LetBinding, AST::StubDecl, AST::VarDecl (13); trace src/ast/symbol_entry.rb:151 +- src/ast/scope.rb:24 `def declare(name, reg, type, is_mutable = true, is_rebindable = false, size = nil, storage = :stack, capabilities = Set.new, _borrowed_paths = [], sync: nil, la`; 455514 foreign scalar call(s), affects 1 slot(s) + - src/ast/scope.rb:24 `Scope#declare` type: String, Symbol into FunctionSignature, Type (455514); trace src/ast/scope.rb:24 +- src/annotator-helpers/effects.rb:671 `def scan_suspend_points(node, fn_node, points)`; 224608 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/effects.rb:671 `EffectTracker#scan_suspend_points` node: String, Symbol into AST::AllOp, AST::AnyOp, AST::Assert, AST::Assignment, AST::AverageOp (224608); trace src/annotator-helpers/effects.rb:671 +- src/ast/type.rb:355 `def ==(other)`; 130397 foreign scalar call(s), affects 1 slot(s) + - src/ast/type.rb:355 Type#== other: Symbol into Type (130397); trace src/ast/type.rb:355 +- src/mir/control_flow.rb:1657 `def self.walk_all_nodes(nodes, visited = Set.new, &block)`; 125432 foreign scalar call(s), affects 1 slot(s) + - src/mir/control_flow.rb:1657 `LoopFrameAnalysis#walk_all_nodes` nodes: String, Symbol into AST::AllOp, AST::AnyOp, AST::AverageOp, AST::BatchWindowOp, AST::BgBlock (125432); trace src/mir/control_flow.rb:1657 +- src/ast/ast.rb:308 `def full_type=(val)`; 118920 foreign scalar call(s), affects 1 slot(s) + - src/ast/ast.rb:308 `AST::Locatable#full_type=` val: Symbol into FunctionSignature, Type (118920); trace src/ast/ast.rb:308 +- src/ast/type.rb:2297 `def is_safe_autocast?(source_type, target_type)`; 60855 foreign scalar call(s), affects 2 slot(s) + - src/ast/type.rb:2297 `TypeHelper#is_safe_autocast?` source_type: Symbol into Type (31337); trace src/ast/type.rb:2297 + - src/ast/type.rb:2297 `TypeHelper#is_safe_autocast?` target_type: Symbol into Type (29518); trace src/ast/type.rb:2297 +- src/ast/type.rb:2292 `def to_type(input)`; 60855 foreign scalar call(s), affects 1 slot(s) + - src/ast/type.rb:2292 `TypeHelper#to_type` input: Symbol into Type (60855); trace src/ast/type.rb:2292 +- src/annotator.rb:6256 `def collect_self_calls(node, fn_name, out = [])`; 49450 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:6256 `SemanticAnnotator#collect_self_calls` node: String, Symbol into AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::CallSiteOverride, AST::ConcurrentOp (49450); trace src/annotator.rb:6256 +- src/annotator.rb:6272 `def collect_returns(node, out = [])`; 45581 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:6272 `SemanticAnnotator#collect_returns` node: String, Symbol into AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::CallSiteOverride, AST::CopyNode (45581); trace src/annotator.rb:6272 +- src/annotator.rb:6088 `def collect_bg_sources_walk(v)`; 35076 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:6088 `SemanticAnnotator#collect_bg_sources_walk` v: String, Symbol into AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BreakNode (35076); trace src/annotator.rb:6088 +- src/annotator.rb:5830 `def get_path_to_root(node)`; 24148 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:5830 `SemanticAnnotator#get_path_to_root` node: String into AST::BgBlock, AST::BinaryOp, AST::CapabilityWrap, AST::CopyNode, AST::FuncCall (24148); trace src/annotator.rb:5830 +- src/ast/type.rb:2305 `def check_prefixed_int_range!(node, effective_type)`; 19914 foreign scalar call(s), affects 1 slot(s) + - src/ast/type.rb:2305 `TypeHelper#check_prefixed_int_range!` effective_type: Symbol into FunctionSignature, Type (19914); trace src/ast/type.rb:2305 +- src/mir/ownership_graph.rb:293 `def [](path)`; 14475 foreign scalar call(s), affects 1 slot(s) + - src/mir/ownership_graph.rb:293 OwnershipGraph#[] path: String into AST::GetField, AST::GetIndex (14475); trace src/mir/ownership_graph.rb:293 +- src/annotator-helpers/generic_analysis.rb:538 `def propagate_declared_type_to_value!(node, final_type)`; 12386 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/generic_analysis.rb:538 `GenericAnalysis#propagate_declared_type_to_value!` final_type: Symbol into Type (12386); trace src/annotator-helpers/generic_analysis.rb:538 +- src/mir/alloc.rb:29 `def finalize_decl_storage!(node, final_type)`; 12386 foreign scalar call(s), affects 1 slot(s) + - src/mir/alloc.rb:29 `AllocHelper#finalize_decl_storage!` final_type: Symbol into Type (12386); trace src/mir/alloc.rb:29 +- src/ast/ast.rb:376 `def finalize_storage!(final_type, &schema_lookup)`; 12386 foreign scalar call(s), affects 1 slot(s) + - src/ast/ast.rb:376 `AST::Locatable#finalize_storage!` final_type: Symbol into Type (12386); trace src/ast/ast.rb:376 +- src/annotator-helpers/generic_analysis.rb:568 `def propagate_collection_metadata!(node, final_type)`; 12386 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/generic_analysis.rb:568 `GenericAnalysis#propagate_collection_metadata!` final_type: Symbol into Type (12386); trace src/annotator-helpers/generic_analysis.rb:568 +- src/mir/alloc.rb:45 `def resolve_resource_close(node, final_type)`; 12386 foreign scalar call(s), affects 1 slot(s) + - src/mir/alloc.rb:45 `AllocHelper#resolve_resource_close` final_type: Symbol into Type (12386); trace src/mir/alloc.rb:45 +- src/annotator-helpers/capabilities.rb:1325 `def record_capability_binding(var_name, node, final_type, storage)`; 12386 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/capabilities.rb:1325 `CapabilityAudit#record_capability_binding` final_type: Symbol into Type (12386); trace src/annotator-helpers/capabilities.rb:1325 +- src/annotator-helpers/function_signature.rb:66 `def initialize(params:, return_type:, return_lifetime: nil, visibility: nil,`; 8635 foreign scalar call(s), affects 2 slot(s) + - src/annotator-helpers/function_signature.rb:66 `FunctionSignature#initialize` return_type: Symbol into Hash, Proc, Type (8599); trace src/annotator-helpers/function_signature.rb:66 + - src/annotator-helpers/function_signature.rb:66 `FunctionSignature#initialize` return_lifetime: String into Array (36); trace src/annotator-helpers/function_signature.rb:66 +- src/annotator-helpers/effects.rb:1008 `def validate_tight_node!(node, loop_node)`; 5679 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/effects.rb:1008 `EffectTracker#validate_tight_node!` node: String, Symbol into AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::CapabilityWrap, AST::EachOp (5679); trace src/annotator-helpers/effects.rb:1008 +- src/mir/mir_lowering.rb:782 `def mir_cast(mir_node, from_type, to_type)`; 5541 foreign scalar call(s), affects 1 slot(s) + - src/mir/mir_lowering.rb:782 `MIRLowering#mir_cast` to_type: Symbol into FunctionSignature (5541); trace src/mir/mir_lowering.rb:782 +- src/mir/fsm_transform/liveness.rb:240 `def walk_idents(node, &block)`; 5524 foreign scalar call(s), affects 1 slot(s) + - src/mir/fsm_transform/liveness.rb:240 `FsmTransform::Liveness#walk_idents` node: String, Symbol into AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr (5524); trace src/mir/fsm_transform/liveness.rb:240 +- src/ast/ast.rb:321 `def coerced_type=(val)`; 5504 foreign scalar call(s), affects 1 slot(s) + - src/ast/ast.rb:321 `AST::Locatable#coerced_type=` val: Symbol into Type (5504); trace src/ast/ast.rb:321 +- src/backends/zig_type_mapper.rb:38 `def transpile_type(type, is_param: false, is_field: false)`; 5188 foreign scalar call(s), affects 1 slot(s) + - src/backends/zig_type_mapper.rb:38 `ZigTypeMapper#transpile_type` type: String, Symbol into FunctionSignature, Type (5188); trace src/backends/zig_type_mapper.rb:38 +- src/annotator.rb:3278 `def validate_assignment_type(node, target_type, value_type)`; 4616 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:3278 `SemanticAnnotator#validate_assignment_type` target_type: Symbol into Type (4616); trace src/annotator.rb:3278 +- src/ast/ast.rb:346 `def coerce!(declared_type)`; 4403 foreign scalar call(s), affects 1 slot(s) + - src/ast/ast.rb:346 `AST::Locatable#coerce!` declared_type: Symbol into Type (4403); trace src/ast/ast.rb:346 +- src/mir/mir_lowering.rb:698 `def coerce_stdlib_arg(arg_zig, spec)`; 4158 foreign scalar call(s), affects 1 slot(s) + - src/mir/mir_lowering.rb:698 `MIRLowering#coerce_stdlib_arg` spec: Symbol into Hash (4158); trace src/mir/mir_lowering.rb:698 +- src/annotator-helpers/generic_analysis.rb:71 `def validate_type_annotation!(node, type_obj, is_param: false)`; 2584 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/generic_analysis.rb:71 `GenericAnalysis#validate_type_annotation!` type_obj: Symbol into Type (2584); trace src/annotator-helpers/generic_analysis.rb:71 +- src/annotator.rb:6555 `def move_if_not_copyable!(node, action: :move, consumer_param_type: nil)`; 1989 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:6555 `SemanticAnnotator#move_if_not_copyable!` consumer_param_type: Symbol into Type (1989); trace src/annotator.rb:6555 +- src/mir/mir_emitter.rb:43 `def emit(node)`; 1850 foreign scalar call(s), affects 1 slot(s) + - src/mir/mir_emitter.rb:43 `MIREmitter#emit` node: String into MIR::AddressOf, MIR::AllocMark, MIR::AllocSlice, MIR::AllocatorRef, MIR::ArrayInit (1850); trace src/mir/mir_emitter.rb:43 +- src/annotator-helpers/auto_inference.rb:108 `def walk(node, current_fn:)`; 1764 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/auto_inference.rb:108 `AutoConstraintCollector#walk` node: String, Symbol into AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::FuncCall (1764); trace src/annotator-helpers/auto_inference.rb:108 +- src/annotator.rb:6519 `def og_declare(name, node, type_info)`; 1637 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:6519 `SemanticAnnotator#og_declare` type_info: Symbol into Type (1637); trace src/annotator.rb:6519 +- src/annotator.rb:6616 `def classify_og_kind(type_info, sync: nil)`; 1637 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:6616 `SemanticAnnotator#classify_og_kind` type_info: Symbol into Type (1637); trace src/annotator.rb:6616 +- src/annotator-helpers/pipe_analysis.rb:1778 `def check_soa_opportunity!(node, item_type)`; 1343 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/pipe_analysis.rb:1778 `PipeAnalysis#check_soa_opportunity!` item_type: Symbol into Type (1343); trace src/annotator-helpers/pipe_analysis.rb:1778 +- src/mir/thunk_transform/recursive_splitter.rb:266 `def contains_self_call?(node, fn_name)`; 954 foreign scalar call(s), affects 1 slot(s) + - src/mir/thunk_transform/recursive_splitter.rb:266 `ThunkTransform::RecursiveSplitter#contains_self_call?` node: String, Symbol into AST::BinaryOp, AST::FuncCall, AST::GetField, AST::Identifier, AST::Literal (954); trace src/mir/thunk_transform/recursive_splitter.rb:266 +- src/mir/mir_emitter.rb:974 `def alloc_expr(alloc)`; 850 foreign scalar call(s), affects 1 slot(s) + - src/mir/mir_emitter.rb:974 `MIREmitter#alloc_expr` alloc: Symbol into MIR::AllocatorRef, MIR::Ident (850); trace src/mir/mir_emitter.rb:974 +- src/annotator-helpers/auto_inference.rb:568 `def walk_for_shape_decls(node, &block)`; 564 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/auto_inference.rb:568 `ShapeEvidenceCollector#walk_for_shape_decls` node: String, Symbol into AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::FuncCall (564); trace src/annotator-helpers/auto_inference.rb:568 +- src/mir/thunk_transform/recursive_splitter.rb:194 `def contains_any_call?(node, names_set)`; 546 foreign scalar call(s), affects 1 slot(s) + - src/mir/thunk_transform/recursive_splitter.rb:194 `ThunkTransform::RecursiveSplitter#contains_any_call?` node: String, Symbol into AST::BinaryOp, AST::FuncCall, AST::Identifier, AST::Literal, Array (546); trace src/mir/thunk_transform/recursive_splitter.rb:194 +- src/annotator-helpers/auto_inference.rb:728 `def walk_for_local_decls(node, &block)`; 512 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/auto_inference.rb:728 `OperatorEvidenceCollector#walk_for_local_decls` node: String, Symbol into AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::FuncCall (512); trace src/annotator-helpers/auto_inference.rb:728 +- src/annotator-helpers/auto_inference.rb:751 `def walk_binops(node, name_to_slot, fn)`; 462 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` node: String, Symbol into AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::FuncCall (462); trace src/annotator-helpers/auto_inference.rb:751 +- src/mir/fsm_transform/liveness.rb:198 `def collect_defs(stmt, into)`; 458 foreign scalar call(s), affects 1 slot(s) + - src/mir/fsm_transform/liveness.rb:198 `FsmTransform::Liveness#collect_defs` stmt: String into AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::ForRange (458); trace src/mir/fsm_transform/liveness.rb:198 +- src/mir/fsm_transform/liveness.rb:230 `def collect_uses(stmt, into)`; 458 foreign scalar call(s), affects 1 slot(s) + - src/mir/fsm_transform/liveness.rb:230 `FsmTransform::Liveness#collect_uses` stmt: String into AST::Assert, AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::ForRange (458); trace src/mir/fsm_transform/liveness.rb:230 +- src/annotator.rb:6601 `def og_set_live(name) = (@og[name]&.state = :live)`; 353 foreign scalar call(s), affects 1 slot(s) + - src/annotator.rb:6601 `SemanticAnnotator#og_set_live` name: String into AST::GetField, AST::GetIndex (353); trace src/annotator.rb:6601 +- src/mir/test_lowering.rb:317 `def collect_identifier_refs(node, name_set, out)`; 285 foreign scalar call(s), affects 1 slot(s) + - src/mir/test_lowering.rb:317 `TestLowering#collect_identifier_refs` node: String, Symbol into AST::Assert, AST::BinaryOp, AST::Identifier, AST::Literal, Lexer::Token (285); trace src/mir/test_lowering.rb:317 +- src/annotator-helpers/auto_inference.rb:589 `def walk(node, name_map)`; 277 foreign scalar call(s), affects 1 slot(s) + - src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` node: String, Symbol into AST::Assignment, AST::BindExpr, AST::HashLit, AST::Identifier, AST::ListLit (277); trace src/annotator-helpers/auto_inference.rb:589 +- src/mir/mir_lowering.rb:3217 `def ast_contains_return?(node)`; 191 foreign scalar call(s), affects 1 slot(s) + - src/mir/mir_lowering.rb:3217 `MIRLowering#ast_contains_return?` node: String, Symbol into AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::GetField, AST::Identifier (191); trace src/mir/mir_lowering.rb:3217 + +## Type Normalizer Sites +- Sites matching `is_a?(Type)` plus `Type.new(...)`: 350 +- src/annotator.rb: 68 + - line 262 `SemanticAnnotator#program_has_auto?`: node.type.is_a?(Type) + - line 263 `SemanticAnnotator#program_has_auto?`: node.return_type.is_a?(Type) + - line 265 `SemanticAnnotator#program_has_auto?`: p[:type].is_a?(Type) + - line 466 `SemanticAnnotator#visit_Program`: sig.is_a?(Type) + - line 607 `SemanticAnnotator#pre_register_function`: p[:type].is_a?(Type) + - ... 63 more +- src/mir/mir_lowering.rb: 58 + - line 273 `MIRLowering#container_borrow_expr?`: ti.is_a?(Type) + - line 274 `MIRLowering#container_borrow_expr?`: ti.is_a?(Type) + - line 787 `MIRLowering#mir_cast`: from_type.is_a?(Type) + - line 788 `MIRLowering#mir_cast`: to_type.is_a?(Type) + - line 851 `MIRLowering#build_drop_entry!`: ti.is_a?(Type) + - ... 53 more +- src/annotator-helpers/generic_analysis.rb: 31 + - line 73 `GenericAnalysis#validate_type_annotation!`: type_obj.is_a?(Type) + - line 193 `GenericAnalysis#validate_type_annotation!`: inner.is_a?(Type) + - line 271 `GenericAnalysis#infer_generic_type_args!`: param[:type].is_a?(Type) + - line 272 `GenericAnalysis#infer_generic_type_args!`: arg.type_info.is_a?(Type) + - line 295 `GenericAnalysis#enforce_shared_family_call_sync!`: param[:type].is_a?(Type) + - ... 26 more +- src/mir/escape_analysis.rb: 30 + - line 152 `EscapeAnalysis#return_expr_is_heap?`: ti.is_a?(Type) + - line 153 `EscapeAnalysis#return_expr_is_heap?`: ti.is_a?(Type) + - line 170 `EscapeAnalysis#per_fn_scan!`: ret_t.is_a?(Type) + - line 214 `EscapeAnalysis#per_fn_scan!`: sym_ti.is_a?(Type) + - line 238 `EscapeAnalysis#per_fn_scan!`: ti.is_a?(Type) + - ... 25 more +- src/ast/type.rb: 25 + - line 102 `Type#coerce_error`: source_type.is_a?(Type) + - line 103 `Type#coerce_error`: target_type.is_a?(Type) + - line 161 `Type#resolve_add_op`: t_left.is_a?(Type) + - line 162 `Type#resolve_add_op`: t_right.is_a?(Type) + - line 187 `Type#safe_autocast?`: from_type.is_a?(Type) + - ... 20 more +- src/annotator-helpers/function_analysis.rb: 24 + - line 170 `FunctionAnalysis#resolve_call`: rt.is_a?(Type) + - line 181 `FunctionAnalysis#resolve_call`: node.full_type.is_a?(Type) + - line 192 `FunctionAnalysis#resolve_call`: inner.is_a?(Type) + - line 219 `FunctionAnalysis#resolve_call`: func_type.is_a?(Type) + - line 243 `FunctionAnalysis#resolve_call`: node.type_info.is_a?(Type) + - ... 19 more +- src/mir/promotion_plan.rb: 24 + - line 43 `PromotionClassifier#classify`: ret_type_sym.is_a?(Type) + - line 180 `PromotionClassifier#fn_has_escapable_return?`: ti.is_a?(Type) + - line 181 `PromotionClassifier#fn_has_escapable_return?`: ti.is_a?(Type) + - line 202 `PromotionClassifier#struct_has_promotable_fields?`: ft.is_a?(Type) + - line 224 `PromotionClassifier#compute_struct_promote`: fdef.is_a?(Type) + - ... 19 more +- src/annotator-helpers/capabilities.rb: 17 + - line 35 `Capabilities#errors_for`: type.is_a?(Type) + - line 95 `CapabilityHelper#cap_var_sync`: var_node.full_type.is_a?(Type) + - line 104 `CapabilityHelper#cap_var_storage`: var_node.full_type.is_a?(Type) + - line 120 `CapabilityHelper#cap_var_layout`: var_node.full_type.is_a?(Type) + - line 186 `CapabilityHelper#validate_capability`: var_type.is_a?(Type) + - ... 12 more +- src/ast/ast.rb: 11 + - line 309 `AST::Locatable#full_type=`: val.is_a?(Type) + - line 325 `AST::Locatable#coerced_type=`: val.is_a?(Type) + - line 404 `AST::Locatable#finalize_storage!`: final_type.is_a?(Type) + - line 407 `AST::Locatable#finalize_storage!`: final_type.is_a?(Type) + - line 412 `AST::Locatable#finalize_storage!`: final_type.is_a?(Type) + - ... 6 more +- src/mir/control_flow.rb: 11 + - line 512 `OwnershipDataflow#init_entry_state`: p[:type].is_a?(Type) + - line 624 `OwnershipDataflow#transfer_stmt`: val_ti_raw.is_a?(Type) + - line 1502 `LoopFrameAnalysis#rhs_references_any?`: ti.is_a?(Type) + - line 1551 `LoopFrameAnalysis#promote_outer_field_assigns!`: val_ti.is_a?(Type) + - line 1564 `LoopFrameAnalysis#promote_value_to_heap!`: ti.is_a?(Type) + - ... 6 more +- src/mir/mir_pass.rb: 9 + - line 127 `MIRPass#transform!`: sig.is_a?(Type) + - line 536 `MIRPass#annotate_bg_exit_promote!`: ft.is_a?(Type) + - line 570 `MIRPass#walk_stream_yields`: ft.is_a?(Type) + - line 670 `MIRPass#or_rescue_needs_fallback_dupe?`: ti.is_a?(Type) + - line 682 `MIRPass#or_rescue_needs_fallback_dupe_left?`: ti.is_a?(Type) + - ... 4 more +- src/annotator-helpers/pipe_analysis.rb: 8 + - line 738 `PipeAnalysis#analyze_pipe_to_identifier`: sig.is_a?(Type) + - line 785 `PipeAnalysis#analyze_pipe_to_named_function`: result_type.is_a?(Type) + - line 1124 `PipeAnalysis#emit_multi_map_warning`: sc.is_a?(Type) + - line 1146 `PipeAnalysis#collect_sharded_names`: t.is_a?(Type) + - line 1169 `PipeAnalysis#pre_scan_node_for_sharded`: t.is_a?(Type) + - ... 3 more +- src/annotator-helpers/auto_inference.rb: 4 + - line 99 `AutoConstraintCollector#auto?`: t.is_a?(Type) + - line 459 `AutoUnifier#stamp_slot!`: type.is_a?(Type) + - line 572 `ShapeEvidenceCollector#walk_for_shape_decls`: node.type.is_a?(Type) + - line 788 `OperatorEvidenceCollector#auto?`: t.is_a?(Type) +- src/annotator-helpers/effects.rb: 4 + - line 362 `EffectTracker#compute_needs_rt!`: fn_node.full_type.is_a?(Type) + - line 364 `EffectTracker#compute_needs_rt!`: ret_type.is_a?(Type) + - line 509 `EffectTracker#enforce_fallible_returns!`: ret.is_a?(Type) + - line 587 `EffectTracker#mark_fn_value_references!`: arg_ft.is_a?(Type) +- src/annotator-helpers/reentrance.rb: 4 + - line 162 `ReentranceBridge#validate_not_logical_return!`: rt.is_a?(Type) + - line 164 `ReentranceBridge#validate_not_logical_return!`: rt.is_a?(Type) + - line 441 `ReentranceBridge#emit_mutual_thunk_unsupported!`: rt.is_a?(Type) + - line 479 `ReentranceBridge#emit_mutual_thunk_unsupported!`: rt.is_a?(Type) +- src/mir/fsm_transform.rb: 3 + - line 181 `FsmTransform#collect_body_locals`: ct_obj.is_a?(Type) + - line 186 `FsmTransform#collect_body_locals`: ct.is_a?(Type) + - line 188 `FsmTransform#collect_body_locals`: ct.is_a?(Type) +- src/mir/fsm_transform/recursive_splitter.rb: 3 + - line 529 `FsmTransform::RecursiveSplitter#emit_for_each_fragment`: coll_type.is_a?(Type) + - line 532 `FsmTransform::RecursiveSplitter#emit_for_each_fragment`: ct.is_a?(Type) + - line 545 `FsmTransform::RecursiveSplitter#emit_for_each_fragment`: ct.is_a?(Type) +- src/annotator-helpers/fixable_helpers.rb: 2 + - line 300 `FixableHelper#emit_use_of_moved_error!`: pt.is_a?(Type) + - line 1484 `FixableHelper#emit_auto_shape_resolved_finding!`: decl.type.is_a?(Type) +- src/ast/parser.rb: 2 + - line 2444 `Parser#reject_auto_in_aggregate_field!`: type.is_a?(Type) + - line 2923 `Parser#type_annotation_source`: type.is_a?(Type) +- src/tools/atomic_migration_suggester.rb: 2 + - line 72 `AtomicMigrationSuggester#candidate_decl_info`: ti.is_a?(Type) + - line 83 `AtomicMigrationSuggester#candidate_decl_info`: field_type.is_a?(Type) + +## Struct Shape Report +- Struct declarations: 322 +- Runtime-observed struct field slots: 566 +- Static constructor field observations: 6741 + +### Struct Field Slot Breakdown +- missing field type with candidate: 4 + - `FmtVerifier::Result.path` -> String (runtime 13) + - `Formatter::Emitter::FnSig.toks` -> T::Array[Formatter::FormatLexer::Token] (runtime 2028) + - `Formatter::Emitter::FnSig.start` -> Integer (runtime 2028) + - `Formatter::Emitter::FnSig.arrow_idx` -> Integer (runtime 2028) +- missing field type with no candidate: 4 + - `FmtVerifier::Result.error` + - `FmtVerifier::Result.diff_excerpt` + - `Formatter::Emitter::FnSig.po` + - `Formatter::Emitter::FnSig.pc` +- untyped with runtime candidate: 317 + - `AutoConstraintCollector::Slot.kind` current `T.untyped` -> Symbol (runtime 73) + - `AutoConstraintCollector::Slot.sources` current `T.untyped` -> T::Array[Symbol] (runtime 73) + - `AutoUnifier::Result.resolved` current `T.untyped` -> Hash (runtime 32) + - `AutoUnifier::Result.ambiguous` current `T.untyped` -> Hash (runtime 32) + - `AutoUnifier::Result.unresolved` current `T.untyped` -> Hash (runtime 32) + - `AutoUnifier::Resolution.slot` current `T.untyped` -> AutoConstraintCollector::Slot (runtime 24) + - `AutoUnifier::Resolution.type` current `T.untyped` -> T.any(Symbol, Type) (runtime 24) + - `AutoUnifier::Resolution.sources` current `T.untyped` -> T::Array[T.any(AST::Identifier, AST::Literal, Symbol)] (runtime 24) + - ... 309 more +- untyped with static candidate: 4 + - `AST::BinaryOp.op` current `T.untyped` -> T.any(String, Symbol) (static) + - `AST::Assert.message` current `T.untyped` -> String (static) + - `MIR::DeferStmt.body` current `T.untyped` -> T.any(MIR::Call, MIR::MethodCall, MIR::ScopeBlock) (static) + - `MIR::RawZig.code` current `T.untyped` -> String (static) +- untyped with no candidate: 332 + - `AutoConstraintCollector::Slot.fn_name` current `T.untyped` + - `AutoConstraintCollector::Slot.index` current `T.untyped` + - `AutoConstraintCollector::Slot.decl_node` current `T.untyped` + - `AutoConstraintCollector::Slot.shape` current `T.untyped` + - `AutoConstraintCollector::Slot.auto_token` current `T.untyped` + - `CapabilityHelper::CaptureAnalysis.strategies` current `T.untyped` + - `CapabilityHelper::CaptureAnalysis.heap_promote_names` current `T.untyped` + - `CapabilityHelper::CaptureAnalysis.move_mark_names` current `T.untyped` + - ... 324 more +- weak collection or union type: 49 + - `Capabilities::Conflict.set_a` current T::Array[`T.untyped`] -> T.any(Array, T::Array[`T.untyped`]) (runtime 2979) + - `Capabilities::Conflict.set_b` current T::Array[`T.untyped`] -> T.any(Array, T::Array[`T.untyped`]) (runtime 2979) + - `AST::Program.statements` current T::Array[`T.untyped`] + - `AST::FunctionDef.params` current T::Array[`T.untyped`] + - `AST::FunctionDef.captures` current T.nilable(T::Array[`T.untyped`]) + - `AST::FunctionDef.body` current T::Array[`T.untyped`] + - `AST::StructDef.type_params` current T::Array[`T.untyped`] + - `AST::ListLit.items` current T::Array[`T.untyped`] -> T.any(Array, T::Array[`T.untyped`]) (runtime 5115) + - ... 41 more +- typed but nilable: 21 + - `AST::Cast.token` current T.nilable(Token) + - `AST::Require.token` current T.nilable(Token) + - `AST::IndexOp.token` current T.nilable(Token) -> Lexer::Token (runtime 60) + - `AST::OrderByOp.token` current T.nilable(Token) -> Lexer::Token (runtime 31) + - `AST::LimitOp.token` current T.nilable(Token) -> Lexer::Token (runtime 244) + - `AST::UnnestOp.token` current T.nilable(Token) -> Lexer::Token (runtime 147) + - `AST::DistinctOp.token` current T.nilable(Token) -> Lexer::Token (runtime 190) + - `AST::SkipOp.token` current T.nilable(Token) -> Lexer::Token (runtime 95) + - ... 13 more +- strongly typed: 321 + - `Capabilities::Conflict.message` current String -> String (static) + - `FixableHelper::AnchorToken.line` current Integer -> Integer (static) + - `FixableHelper::AnchorToken.column` current Integer -> Integer (static) + - `AST::Program.token` current Lexer::Token -> Lexer::Token (static) + - `AST::RequireNode.token` current Token + - `AST::RequireNode.kind` current Symbol -> Symbol (static) + - `AST::FunctionDef.token` current Token + - `AST::FunctionDef.visibility` current Symbol -> Symbol (static) + - ... 313 more + +### Struct Field Type Candidates +- `OwnershipDataflow::OwnerEntry.allocator`; Symbol; runtime; 77640 call(s) +- `OwnershipDataflow::OwnerEntry.needs_cleanup`; T::Boolean; runtime; 77640 call(s) +- `OwnershipDataflow::OwnerEntry.state`; Symbol; runtime; 77640 call(s) +- `AST::FuncCall.args`; T.any(Array, T::Array[`T.untyped`]); runtime; 32152 call(s) +- `AST::FuncCall.name`; String; runtime; 32152 call(s) +- `OwnershipGraph::Node.kind`; Symbol; runtime; 29669 call(s) +- `OwnershipGraph::Node.line`; Integer; runtime; 29669 call(s) +- `OwnershipGraph::Node.path`; String; runtime; 29669 call(s) +- `OwnershipGraph::Node.scope_depth`; Integer; runtime; 29669 call(s) +- `OwnershipGraph::Node.state`; Symbol; runtime; 29669 call(s) +- `AST::MethodCall.name`; String; runtime; 24882 call(s) +- `BinaryOpResult.type`; Type; runtime; 23831 call(s) +- `OwnershipDataflow::DataflowStep.consumed`; Set; runtime; 21238 call(s) +- `OwnershipDataflow::DataflowStep.state`; Hash; runtime; 21238 call(s) +- `AST::StructLit.fields`; T.any(Hash, T::Hash[`T.untyped`, `T.untyped`]); runtime; 13077 call(s) +- `FsmTransform::Segments::Segment.stmts`; T.any(Array, T::Array[`T.untyped`]); runtime; 7484 call(s) +- `MIR::Call.args`; T.any(Array, T::Array[`T.untyped`]); runtime; 7168 call(s) +- `MIR::Call.callee`; String; runtime; 7168 call(s) +- `FsmOps::CallExpr.args`; T.any(Array, T::Array[`T.untyped`]); runtime; 5969 call(s) +- `AST::CopyNode.token`; Lexer::Token; runtime; 5301 call(s) +- `AST::ListLit.items`; T.any(Array, T::Array[`T.untyped`]); runtime; 5115 call(s) +- `FsmOps::AssignField.value`; T.any(FsmOps::AllocExpr, FsmOps::CallExpr); runtime; 4975 call(s) +- `FsmOps::StateFieldDecl.init_zig`; String; runtime; 4972 call(s) +- `FsmOps::StateFieldDecl.name`; String; runtime; 4972 call(s) +- `FsmOps::StateFieldDecl.zig_type`; String; runtime; 4972 call(s) +- `MIR::Param.name`; String; runtime; 4804 call(s) +- `AST::WithBlock.capabilities`; T.any(Array, T::Array[T::Hash[`T.untyped`, `T.untyped`]]); runtime; 3911 call(s) +- `MIR::MethodCall.args`; T.any(Array, T::Array[`T.untyped`]); runtime; 3845 call(s) +- `MIR::FnDef.params`; T.any(Array, T::Array[MIR::Param], T::Array[`T.untyped`]); runtime; 3456 call(s) +- `MIR::FsmStateArm.index`; Integer; runtime; 3324 call(s) +- `BinaryOpResult.storage`; Symbol; runtime; 3280 call(s) +- `AST::MatchStatement.cases`; T.any(Array, T::Array[T::Hash[`T.untyped`, `T.untyped`]]); runtime; 3142 call(s) +- `FsmOps::StmtCall.args`; T.any(Array, T::Array[`T.untyped`]); runtime; 2984 call(s) +- `Capabilities::Conflict.set_a`; T.any(Array, T::Array[`T.untyped`]); runtime; 2979 call(s) +- `Capabilities::Conflict.set_b`; T.any(Array, T::Array[`T.untyped`]); runtime; 2979 call(s) +- `AST::RangeLit.start`; T.any(AST::BinaryOp, AST::Identifier, AST::Literal); runtime; 2939 call(s) +- `MIR::StructInit.fields`; T.any(Array, T::Array[`T.untyped`], T::Array[T::Hash[`T.untyped`, `T.untyped`]]); runtime; 2879 call(s) +- `MIR::FieldDef.zig_type`; String; runtime; 2864 call(s) +- `CapabilityHelper::CaptureAnalysis.capture_symbols`; Hash; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.captures`; Hash; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.close_patterns`; Hash; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.has_affine_locked`; T::Boolean; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.has_local`; T::Boolean; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.has_non_escaping_capture`; T::Boolean; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.has_outer_ref`; T::Boolean; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.has_rc`; T::Boolean; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.has_sharded`; T::Boolean; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.has_shared`; T::Boolean; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.pointer_captures`; Set; runtime; 2758 call(s) +- `CapabilityHelper::CaptureAnalysis.resource_captures`; Set; runtime; 2758 call(s) + +## Collection Type Report +- Array signature slots: 658 total, 111 strong, 547 weak, 167 nilable +- Hash signature slots: 361 total, 79 strong, 282 weak, 109 nilable + +### Hash Record Struct Candidates (Shapes + Pressure) +- literal shape: a statically observed hash literal instantiation site in this candidate cluster +- similar keyset: a distinct hash key set grouped into the same likely record, e.g. `{name, id}` with `{name, id, type}` +- BodyRecord: 8 literal shape(s), 3 similar keyset(s), total pressure 119 + - common keys: body, kind, value + - optional keys: binding, destructure, extra_values + - read keys: binding(24), value(24), body(17), kind(9), extra_values(8), destructure(2) + - accounts for: return 0, param 39, ivar 0, collection 80 + - related pressure records: local hash record c at src/mir/promotion_plan.rb (26); hash record param clause at src/mir/mir_lowering.rb:3426 (22); local hash record a at src/mir/mir_checker.rb (20); local hash record c at src/mir/control_flow.rb (17); local hash record c at src/mir/mir_pass.rb (17) + - src/annotator.rb:1452 c[:binding]; receiver c + - src/annotator.rb:1453 c[:value]; receiver c + - src/annotator.rb:1454 c[:value]; receiver c + - src/annotator.rb:1455 c[:value]; receiver c + - suggested struct: + class BodyRecord < T::Struct + prop :binding, T.nilable(String) + const :body, T.nilable(T::Array[`T.untyped`]) + prop :destructure, T.nilable(AST::StructPattern) + prop :extra_values, `T.untyped` + const :kind, Symbol + const :value, AST::StructPattern + end +- NameRecord: 2 literal shape(s), 1 similar keyset(s), total pressure 53 + - common keys: name, name_token, value + - read keys: name(21), value(8), name_token(2) + - accounts for: return 0, param 23, ivar 0, collection 30 + - related pressure records: local hash record f at src/mir/mir_lowering.rb (30); local hash record f at src/mir/mir_emitter.rb (25); local hash record param at src/annotator-helpers/with_match_check.rb (21); hash record return [] at src/mir/mir_lowering.rb:7292 (20); hash record return [] at src/annotator-helpers/fixable_helpers.rb:1627 (19) + - src/annotator.rb:1368 f[:value]; receiver f + - src/annotator.rb:1371 f[:name]; receiver f + - src/annotator.rb:1372 f[:name_token]; receiver f + - src/annotator.rb:1376 f[:name]; receiver f + - suggested struct: + class NameRecord < T::Struct + const :name, T.nilable(String) + const :name_token, T.nilable(Lexer::Token) + const :value, Symbol + end +- BodyRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 46 + - common keys: body, can_smash, parallel, pinned, stack_size + - read keys: body(18), parallel(3), capture_analysis(2), pinned(2), can_smash(1), cond(1), stack_size(1) + - accounts for: return 0, param 20, ivar 0, collection 26 + - related pressure records: local hash record a at src/mir/mir_checker.rb (20); local hash record c at src/mir/control_flow.rb (17); local hash record c at src/mir/mir_pass.rb (17); local hash record c at src/ast/ast.rb (14); local hash record c at src/backends/pipeline_rewriter.rb (14) + - src/annotator.rb:5091 branch[:body]; receiver branch + - src/annotator.rb:5093 branch[:body]; receiver branch + - src/annotator.rb:5093 branch[:parallel]; receiver branch + - src/annotator.rb:5096 branch[:parallel]; receiver branch + - suggested struct: + class BodyRecord < T::Struct + const :body, T::Array[`T.untyped`] + const :can_smash, T.nilable(T::Boolean) + const :parallel, T.nilable(T::Boolean) + const :pinned, T.nilable(T::Boolean) + const :stack_size, `T.untyped` + end +- ActionRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 41 + - common keys: action, retries, selectors, token + - read keys: action(4), retries(4), bubble_types(3), matched_types(3), body(2), message(2), selectors(2), value(1) + - accounts for: return 0, param 20, ivar 0, collection 21 + - related pressure records: hash record return [] at src/annotator-helpers/lock_helper.rb:109 (2); hash record param clause at src/annotator.rb:5035 (1); hash record return lock_error_clause at src/annotator-helpers/lock_helper.rb:384 (1); hash record return must at src/ast/parser.rb:3499 (1); local hash record clause at src/annotator-helpers/with_match_check.rb (1) + - src/annotator-helpers/with_match_check.rb:414 clause[:selectors]; receiver clause + - src/annotator.rb:976 h[:selectors]; receiver h + - src/mir/fsm_lowering.rb:300 with_node.lock_error_clause[:retries]; receiver with_node.lock_error_clause + - src/mir/mir_lowering.rb:2920 clause[:action]; receiver clause + - suggested struct: + class SelectorsRecord < T::Struct + const :form, Symbol + const :name, Symbol + const :token, NilClass + end + + class ActionRecord < T::Struct + const :action, Symbol + const :retries, Integer + const :selectors, T::Array[SelectorsRecord] + const :token, NilClass + end +- BodyStmtsRecord: 2 literal shape(s), 1 similar keyset(s), total pressure 30 + - common keys: body_stmts, descriptor, fn_name, index, prologue_stmts, rt_suppress, tail + - read keys: tail(8), body_stmts(4), index(4), prologue_stmts(4), fn_name(3), descriptor(2), rt_suppress(2), extra_prologue_zig(1) + - accounts for: return 0, param 6, ivar 0, collection 24 + - related pressure records: local hash record spec at src/mir/fsm_transform/emit.rb (22); hash record param spec at src/mir/fsm_transform/emit.rb:697 (10); hash record param spec at src/mir/fsm_transform/emit.rb:209 (9); local hash record s at src/mir/fsm_transform/emit.rb (1) + - src/mir/fsm_transform/emit.rb:115 s[:descriptor]; receiver s + - src/mir/fsm_transform/emit.rb:118 s[:tail]; receiver s + - src/mir/fsm_transform/emit.rb:118 s[:tail]; receiver s + - src/mir/fsm_transform/emit.rb:119 s[:tail]; receiver s + - suggested struct: + class BodyStmtsRecord < T::Struct + const :body_stmts, T::Array[`T.untyped`] + const :descriptor, NilClass + const :fn_name, T.nilable(String) + const :index, `T.untyped` + const :prologue_stmts, NilClass + const :rt_suppress, String + const :tail, `T.untyped` + end +- TypeRecord: 14 literal shape(s), 6 similar keyset(s), total pressure 28 + - common keys: type + - optional keys: default, mutable, name, required, sync, takes + - read keys: type(10), default(4), name(4), mutable(3), takes(2) + - accounts for: return 0, param 10, ivar 0, collection 18 + - related pressure records: local hash record param at src/annotator-helpers/function_analysis.rb (76); hash record return [] at src/annotator-helpers/function_analysis.rb:341 (41); local hash record p at src/mir/mir_lowering.rb (37); local hash record param at src/mir/mir_lowering.rb (37); hash record param param at src/annotator-helpers/function_analysis.rb:547 (32) + - src/annotator-helpers/function_analysis.rb:685 p[:name]; receiver p + - src/annotator-helpers/with_match_check.rb:55 p[:name]; receiver p + - src/annotator.rb:601 p[:name]; receiver p + - src/annotator.rb:602 p[:type]; receiver p + - suggested struct: + class TypeRecord < T::Struct + prop :default, NilClass + prop :mutable, T.nilable(T::Boolean) + prop :name, T.nilable(String) + prop :required, T.nilable(T::Boolean) + prop :sync, T.nilable(T::Boolean) + prop :takes, T.nilable(T::Boolean) + const :type, Symbol + end +- AddrExprRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 28 + - common keys: addr_expr, alias_name, guard_var, held_var, i, lock_expr, method + - read keys: guard_var(9), held_var(5), alias_name(4), lock_expr(4), method(4), i(3), addr_expr(2) + - accounts for: return 0, param 0, ivar 0, collection 28 + - related pressure records: local hash record e at src/mir/mir_lowering.rb (8) + - src/mir/mir_lowering.rb:3503 e[:guard_var]; receiver e + - src/mir/mir_lowering.rb:3503 e[:lock_expr]; receiver e + - src/mir/mir_lowering.rb:3503 e[:method]; receiver e + - src/mir/mir_lowering.rb:3506 e[:addr_expr]; receiver e + - suggested struct: + class AddrExprRecord < T::Struct + const :addr_expr, String + const :alias_name, String + const :guard_var, String + const :held_var, String + const :i, `T.untyped` + const :lock_expr, String + const :method, `T.untyped` + end +- BodyRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 27 + - common keys: body, name, params, return_type, token, visibility + - read keys: name(5), params(4), return_type(3), token(3), body(2), visibility(1) + - accounts for: return 0, param 9, ivar 0, collection 18 + - related pressure records: local hash record sel at src/annotator.rb (32); local hash record param at src/annotator-helpers/with_match_check.rb (21); hash record return [] at src/mir/mir_lowering.rb:7292 (20); local hash record a at src/mir/mir_checker.rb (20); local hash record b at src/mir/mir_pass.rb (20) + - src/annotator-helpers/union.rb:21 req[:name]; receiver req + - src/annotator-helpers/union.rb:22 req[:token]; receiver req + - src/annotator-helpers/union.rb:22 req[:name]; receiver req + - src/annotator-helpers/union.rb:24 req[:name]; receiver req + - suggested struct: + class BodyRecord < T::Struct + const :body, T.nilable(T::Array[`T.untyped`]) + const :name, T.nilable(String) + const :params, `T.untyped` + const :return_type, T.nilable(Type) + const :token, T.nilable(Lexer::Token) + const :visibility, Symbol + end +- NameRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 25 + - common keys: name, value + - read keys: name(1), value(1) + - accounts for: return 0, param 23, ivar 0, collection 2 + - related pressure records: local hash record f at src/mir/mir_lowering.rb (30); local hash record f at src/mir/mir_emitter.rb (25); local hash record param at src/annotator-helpers/with_match_check.rb (21); hash record return [] at src/mir/mir_lowering.rb:7292 (20); hash record return [] at src/annotator-helpers/fixable_helpers.rb:1627 (19) + - src/mir/mir_emitter.rb:1348 f[:name]; receiver f + - src/mir/mir_emitter.rb:1348 f[:value]; receiver f + - suggested struct: + class NameRecord < T::Struct + const :name, String + const :value, MIR::StructInit + end +- ExprRecord: 2 literal shape(s), 1 similar keyset(s), total pressure 20 + - common keys: expr, name, name_token + - read keys: expr(7), name(4), unwrapped_type(1) + - accounts for: return 0, param 9, ivar 0, collection 11 + - related pressure records: local hash record param at src/annotator-helpers/with_match_check.rb (21); hash record return [] at src/mir/mir_lowering.rb:7292 (20); hash record return [] at src/annotator-helpers/fixable_helpers.rb:1627 (19); hash record return [] at src/mir/thunk_transform/emit.rb:104 (19); local hash record param at src/mir/concurrency_checks.rb (19) + - src/annotator.rb:1303 b[:expr]; receiver b + - src/annotator.rb:1304 b[:expr]; receiver b + - src/annotator.rb:1306 b[:expr]; receiver b + - src/annotator.rb:1306 b[:expr]; receiver b + - suggested struct: + class ExprRecord < T::Struct + const :expr, MIR::FieldGet + const :name, T.nilable(String) + const :name_token, T.nilable(Lexer::Token) + end +- AddrsRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 18 + - common keys: addrs, allocs, bytes, free_bytes, frees, live + - read keys: addrs(2), allocs(2), bytes(2), free_bytes(1), frees(1) + - accounts for: return 0, param 10, ivar 0, collection 8 + - related pressure records: local hash record s at src/tools/doctor.rb (61); local hash record v at src/tools/doctor.rb (8); local hash record vals at src/tools/doctor.rb (4); local hash record s at src/tools/pprof_converter.rb (3) + - src/tools/pprof_converter.rb:145 s[:addrs]; receiver s + - src/tools/pprof_converter.rb:149 s[:allocs]; receiver s + - src/tools/pprof_converter.rb:150 s[:bytes]; receiver s + - src/tools/pprof_converter.rb:151 s[:allocs]; receiver s + - suggested struct: + class AddrsRecord < T::Struct + const :addrs, `T.untyped` + const :allocs, Integer + const :bytes, Integer + const :free_bytes, Integer + const :frees, Integer + const :live, Integer + end +- BindingRecord: 2 literal shape(s), 1 similar keyset(s), total pressure 15 + - common keys: binding, expr + - read keys: expr(6), binding(4) + - accounts for: return 0, param 6, ivar 0, collection 9 + - related pressure records: local hash record step at src/mir/mir_lowering.rb (11); local hash record step at src/annotator-helpers/capabilities.rb (8); hash record return pop at src/mir/fsm_lowering.rb:108 (7); hash record return pop at src/mir/mir_lowering.rb:3870 (6); local hash record step at src/annotator.rb (5) + - src/annotator.rb:5307 step[:expr]; receiver step + - src/annotator.rb:5308 step[:expr]; receiver step + - src/annotator.rb:5308 step[:expr]; receiver step + - src/annotator.rb:5310 step[:binding]; receiver step + - suggested struct: + class BindingRecord < T::Struct + const :binding, T.nilable(String) + const :expr, AST::Locatable + end +- AllocsRecord: 8 literal shape(s), 3 similar keyset(s), total pressure 14 + - common keys: allocs, bytes + - optional keys: addr, free_bytes, frees, inuse_allocs, inuse_bytes, live, trace + - read keys: bytes(6), allocs(5) + - accounts for: return 0, param 3, ivar 0, collection 11 + - related pressure records: local hash record s at src/tools/doctor.rb (61); local hash record v at src/tools/doctor.rb (8); local hash record vals at src/tools/doctor.rb (4) + - src/tools/doctor.rb:1333 self_total[:bytes]; receiver self_total + - src/tools/doctor.rb:1341 self_total[:bytes]; receiver self_total + - src/tools/doctor.rb:1341 self_total[:allocs]; receiver self_total + - src/tools/doctor.rb:1466 b[:bytes]; receiver b + - suggested struct: + class AllocsRecord < T::Struct + prop :addr, `T.untyped` + const :allocs, Integer + const :bytes, Integer + prop :free_bytes, T.nilable(Integer) + prop :frees, T.nilable(Integer) + prop :inuse_allocs, `T.untyped` + prop :inuse_bytes, `T.untyped` + prop :live, T.nilable(Integer) + prop :trace, `T.untyped` + end +- ArenaRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 14 + - common keys: arena, can_smash, can_smash_token, parallel, pinned, stack_size, stack_size_token + - read keys: arena(1), can_smash(1), can_smash_token(1), parallel(1), pinned(1), stack_size(1), stack_size_token(1) + - accounts for: return 0, param 7, ivar 0, collection 7 + - related pressure records: local hash record T.must(prefix) at src/ast/parser.rb (7); hash record return [] at src/ast/parser.rb:3771 (6); hash record return [] at src/ast/parser.rb:3697 (5); hash record return options at src/annotator-helpers/pipe_analysis.rb:1605 (1); hash record return options at src/annotator-helpers/pipe_analysis.rb:1606 (1) + - src/ast/parser.rb:3815 T.must(prefix)[:stack_size]; receiver T.must(prefix) + - src/ast/parser.rb:3815 T.must(prefix)[:pinned]; receiver T.must(prefix) + - src/ast/parser.rb:3815 T.must(prefix)[:parallel]; receiver T.must(prefix) + - src/ast/parser.rb:3815 T.must(prefix)[:arena]; receiver T.must(prefix) + - suggested struct: + class ArenaRecord < T::Struct + const :arena, T::Boolean + const :can_smash, T::Boolean + const :can_smash_token, T.nilable(Lexer::Token) + const :parallel, T::Boolean + const :pinned, T::Boolean + const :stack_size, `T.untyped` + const :stack_size_token, T.nilable(Lexer::Token) + end +- BodyRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 14 + - common keys: body, filters, items + - read keys: body(1) + - accounts for: return 0, param 13, ivar 0, collection 1 + - related pressure records: local hash record a at src/mir/mir_checker.rb (20); local hash record c at src/mir/control_flow.rb (17); local hash record c at src/mir/mir_pass.rb (17); local hash record c at src/ast/ast.rb (14); local hash record c at src/backends/pipeline_rewriter.rb (14) + - src/annotator.rb:851 c[:body]; receiver c + - suggested struct: + class BodyRecord < T::Struct + const :body, T.nilable(T::Array[`T.untyped`]) + const :filters, T::Array[T.nilable(T::Hash[`T.untyped`, `T.untyped`])] + const :items, T::Array[T::Hash[`T.untyped`, `T.untyped`]] + end +- ContendedRecord: 6 literal shape(s), 5 similar keyset(s), total pressure 12 + - common keys: contended, read_contended, read_total_wait_ns, total_wait_ns + - optional keys: acquires, addr, caller_trace, max_hold_ns, max_wait_ns, read_acquires, read_max_wait_ns, total_hold_ns, trace, traces + - read keys: contended(2), read_contended(2), read_total_wait_ns(2), total_wait_ns(2) + - accounts for: return 0, param 4, ivar 0, collection 8 + - related pressure records: local hash record l at src/tools/pprof_converter.rb (26); local hash record l at src/tools/doctor.rb (20); local hash record r at src/tools/doctor.rb (20) + - src/tools/doctor.rb:1537 b[:contended]; receiver b + - src/tools/doctor.rb:1537 b[:read_contended]; receiver b + - src/tools/doctor.rb:1538 a[:contended]; receiver a + - src/tools/doctor.rb:1538 a[:read_contended]; receiver a + - suggested struct: + class ContendedRecord < T::Struct + prop :acquires, T.nilable(Integer) + prop :addr, T.nilable(String) + prop :caller_trace, T.nilable(T::Array[`T.untyped`]) + const :contended, Integer + prop :max_hold_ns, T.nilable(Integer) + prop :max_wait_ns, T.nilable(Integer) + prop :read_acquires, T.nilable(Integer) + const :read_contended, Integer + prop :read_max_wait_ns, T.nilable(Integer) + const :read_total_wait_ns, Integer + prop :total_hold_ns, T.nilable(Integer) + const :total_wait_ns, Integer + prop :trace, T.nilable(T::Array[`T.untyped`]) + prop :traces, `T.untyped` + end +- CondZigRecord: 2 literal shape(s), 1 similar keyset(s), total pressure 10 + - common keys: cond_zig, value_zig + - read keys: cond_ast(2), value_ast(2), cond_zig(1), value_zig(1) + - accounts for: return 0, param 4, ivar 0, collection 6 + - related pressure records: local hash record bc at src/mir/mir_emitter.rb (4) + - src/mir/mir_emitter.rb:783 bc.fetch(:cond_zig); receiver bc + - src/mir/mir_emitter.rb:784 bc.fetch(:value_zig); receiver bc + - src/mir/thunk_transform/emit.rb:95 bc[:cond_ast]; receiver bc + - src/mir/thunk_transform/emit.rb:96 bc[:value_ast]; receiver bc + - suggested struct: + class CondZigRecord < T::Struct + const :cond_zig, `T.untyped` + const :value_zig, `T.untyped` + end +- CommitsRecord: 6 literal shape(s), 4 similar keyset(s), total pressure 8 + - common keys: commits, reads, retries + - optional keys: addr, caller_trace, multi_commits, struct_size, trace, traces, update_failures + - read keys: retries(4), commits(2) + - accounts for: return 0, param 2, ivar 0, collection 6 + - related pressure records: local hash record c at src/tools/pprof_converter.rb (34); hash record return first at src/tools/doctor.rb:933 (1) + - src/tools/doctor.rb:1605 a[:commits]; receiver a + - src/tools/doctor.rb:1605 b[:commits]; receiver b + - src/tools/doctor.rb:1606 a[:retries]; receiver a + - src/tools/doctor.rb:1606 b[:retries]; receiver b + - suggested struct: + class CommitsRecord < T::Struct + prop :addr, T.nilable(String) + prop :caller_trace, T.nilable(T::Array[`T.untyped`]) + const :commits, Integer + prop :multi_commits, T.nilable(Integer) + const :reads, Integer + const :retries, Integer + prop :struct_size, T.nilable(Integer) + prop :trace, T.nilable(T::Array[`T.untyped`]) + prop :traces, `T.untyped` + prop :update_failures, T.nilable(Integer) + end +- PinnedRecord: 4 literal shape(s), 3 similar keyset(s), total pressure 8 + - common keys: pinned + - optional keys: arena, can_smash, can_smash_token, parallel, stack_size, stack_size_token + - read keys: can_smash(1), parallel(1), pinned(1), stack_size(1) + - accounts for: return 0, param 4, ivar 0, collection 4 + - related pressure records: local hash record T.must(prefix) at src/ast/parser.rb (7); hash record return [] at src/ast/parser.rb:3771 (6); hash record return [] at src/ast/parser.rb:3697 (5); hash record return options at src/annotator-helpers/pipe_analysis.rb:1605 (1); hash record return options at src/annotator-helpers/pipe_analysis.rb:1606 (1) + - src/ast/parser.rb:3741 T.must(prefix)[:pinned]; receiver T.must(prefix) + - src/ast/parser.rb:3741 T.must(prefix)[:parallel]; receiver T.must(prefix) + - src/ast/parser.rb:3741 T.must(prefix)[:stack_size]; receiver T.must(prefix) + - src/ast/parser.rb:3741 T.must(prefix)[:can_smash]; receiver T.must(prefix) + - suggested struct: + class PinnedRecord < T::Struct + prop :arena, T.nilable(T::Boolean) + prop :can_smash, T.nilable(T::Boolean) + prop :can_smash_token, NilClass + prop :parallel, T.nilable(T::Boolean) + const :pinned, T::Boolean + prop :stack_size, `T.untyped` + prop :stack_size_token, NilClass + end +- AllocRecord: 21 literal shape(s), 5 similar keyset(s), total pressure 7 + - common keys: alloc + - optional keys: elem_zig_type, has_moved_guard, kind, zig_type + - read keys: alloc(3), zig_type(1) + - accounts for: return 0, param 3, ivar 0, collection 4 + - related pressure records: hash record return [] at src/mir/mir_pass.rb:852 (15); hash record return [] at src/mir/mir_pass.rb:866 (15); hash record return [] at src/mir/mir_pass.rb:800 (14); local hash record vp at src/mir/mir_pass.rb (11); hash record return [] at src/mir/mir_pass.rb:823 (9) + - src/mir/mir_lowering.rb:6264 node.reassign_cleanup[:zig_type]; receiver node.reassign_cleanup + - src/mir/mir_lowering.rb:6265 node.reassign_cleanup[:alloc]; receiver node.reassign_cleanup + - src/mir/mir_pass.rb:312 stmt.reassign_cleanup[:alloc]; receiver stmt.reassign_cleanup + - src/mir/mir_pass.rb:317 stmt.field_pre_cleanup[:alloc]; receiver stmt.field_pre_cleanup + - suggested struct: + class AllocRecord < T::Struct + const :alloc, Symbol + prop :elem_zig_type, T.nilable(Object) + prop :has_moved_guard, T.nilable(T::Boolean) + prop :kind, T.nilable(Symbol) + prop :zig_type, T.nilable(String) + end +- NameRecord: 3 literal shape(s), 2 similar keyset(s), total pressure 7 + - common keys: name, stack_bytes + - optional keys: zig_name + - read keys: line(3), usage_pct(1) + - accounts for: return 0, param 3, ivar 0, collection 4 + - related pressure records: local hash record param at src/annotator-helpers/with_match_check.rb (21); hash record return [] at src/mir/mir_lowering.rb:7292 (20); hash record return [] at src/annotator-helpers/fixable_helpers.rb:1627 (19); hash record return [] at src/mir/thunk_transform/emit.rb:104 (19); local hash record param at src/mir/concurrency_checks.rb (19) + - src/tools/stack_verifier.rb:120 entry[:line]; receiver entry + - src/tools/stack_verifier.rb:128 entry[:line]; receiver entry + - src/tools/stack_verifier.rb:136 entry[:line]; receiver entry + - src/tools/stack_verifier.rb:139 entry[:usage_pct]; receiver entry + - suggested struct: + class NameRecord < T::Struct + const :name, String + const :stack_bytes, T.nilable(Integer) + prop :zig_name, NilClass + end +- BytesRecord: 2 literal shape(s), 1 similar keyset(s), total pressure 7 + - common keys: bytes, reg + - read keys: bytes(2), reg(2) + - accounts for: return 0, param 3, ivar 0, collection 4 + - related pressure records: hash record hash literal at src/tools/stack_verifier.rb:285 (3); hash record hash literal at src/tools/stack_verifier.rb:80 (3) + - src/tools/stack_verifier.rb:81 pending_mov[:reg]; receiver pending_mov + - src/tools/stack_verifier.rb:83 pending_mov[:bytes]; receiver pending_mov + - src/tools/stack_verifier.rb:286 pending_mov[:reg]; receiver pending_mov + - src/tools/stack_verifier.rb:287 pending_mov[:bytes]; receiver pending_mov + - suggested struct: + class BytesRecord < T::Struct + const :bytes, Integer + const :reg, `T.untyped` + end +- LayoutRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 7 + - common keys: layout, lock_rank, ownership, sync + - read keys: lock_rank(3), layout(1), ownership(1), sync(1) + - accounts for: return 0, param 1, ivar 0, collection 6 + - related pressure records: hash record return [] at src/annotator.rb:2560 (7); hash record return [] at src/annotator-helpers/function_analysis.rb:1020 (5) + - src/ast/parser.rb:3611 dims[:ownership]; receiver dims + - src/ast/parser.rb:3611 dims[:sync]; receiver dims + - src/ast/parser.rb:3611 dims[:layout]; receiver dims + - src/ast/parser.rb:3611 dims[:lock_rank]; receiver dims + - suggested struct: + class LayoutRecord < T::Struct + const :layout, NilClass + const :lock_rank, NilClass + const :ownership, NilClass + const :sync, NilClass + end +- DescriptionRecord: 11 literal shape(s), 1 similar keyset(s), total pressure 6 + - common keys: description, sigil + - read keys: description(1), sigil(1) + - accounts for: return 0, param 4, ivar 0, collection 2 + - related pressure records: local hash record c at src/annotator-helpers/fixable_helpers.rb (9) + - src/annotator-helpers/fixable_helpers.rb:976 c[:sigil]; receiver c + - src/annotator-helpers/fixable_helpers.rb:976 c[:description]; receiver c + - suggested struct: + class DescriptionRecord < T::Struct + const :description, String + const :sigil, String + end +- KeyExprRecord: 2 literal shape(s), 2 similar keyset(s), total pressure 6 + - common keys: key_expr, map_var, shard_count + - optional keys: auto_detected, body_allocates_frame, key_allocates_frame + - read keys: body_allocates_frame(1), key_allocates_frame(1), key_expr(1), shard_count(1) + - accounts for: return 0, param 2, ivar 0, collection 4 + - related pressure records: hash record param ctx at src/backends/pipeline_host.rb:3554 (4); hash record return shard_context at src/backends/pipeline_host.rb:3493 (4); hash record return shard_context at src/mir/control_flow.rb:1685 (3); hash record return first at src/annotator-helpers/pipe_analysis.rb:1253 (1) + - src/backends/pipeline_host.rb:3557 ctx[:shard_count]; receiver ctx + - src/backends/pipeline_host.rb:3577 ctx[:key_expr]; receiver ctx + - src/backends/pipeline_host.rb:3619 ctx[:key_allocates_frame]; receiver ctx + - src/backends/pipeline_host.rb:3624 ctx[:body_allocates_frame]; receiver ctx + - suggested struct: + class KeyExprRecord < T::Struct + prop :auto_detected, T.nilable(T::Boolean) + prop :body_allocates_frame, T.nilable(T::Boolean) + prop :key_allocates_frame, T.nilable(T::Boolean) + const :key_expr, MIR::Ident + const :map_var, AST::Identifier + const :shard_count, `T.untyped` + end +- BodyRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 6 + - common keys: body, pattern + - read keys: body(1), pattern(1) + - accounts for: return 0, param 4, ivar 0, collection 2 + - related pressure records: local hash record a at src/mir/mir_checker.rb (20); local hash record c at src/mir/control_flow.rb (17); local hash record c at src/mir/mir_pass.rb (17); local hash record c at src/ast/ast.rb (14); local hash record c at src/backends/pipeline_rewriter.rb (14) + - src/mir/mir_emitter.rb:693 arm[:body]; receiver arm + - src/mir/mir_emitter.rb:694 arm[:pattern]; receiver arm + - suggested struct: + class BodyRecord < T::Struct + const :body, `T.untyped` + const :pattern, String + end +- DispatchRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 5 + - common keys: dispatch, exits, form, id, max_lifetime_ns, runs, scheds, spawns, total_lifetime_ns + - read keys: runs(3), dispatch(1), scheds(1) + - accounts for: return 0, param 1, ivar 0, collection 4 + - related pressure records: local hash record site at src/tools/doctor.rb (10); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4034 (7); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4064 (7); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4093 (7); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4164 (7) + - src/tools/doctor.rb:528 site[:runs]; receiver site + - src/tools/doctor.rb:528 site[:runs]; receiver site + - src/tools/doctor.rb:531 site[:dispatch]; receiver site + - src/tools/doctor.rb:582 site[:scheds]; receiver site + - suggested struct: + class DispatchRecord < T::Struct + const :dispatch, T.nilable(String) + const :exits, Integer + const :form, T.nilable(String) + const :id, Integer + const :max_lifetime_ns, Integer + const :runs, Integer + const :scheds, Object + const :spawns, Integer + const :total_lifetime_ns, Integer + end +- ElemZigRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 5 + - common keys: elem_zig, initial_capture, item_used, item_var, next_method, outer_stmts, range_let, source_name, stage_stmts + - read keys: item_var(2) + - accounts for: return 0, param 3, ivar 0, collection 2 + - related pressure records: hash record param p at src/backends/pipeline_host.rb:2657 (33); hash record param p at src/backends/pipeline_host.rb:2906 (4); hash record param p at src/backends/pipeline_host.rb:2993 (4); hash record return build_lazy_range_prefix at src/backends/pipeline_host.rb:2554 (3); hash record return build_lazy_range_prefix at src/backends/pipeline_host.rb:3051 (3) + - src/backends/pipeline_host.rb:2847 p[:item_var]; receiver p + - src/backends/pipeline_host.rb:2944 p[:item_var]; receiver p + - suggested struct: + class ElemZigRecord < T::Struct + const :elem_zig, String + const :initial_capture, String + const :item_used, T::Boolean + const :item_var, String + const :next_method, String + const :outer_stmts, T::Array[MIR::Let] + const :range_let, T.nilable(MIR::Let) + const :source_name, String + const :stage_stmts, `T.untyped` + end +- FunctionsRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 5 + - common keys: functions, source_file, warnings + - read keys: warnings(3), functions(2) + - accounts for: return 0, param 0, ivar 0, collection 5 + - related pressure records: hash record hash literal at src/tools/stack_verifier.rb:96 (5) + - src/tools/stack_verifier.rb:121 report[:warnings]; receiver report + - src/tools/stack_verifier.rb:129 report[:warnings]; receiver report + - src/tools/stack_verifier.rb:137 report[:warnings]; receiver report + - src/tools/stack_verifier.rb:147 report[:functions]; receiver report + - suggested struct: + class FunctionsRecord < T::Struct + const :functions, T::Array[`T.untyped`] + const :source_file, T.nilable(String) + const :warnings, T::Array[`T.untyped`] + end +- FilenameIdxRecord: 1 literal shape(s), 1 similar keyset(s), total pressure 4 + - common keys: filename_idx, id, name_idx, start_line, system_name_idx + - read keys: id(1) + - accounts for: return 2, param 1, ivar 0, collection 1 + - related pressure records: hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4034 (7); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4064 (7); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4093 (7); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4164 (7); hash record return build_bounded_concurrent_callback at src/backends/pipeline_host.rb:4201 (7) + - src/tools/pprof.rb:152 f[:id]; receiver f + - suggested struct: + class FilenameIdxRecord < T::Struct + const :filename_idx, Integer + const :id, Integer + const :name_idx, Integer + const :start_line, Integer + const :system_name_idx, Integer + end + +### Weak Collection Slots With Runtime Candidates +- src/annotator-helpers/auto_inference.rb:65 `AutoConstraintCollector#collect!` return return: T::Hash[T::Array[`T.untyped`], AutoConstraintCollector::Slot] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], AutoConstraintCollector::Slot] (61 call(s)) +- src/annotator-helpers/auto_inference.rb:135 `AutoConstraintCollector#record_constraint` return return: T.nilable(T::Array[`T.untyped`]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (1207 call(s)) +- src/annotator-helpers/auto_inference.rb:149 `AutoConstraintCollector#record_call_site` return return: T.nilable(T::Array[`T.untyped`]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (20 call(s)) +- src/annotator-helpers/auto_inference.rb:348 `AutoUnifier#initialize` param slots: T::Hash[T::Array[`T.untyped`], AutoConstraintCollector::Slot] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], AutoConstraintCollector::Slot] (32 call(s)) +- src/annotator-helpers/auto_inference.rb:492 `AutoUnifier#stamp_map_pairs!` param resolved_slots: T::Hash[T::Array[`T.untyped`], AutoUnifier::Resolution] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], AutoUnifier::Resolution] (22 call(s)) +- src/annotator-helpers/auto_inference.rb:492 `AutoUnifier#stamp_map_pairs!` return return: T::Hash[Integer, T::Hash[`T.untyped`, `T.untyped`]] -> T::Hash[Integer, T::Hash[Symbol, AutoUnifier::Resolution]] (22 call(s)) +- src/annotator-helpers/auto_inference.rb:530 `ShapeEvidenceCollector#initialize` param slots: T::Hash[T::Array[`T.untyped`], AutoConstraintCollector::Slot] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], AutoConstraintCollector::Slot] (29 call(s)) +- src/annotator-helpers/auto_inference.rb:536 `ShapeEvidenceCollector#collect!` return return: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], AutoConstraintCollector::Slot] (29 call(s)) +- src/annotator-helpers/auto_inference.rb:555 `ShapeEvidenceCollector#build_name_map` return return: T::Hash[String, T::Hash[`T.untyped`, `T.untyped`]] -> T::Hash[String, T::Hash[Symbol, T.nilable(AutoConstraintCollector::Slot)]] (40 call(s)) +- src/annotator-helpers/auto_inference.rb:589 `ShapeEvidenceCollector#walk` param name_map: T::Hash[String, T::Hash[`T.untyped`, `T.untyped`]] -> T::Hash[String, T::Hash[Symbol, T.nilable(AutoConstraintCollector::Slot)]] (776 call(s)) +- src/annotator-helpers/auto_inference.rb:615 `ShapeEvidenceCollector#record_method_call` param name_map: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[String, T::Hash[Symbol, T.nilable(AutoConstraintCollector::Slot)]] (10 call(s)) +- src/annotator-helpers/auto_inference.rb:642 `ShapeEvidenceCollector#record_map_pair_evidence` param slots: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[Symbol, T.nilable(AutoConstraintCollector::Slot)] (3 call(s)) +- src/annotator-helpers/auto_inference.rb:651 `ShapeEvidenceCollector#record_index_assign` param name_map: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[String, T::Hash[Symbol, T.nilable(AutoConstraintCollector::Slot)]] (4 call(s)) +- src/annotator-helpers/auto_inference.rb:688 `OperatorEvidenceCollector#initialize` param slots: T::Hash[T::Array[`T.untyped`], AutoConstraintCollector::Slot] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], AutoConstraintCollector::Slot] (26 call(s)) +- src/annotator-helpers/auto_inference.rb:695 `OperatorEvidenceCollector#collect!` return return: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], T::Set[Symbol]] (26 call(s)) +- src/annotator-helpers/auto_inference.rb:713 `OperatorEvidenceCollector#build_name_map` return return: T::Hash[String, T::Array[`T.untyped`]] -> T::Hash[String, T::Array[T.any(Integer, String, Symbol)]] (37 call(s)) +- src/annotator-helpers/auto_inference.rb:751 `OperatorEvidenceCollector#walk_binops` param name_to_slot: T::Hash[String, T::Array[`T.untyped`]] -> T::Hash[String, T::Array[T.any(Integer, String, Symbol)]] (1307 call(s)) +- src/annotator-helpers/auto_inference.rb:778 `OperatorEvidenceCollector#record_binop` param name_to_slot: T::Hash[String, T::Array[`T.untyped`]] -> T::Hash[String, T::Array[T.any(Integer, String, Symbol)]] (22 call(s)) +- src/annotator-helpers/capabilities.rb:126 `CapabilityHelper#validate_capability` return return: T.nilable(T::Array[T::Hash[`T.untyped`, `T.untyped`]]) -> T::Array[T::Hash[Symbol, Symbol]] (2101 call(s)) +- src/annotator-helpers/capabilities.rb:362 `CapabilityHelper#record_predicate_call_site!` return return: T.nilable(T::Array[T::Hash[`T.untyped`, `T.untyped`]]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (17826 call(s)) +- src/annotator-helpers/capabilities.rb:379 `CapabilityHelper#validate_predicate_purity!` return return: T.nilable(T::Array[`T.untyped`]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (4781 call(s)) +- src/annotator-helpers/capabilities.rb:1030 `CapabilityHelper#_unified_capture_walk` param nodes: T::Array[`T.untyped`] -> T::Array[T::Hash[Symbol, `T.untyped`]] (17463 call(s)) +- src/annotator-helpers/effects.rb:89 `EffectTracker#effects_init!` return return: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[String, Hash] (5760 call(s)) +- src/annotator-helpers/effects.rb:1051 `EffectTracker#scan_for_calls` return return: T::Array[`T.untyped`] -> T::Array[T::Set[String]] (13317 call(s)) +- src/annotator-helpers/fixable_helpers.rb:101 `FixableHelper#emit_registry_mismatch!` param candidates: T::Array[`T.untyped`] -> T::Array[Symbol] (7 call(s)) +- src/annotator-helpers/fixable_helpers.rb:371 `FixableHelper#emit_use_of_moved_path_error!` param path: T::Array[`T.untyped`] -> T::Array[Symbol] (3 call(s)) +- src/annotator-helpers/fixable_helpers.rb:713 `FixableHelper#emit_ambiguous_return_error!` param found_returns: T::Array[`T.untyped`] -> T::Array[T::Hash[Symbol, Symbol]] (5 call(s)) +- src/annotator-helpers/fixable_helpers.rb:1251 `FixableHelper#emit_with_cap_mismatch!` param candidates: T::Array[`T.untyped`] -> T::Array[T::Hash[Symbol, String]] (9 call(s)) +- src/annotator-helpers/fixable_helpers.rb:1371 `FixableHelper#auto_rank_candidates` return return: T::Array[T::Array[`T.untyped`]] -> T::Array[T::Array[T.nilable(T.any(String, Symbol))]] (22 call(s)) +- src/annotator-helpers/fixable_helpers.rb:1427 `FixableHelper#build_auto_op_evidence_block` param candidates: T::Array[`T.untyped`] -> T::Array[T::Array[T.nilable(T.any(String, Symbol))]] (4 call(s)) +- src/annotator-helpers/fixable_helpers.rb:1502 `FixableHelper#build_auto_replace_fixes` return return: T::Array[`T.untyped`] -> T::Array[Fix] (15 call(s)) +- src/annotator-helpers/fixable_helpers.rb:1524 `FixableHelper#emit_auto_ambiguity_finding!` param op_evidence: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], T::Set[Symbol]] (4 call(s)) +- src/annotator-helpers/fixable_helpers.rb:1555 `FixableHelper#emit_auto_unresolved_finding!` param op_evidence: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[T::Array[T.any(Integer, String, Symbol)], T::Set[Symbol]] (9 call(s)) +- src/annotator-helpers/fixable_helpers.rb:1659 `FixableHelper#build_auto_ambiguity_message` param observed_strs: T::Array[`T.untyped`] -> T::Array[String] (4 call(s)) +- src/annotator-helpers/function_analysis.rb:719 `FunctionAnalysis#declare_and_verify_params` return return: T.nilable(T::Array[T::Hash[Symbol, `T.untyped`]]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (9409 call(s)) +- src/annotator-helpers/function_analysis.rb:814 `FunctionAnalysis#verify_captures!` return return: T.nilable(T::Array[`T.untyped`]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (9415 call(s)) +- src/annotator-helpers/function_analysis.rb:854 `FunctionAnalysis#declare_captures` return return: T.nilable(T::Array[`T.untyped`]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (9403 call(s)) +- src/annotator-helpers/function_analysis.rb:1007 `FunctionAnalysis#find_matching_intrinsic` param definitions: T::Array[`T.untyped`] -> T::Array[T::Hash[Symbol, `T.untyped`]] (16275 call(s)) +- src/annotator-helpers/function_analysis.rb:1007 `FunctionAnalysis#find_matching_intrinsic` return return: T.nilable(T::Hash[Symbol, `T.untyped`]) -> T::Hash[Symbol, `T.untyped`] (16275 call(s)) +- src/annotator-helpers/function_analysis.rb:1038 `FunctionAnalysis#format_intrinsic_args` param args: T::Array[`T.untyped`] -> T::Array[Symbol] (10 call(s)) +- src/annotator-helpers/lock_helper.rb:42 `LockHelper#init_lock_analysis!` return return: T::Hash[`T.untyped`, `T.untyped`] -> T::Hash[String, Array] (5760 call(s)) +- src/annotator-helpers/lock_helper.rb:83 `LockHelper#record_lock_clause_site!` return return: T.nilable(T::Array[T::Hash[`T.untyped`, `T.untyped`]]) -> T::Array[T::Hash[Symbol, T::Array[Symbol]]] (1788 call(s)) +- src/annotator-helpers/lock_helper.rb:101 `LockHelper#check_nested_lock_reacquire!` return return: T.nilable(T::Array[T::Hash[Symbol, `T.untyped`]]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (1945 call(s)) +- src/annotator-helpers/lock_helper.rb:130 `LockHelper#check_lock_rank_ordering!` return return: T.nilable(T::Array[T::Hash[`T.untyped`, `T.untyped`]]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (1942 call(s)) +- src/annotator-helpers/lock_helper.rb:179 `LockHelper#record_with_acquire!` return return: T.nilable(T::Array[T::Hash[Symbol, `T.untyped`]]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (1359 call(s)) +- src/annotator-helpers/lock_helper.rb:259 `LockHelper#build_lock_graph` return return: T::Hash[Symbol, `T.untyped`] -> T::Hash[Symbol, T.any(T::Array[LockHelper::LockEdge], T::Hash[Symbol, T::Set[Symbol]], T::Set[Symbol])] (4927 call(s)) +- src/annotator-helpers/lock_helper.rb:331 `LockHelper#check_lock_cycles!` return return: T.nilable(T::Array[`T.untyped`]) -> T::Array[T::Hash[Symbol, T::Array[Symbol]]] (4774 call(s)) +- src/annotator-helpers/lock_helper.rb:350 `LockHelper#check_lock_handler_reachability!` return return: T.nilable(T::Array[`T.untyped`]) -> T::Array[T::Hash[Symbol, T::Array[Symbol]]] (4769 call(s)) +- src/annotator-helpers/lock_helper.rb:381 `LockHelper#verify_handler_reachability!` param site: T::Hash[Symbol, `T.untyped`] -> T::Hash[Symbol, T::Array[Symbol]] (275 call(s)) +- src/annotator-helpers/lock_helper.rb:381 `LockHelper#verify_handler_reachability!` return return: T.nilable(T::Array[T::Hash[Symbol, `T.untyped`]]) -> T::Array[T::Hash[Symbol, `T.untyped`]] (275 call(s)) + +### Weak Collection Slots Without Candidate +- src/annotator-helpers/auto_inference.rb:46 `AutoConstraintCollector#initialize` param fn_nodes: T::Hash[String, `T.untyped`]; key observations String; value observations AST::FunctionDef +- src/annotator-helpers/auto_inference.rb:77 `AutoConstraintCollector#register_signature_slots` return return: T::Hash[`T.untyped`, `T.untyped`]; key observations String; value observations AST::FunctionDef +- src/annotator-helpers/auto_inference.rb:165 `AutoConstraintCollector#record_return` return return: T.nilable(T::Array[`T.untyped`]); element observations are heterogeneous or AST/MIR-specific: AST::BinaryOp, AST::Identifier, AST::Literal, AST::UnaryOp +- src/annotator-helpers/auto_inference.rb:188 `AutoConstraintCollector#record_local` return return: T.nilable(T::Array[`T.untyped`]); element observations are heterogeneous or AST/MIR-specific: AST::Literal, Integer, Symbol +- src/annotator-helpers/auto_inference.rb:228 `AutoConstraintCollector#record_reassignment_sources` return return: T.nilable(T::Array[`T.untyped`]); element observations are heterogeneous or AST/MIR-specific: AST::Literal +- src/annotator-helpers/auto_inference.rb:415 `AutoUnifier#collect_observed_types` return return: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: Symbol, Type +- src/annotator-helpers/auto_inference.rb:530 `ShapeEvidenceCollector#initialize` param fn_nodes: T::Hash[String, `T.untyped`]; key observations String; value observations AST::FunctionDef +- src/annotator-helpers/auto_inference.rb:547 `ShapeEvidenceCollector#collect_in_function` return return: T.nilable(T::Array[`T.untyped`]); element observations are heterogeneous or AST/MIR-specific: AST::Assignment, AST::BindExpr, AST::MethodCall, AST::ReturnNode, AST::VarDecl +- src/annotator-helpers/auto_inference.rb:615 `ShapeEvidenceCollector#record_method_call` return return: T.nilable(T::Array[`T.untyped`]); element observations are heterogeneous or AST/MIR-specific: AST::Literal +- src/annotator-helpers/auto_inference.rb:642 `ShapeEvidenceCollector#record_map_pair_evidence` param args: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: AST::Literal +- src/annotator-helpers/auto_inference.rb:642 `ShapeEvidenceCollector#record_map_pair_evidence` return return: T.nilable(T::Array[`T.untyped`]); element observations are heterogeneous or AST/MIR-specific: AST::Literal +- src/annotator-helpers/auto_inference.rb:651 `ShapeEvidenceCollector#record_index_assign` return return: T.nilable(T::Array[`T.untyped`]); element observations are heterogeneous or AST/MIR-specific: AST::Literal +- src/annotator-helpers/auto_inference.rb:688 `OperatorEvidenceCollector#initialize` param fn_nodes: T::Hash[String, `T.untyped`]; key observations String; value observations AST::FunctionDef +- src/annotator-helpers/auto_inference.rb:703 `OperatorEvidenceCollector#collect_in_function` return return: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BindExpr, AST::MethodCall, AST::ReturnNode, AST::VarDecl +- src/annotator-helpers/auto_inference.rb:778 `OperatorEvidenceCollector#record_binop` return return: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: AST::BinaryOp, AST::Identifier, AST::Literal +- src/annotator-helpers/capabilities.rb:34 `Capabilities#errors_for` return return: T::Array[`T.untyped`]; no element observations +- src/annotator-helpers/capabilities.rb:428 `CapabilityHelper#validate_and_visit_with_guards!` return return: T.nilable(T::Array[T::Hash[`T.untyped`, `T.untyped`]]); method not observed at runtime +- src/annotator-helpers/capabilities.rb:482 `CapabilityHelper#visit_pre_clauses!` return return: T.nilable(T::Array[T::Hash[`T.untyped`, `T.untyped`]]); method not observed at runtime +- src/annotator-helpers/capabilities.rb:640 `CapabilityHelper#acquire_capability!` param cap: T::Hash[Symbol, `T.untyped`]; key observations Symbol; value observations AST::BinaryOp, AST::FuncCall, AST::GetField, AST::Identifier +- src/annotator-helpers/capabilities.rb:640 `CapabilityHelper#acquire_capability!` param expanded: T::Array[T::Hash[Symbol, `T.untyped`]]; element observations are heterogeneous or AST/MIR-specific: Hash +- src/annotator-helpers/capabilities.rb:752 `CapabilityHelper#declare_capability_scope!` param cap: T::Hash[Symbol, `T.untyped`]; key observations Symbol; value observations AST::BinaryOp, AST::FuncCall, AST::GetField, AST::Identifier +- src/annotator-helpers/capabilities.rb:965 `CapabilityHelper#analyze_fiber_captures` param body_exprs: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::ForEach, AST::ForRange +- src/annotator-helpers/capabilities.rb:982 `CapabilityHelper#validate_fiber_captures!` param body: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: AST::BinaryOp, AST::BindExpr, AST::Identifier, AST::WithBlock +- src/annotator-helpers/capabilities.rb:1004 `CapabilityHelper#walk_bg_capture_moves` param stmts: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::ForEach, AST::ForRange, AST::FuncCall +- src/annotator-helpers/capabilities.rb:1004 `CapabilityHelper#walk_bg_capture_moves` return return: T::Array[`T.untyped`]; element observations are heterogeneous or AST/MIR-specific: AST::Assignment, AST::BinaryOp, AST::BindExpr, AST::ForEach, AST::ForRange, AST::FuncCall +- src/annotator-helpers/capabilities.rb:1030 `CapabilityHelper#_unified_capture_walk` return return: T::Array[T::Hash[Symbol, `T.untyped`]]; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::CloneNode +- src/annotator-helpers/capabilities.rb:1318 `CapabilityAudit#capability_audit_init!` return return: T::Hash[String, T::Hash[Symbol, `T.untyped`]]; key observations String; value observations Hash +- src/annotator-helpers/capabilities.rb:1325 `CapabilityAudit#record_capability_binding` return return: T.nilable(T::Hash[`T.untyped`, `T.untyped`]); key observations Symbol; value observations FalseClass, Integer, NilClass, String +- src/annotator-helpers/capabilities.rb:1369 `CapabilityAudit#finalize_capability_audit!` return return: T::Hash[String, T::Hash[Symbol, `T.untyped`]]; key observations String; value observations Hash +- src/annotator-helpers/effects.rb:202 `EffectTracker#compute_effects!` return return: T::Hash[`T.untyped`, `T.untyped`]; key observations String; value observations AST::FunctionDef +- ... 643 more + +### Collection Blocker Pressure +- method_param points array at src/annotator-helpers/effects.rb:671; element observations are heterogeneous or AST/MIR-specific: Hash: 1 slot(s), 616927 observation(s) + - mutation sites: src/annotator-helpers/effects.rb:791 (2007), src/annotator-helpers/effects.rb:798 (1573), src/annotator-helpers/effects.rb:794 (1244) + - src/annotator-helpers/effects.rb:671 `EffectTracker#scan_suspend_points` param points: T::Array[T::Hash[Symbol, `T.untyped`]] +- method_param _borrowed_paths array at src/ast/scope.rb:24; no element observations: 1 slot(s), 490381 observation(s) + - src/ast/scope.rb:24 `Scope#declare` param _borrowed_paths: T::Array[`T.untyped`] +- method_return strip_capability_suffix array at src/ast/type.rb:1966; element observations are heterogeneous or AST/MIR-specific: NilClass, String, Symbol: 1 slot(s), 476259 observation(s) + - src/ast/type.rb:1966 `Type#strip_capability_suffix` return return: T::Array[`T.untyped`] +- method_return walk_all_nodes array at src/mir/control_flow.rb:1657; candidate still contains `T.untyped`: T::Array[`T.untyped`]: 1 slot(s), 358241 observation(s) + - src/mir/control_flow.rb:1657 `LoopFrameAnalysis#walk_all_nodes` return return: T.nilable(T::Array[`T.untyped`]) +- method_param body array at src/ast/ast.rb:18; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BlockExpr: 1 slot(s), 347740 observation(s) + - src/ast/ast.rb:18 `AST#walk_body` param body: T::Array[`T.untyped`] +- method_return walk_body array at src/ast/ast.rb:18; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BlockExpr: 1 slot(s), 347658 observation(s) + - src/ast/ast.rb:18 `AST#walk_body` return return: T.nilable(T::Array[`T.untyped`]) +- method_param out array at src/annotator.rb:6256; element observations are heterogeneous or AST/MIR-specific: AST::FuncCall: 1 slot(s), 127511 observation(s) + - mutation sites: src/annotator.rb:7295 (353) + - src/annotator.rb:6256 `SemanticAnnotator#collect_self_calls` param out: T::Array[`T.untyped`] +- method_return collect_self_calls array at src/annotator.rb:6256; element observations are heterogeneous or AST/MIR-specific: AST::FuncCall: 1 slot(s), 127511 observation(s) + - mutation sites: src/annotator.rb:7295 (353) + - src/annotator.rb:6256 `SemanticAnnotator#collect_self_calls` return return: T::Array[`T.untyped`] +- method_param out array at src/annotator.rb:6272; element observations are heterogeneous or AST/MIR-specific: AST::ReturnNode: 1 slot(s), 118326 observation(s) + - mutation sites: src/annotator.rb:7324 (1655) + - src/annotator.rb:6272 `SemanticAnnotator#collect_returns` param out: T::Array[`T.untyped`] +- method_return collect_returns array at src/annotator.rb:6272; element observations are heterogeneous or AST/MIR-specific: AST::ReturnNode: 1 slot(s), 118326 observation(s) + - mutation sites: src/annotator.rb:7324 (1655) + - src/annotator.rb:6272 `SemanticAnnotator#collect_returns` return return: T::Array[`T.untyped`] +- method_return each_bg_block array at src/ast/ast.rb:60; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 55424 observation(s) + - src/ast/ast.rb:60 `AST#each_bg_block` return return: T.nilable(T::Array[`T.untyped`]) +- method_return process_pattern array at src/ast/parser.rb:474; element observations are heterogeneous or AST/MIR-specific: AST::BgStreamBlock, AST::BinaryOp, AST::CapabilityWrap, AST::CopyNode, AST::FuncCall, AST::GetField: 1 slot(s), 53270 observation(s) + - mutation sites: src/ast/parser.rb:39 (11509) + - src/ast/parser.rb:474 `Parser#process_pattern` return return: T.nilable(T::Array[`T.untyped`]) +- method_return parse_block_body array at src/ast/parser.rb:1694; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 43956 observation(s) + - src/ast/parser.rb:1694 `Parser#parse_block_body` return return: T.nilable(T::Array[`T.untyped`]) +- method_return _bg_visit_recursive array at src/ast/ast.rb:67; element observations are heterogeneous or AST/MIR-specific: AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::CapabilityWrap, AST::CopyNode: 1 slot(s), 35441 observation(s) + - src/ast/ast.rb:67 `AST#_bg_visit_recursive` return return: T.nilable(T::Array[`T.untyped`]) +- method_param result array at src/mir/mir_pass.rb:589; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 35337 observation(s) + - mutation sites: src/mir/mir_pass.rb:418 (17375), src/mir/mir_pass.rb:422 (172), src/mir/mir_pass.rb:1437 (141) + - src/mir/mir_pass.rb:589 `MIRPass#insert_bg_escape_promote!` param result: T::Array[`T.untyped`] +- method_param result array at src/mir/mir_pass.rb:630; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 35337 observation(s) + - mutation sites: src/mir/mir_pass.rb:418 (17375), src/mir/mir_pass.rb:422 (172), src/mir/mir_pass.rb:1437 (141) + - src/mir/mir_pass.rb:630 `MIRPass#insert_or_fallback_dupe!` param result: T::Array[`T.untyped`] +- method_param result array at src/mir/mir_pass.rb:881; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 35337 observation(s) + - mutation sites: src/mir/mir_pass.rb:418 (17375), src/mir/mir_pass.rb:422 (172), src/mir/mir_pass.rb:1437 (141) + - src/mir/mir_pass.rb:881 `MIRPass#insert_container_promote!` param result: T::Array[`T.untyped`] +- method_param params array at src/annotator-helpers/function_signature.rb:66; element observations are heterogeneous or AST/MIR-specific: Hash: 1 slot(s), 31599 observation(s) + - src/annotator-helpers/function_signature.rb:66 `FunctionSignature#initialize` param params: T::Array[T::Hash[Symbol, `T.untyped`]] +- method_param result array at src/mir/mir_pass.rb:399; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 30036 observation(s) + - mutation sites: src/mir/mir_pass.rb:418 (12177), src/mir/mir_pass.rb:1437 (141), src/mir/mir_pass.rb:1433 (138) + - src/mir/mir_pass.rb:399 `MIRPass#insert_suppress_cleanup!` param result: T::Array[`T.untyped`] +- method_param result array at src/mir/mir_pass.rb:422; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 30035 observation(s) + - mutation sites: src/mir/mir_pass.rb:418 (12177), src/mir/mir_pass.rb:1437 (141), src/mir/mir_pass.rb:1433 (138) + - src/mir/mir_pass.rb:422 `MIRPass#insert_bg_give_suppress!` param result: T::Array[`T.untyped`] +- method_param result array at src/mir/mir_pass.rb:446; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BreakNode: 1 slot(s), 30035 observation(s) + - mutation sites: src/mir/mir_pass.rb:418 (12177), src/mir/mir_pass.rb:1437 (141), src/mir/mir_pass.rb:1433 (138) + - src/mir/mir_pass.rb:446 `MIRPass#insert_bg_resource_suppress!` param result: T::Array[`T.untyped`] +- method_param pattern array at src/ast/parser.rb:42; element observations are heterogeneous or AST/MIR-specific: String, Symbol: 1 slot(s), 28797 observation(s) + - src/ast/parser.rb:42 `Parser#primary` param pattern: T.nilable(T::Array[`T.untyped`]) +- method_return flush_pending array at src/mir/mir_lowering.rb:98; element observations are heterogeneous or AST/MIR-specific: MIR::AllocMark, MIR::Cleanup, MIR::ErrCleanup, MIR::Let: 1 slot(s), 27785 observation(s) + - src/mir/mir_lowering.rb:98 `MIRLowering#flush_pending` return return: T::Array[`T.untyped`] +- method_return declare_type hash at src/ast/scope.rb:122; candidate still contains `T.untyped`: T::Hash[Symbol, T::Hash[T.any(String, Symbol), `T.untyped`]]: 1 slot(s), 26991 observation(s) + - src/ast/scope.rb:122 `Scope#declare_type` return return: T::Hash[Symbol, `T.untyped`] +- method_param stmts array at src/annotator.rb:1095; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::BlockExpr: 1 slot(s), 25399 observation(s) + - src/annotator.rb:1095 `SemanticAnnotator#visit_stmts` param stmts: T.nilable(T::Array[`T.untyped`]) +- method_param body array at src/mir/promotion_plan.rb:378; element observations are heterogeneous or AST/MIR-specific: AST::Assert, AST::Assignment, AST::BgBlock, AST::BinaryOp, AST::BindExpr, AST::DoBlock: 1 slot(s), 24223 observation(s) + - src/mir/promotion_plan.rb:378 `CleanupClassifier#body_calls_promoted?` param body: T::Array[`T.untyped`] +- method_return coerce! array at src/ast/ast.rb:346; element observations are heterogeneous or AST/MIR-specific: NilClass, String, Symbol, Type: 1 slot(s), 21612 observation(s) + - src/ast/ast.rb:346 `AST::Locatable#coerce!` return return: T::Array[`T.untyped`] +- method_return fork_lightweight hash at src/mir/ownership_graph.rb:212; key observations Symbol; value observations Hash, Integer: 1 slot(s), 21147 observation(s) + - src/mir/ownership_graph.rb:212 `OwnershipGraph#fork_lightweight` return return: T::Hash[Symbol, T::Hash[String, T::Hash[Symbol, `T.untyped`]]] +- method_return errors_for array at src/annotator-helpers/capabilities.rb:34; no element observations: 1 slot(s), 19769 observation(s) + - src/annotator-helpers/capabilities.rb:34 `Capabilities#errors_for` return return: T::Array[`T.untyped`] +- method_return resolve_resource_close array at src/ast/type.rb:932; element observations are heterogeneous or AST/MIR-specific: FalseClass, NilClass, String, TrueClass: 1 slot(s), 19769 observation(s) + - src/ast/type.rb:932 `Type#resolve_resource_close` return return: T::Array[`T.untyped`] + +### Runtime Collection Mutation Observations +- method_param: 113675 slot(s) +- ivar: 113332 slot(s) +- method_return: 111128 slot(s) +- struct_field: 32426 slot(s) + - src/ast/lexer.rb:42 ivar @tokens; array; T::Array[Lexer::Token]; 653919 observation(s) + - src/tools/lint_fix_rewriter.rb:68 method_param set; set; T::Set[String]; 561096 observation(s) + - src/tools/lint_fix_rewriter.rb:89 method_param set; set; T::Set[String]; 560815 observation(s) + - src/tools/lint_fix_rewriter.rb:197 method_param edits; array; T::Array[Hash]; 560247 observation(s) + - src/tools/predicate_rewriter.rb:103 method_param edits; array; T::Array[Hash]; 552178 observation(s) + - src/tools/method_rewriter.rb:138 method_param edits; array; T::Array[Hash]; 551576 observation(s) + - src/tools/method_rewriter.rb:138 method_param methods; set; T::Set[String]; 551428 observation(s) + - src/tools/method_rewriter.rb:65 method_param fns; set; T::Set[String]; 511544 observation(s) + - src/tools/method_rewriter.rb:65 method_param methods; set; T::Set[`T.untyped`]; 509654 observation(s) + - src/tools/formatter.rb:151 ivar @out; array; T::Array[Formatter::FormatLexer::Token]; 300641 observation(s) + - src/annotator-helpers/effects.rb:671 method_param points; array; T::Array[Hash]; 192704 observation(s) + - src/ast/type.rb:1966 method_return strip_capability_suffix; array; T::Array[T.nilable(String)]; 122214 observation(s) + - src/ast/scope.rb:22 ivar @locals; hash; T::Hash[String, SymbolEntry]; 87262 observation(s) + - src/ast/scope.rb:25 ivar @owned_names; set; T::Set[String]; 87262 observation(s) + - src/ast/symbol_entry.rb:181 ivar @capabilities; set; T::Set[Symbol]; 85743 observation(s) + - src/ast/scope.rb:24 method_param _borrowed_paths; array; T::Array[`T.untyped`]; 85186 observation(s) + - src/ast/lexer.rb:42 ivar @tokens; array; T::Array[Lexer::Token]; 82293 observation(s) + - src/annotator-helpers/effects.rb:671 method_param points; array; T::Array[Hash]; 69579 observation(s) + - src/tools/lint_fix_rewriter.rb:211 method_param n; array; T::Array[`T.untyped`]; 69174 observation(s) + - src/ast/type.rb:1966 method_return strip_capability_suffix; array; T::Array[T.nilable(String)]; 51798 observation(s) + - src/tools/formatter.rb:2560 ivar @generic_bracket_indices; set; T::Set[Integer]; 50976 observation(s) + - src/tools/formatter.rb:2561 ivar @struct_lit_brace_indices; set; T::Set[Integer]; 50976 observation(s) + - src/ast/scope.rb:22 ivar @locals; hash; T::Hash[String, SymbolEntry]; 45871 observation(s) + - src/ast/scope.rb:25 ivar @owned_names; set; T::Set[String]; 45871 observation(s) + - src/ast/symbol_entry.rb:181 ivar @capabilities; set; T::Set[Symbol]; 45591 observation(s) + - src/ast/scope.rb:24 method_param _borrowed_paths; array; T::Array[`T.untyped`]; 45342 observation(s) + - src/ast/parser.rb:3905 method_return parse_comma_seq; array; T::Array[`T.untyped`]; 40091 observation(s) + - src/ast/lexer.rb:42 ivar @tokens; array; T::Array[Lexer::Token]; 32772 observation(s) + - src/ast/lexer.rb:42 ivar @tokens; array; T::Array[Lexer::Token]; 32772 observation(s) + - src/ast/lexer.rb:42 ivar @tokens; array; T::Array[Lexer::Token]; 32772 observation(s) + - src/ast/parser.rb:474 method_return process_pattern; array; T::Array[`T.untyped`]; 30052 observation(s) + - src/ast/lexer.rb:42 ivar @tokens; array; T::Array[Lexer::Token]; 28485 observation(s) + - src/ast/ast.rb:18 method_param body; array; T::Array[`T.untyped`]; 27179 observation(s) + - src/ast/ast.rb:18 method_return walk_body; array; T::Array[`T.untyped`]; 27179 observation(s) + - src/mir/control_flow.rb:1657 method_return walk_all_nodes; array; T::Array[`T.untyped`]; 27097 observation(s) + - src/annotator-helpers/effects.rb:671 method_param points; array; T::Array[Hash]; 27047 observation(s) + - src/annotator-helpers/effects.rb:671 method_param points; array; T::Array[Hash]; 27047 observation(s) + - src/ast/ast.rb:18 method_param body; array; T::Array[`T.untyped`]; 26604 observation(s) + - src/ast/ast.rb:18 method_return walk_body; array; T::Array[`T.untyped`]; 26604 observation(s) + - src/ast/scope.rb:22 ivar @locals; hash; T::Hash[String, SymbolEntry]; 26418 observation(s) + +### Collection Index Lookup Provenance +- provenance: the inferred origin of the collection receiver being indexed with `[]`, `fetch`, or similar lookup syntax +- receiver origin: the parameter, literal, forwarded return, instance variable, or local record that produced the indexed receiver +- weak index lookup: an index lookup where the receiver is unknown, `T.untyped`, or a weak collection type +- unknown receiver type: 2207 +- weak collection receiver: 811 +- typed lookup: 513 +- typed collection receiver: 225 +- non-collection or unresolved receiver: 87 + +### Unknown Or Weak Index Lookups By Receiver Origin +- local hash record c at src/tools/doctor.rb: 74 + - src/tools/doctor.rb:393 c[:pushes]; receiver c; index :pushes; receiver type unknown + - src/tools/doctor.rb:393 c[:pops]; receiver c; index :pops; receiver type unknown + - src/tools/doctor.rb:402 c[:capacity]; receiver c; index :capacity; receiver type unknown + - src/tools/doctor.rb:403 c[:max_depth]; receiver c; index :max_depth; receiver type unknown + - src/tools/doctor.rb:404 c[:pushes]; receiver c; index :pushes; receiver type unknown +- local hash record c at src/annotator.rb: 63 + - src/annotator.rb:797 c[:body]; receiver c; index :body; receiver type unknown + - src/annotator.rb:851 c[:body]; receiver c; index :body; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/annotator.rb:1452 c[:binding]; receiver c; index :binding; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/annotator.rb:1453 c[:value]; receiver c; index :value; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/annotator.rb:1454 c[:value]; receiver c; index :value; receiver type T::Hash[`T.untyped`, `T.untyped`] +- local hash record s at src/tools/doctor.rb: 57 + - src/tools/doctor.rb:164 s[:trace]; receiver s; index :trace; receiver type unknown + - src/tools/doctor.rb:209 s[:trace]; receiver s; index :trace; receiver type unknown + - src/tools/doctor.rb:213 s[:bytes]; receiver s; index :bytes; receiver type unknown + - src/tools/doctor.rb:214 s[:allocs]; receiver s; index :allocs; receiver type unknown + - src/tools/doctor.rb:225 s[:trace]; receiver s; index :trace; receiver type unknown +- local hash record c at src/mir/mir_lowering.rb: 54 + - src/mir/mir_lowering.rb:1647 c[:body]; receiver c; index :body; receiver type unknown + - src/mir/mir_lowering.rb:1647 c[:body]; receiver c; index :body; receiver type unknown + - src/mir/mir_lowering.rb:1675 c[:body]; receiver c; index :body; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/mir_lowering.rb:2288 c[:name]; receiver c; index :name; receiver type unknown + - src/mir/mir_lowering.rb:2612 c[:capability]; receiver c; index :capability; receiver type unknown +- local hash record param at src/annotator-helpers/function_analysis.rb: 53 + - src/annotator-helpers/function_analysis.rb:72 param[:name]; receiver param; index :name; receiver type unknown + - src/annotator-helpers/function_analysis.rb:73 param[:type]; receiver param; index :type; receiver type unknown + - src/annotator-helpers/function_analysis.rb:74 param[:default]; receiver param; index :default; receiver type unknown + - src/annotator-helpers/function_analysis.rb:75 param[:default]; receiver param; index :default; receiver type unknown + - src/annotator-helpers/function_analysis.rb:76 param[:mutable]; receiver param; index :mutable; receiver type unknown +- local hash record d at src/tools/doctor.rb: 44 + - src/tools/doctor.rb:1471 d[:delta_bytes]; receiver d; index :delta_bytes; receiver type unknown + - src/tools/doctor.rb:1471 d[:delta_allocs]; receiver d; index :delta_allocs; receiver type unknown + - src/tools/doctor.rb:1472 d[:delta_bytes]; receiver d; index :delta_bytes; receiver type unknown + - src/tools/doctor.rb:1480 d[:delta_bytes]; receiver d; index :delta_bytes; receiver type unknown + - src/tools/doctor.rb:1482 d[:func]; receiver d; index :func; receiver type unknown +- hash record param cap at src/annotator-helpers/capabilities.rb:752: 43 + - src/annotator-helpers/capabilities.rb:755 cap[:var_node]; receiver cap; index :var_node; receiver type T::Hash[Symbol, `T.untyped`] + - src/annotator-helpers/capabilities.rb:756 cap[:old_scope]; receiver cap; index :old_scope; receiver type T::Hash[Symbol, `T.untyped`] + - src/annotator-helpers/capabilities.rb:759 cap[:var_node]; receiver cap; index :var_node; receiver type T::Hash[Symbol, `T.untyped`] + - src/annotator-helpers/capabilities.rb:763 cap[:capability]; receiver cap; index :capability; receiver type T::Hash[Symbol, `T.untyped`] + - src/annotator-helpers/capabilities.rb:763 cap[:capability]; receiver cap; index :capability; receiver type T::Hash[Symbol, `T.untyped`] +- local hash record p at src/mir/mir_lowering.rb: 40 + - src/mir/mir_lowering.rb:1171 p[:mutable]; receiver p; index :mutable; receiver type unknown + - src/mir/mir_lowering.rb:1172 p[:type]; receiver p; index :type; receiver type unknown + - src/mir/mir_lowering.rb:1172 p[:type]; receiver p; index :type; receiver type unknown + - src/mir/mir_lowering.rb:1172 p[:type]; receiver p; index :type; receiver type unknown + - src/mir/mir_lowering.rb:1175 p[:type]; receiver p; index :type; receiver type unknown +- local hash record f at src/annotator.rb: 36 + - src/annotator.rb:581 f[:type]; receiver f; index :type; receiver type unknown + - src/annotator.rb:1044 f[:form]; receiver f; index :form; receiver type unknown + - src/annotator.rb:1046 f[:value]; receiver f; index :value; receiver type unknown + - src/annotator.rb:1048 f[:token]; receiver f; index :token; receiver type unknown + - src/annotator.rb:1048 f[:value]; receiver f; index :value; receiver type unknown +- local hash record p at src/annotator.rb: 33 + - src/annotator.rb:265 p[:type]; receiver p; index :type; receiver type unknown + - src/annotator.rb:265 p[:type]; receiver p; index :type; receiver type unknown + - src/annotator.rb:544 p[:name]; receiver p; index :name; receiver type unknown + - src/annotator.rb:545 p[:type]; receiver p; index :type; receiver type unknown + - src/annotator.rb:546 p[:default]; receiver p; index :default; receiver type unknown +- local hash record e at src/mir/mir_lowering.rb: 32 + - src/mir/mir_lowering.rb:1281 e[:alloc]; receiver e; index :alloc; receiver type unknown + - src/mir/mir_lowering.rb:3503 e[:guard_var]; receiver e; index :guard_var; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/mir_lowering.rb:3503 e[:lock_expr]; receiver e; index :lock_expr; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/mir_lowering.rb:3503 e[:method]; receiver e; index :method; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/mir_lowering.rb:3506 e[:addr_expr]; receiver e; index :addr_expr; receiver type T::Hash[`T.untyped`, `T.untyped`] +- local hash record cap at src/mir/mir_lowering.rb: 31 + - src/mir/mir_lowering.rb:2623 cap[:var_node]; receiver cap; index :var_node; receiver type unknown + - src/mir/mir_lowering.rb:2624 cap[:alias]; receiver cap; index :alias; receiver type unknown + - src/mir/mir_lowering.rb:2637 cap[:var_node]; receiver cap; index :var_node; receiver type unknown + - src/mir/mir_lowering.rb:2639 cap[:alias]; receiver cap; index :alias; receiver type unknown + - src/mir/mir_lowering.rb:2640 cap[:resolved_type]; receiver cap; index :resolved_type; receiver type unknown +- hash record return [] at src/annotator-helpers/method_analysis.rb:60: 26 + - src/annotator-helpers/method_analysis.rb:73 defn[:arity]; receiver defn; index :arity; receiver type unknown + - src/annotator-helpers/method_analysis.rb:73 defn[:arity]; receiver defn; index :arity; receiver type unknown + - src/annotator-helpers/method_analysis.rb:74 defn[:arity]; receiver defn; index :arity; receiver type unknown + - src/annotator-helpers/method_analysis.rb:77 defn[:arity]; receiver defn; index :arity; receiver type unknown + - src/annotator-helpers/method_analysis.rb:83 defn[:validate]; receiver defn; index :validate; receiver type unknown +- local variable f: 26 + - src/tools/pprof_converter.rb:115 f[0]; receiver f; index 0; receiver type unknown + - src/tools/pprof_converter.rb:118 f[1]; receiver f; index 1; receiver type unknown + - src/tools/pprof_converter.rb:119 f[2]; receiver f; index 2; receiver type unknown + - src/tools/pprof_converter.rb:120 f[3]; receiver f; index 3; receiver type unknown + - src/tools/pprof_converter.rb:121 f[4]; receiver f; index 4; receiver type unknown +- local hash record r at src/tools/doctor.rb: 24 + - src/tools/doctor.rb:506 r[:runs]; receiver r; index :runs; receiver type unknown + - src/tools/doctor.rb:507 r[:runs]; receiver r; index :runs; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/tools/doctor.rb:509 r[:runs]; receiver r; index :runs; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/tools/doctor.rb:511 r[:idx]; receiver r; index :idx; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/tools/doctor.rb:511 r[:runs]; receiver r; index :runs; receiver type T::Hash[`T.untyped`, `T.untyped`] +- local hash record schema at src/ast/type.rb: 22 + - src/ast/type.rb:942 schema[:close_zig]; receiver schema; index :close_zig; receiver type unknown + - src/ast/type.rb:946 schema[:kind]; receiver schema; index :kind; receiver type unknown + - src/ast/type.rb:1427 schema[:kind]; receiver schema; index :kind; receiver type unknown + - src/ast/type.rb:1428 schema[:kind]; receiver schema; index :kind; receiver type unknown + - src/ast/type.rb:1429 schema[:variants]; receiver schema; index :variants; receiver type unknown +- hash record param ctx at src/mir/fsm_transform/emit.rb:902: 21 + - src/mir/fsm_transform/emit.rb:904 ctx[:pin_mode]; receiver ctx; index :pin_mode; receiver type unknown + - src/mir/fsm_transform/emit.rb:904 ctx[:pin_mode]; receiver ctx; index :pin_mode; receiver type unknown + - src/mir/fsm_transform/emit.rb:905 ctx[:pin_mode]; receiver ctx; index :pin_mode; receiver type unknown + - src/mir/fsm_transform/emit.rb:905 ctx[:pin_mode]; receiver ctx; index :pin_mode; receiver type unknown + - src/mir/fsm_transform/emit.rb:905 ctx[:parallel]; receiver ctx; index :parallel; receiver type unknown +- local hash record f at src/tools/stack_verifier.rb: 21 + - src/tools/stack_verifier.rb:99 f[:name]; receiver f; index :name; receiver type unknown + - src/tools/stack_verifier.rb:99 f[:stack_bytes]; receiver f; index :stack_bytes; receiver type unknown + - src/tools/stack_verifier.rb:101 f[:name]; receiver f; index :name; receiver type unknown + - src/tools/stack_verifier.rb:117 f[:stack_bytes]; receiver f; index :stack_bytes; receiver type unknown + - src/tools/stack_verifier.rb:123 f[:name]; receiver f; index :name; receiver type unknown +- local hash record sel at src/annotator.rb: 20 + - src/annotator.rb:913 sel[:form]; receiver sel; index :form; receiver type unknown + - src/annotator.rb:914 sel[:name]; receiver sel; index :name; receiver type unknown + - src/annotator.rb:916 sel[:token]; receiver sel; index :token; receiver type unknown + - src/annotator.rb:920 sel[:token]; receiver sel; index :token; receiver type unknown + - src/annotator.rb:931 sel[:form]; receiver sel; index :form; receiver type unknown +- hash record param ctx at src/mir/fsm_transform/emit.rb:296: 18 + - src/mir/fsm_transform/emit.rb:300 ctx[:id]; receiver ctx; index :id; receiver type unknown + - src/mir/fsm_transform/emit.rb:301 ctx[:bg_rt]; receiver ctx; index :bg_rt; receiver type unknown + - src/mir/fsm_transform/emit.rb:302 ctx[:captured]; receiver ctx; index :captured; receiver type unknown + - src/mir/fsm_transform/emit.rb:303 ctx[:bg_string_promotes]; receiver ctx; index :bg_string_promotes; receiver type unknown + - src/mir/fsm_transform/emit.rb:304 ctx[:capture_close_zig]; receiver ctx; index :capture_close_zig; receiver type unknown +- hash record param result at src/ast/parser.rb:3097: 18 + - src/ast/parser.rb:3100 result[:ownership]; receiver result; index :ownership; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/ast/parser.rb:3103 result[:ownership]; receiver result; index :ownership; receiver type T::Hash[Symbol, Symbol] + - src/ast/parser.rb:3106 result[:ownership]; receiver result; index :ownership; receiver type T::Hash[Symbol, Symbol] + - src/ast/parser.rb:3109 result[:ownership]; receiver result; index :ownership; receiver type T::Hash[Symbol, Symbol] + - src/ast/parser.rb:3112 result[:sync]; receiver result; index :sync; receiver type T::Hash[Symbol, Symbol] +- hash record return find_matching_intrinsic at src/annotator.rb:2524: 18 + - src/annotator.rb:2550 matched_def[:reject_when]; receiver matched_def; index :reject_when; receiver type unknown + - src/annotator.rb:2550 matched_def[:reject_when]; receiver matched_def; index :reject_when; receiver type unknown + - src/annotator.rb:2551 matched_def[:reject_error]; receiver matched_def; index :reject_error; receiver type unknown + - src/annotator.rb:2560 matched_def[:return]; receiver matched_def; index :return; receiver type unknown + - src/annotator.rb:2573 matched_def[:zig]; receiver matched_def; index :zig; receiver type unknown +- local hash record req at src/annotator-helpers/union.rb: 18 + - src/annotator-helpers/union.rb:21 req[:name]; receiver req; index :name; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/annotator-helpers/union.rb:22 req[:token]; receiver req; index :token; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/annotator-helpers/union.rb:22 req[:name]; receiver req; index :name; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/annotator-helpers/union.rb:24 req[:name]; receiver req; index :name; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/annotator-helpers/union.rb:28 req[:name]; receiver req; index :name; receiver type T::Hash[`T.untyped`, `T.untyped`] +- local variable args: 18 + - src/annotator-helpers/auto_inference.rb:628 args[0]; receiver args; index 0; receiver type T::Array[`T.untyped`] + - src/annotator.rb:5567 args[idx]; receiver args; index idx; receiver type T::Array[`T.untyped`] + - src/annotator.rb:5567 args[idx]; receiver args; index idx; receiver type T::Array[`T.untyped`] + - src/annotator.rb:5599 args[param_index]; receiver args; index param_index; receiver type T::Array[`T.untyped`] + - src/ast/std_lib.rb:213 args[0]; receiver args; index 0; receiver type unknown +- hash record return cleanup_entry at src/mir/mir_emitter.rb:985: 17 + - src/mir/mir_emitter.rb:988 entry[:has_moved_guard]; receiver entry; index :has_moved_guard; receiver type unknown + - src/mir/mir_emitter.rb:989 entry[:zig_type]; receiver entry; index :zig_type; receiver type unknown + - src/mir/mir_emitter.rb:995 entry[:elem_zig_type]; receiver entry; index :elem_zig_type; receiver type unknown + - src/mir/mir_emitter.rb:999 entry[:via_pointer]; receiver entry; index :via_pointer; receiver type unknown + - src/mir/mir_emitter.rb:1002 entry[:kind]; receiver entry; index :kind; receiver type unknown +- hash record return options at src/annotator-helpers/pipe_analysis.rb:388: 16 + - src/annotator-helpers/pipe_analysis.rb:401 opts["size"]; receiver opts; index "size"; receiver type unknown + - src/annotator-helpers/pipe_analysis.rb:402 opts["size"]; receiver opts; index "size"; receiver type unknown + - src/annotator-helpers/pipe_analysis.rb:403 opts["size"]; receiver opts; index "size"; receiver type unknown + - src/annotator-helpers/pipe_analysis.rb:405 opts["size"]; receiver opts; index "size"; receiver type unknown + - src/annotator-helpers/pipe_analysis.rb:407 opts["size"]; receiver opts; index "size"; receiver type unknown +- local hash record c at src/tools/pprof_converter.rb: 16 + - src/tools/pprof_converter.rb:68 c[:pushes]; receiver c; index :pushes; receiver type unknown + - src/tools/pprof_converter.rb:68 c[:pops]; receiver c; index :pops; receiver type unknown + - src/tools/pprof_converter.rb:272 c[:reads]; receiver c; index :reads; receiver type unknown + - src/tools/pprof_converter.rb:272 c[:commits]; receiver c; index :commits; receiver type unknown + - src/tools/pprof_converter.rb:275 c[:addr]; receiver c; index :addr; receiver type unknown +- local hash record l at src/tools/pprof_converter.rb: 16 + - src/tools/pprof_converter.rb:211 l[:acquires]; receiver l; index :acquires; receiver type unknown + - src/tools/pprof_converter.rb:211 l[:read_acquires]; receiver l; index :read_acquires; receiver type unknown + - src/tools/pprof_converter.rb:217 l[:addr]; receiver l; index :addr; receiver type unknown + - src/tools/pprof_converter.rb:217 l[:caller_trace]; receiver l; index :caller_trace; receiver type unknown + - src/tools/pprof_converter.rb:232 l[:addr]; receiver l; index :addr; receiver type unknown +- local hash record spec at src/mir/fsm_transform/emit.rb: 16 + - src/mir/fsm_transform/emit.rb:131 spec[:prologue_stmts]; receiver spec; index :prologue_stmts; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/fsm_transform/emit.rb:132 spec[:index]; receiver spec; index :index; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/fsm_transform/emit.rb:134 spec[:index]; receiver spec; index :index; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/fsm_transform/emit.rb:137 spec[:fn_name]; receiver spec; index :fn_name; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/mir/fsm_transform/emit.rb:138 spec[:body_stmts]; receiver spec; index :body_stmts; receiver type T::Hash[`T.untyped`, `T.untyped`] +- forwarded return let at src/mir/ownership_graph.rb:43: 15 + - src/mir/ownership_graph.rb:96 @nodes[from]; receiver @nodes; index from; receiver type unknown + - src/mir/ownership_graph.rb:114 @nodes[path]; receiver @nodes; index path; receiver type unknown + - src/mir/ownership_graph.rb:123 @nodes[source]; receiver @nodes; index source; receiver type unknown + - src/mir/ownership_graph.rb:151 @nodes[path]; receiver @nodes; index path; receiver type unknown + - src/mir/ownership_graph.rb:156 @nodes[p]; receiver @nodes; index p; receiver type unknown +- hash record return let at src/tools/doctor.rb:1387: 15 + - src/tools/doctor.rb:74 @opts[:ignore]; receiver @opts; index :ignore; receiver type unknown + - src/tools/doctor.rb:74 @opts[:ignore]; receiver @opts; index :ignore; receiver type unknown + - src/tools/doctor.rb:75 @opts[:focus]; receiver @opts; index :focus; receiver type unknown + - src/tools/doctor.rb:76 @opts[:focus]; receiver @opts; index :focus; receiver type unknown + - src/tools/doctor.rb:81 @opts[:cumulative]; receiver @opts; index :cumulative; receiver type unknown +- local hash record op at src/mir/mir_lowering.rb: 15 + - src/mir/mir_lowering.rb:6450 op[:shard_direct_zig]; receiver op; index :shard_direct_zig; receiver type unknown + - src/mir/mir_lowering.rb:6454 op[:shard_direct_value_transforms]; receiver op; index :shard_direct_value_transforms; receiver type unknown + - src/mir/mir_lowering.rb:6454 op[:value_transforms]; receiver op; index :value_transforms; receiver type unknown + - src/mir/mir_lowering.rb:6455 op[:value_transforms]; receiver op; index :value_transforms; receiver type unknown + - src/mir/mir_lowering.rb:6493 op[:sharded_zig]; receiver op; index :sharded_zig; receiver type unknown +- local hash record r at src/tools/pprof_converter.rb: 15 + - src/tools/pprof_converter.rb:82 r[:id]; receiver r; index :id; receiver type unknown + - src/tools/pprof_converter.rb:84 r[:id]; receiver r; index :id; receiver type unknown + - src/tools/pprof_converter.rb:87 r[:pushes]; receiver r; index :pushes; receiver type unknown + - src/tools/pprof_converter.rb:87 r[:pops]; receiver r; index :pops; receiver type unknown + - src/tools/pprof_converter.rb:87 r[:push_blocked]; receiver r; index :push_blocked; receiver type unknown +- local hash record var_data at src/mir/mir_lowering.rb: 15 + - src/mir/mir_lowering.rb:973 var_data[:indirect_fields]; receiver var_data; index :indirect_fields; receiver type unknown + - src/mir/mir_lowering.rb:974 var_data[:indirect_fields]; receiver var_data; index :indirect_fields; receiver type unknown + - src/mir/mir_lowering.rb:981 var_data[:kind]; receiver var_data; index :kind; receiver type unknown + - src/mir/mir_lowering.rb:982 var_data[:indirect_fields]; receiver var_data; index :indirect_fields; receiver type unknown + - src/mir/mir_lowering.rb:983 var_data[:fields]; receiver var_data; index :fields; receiver type unknown +- hash record param m at src/tools/pprof.rb:273: 14 + - src/tools/pprof.rb:274 m[:id]; receiver m; index :id; receiver type unknown + - src/tools/pprof.rb:275 m[:memory_start]; receiver m; index :memory_start; receiver type unknown + - src/tools/pprof.rb:275 m[:memory_start]; receiver m; index :memory_start; receiver type unknown + - src/tools/pprof.rb:276 m[:memory_limit]; receiver m; index :memory_limit; receiver type unknown + - src/tools/pprof.rb:276 m[:memory_limit]; receiver m; index :memory_limit; receiver type unknown +- hash record return build_lazy_range_prefix at src/backends/pipeline_host.rb:3249: 14 + - src/backends/pipeline_host.rb:3250 p[:item_var]; receiver p; index :item_var; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/backends/pipeline_host.rb:3251 p[:source_name]; receiver p; index :source_name; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/backends/pipeline_host.rb:3251 p[:next_method]; receiver p; index :next_method; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/backends/pipeline_host.rb:3267 p[:source_name]; receiver p; index :source_name; receiver type T::Hash[`T.untyped`, `T.untyped`] + - src/backends/pipeline_host.rb:3271 p[:initial_capture]; receiver p; index :initial_capture; receiver type T::Hash[`T.untyped`, `T.untyped`] +- hash record return let at src/annotator-helpers/capabilities.rb:545: 14 + - src/annotator-helpers/capabilities.rb:316 ctx[:kind]; receiver ctx; index :kind; receiver type unknown + - src/annotator-helpers/capabilities.rb:318 ctx[:alias]; receiver ctx; index :alias; receiver type unknown + - src/annotator-helpers/capabilities.rb:321 ctx[:sibling_aliases]; receiver ctx; index :sibling_aliases; receiver type unknown + - src/annotator-helpers/capabilities.rb:336 ctx[:param_names]; receiver ctx; index :param_names; receiver type unknown + - src/annotator-helpers/capabilities.rb:341 ctx[:fn_name]; receiver ctx; index :fn_name; receiver type unknown +- local hash record param at src/mir/mir_lowering.rb: 14 + - src/mir/mir_lowering.rb:1196 param[:name]; receiver param; index :name; receiver type unknown + - src/mir/mir_lowering.rb:1196 param[:name]; receiver param; index :name; receiver type unknown + - src/mir/mir_lowering.rb:1196 param[:name]; receiver param; index :name; receiver type unknown + - src/mir/mir_lowering.rb:1197 param[:type]; receiver param; index :type; receiver type unknown + - src/mir/mir_lowering.rb:1197 param[:type]; receiver param; index :type; receiver type unknown +- forwarded return let at src/annotator.rb:130: 13 + - src/annotator.rb:1495 @og[source_name]; receiver @og; index source_name; receiver type unknown + - src/annotator.rb:1513 @og[source_name]; receiver @og; index source_name; receiver type unknown + - src/annotator.rb:1513 @og[source_name]; receiver @og; index source_name; receiver type unknown + - src/annotator.rb:1621 @og[c[:binding]]; receiver @og; index c[:binding]; receiver type unknown + - src/annotator.rb:1912 @og&.[](name); receiver @og; index name; receiver type unknown +- hash record return [] at src/annotator.rb:2334: 13 + - src/annotator.rb:2357 method_def[:args]; receiver method_def; index :args; receiver type unknown + - src/annotator.rb:2369 method_def[:zig]; receiver method_def; index :zig; receiver type unknown + - src/annotator.rb:2370 method_def[:return]; receiver method_def; index :return; receiver type unknown + - src/annotator.rb:2372 method_def[:allocates]; receiver method_def; index :allocates; receiver type unknown + - src/annotator.rb:2373 method_def[:mutates_receiver]; receiver method_def; index :mutates_receiver; receiver type unknown + +## Tuple-Like Array Report +- tuple-like array: an array literal whose position-specific element types look meaningful enough to model as a tuple/record +- confidence: `high` means the static shape is regular enough for a likely-safe tuple type; `review` means the shape is useful but needs human inspection +- Tuple-like array literals: 261 +- Runtime-observed tuple-like array slots: 558 + +### Runtime Tuple-Like Array Slots +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 122214 call(s); complete, mixed, size 3 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 51798 call(s); complete, mixed, size 3 +- src/ast/parser.rb:3905 return parse_comma_seq; [Lexer::Token, Array]; 40091 call(s); complete, mixed, size 2 +- src/annotator-helpers/effects.rb:671 param points; [Hash, Hash]; 21456 call(s); complete, size 2 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 17027 call(s); complete, mixed, size 3 +- src/ast/parser.rb:474 param pattern; [String, Symbol, Hash, String]; 15704 call(s); complete, mixed, size 4 +- src/tools/lint_fix_rewriter.rb:197 param edits; [Hash, Hash]; 14707 call(s); complete, size 2 +- src/ast/parser.rb:474 return process_pattern; [AST::BinaryOp, String]; 13021 call(s); complete, mixed, size 2 +- src/annotator-helpers/effects.rb:671 param points; [Hash, Hash, Hash, Hash]; 11324 call(s); complete, size 4 +- src/annotator-helpers/effects.rb:671 param points; [Hash, Hash, Hash]; 10060 call(s); complete, size 3 +- src/tools/lint_fix_rewriter.rb:197 param edits; [Hash, Hash, Hash]; 9785 call(s); complete, size 3 +- src/annotator-helpers/effects.rb:671 param points; [Hash, Hash]; 8117 call(s); complete, size 2 +- src/ast/parser.rb:1618 return parse_effects_decl; [NilClass, NilClass]; 7440 call(s); complete, size 2 +- src/tools/lint_fix_rewriter.rb:197 param edits; [Hash, Hash, Hash, Hash]; 6845 call(s); complete, size 4 +- src/annotator-helpers/effects.rb:671 param points; [Hash, Hash, Hash, Hash, Hash, Hash]; 5276 call(s); complete, size 6 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 5215 call(s); complete, mixed, size 3 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 5215 call(s); complete, mixed, size 3 +- src/ast/parser.rb:474 param pattern; [String, Symbol, Hash, String, Symbol, String]; 5070 call(s); complete, mixed, size 6 +- src/ast/parser.rb:3905 return parse_comma_seq; [Lexer::Token, Array]; 5047 call(s); complete, mixed, size 2 +- src/annotator-helpers/effects.rb:671 param points; [Hash, Hash, Hash, Hash, Hash]; 4982 call(s); complete, size 5 +- src/ast/type.rb:932 return resolve_resource_close; [FalseClass, NilClass]; 4871 call(s); complete, mixed, size 2 +- src/mir/alloc.rb:45 return resolve_resource_close; [FalseClass, NilClass]; 4871 call(s); complete, mixed, size 2 +- src/tools/lint_fix_rewriter.rb:197 param edits; [Hash, Hash, Hash, Hash, Hash]; 4762 call(s); complete, size 5 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 4682 call(s); complete, mixed, size 3 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 4682 call(s); complete, mixed, size 3 +- src/annotator-helpers/effects.rb:671 param points; [Hash, Hash]; 4636 call(s); complete, size 2 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 4495 call(s); complete, mixed, size 3 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 4495 call(s); complete, mixed, size 3 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 4495 call(s); complete, mixed, size 3 +- src/ast/type.rb:1966 return strip_capability_suffix; [String, NilClass, NilClass]; 4112 call(s); complete, mixed, size 3 +- [String, Symbol] appears 27 time(s), confidence high; first site src/ast/parser.rb:203 +- [Symbol, Integer] appears 16 time(s), confidence high; first site src/annotator-helpers/auto_inference.rb:201 +- [MIR::Let, MIR::ForStmt, MIR::BreakStmt] appears 13 time(s), confidence review; first site src/backends/pipeline_host.rb:874 +- [MIR::AllocatorRef, MIR::Ident] appears 10 time(s), confidence review; first site src/backends/pipeline_host.rb:1064 +- [MIR::Set, MIR::BreakStmt] appears 9 time(s), confidence review; first site src/backends/pipeline_host.rb:829 +- [MIR::Let, MIR::IfStmt] appears 9 time(s), confidence review; first site src/backends/pipeline_host.rb:939 +- [Symbol, T.nilable(String)] appears 8 time(s), confidence high; first site src/annotator-helpers/auto_inference.rb:168 +- [T::Array[`T.untyped`], String] appears 7 time(s), confidence review; first site src/backends/pipeline_host.rb:483 +- [T::Array[MIR::Let], T.nilable(T::Array[`T.untyped`]), T::Array[`T.untyped`], MIR::Ident] appears 6 time(s), confidence high; first site src/backends/pipeline_host.rb:2327 +- [T::Boolean, NilClass] appears 6 time(s), confidence review; first site src/ast/error_registry.rb:130 +- [Symbol, T::Hash[`T.untyped`, `T.untyped`]] appears 6 time(s), confidence review; first site src/ast/parser.rb:1633 +- [Symbol, String] appears 4 time(s), confidence high; first site src/ast/parser.rb:29 +- [T::Array[`T.untyped`], MIR::AddressOf] appears 4 time(s), confidence high; first site src/backends/pipeline_host.rb:4022 +- [MIR::InlineZig, T.nilable(MIR::StructDef), T.nilable(MIR::Let), MIR::BreakStmt] appears 4 time(s), confidence high; first site src/backends/pipeline_host.rb:4332 +- [MIR::Let, MIR::DeferStmt] appears 4 time(s), confidence review; first site src/backends/pipeline_host.rb:540 +- [MIR::Set, MIR::Set, MIR::BreakStmt] appears 4 time(s), confidence review; first site src/backends/pipeline_host.rb:846 +- [MIR::Let, MIR::WhileStmt] appears 4 time(s), confidence review; first site src/backends/pipeline_host.rb:1358 +- [Symbol, NilClass] appears 3 time(s), confidence high; first site src/annotator-helpers/effects.rb:793 +- [T::Boolean, String] appears 3 time(s), confidence review; first site src/ast/type.rb:934 +- [MIR::Let, MIR::Let, MIR::ForStmt, MIR::BreakStmt] appears 3 time(s), confidence review; first site src/backends/pipeline_host.rb:908 +- [MIR::Let, MIR::ExprStmt] appears 3 time(s), confidence review; first site src/backends/pipeline_host.rb:1084 +- [AST::Assignment, AST::BreakNode] appears 3 time(s), confidence review; first site src/backends/pipeline_rewriter.rb:628 +- [String, T::Array[`T.untyped`]] appears 3 time(s), confidence review; first site src/mir/mir_lowering.rb:5681 +- [T::Array[`T.untyped`], T.nilable(Lexer::Token)] appears 2 time(s), confidence high; first site src/ast/parser.rb:2537 +- [NilClass, Integer] appears 2 time(s), confidence high; first site src/tools/doctor.rb:553 +- [MIR::IfStmt, MIR::Let, MIR::ForStmt, MIR::BreakStmt] appears 2 time(s), confidence review; first site src/backends/pipeline_host.rb:930 +- [MIR::Ident, MIR::ListLength] appears 2 time(s), confidence review; first site src/backends/pipeline_host.rb:1137 +- [MIR::Let, MIR::Let, MIR::ForStmt] appears 2 time(s), confidence review; first site src/backends/pipeline_host.rb:1302 +- [MIR::Let, MIR::Let, MIR::Let, MIR::ExprStmt, MIR::Set] appears 2 time(s), confidence review; first site src/backends/pipeline_host.rb:1479 +- [MIR::Ident, MIR::Ident, MIR::AddressOf] appears 2 time(s), confidence review; first site src/backends/pipeline_host.rb:1739 +- [Symbol, MIR::FieldGet] appears 2 time(s), confidence review; first site src/backends/pipeline_host.rb:3478 +- [AST::Assignment, AST::IfStatement] appears 2 time(s), confidence review; first site src/backends/pipeline_rewriter.rb:567 +- [T::Array[`T.untyped`], T::Hash[`T.untyped`, `T.untyped`]] appears 2 time(s), confidence review; first site src/mir/fsm_transform/recursive_splitter.rb:816 +- [String, String, T.nilable(String), T.nilable(String), String] appears 2 time(s), confidence review; first site src/mir/fsm_wrapper_emitter.rb:116 +- [T::Boolean, Symbol] appears 2 time(s), confidence review; first site src/mir/mir_lowering.rb:2529 +- [MIR::ExprStmt, MIR::ExprStmt, MIR::ReturnStmt] appears 2 time(s), confidence review; first site src/mir/mir_lowering.rb:3246 +- [MIR::Ident, T.any(MIR::InlineBc, MIR::InlineZig), MIR::Ident] appears 2 time(s), confidence review; first site src/mir/mir_lowering.rb:6467 +- [MIR::Let, MIR::DeferStmt, MIR::Let] appears 2 time(s), confidence review; first site src/mir/mir_lowering.rb:6683 +- [MIR::Ident, MIR::MethodCall, MIR::Ident] appears 2 time(s), confidence review; first site src/mir/mir_lowering.rb:6737 +- [T.nilable(Type), NilClass] appears 1 time(s), confidence high; first site src/ast/ast.rb:348 +- [T.nilable(String), T.nilable(Type)] appears 1 time(s), confidence high; first site src/ast/parser.rb:1177 +- [T::Array[MIR::Let], T.nilable(T::Array[`T.untyped`]), T::Array[`T.untyped`], MIR::Conditional] appears 1 time(s), confidence high; first site src/backends/pipeline_host.rb:2349 +- [T::Array[MIR::Let], T.nilable(T::Array[`T.untyped`]), T::Array[MIR::IfStmt], MIR::Conditional] appears 1 time(s), confidence high; first site src/backends/pipeline_host.rb:2405 +- [MIR::InlineZig, T.nilable(MIR::StructDef), T.nilable(MIR::Let), MIR::ExprStmt] appears 1 time(s), confidence high; first site src/backends/pipeline_host.rb:4462 +- [T.nilable(String), NilClass] appears 1 time(s), confidence high; first site src/mir/mir_emitter.rb:684 +- [T.nilable(Integer), Integer, String] appears 1 time(s), confidence high; first site src/tools/doctor.rb:511 +- [MIR::FieldGet, MIR::Lit] appears 1 time(s), confidence review; first site src/annotator-helpers/auto_inference.rb:779 +- [Symbol, NilClass, NilClass] appears 1 time(s), confidence review; first site src/annotator-helpers/fixable_helpers.rb:1588 +- [T::Boolean, T::Hash[`T.untyped`, `T.untyped`]] appears 1 time(s), confidence review; first site src/ast/error_registry.rb:133 +- [String, Symbol, T::Hash[`T.untyped`, `T.untyped`], String, Symbol, String] appears 1 time(s), confidence review; first site src/ast/parser.rb:121 diff --git a/gems/nil-kill/spec/apply_spec.rb b/gems/nil-kill/spec/apply_spec.rb new file mode 100644 index 000000000..f0c02b8cf --- /dev/null +++ b/gems/nil-kill/spec/apply_spec.rb @@ -0,0 +1,763 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe NilKill::Apply do + def applier + described_class.allocate.tap do |instance| + instance.instance_variable_set(:@dry_run, false) + instance.instance_variable_set(:@all, true) + end + end + + it "refuses raw --all because review actions require verification" do + NilKill::Store.new.write + + expect { + described_class.new(["--all"]).run + }.to raise_error(SystemExit).and output(/apply --all.*without verification/).to_stderr + end + + it "adds sigs with sorbet runtime and T::Sig extension" do + _path, rel = repo_tmp_file("apply_add_sig.rb", <<~RUBY) + class Example + def name(value) + value.to_s + end + end + RUBY + + applier.apply_actions([ + { "kind" => "add_sig", "confidence" => "high", "path" => rel, "line" => 2, + "data" => { "sig" => "sig { params(value: String).returns(String) }", "scope" => ["Example"] } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("require \"sorbet-runtime\"") + expect(source).to include("extend T::Sig") + expect(source).to include("sig { params(value: String).returns(String) }\n def name(value)") + end + + # Regression for the c34cc62f corruption: an add_sig whose `line` is + # STALE (the source shifted under it, e.g. a rebase) must NEVER be + # blindly inserted at that raw index -- that drops the sig as dead + # code mid-body / before module_function and poisons sorbet-runtime. + # It must relocate to the real def (matched by method name) or skip. + it "relocates a stale-line add_sig to the correct def; never corrupts mid-body" do + _path, rel = repo_tmp_file("apply_stale_sig.rb", <<~RUBY) + class PR + def scan(source) + i = 0 + while i < source.length + if terminator?(source, i) + return i + end + end + i + end + + def expression_terminator_op?(source, j) + source[j, 2] == "==" + end + end + RUBY + + # line 6 = `return i`, deep inside scan's body -- NOT the def at 12. + applier.apply_actions([ + { "kind" => "add_sig", "confidence" => "high", "path" => rel, "line" => 6, + "data" => { "sig" => "sig { params(source: String, j: Integer).returns(T::Boolean) }", + "scope" => ["PR"], "method" => "expression_terminator_op?" } }, + ]) + + src = File.read(File.join(NilKill::ROOT, rel)) + expect(src).to include( + "sig { params(source: String, j: Integer).returns(T::Boolean) }\n def expression_terminator_op?(source, j)" + ) + expect(src).not_to match(/return i\n\s*sig \{/) # not dead code after return + expect(src.scan(/sig \{ params\(source: String/).size).to eq(1) + end + + it "applies signature and T.let rewrites without touching unrelated lines" do + _path, rel = repo_tmp_file("apply_rewrites.rb", <<~RUBY) + class Example + sig { params(raw: T.untyped, items: T::Array[T.untyped], opts: T::Hash[T.untyped, T.untyped]).returns(T::Array[T.untyped]) } + def convert(raw, items, opts) + @memo = [] + local = value + items + end + end + RUBY + + applier.apply_actions([ + { "kind" => "fix_sig_param", "confidence" => "high", "path" => rel, "line" => 3, + "data" => { "name" => "raw", "type" => "String" } }, + { "kind" => "narrow_generic_param", "confidence" => "high", "path" => rel, "line" => 3, + "data" => { "name" => "opts", "from" => "T::Hash[T.untyped, T.untyped]", "type" => "T::Hash[Symbol, String]" } }, + { "kind" => "narrow_generic_return", "confidence" => "high", "path" => rel, "line" => 3, + "data" => { "from" => "T::Array[T.untyped]", "type" => "T::Array[String]" } }, + { "kind" => "add_tlet", "confidence" => "high", "path" => rel, "line" => 5, + "data" => { "name" => "local", "type" => "String" } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("raw: String") + expect(source).to include("opts: T::Hash[Symbol, String]") + expect(source).to include("returns(T::Array[String])") + expect(source).to include("local = T.let(value, String)") + expect(source).to include("@memo = []") + end + + it "rewrites T.untyped returns to void when requested" do + _path, rel = repo_tmp_file("apply_void_return.rb", <<~RUBY) + class Example + sig { returns(T.untyped) } + def emit + puts "event" + end + end + RUBY + + applier.apply_actions([ + { "kind" => "fix_sig_return", "confidence" => "high", "path" => rel, "line" => 3, + "data" => { "type" => "void" } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("sig { void }") + expect(source).not_to include("returns(T.untyped)") + end + + it "rewrites a T.untyped return to a concrete sorbet type" do + _path, rel = repo_tmp_file("apply_concrete_return.rb", <<~RUBY) + class Example + sig { returns(T.untyped) } + def label + "hello" + end + end + RUBY + + applier.apply_actions([ + { "kind" => "fix_sig_return", "confidence" => "review", "path" => rel, "line" => 3, + "data" => { "type" => "String", "source" => "forwarded_return_chain", "chain" => ["a"] } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("sig { returns(String) }") + expect(source).not_to include("returns(T.untyped)") + end + + it "applies nilable generic narrowing without dropping the nilable wrapper" do + _path, rel = repo_tmp_file("apply_nilable_generic_rewrites.rb", <<~RUBY) + class Example + sig { params(items: T.nilable(T::Array[T.untyped])).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + def convert(items) + {} + end + end + RUBY + + applier.apply_actions([ + { "kind" => "narrow_generic_param", "confidence" => "high", "path" => rel, "line" => 3, + "data" => { "name" => "items", "from" => "T.nilable(T::Array[T.untyped])", "type" => "T.nilable(T::Array[String])" } }, + { "kind" => "narrow_generic_return", "confidence" => "high", "path" => rel, "line" => 3, + "data" => { "from" => "T.nilable(T::Hash[T.untyped, T.untyped])", "type" => "T.nilable(T::Hash[Symbol, String])" } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("items: T.nilable(T::Array[String])") + expect(source).to include("returns(T.nilable(T::Hash[Symbol, String]))") + end + + it "applies nil-check and nil-default rewrites narrowly" do + _path, rel = repo_tmp_file("apply_nil_rewrites.rb", <<~RUBY) + def run(reason, resolved) + puts(reason.nil?) + resolved&.dig(:name) + consume(nil) + end + RUBY + + applier.apply_actions([ + { "kind" => "replace_dead_nil_check", "confidence" => "high", "path" => rel, "line" => 2, + "data" => { "code" => "reason.nil?" } }, + { "kind" => "remove_dead_safe_nav", "confidence" => "high", "path" => rel, "line" => 3, + "data" => { "code" => "resolved&.dig(:name)" } }, + { "kind" => "replace_nil_with_default", "confidence" => "high", "path" => rel, "line" => 4, + "data" => { "default" => "[]" } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("puts(false)") + expect(source).to include("resolved.dig(:name)") + expect(source).to include("consume([])") + end + + it "does not rewrite nil defaults when a line has multiple nil literals" do + _path, rel = repo_tmp_file("apply_nil_guard.rb", "consume(nil, nil)\n") + + changed = applier.apply_actions([ + { "kind" => "replace_nil_with_default", "confidence" => "high", "path" => rel, "line" => 1, + "data" => { "default" => "[]" } }, + ]) + + expect(changed).to eq(0) + expect(File.read(File.join(NilKill::ROOT, rel))).to eq("consume(nil, nil)\n") + end + + it "promotes a local hash record to a T::Struct and rewrites literal field reads" do + _path, rel = repo_tmp_file("apply_hash_record_struct.rb", <<~RUBY) + class Example + extend T::Sig + + def label + entry = {name: "Ada", id: 1} + "\#{entry[:name]}:\#{entry.fetch(:id)}:\#{entry[:name]}" + end + end + RUBY + + applier.apply_actions([ + { "kind" => "promote_hash_record_to_struct", "confidence" => "review", "path" => rel, "line" => 5, + "data" => { + "name" => "entry", + "struct_name" => "EntryRecord", + "scope" => ["Example"], + "literal" => { "line" => 5, "code" => "{name: \"Ada\", id: 1}" }, + "fields" => [ + { "name" => "name", "type" => "String" }, + { "name" => "id", "type" => "Integer" }, + ], + "read_rewrites" => [ + { "line" => 6, "code" => "entry[:name]", "replacement" => "entry.name" }, + { "line" => 6, "code" => "entry.fetch(:id)", "replacement" => "entry.id" }, + ], + } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("require \"sorbet-runtime\"") + expect(source).to include("class EntryRecord < T::Struct") + expect(source).to include("const :name, String") + expect(source).to include("const :id, Integer") + expect(source).to include("entry = EntryRecord.new(name: \"Ada\", id: 1)") + expect(source).to include('"#{entry.name}:#{entry.id}:#{entry.name}"') + end + + it "promotes a returned hash record to a T::Struct and rewrites forwarded field reads" do + _path, rel = repo_tmp_file("apply_return_hash_record_struct.rb", <<~RUBY) + class Example + extend T::Sig + + def build_user + {name: "Ada", id: 1} + end + + def label + user = build_user + "\#{user[:name]}:\#{user.fetch(:id)}" + end + end + RUBY + + applier.apply_actions([ + { "kind" => "promote_hash_record_to_struct", "confidence" => "review", "path" => rel, "line" => 5, + "data" => { + "name" => "user", + "struct_name" => "UserRecord", + "scope" => ["Example"], + "literal" => { "line" => 5, "code" => "{name: \"Ada\", id: 1}" }, + "fields" => [ + { "name" => "name", "type" => "String" }, + { "name" => "id", "type" => "Integer" }, + ], + "read_rewrites" => [ + { "line" => 10, "code" => "user[:name]", "replacement" => "user.name" }, + { "line" => 10, "code" => "user.fetch(:id)", "replacement" => "user.id" }, + ], + } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("class UserRecord < T::Struct") + expect(source).to include("const :name, String") + expect(source).to include("const :id, Integer") + expect(source).to include("UserRecord.new(name: \"Ada\", id: 1)") + expect(source).to include('"#{user.name}:#{user.id}"') + end + + it "promotes cluster producer hash literals including returned records and array elements" do + _path, rel = repo_tmp_file("apply_hash_record_cluster_producers.rb", <<~RUBY) + class Example + extend T::Sig + + def build_user + {name: "Ada", id: 1} + end + + def users + [{name: "Grace", id: 2}] + end + + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def typed_users + [{name: "Hedy", id: 4}] + end + + sig { returns(T::Hash[Symbol, T.untyped]) } + def build_typed_user + {name: "Katherine", id: 3} + end + + sig { params(user: T::Hash[Symbol, T.untyped]).returns(String) } + def typed_label(user) + "\#{user[\"name\"]}:\#{user.fetch(\"id\")}" + end + + def label + user = build_user + "\#{user[:name]}:\#{user.fetch(:id)}" + end + end + RUBY + + applier.apply_actions([ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", "path" => rel, "line" => 5, + "data" => { + "struct_name" => "UserRecord", + "scope" => ["Example"], + "fields" => [ + { "name" => "name", "type" => "String", "optional" => false }, + { "name" => "id", "type" => "Integer", "optional" => false }, + ], + "producers" => [ + { "path" => rel, "line" => 5, "code" => "{name: \"Ada\", id: 1}", "keys" => %w[name id] }, + { "path" => rel, "line" => 9, "code" => "{name: \"Grace\", id: 2}", "keys" => %w[name id] }, + { "path" => rel, "line" => 14, "code" => "{name: \"Hedy\", id: 4}", "keys" => %w[name id] }, + { "path" => rel, "line" => 19, "code" => "{name: \"Katherine\", id: 3}", "keys" => %w[name id] }, + ], + "consumers" => [ + { "path" => rel, "line" => 24, "code" => "user[\"name\"]", "receiver" => "user", "key" => "name" }, + { "path" => rel, "line" => 24, "code" => "user.fetch(\"id\")", "receiver" => "user", "key" => "id" }, + { "path" => rel, "line" => 29, "code" => "user[:name]", "receiver" => "user", "key" => "name" }, + { "path" => rel, "line" => 29, "code" => "user.fetch(:id)", "receiver" => "user", "key" => "id" }, + ], + "signatures" => [ + { "path" => rel, "line" => 13, "kind" => "return", "from" => "T::Array[T::Hash[Symbol, T.untyped]]", "type" => "T::Array[UserRecord]" }, + { "path" => rel, "line" => 18, "kind" => "return", "from" => "T::Hash[Symbol, T.untyped]", "type" => "UserRecord" }, + { "path" => rel, "line" => 23, "kind" => "param", "name" => "user", "from" => "T::Hash[Symbol, T.untyped]", "type" => "UserRecord" }, + ], + "blockers" => [], + } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("class UserRecord < T::Struct") + expect(source).to include("UserRecord.new(name: \"Ada\", id: 1)") + expect(source).to include("[UserRecord.new(name: \"Grace\", id: 2)]") + expect(source).to include("[UserRecord.new(name: \"Hedy\", id: 4)]") + expect(source).to include("returns(T::Array[UserRecord])") + expect(source).to include("returns(UserRecord)") + expect(source).to include("params(user: UserRecord)") + expect(source).to include("UserRecord.new(name: \"Katherine\", id: 3)") + expect(source).to include('"#{user.name}:#{user.id}"') + end + + it "promotes nested hash-record array fields into nested structs" do + _path, rel = repo_tmp_file("apply_hash_record_cluster_nested_fields.rb", <<~RUBY) + module Example + def self.plan + {name: "compile", steps: [{expr: "load", id: 1}]} + end + end + RUBY + + applier.apply_actions([ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", "path" => rel, "line" => 3, + "data" => { + "struct_name" => "PlanRecord", + "type_name" => "Example::PlanRecord", + "scope" => ["Example"], + "struct_path" => rel, + "fields" => [ + { "name" => "name", "type" => "String", "optional" => false }, + { "name" => "steps", "type" => "T::Array[Example::StepsRecord]", "optional" => false, + "nested" => { "kind" => "array", "struct_name" => "StepsRecord", "type_name" => "Example::StepsRecord", + "fields" => [ + { "name" => "expr", "type" => "String", "optional" => false }, + { "name" => "id", "type" => "Integer", "optional" => false }, + ] } }, + ], + "nested_structs" => [ + { "kind" => "array", "struct_name" => "StepsRecord", "type_name" => "Example::StepsRecord", + "fields" => [ + { "name" => "expr", "type" => "String", "optional" => false }, + { "name" => "id", "type" => "Integer", "optional" => false }, + ] }, + ], + "producers" => [ + { "path" => rel, "line" => 3, "code" => "{name: \"compile\", steps: [{expr: \"load\", id: 1}]}", "keys" => %w[name steps] }, + ], + "consumers" => [], + "signatures" => [], + "blockers" => [], + } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("class StepsRecord < T::Struct") + expect(source).to include("const :steps, T::Array[Example::StepsRecord]") + expect(source).to include("Example::StepsRecord.new(expr: \"load\", id: 1)") + expect(source).to include("Example::PlanRecord.new(name: \"compile\", steps: [Example::StepsRecord.new(expr: \"load\", id: 1)])") + end + + it "rewrites cluster consumers across files while inserting the struct once" do + _producer_path, producer_rel = repo_tmp_file("apply_hash_record_cluster_cross_file_producer.rb", <<~RUBY) + class Producer + def build_user + {name: "Ada", id: 1} + end + end + RUBY + _consumer_path, consumer_rel = repo_tmp_file("apply_hash_record_cluster_cross_file_consumer.rb", <<~RUBY) + class Consumer + def label(user) + "\#{user[:name]}:\#{user.fetch(:id)}" + end + end + RUBY + + applier.apply_actions([ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", "path" => producer_rel, "line" => 3, + "data" => { + "struct_name" => "UserRecord", + "scope" => ["Producer"], + "fields" => [ + { "name" => "name", "type" => "String", "optional" => false }, + { "name" => "id", "type" => "Integer", "optional" => false }, + ], + "producers" => [ + { "path" => producer_rel, "line" => 3, "code" => "{name: \"Ada\", id: 1}", "keys" => %w[name id] }, + ], + "consumers" => [ + { "path" => consumer_rel, "line" => 3, "code" => "user[:name]", "receiver" => "user", "key" => "name" }, + { "path" => consumer_rel, "line" => 3, "code" => "user.fetch(:id)", "receiver" => "user", "key" => "id" }, + ], + "signatures" => [], + "blockers" => [], + } }, + ]) + + producer = File.read(File.join(NilKill::ROOT, producer_rel)) + consumer = File.read(File.join(NilKill::ROOT, consumer_rel)) + expect(producer).to include("class UserRecord < T::Struct") + expect(producer).to include("UserRecord.new(name: \"Ada\", id: 1)") + expect(consumer).to include('"#{user.name}:#{user.id}"') + expect(consumer).not_to include("class UserRecord < T::Struct") + end + + it "casts weak producer values when hash-record fields are narrowed by protocol evidence" do + _path, rel = repo_tmp_file("apply_hash_record_cluster_cast_fields.rb", <<~RUBY) + module AST + module Locatable + end + end + + class Example + def build(expr) + {expr: expr, binding: nil} + end + end + RUBY + + applier.apply_actions([ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", "path" => rel, "line" => 8, + "data" => { + "struct_name" => "BindingRecord", + "type_name" => "AST::BindingRecord", + "scope" => ["AST"], + "struct_path" => rel, + "fields" => [ + { "name" => "expr", "type" => "AST::Locatable", "optional" => false, "required_members" => %w[full_type token] }, + { "name" => "binding", "type" => "NilClass", "optional" => false }, + ], + "producers" => [ + { "path" => rel, "line" => 8, "code" => "{expr: expr, binding: nil}", "keys" => %w[expr binding] }, + ], + "consumers" => [], + "signatures" => [], + "blockers" => [], + } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(source).to include("AST::BindingRecord.new(expr: T.cast(expr, AST::Locatable), binding: nil)") + expect(source).to match(/module Locatable\n\s+end\n\s+class BindingRecord < T::Struct/) + end + + it "rewrites hash-record signatures through Prism CST ranges for multiline sigs" do + _path, rel = repo_tmp_file("apply_hash_record_cluster_multiline_sig.rb", <<~RUBY) + class Example + extend T::Sig + + sig do + params( + user: T::Hash[Symbol, T.untyped], + ) + .returns(T::Hash[Symbol, T.untyped]) + end + def transform(user) + {name: user[:name], id: user.fetch(:id)} + end + end + RUBY + + file = File.join(NilKill::ROOT, rel) + lines = File.readlines(file) + def_line = lines.index { |line| line.include?("def transform") } + 1 + body_line = lines.index { |line| line.include?("{name: user[:name]") } + 1 + + applier.apply_actions([ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", "path" => rel, "line" => body_line, + "data" => { + "struct_name" => "UserRecord", + "scope" => ["Example"], + "fields" => [ + { "name" => "name", "type" => "String", "optional" => false }, + { "name" => "id", "type" => "Integer", "optional" => false }, + ], + "producers" => [ + { "path" => rel, "line" => body_line, "code" => "{name: user[:name], id: user.fetch(:id)}", "keys" => %w[name id] }, + ], + "consumers" => [ + { "path" => rel, "line" => body_line, "code" => "user[:name]", "receiver" => "user", "key" => "name" }, + { "path" => rel, "line" => body_line, "code" => "user.fetch(:id)", "receiver" => "user", "key" => "id" }, + ], + "signatures" => [ + { "path" => rel, "line" => def_line, "kind" => "param", "name" => "user", "from" => "T::Hash[Symbol, T.untyped]", "type" => "UserRecord" }, + { "path" => rel, "line" => def_line, "kind" => "return", "from" => "T::Hash[Symbol, T.untyped]", "type" => "UserRecord" }, + ], + "blockers" => [], + } }, + ]) + + source = File.read(file) + expect(source).to include("user: UserRecord") + expect(source).to include(".returns(UserRecord)") + expect(source).to include("UserRecord.new(name: user.name, id: user.id)") + end + + it "emits optional hash-record fields as props and blocks untyped field clusters" do + _path, rel = repo_tmp_file("apply_hash_record_cluster_optional.rb", <<~RUBY) + class Example + extend T::Sig + + def build_user + {name: "Ada", id: 1} + end + end + RUBY + + changed = applier.apply_actions([ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", "path" => rel, "line" => 5, + "data" => { + "struct_name" => "UserRecord", + "scope" => ["Example"], + "fields" => [ + { "name" => "name", "type" => "String", "optional" => false }, + { "name" => "email", "type" => "T.nilable(String)", "optional" => true }, + ], + "producers" => [ + { "path" => rel, "line" => 5, "code" => "{name: \"Ada\", id: 1}", "keys" => %w[name id] }, + ], + "consumers" => [], + "signatures" => [], + "blockers" => [], + } }, + ]) + + source = File.read(File.join(NilKill::ROOT, rel)) + expect(changed).to eq(1) + expect(source).to include("const :name, String") + expect(source).to include("prop :email, T.nilable(String)") + + blocked_path, blocked_rel = repo_tmp_file("apply_hash_record_cluster_untyped_blocker.rb", <<~RUBY) + class Blocked + def build_user + {name: "Ada", id: unknown} + end + end + RUBY + + changed = applier.apply_actions([ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", "path" => blocked_rel, "line" => 3, + "data" => { + "struct_name" => "BlockedRecord", + "scope" => ["Blocked"], + "fields" => [ + { "name" => "name", "type" => "String", "optional" => false }, + { "name" => "id", "type" => "T.untyped", "optional" => false }, + ], + "producers" => [ + { "path" => blocked_rel, "line" => 3, "code" => "{name: \"Ada\", id: unknown}", "keys" => %w[name id] }, + ], + "consumers" => [], + "signatures" => [], + "blockers" => ["one or more fields are still T.untyped"], + } }, + ]) + + expect(changed).to eq(0) + expect(File.read(blocked_path)).not_to include("BlockedRecord") + end + + it "improves report pressure after a traceable cluster promotion" do + _path, rel = repo_tmp_file("apply_hash_record_report_improvement.rb", <<~RUBY) + class Example + extend T::Sig + + sig { returns(String) } + def label + user = {name: "Ada", id: 1} + "\#{user[:name]}:\#{user.fetch(:id)}" + end + end + RUBY + + evidence_for = lambda do |path| + NilKill::SourceIndex.reset_global_shape_indexes + NilKill::SourceIndex.new(path) + idx = NilKill::SourceIndex.new(path) + { + "facts" => { + "hash_shapes" => idx.hash_shapes, + "collection_index_lookups" => idx.collection_index_lookups, + "hash_record_blockers" => idx.hash_record_blockers, + "return_origins" => idx.return_origins, + "param_origins" => idx.param_origins, + "existing_sigs" => idx.methods.select { |method| method["has_sig"] }, + }, + "methods" => [], + } + end + + before = evidence_for.call(File.join(NilKill::ROOT, rel)) + report = NilKill::Report.allocate + report.instance_variable_set(:@evidence, before) + before_candidates = report.hash_record_struct_candidates(before) + + store = NilKill::Store.new + store.facts["hash_shapes"] = before.dig("facts", "hash_shapes") + store.facts["collection_index_lookups"] = before.dig("facts", "collection_index_lookups") + store.facts["hash_record_blockers"] = before.dig("facts", "hash_record_blockers") + store.facts["return_origins"] = before.dig("facts", "return_origins") + store.facts["param_origins"] = before.dig("facts", "param_origins") + store.facts["existing_sigs"] = before.dig("facts", "existing_sigs") + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + infer.send(:propose_hash_record_cluster_actions) + action = store.actions.find { |candidate| candidate["kind"] == "promote_hash_record_cluster_to_struct" } + + expect(before_candidates.first["total_pressure"]).to eq(2) + expect(action).not_to be_nil + + applier.apply_actions([action]) + + after = evidence_for.call(File.join(NilKill::ROOT, rel)) + after_report = NilKill::Report.allocate + after_report.instance_variable_set(:@evidence, after) + after_candidates = after_report.hash_record_struct_candidates(after) + + expect(after_candidates).to be_empty + end + + it "restores files and skips a hash-record action when verification fails" do + _path, rel = repo_tmp_file("apply_hash_record_cluster_rollback.rb", <<~RUBY) + class RollbackHashRecord + def build + {name: "Ada"} + end + end + RUBY + path = File.join(NilKill::ROOT, rel) + original = File.read(path) + loop = NilKill::Loop.allocate + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@z3_solver, nil) + loop.define_singleton_method(:verify) { |actions: nil| [false, "forced verification failure"] } + NilKill::Store.new.write + action = { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", + "path" => rel, "line" => 3, "message" => "plan UserRecord", + "data" => { + "struct_name" => "UserRecord", + "scope" => ["RollbackHashRecord"], + "fields" => [{ "name" => "name", "type" => "String", "optional" => false }], + "producers" => [{ "path" => rel, "line" => 3, "code" => "{name: \"Ada\"}", "keys" => ["name"] }], + "consumers" => [], + "signatures" => [], + "blockers" => [], + } } + + changed = loop.send(:apply_verified, [action]) + + expect(changed).to eq(0) + expect(File.read(path)).to eq(original) + expect(loop.instance_variable_get(:@skipped)).to include(loop.send(:fingerprint, action)) + end + + it "retries verified hash-record promotion after removing useless T.cast feedback" do + _path, rel = repo_tmp_file("apply_hash_record_useless_tcast_retry.rb", <<~RUBY) + class CastRetryHashRecord + def build(expr) + {expr: T.cast(expr, String)} + end + end + RUBY + path = File.join(NilKill::ROOT, rel) + NilKill::Store.new.write + action = { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", + "path" => rel, "line" => 3, "message" => "plan ExprRecord", + "data" => { + "struct_name" => "ExprRecord", + "scope" => ["CastRetryHashRecord"], + "fields" => [{ "name" => "expr", "type" => "String", "optional" => false }], + "producers" => [{ "path" => rel, "line" => 3, "code" => "{expr: T.cast(expr, String)}", "keys" => ["expr"] }], + "consumers" => [], + "signatures" => [], + "blockers" => [], + } } + + loop = NilKill::Loop.allocate + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@z3_solver, nil) + verify_calls = 0 + loop.define_singleton_method(:verify) do |actions: nil| + verify_calls += 1 + if verify_calls == 1 + cast_line = File.readlines(path).index { |line| line.include?("T.cast(expr, String)") } + 1 + [false, <<~OUT] + #{rel}:#{cast_line}: `T.cast` is useless because `expr` is already a `String` https://srb.help/7015 + #{rel}:#{cast_line}: Replace with `expr` + OUT + else + [true, ""] + end + end + + changed = loop.send(:apply_verified, [action]) + source = File.read(path) + + expect(changed).to eq(2) + expect(verify_calls).to eq(2) + expect(source).to include("class ExprRecord < T::Struct") + expect(source).to include("ExprRecord.new(expr: expr)") + expect(source).not_to include("T.cast(expr, String)") + end + + it "tools/clear-nil-kill-verify.sh runs the host spec suite (regression: prevents Sorbet-only verifier)" do + script = File.read(File.join(NilKill::ROOT, "tools", "clear-nil-kill-verify.sh")) + expect(script).to match(/bundle exec prspec spec\/(?:\s|$)/), + "verify script must include `bundle exec prspec spec/` so loop changes are gated on host behavioral tests, not just `srb tc`" + end +end diff --git a/gems/nil-kill/spec/evidence_gap_invariant_spec.rb b/gems/nil-kill/spec/evidence_gap_invariant_spec.rb new file mode 100644 index 000000000..fd5d2465b --- /dev/null +++ b/gems/nil-kill/spec/evidence_gap_invariant_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +# +# THE PERMANENT CONTRACT. Property-level (survives corpus changes): +# over the collect's OWN Coverage, every interior-covered sampled +# method has a record or is excused (arg-untraceable / pruned) -- the +# exact contrapositive of collect_ran_untraced. Plus: the foreign +# SimpleCov baseline is gone and stays gone, and a deliberately +# mis-wired (uninstrumented) collect MUST violate the invariant +# (negative control -- proves it is not vacuously green). + +require_relative "spec_helper" + +RSpec.describe "evidence-gap invariant" do + corpus = File.join(__dir__, "fixtures", "zero_gap_corpus") + + def run_corpus(corpus, instrument:) + Dir.mktmpdir("nk-inv", NilKill::ROOT) do |dir| + Dir.glob(File.join(corpus, "*_lib.rb")).each { |f| FileUtils.cp(f, dir) } + yield full_collect(dir, File.read(File.join(corpus, "workload.rb")), instrument: instrument) + end + end + + it "INVARIANT: interior-covered sampled methods all have a record (0 exceptions)" do + run_corpus(corpus, instrument: true) do |r| + # The report's own predicate: untyped_evidence_gaps RAISES if any + # method ran-without-a-record (collect_ran_untraced) or there is + # no collect coverage (never_run). On the in-place corpus it must + # NOT raise, and the hard reasons must be absent from gaps. + gaps = nil + expect { gaps = r[:report].send(:untyped_evidence_gaps, r[:evidence]) }.not_to raise_error + expect(gaps.keys & %w[collect_ran_untraced never_run]).to eq([]) + # Independent contrapositive cross-check, computed from raw + # evidence (not the report): every method the workload called has + # a runtime record. If a load path bypassed in-place recording + # this fails even if the report classifier were buggy. + recorded = r[:methods].select { |m| m["calls"].to_i.positive? } + .map { |m| [m["class"], m["method"]] }.to_set + %w[PlainReq:transform AbsReq:walk AbsReq:run SubProc:in_child + EnsurePunt:guarded StructColl:build KernelLoad:handle].each do |sig| + cls, meth = sig.split(":") + expect(recorded).to include([cls, meth]), "missing record for #{cls}##{meth}" + end + end + end + + it "INVARIANT: foreign baseline gone AND forbidden states are hard failures, not columns" do + expect(NilKill::Report::EVIDENCE_GAP_REASONS.keys) + .not_to include("untraced_covered", "collect_ran_untraced", "never_run") + expect(NilKill::Report::EVIDENCE_GAP_HARD.keys) + .to contain_exactly("collect_ran_untraced", "never_run") + expect(NilKill::Report.const_defined?(:SIMPLECOV_RESULTSET)).to be(false) + expect(NilKill::Report.new.respond_to?(:simplecov_covered_files, true)).to be(false) + end + + it "INVARIANT: the rendered table has no forbidden column on the real corpus" do + run_corpus(corpus, instrument: true) do |r| + lines = [] + r[:report].send(:append_untyped_evidence_gaps, lines, r[:evidence]) + header = lines.find { |l| l.start_with?("| |") } + next unless header + expect(header).not_to include("collect ran untraced") + expect(header).not_to include("untraced covered") + expect(header).not_to include("never run") + end + end + + it "NEGATIVE CONTROL: an uninstrumented collect makes infer/report RAISE (not silently zero)" do + # Coverage marked bodies executed but the source-wrap recorder never + # fired -> ran-without-a-record == collect_ran_untraced. The hard + # guard fires inside Infer's own report generation, so the WHOLE + # pipeline raises loudly -- a recording bypass cannot be silent. If + # this didn't raise, the guarantee would be toothless. + expect do + Dir.mktmpdir("nk-inv-neg", NilKill::ROOT) do |dir| + Dir.glob(File.join(corpus, "*_lib.rb")).each { |f| FileUtils.cp(f, dir) } + full_collect(dir, File.read(File.join(corpus, "workload.rb")), instrument: false) + end + end.to raise_error(/collect_ran_untraced .* tracer\/trace-plan regression/) + end +end diff --git a/gems/nil-kill/spec/fixtures/sorbet/7002.txt b/gems/nil-kill/spec/fixtures/sorbet/7002.txt new file mode 100644 index 000000000..e3ff739bb --- /dev/null +++ b/gems/nil-kill/spec/fixtures/sorbet/7002.txt @@ -0,0 +1,7 @@ +lib/example.rb:20: Expected `String` but found `T.nilable(String)` for argument `name` https://srb.help/7002 + 20 | save(maybe_name) + ^^^^^^^^^^ + Expected `String` for argument `name` of method `Example#save`: + lib/example.rb:8: + 8 | sig { params(name: String).void } + diff --git a/gems/nil-kill/spec/fixtures/sorbet/7005.txt b/gems/nil-kill/spec/fixtures/sorbet/7005.txt new file mode 100644 index 000000000..247025cb8 --- /dev/null +++ b/gems/nil-kill/spec/fixtures/sorbet/7005.txt @@ -0,0 +1,7 @@ +lib/example.rb:12: Expected `String` but found `T.nilable(String)` for method result type https://srb.help/7005 + 12 | end + ^^^ + Expected `String` for result type of method `name`: + lib/example.rb:8: + 8 | sig { returns(String) } + diff --git a/gems/nil-kill/spec/fixtures/sorbet/7034.txt b/gems/nil-kill/spec/fixtures/sorbet/7034.txt new file mode 100644 index 000000000..d394dc913 --- /dev/null +++ b/gems/nil-kill/spec/fixtures/sorbet/7034.txt @@ -0,0 +1,7 @@ +lib/example.rb:25: Used `&.` operator on `String`, which can never be nil https://srb.help/7034 + 25 | user&.name + ^^ + Got `String` originating from: + lib/example.rb:18: + 18 | sig { returns(String) } + diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/abs_require_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/abs_require_lib.rb new file mode 100644 index 000000000..4b3ec4ecb --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/abs_require_lib.rb @@ -0,0 +1,30 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# Reached via an ABSOLUTE-path require. This is the exact +# collect_bg_blocks shape: a recursive method invoked from inside a +# host `.each do..end` block, with each_pair recursion. Under the old +# parallel-tree redirect this load path bypassed the wrapper -> the +# residual `collect_ran_untraced`. In-place wrapping must record it. +class AbsReq + extend T::Sig + + sig { params(node: T.untyped, acc: T.untyped).returns(T.untyped) } + def walk(node, acc) + case node + when Array then node.each { |n| walk(n, acc) } + when Hash then node.each_pair { |_, v| walk(v, acc) } + else acc << node + end + acc + end + + sig { params(tree: T.untyped).returns(T.untyped) } + def run(tree) + out = [] + [tree].each { |t| walk(t, out) } + out + end +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/autoload_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/autoload_lib.rb new file mode 100644 index 000000000..c820cb9ef --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/autoload_lib.rb @@ -0,0 +1,14 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# Reached via autoload (first const reference triggers the load). +# One-line classic def: has an `end` (wrappable) but an empty interior, +# so like the endless def it can only be proven via the runtime record. +class AutoLib + extend T::Sig + + sig { params(v: T.untyped).returns(T.untyped) } + def one_line(v); v.to_s.upcase; end +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/ensure_punt_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/ensure_punt_lib.rb new file mode 100644 index 000000000..bc69bcc34 --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/ensure_punt_lib.rb @@ -0,0 +1,21 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# `ensure` -> the inline wrapper cannot express it, so the method is +# punted to the targeted-TracePoint fallback. It must STILL produce a +# record (the fallback fires in-process) -- a multi-line interior so +# collect_ran? would also see it run. +class EnsurePunt + extend T::Sig + + sig { params(v: T.untyped).returns(T.untyped) } + def guarded(v) + acc = 0 + acc += v.to_i + acc * 3 + ensure + acc = acc.to_s if acc + end +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/kernel_load_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/kernel_load_lib.rb new file mode 100644 index 000000000..8c3d7f43e --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/kernel_load_lib.rb @@ -0,0 +1,18 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# Reached via Kernel#load. `opts` is a real positional T.untyped slot +# (a sampled NoEvidence candidate -> must get a record). The splat / +# kwsplat / block slots are untraceable-by-design (arg_untraced) and +# must NEVER land in the two forbidden columns. +class KernelLoad + extend T::Sig + + sig { params(opts: T.untyped, rest: T.untyped, kw: T.untyped, blk: T.untyped).returns(T.untyped) } + def handle(opts, *rest, **kw, &blk) + base = opts.fetch(:n, 0) + base + rest.sum + kw.size + (blk ? blk.call : 0) + end +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/plain_require_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/plain_require_lib.rb new file mode 100644 index 000000000..8590ce79c --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/plain_require_lib.rb @@ -0,0 +1,15 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# Reached via a bare `require "plain_require_lib"` ($LOAD_PATH). +class PlainReq + extend T::Sig + + sig { params(x: T.untyped).returns(T.untyped) } + def transform(x) + doubled = x * 2 + doubled + 1 + end +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/require_relative_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/require_relative_lib.rb new file mode 100644 index 000000000..c23338f7c --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/require_relative_lib.rb @@ -0,0 +1,15 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# Reached via require_relative. Endless def: NO `end` to anchor a +# suffix wrapper AND no interior body line, so collect_ran? can never +# prove it ran from line coverage -- it MUST surface via a +# source-wrapped runtime record or it would wrongly look unseen. +class RelReq + extend T::Sig + + sig { params(v: T.untyped).returns(T.untyped) } + def calc(v) = v.to_s +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/struct_collection_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/struct_collection_lib.rb new file mode 100644 index 000000000..3e56c9571 --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/struct_collection_lib.rb @@ -0,0 +1,23 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# A Struct field (struct_field_runtime signal), a T.let site, and a +# T::Array[T.untyped] collection element -- the non-method evidence +# kinds, so the guarantee covers struct_unobserved / collection_no_* +# alongside the method columns. +Pair = Struct.new(:a, :b) + +class StructColl + extend T::Sig + + sig { params(items: T::Array[T.untyped]).returns(T.untyped) } + def build(items) + tag = T.let(items.first.to_s, T.untyped) + p = Pair.new(tag, items.length) + p.a = tag.upcase + p.b = items.sum + [p, items] + end +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/subprocess_lib.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/subprocess_lib.rb new file mode 100644 index 000000000..9f766234d --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/subprocess_lib.rb @@ -0,0 +1,18 @@ +# typed: false +# frozen_string_literal: true + +require "sorbet-runtime" + +# Reached ONLY from a spawned `ruby` child process the workload starts +# (a re-exec / Process.spawn boundary). This is precisely the class of +# code the old README disclaimed as "out of scope" -- the actual +# collect_bg_blocks failure. In-place wrapping makes the single copy +# on disk the wrapped one, so the child records it like any other. +class SubProc + extend T::Sig + + sig { params(payload: T.untyped).returns(T.untyped) } + def in_child(payload) + payload.to_s.bytes.sum + end +end diff --git a/gems/nil-kill/spec/fixtures/zero_gap_corpus/workload.rb b/gems/nil-kill/spec/fixtures/zero_gap_corpus/workload.rb new file mode 100644 index 000000000..347b4f8d0 --- /dev/null +++ b/gems/nil-kill/spec/fixtures/zero_gap_corpus/workload.rb @@ -0,0 +1,72 @@ +# typed: false +# frozen_string_literal: true +# +# The sealed workload: load every lib via its DESIGNATED load path and +# call each method once with a concrete (non-nil) value. Every step is +# fault-isolated so one failure does not hide the rest -- a missing +# record then surfaces as a precise guarantee-spec failure, not a +# blanket abort. __dir__ resolves to the real corpus dir (in-place +# wrapping keeps the file at its real path). + +require "rbconfig" + +here = __dir__ + +step = lambda do |label, &blk| + blk.call +rescue StandardError, LoadError, ScriptError => e + warn "workload step #{label} failed: #{e.class}: #{e.message}" +end + +# 1. bare require via $LOAD_PATH +step.call("plain_require") do + $LOAD_PATH.unshift(here) unless $LOAD_PATH.include?(here) + require "plain_require_lib" + PlainReq.new.transform(21) +end + +# 2. require_relative + endless def +step.call("require_relative") do + require_relative "require_relative_lib" + RelReq.new.calc(42) +end + +# 3. Kernel#load + splat/kwsplat/block +step.call("kernel_load") do + load File.join(here, "kernel_load_lib.rb") + KernelLoad.new.handle({ n: 1 }, 2, 3, k: 9) { 4 } +end + +# 4. autoload +step.call("autoload") do + Object.autoload(:AutoLib, File.join(here, "autoload_lib.rb")) + AutoLib.new.one_line("hi") +end + +# 5. absolute-path require + recursive-from-.each (collect_bg_blocks shape) +step.call("abs_require") do + require File.expand_path("abs_require_lib.rb", here) + AbsReq.new.run([{ a: [1] }, 2, { b: { c: [3] } }]) +end + +# 6. reached ONLY through a spawned ruby child (re-exec boundary). The +# child inherits RUBYOPT + NIL_KILL_* so it traces and dumps its own +# runtime jsonl into the shared RUNTIME_DIR. +step.call("subprocess") do + sub = File.expand_path("subprocess_lib.rb", here) + code = "require #{sub.inspect}; SubProc.new.in_child('xyz-payload')" + pid = Process.spawn(RbConfig.ruby, "-e", code) + Process.wait(pid) +end + +# 7. ensure-punt (TracePoint fallback) +step.call("ensure_punt") do + require_relative "ensure_punt_lib" + EnsurePunt.new.guarded(7) +end + +# 8. Struct field + T.let + collection element +step.call("struct_collection") do + require_relative "struct_collection_lib" + StructColl.new.build([1, 2, 3]) +end diff --git a/gems/nil-kill/spec/generic_narrowing_spec.rb b/gems/nil-kill/spec/generic_narrowing_spec.rb new file mode 100644 index 000000000..d7172020b --- /dev/null +++ b/gems/nil-kill/spec/generic_narrowing_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe "nil-kill generic narrowing" do + def infer + NilKill::Infer.allocate.tap { |instance| instance.instance_variable_set(:@store, NilKill::Store.new) } + end + + it "narrows hash key and value slots only when both are known" do + instance = infer + + candidate = instance.send(:generic_candidate_type, + "T::Hash[T.untyped, T.untyped]", + [], + [%w[Symbol], %w[String]]) + + expect(candidate).to eq("T::Hash[Symbol, String]") + end + + it "does not narrow hash slots when one side is unknown" do + instance = infer + + candidate = instance.send(:generic_candidate_type, + "T::Hash[T.untyped, T.untyped]", + [], + [%w[Symbol], []]) + + expect(candidate).to be_nil + end + + it "uses T::Boolean for true/false element pairs" do + expect(NilKill.conservative_element_type(%w[FalseClass TrueClass])).to eq("T::Boolean") + end + + it "refuses multi-class scalar element unions by default" do + expect(NilKill.conservative_element_type(%w[String Symbol])).to be_nil + end + + it "cuts off scalar T.any candidates above the union limit" do + isolated_env("NIL_KILL_UNION_POLICY" => "any") do + expect(NilKill.sorbet_type(%w[Float Hash Integer String])).to eq("T.untyped") + end + end + + it "preserves nilability when narrowing a single observed element type" do + expect(NilKill.conservative_element_type(%w[NilClass String])).to eq("T.nilable(String)") + end + + it "generalizes broad nested array shape unions at the unstable element slot" do + instance = infer + shapes = %w[Float Hash Integer String].map { |name| { "kind" => "class", "name" => name } } + + candidate = instance.send(:generic_candidate_type, + "T::Array[T.untyped]", + [], + nil, + [{ "kind" => "array", "elements" => shapes }]) + + expect(candidate).to eq("T::Array[T::Array[T.untyped]]") + end + + it "detects union candidates by total nested union complexity" do + type = "T::Array[T.any(T::Hash[Symbol, T.any(String, Symbol)], T::Hash[Symbol, Integer])]" + + expect(NilKill.broad_union_type?(type)).to be(true) + end + + it "generalizes broad hash value unions from shape evidence" do + instance = infer + value_shapes = %w[Float Hash Integer String].map { |name| { "kind" => "class", "name" => name } } + + candidate = instance.send(:generic_candidate_type, + "T::Hash[T.untyped, T.untyped]", + [], + nil, + nil, + [[{ "kind" => "class", "name" => "Symbol" }], value_shapes]) + + expect(candidate).to eq("T::Hash[Symbol, T.untyped]") + end + + it "generalizes nested unions to the nearest stable container shape" do + instance = infer + hash_shapes = [ + { + "kind" => "hash", + "keys" => [{ "kind" => "class", "name" => "Symbol" }], + "values" => [ + { "kind" => "class", "name" => "String" }, + { "kind" => "class", "name" => "Symbol" }, + ], + }, + { + "kind" => "hash", + "keys" => [{ "kind" => "class", "name" => "Symbol" }], + "values" => [{ "kind" => "class", "name" => "Integer" }], + }, + ] + + candidate = instance.send(:generic_candidate_type, + "T::Array[T.untyped]", + [], + nil, + hash_shapes) + + expect(candidate).to eq("T::Array[T::Hash[Symbol, T.untyped]]") + end +end diff --git a/gems/nil-kill/spec/infer_pipeline_spec.rb b/gems/nil-kill/spec/infer_pipeline_spec.rb new file mode 100644 index 000000000..f65e732fb --- /dev/null +++ b/gems/nil-kill/spec/infer_pipeline_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe "nil-kill infer pipeline" do + it "loads runtime evidence, indexes sources, builds actions, and writes a report" do + Dir.mktmpdir("nil-kill-pipeline", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + class PipelineExample + extend T::Sig + + sig { params(items: T::Array[T.untyped], reason: String).returns(T.untyped) } + def call(items, reason) + reason.nil? + items + end + + def unsigned(value) + value + end + end + RUBY + + runtime_dir = NilKill::RUNTIME_DIR + FileUtils.rm_rf(runtime_dir) + FileUtils.mkdir_p(runtime_dir) + File.write(File.join(runtime_dir, "methods-test.jsonl"), JSON.generate( + "class" => "PipelineExample", + "method" => "call", + "kind" => "instance", + "path" => source, + "line" => 5, + "calls" => 25, + "ok_calls" => 25, + "raised_calls" => 0, + "params_by_name" => { "items" => ["Array"], "reason" => ["String"] }, + "params_ok" => { "items" => ["Array"], "reason" => ["String"] }, + "params_raised" => {}, + "param_sites" => {}, + "param_sites_ok" => {}, + "param_sites_raised" => {}, + "param_traces" => {}, + "param_traces_ok" => {}, + "param_traces_raised" => {}, + "param_elem" => { "items" => ["String"] }, + "param_kv" => {}, + "returns" => ["Array"], + "return_elem" => ["String"], + "return_kv" => [[], []], + "raised" => [] + ) + "\n") + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + actions = evidence["actions"] + + expect(actions).to include(a_hash_including("kind" => "fix_sig_return", "confidence" => "review")) + expect(actions).to include(a_hash_including("kind" => "narrow_generic_param", "confidence" => "high")) + expect(actions).to include(a_hash_including("kind" => "add_sig", "path" => Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s)) + expect(actions).to include(a_hash_including("kind" => "replace_dead_nil_check", "confidence" => "review")) + expect(File.read(NilKill::REPORT_PATH)).to include("PipelineExample#call") + end + end +end diff --git a/gems/nil-kill/spec/inplace_lifecycle_spec.rb b/gems/nil-kill/spec/inplace_lifecycle_spec.rb new file mode 100644 index 000000000..9a1f5a4e3 --- /dev/null +++ b/gems/nil-kill/spec/inplace_lifecycle_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true +# +# Crash-safety contract for B1 in-place instrumentation: snapshot -> +# wrap-in-place -> byte-perfect restore, idempotent, sentinel-driven +# self-heal. This is the safety net that lets `collect` mutate the +# real src tree. + +require_relative "spec_helper" + +RSpec.describe "in-place instrumentation lifecycle" do + def corpus_with_sigd_method + dir = Dir.mktmpdir("nk-inplace", NilKill::ROOT) + File.write(File.join(dir, "a.rb"), <<~RUBY) + # typed: false + require "sorbet-runtime" + class A + extend T::Sig + sig { params(x: T.untyped).returns(T.untyped) } + def go(x) + y = x + 1 + y * 2 + end + end + RUBY + dir + end + + it "snapshots pristine, wraps the real file in place, restores byte-perfect" do + dir = corpus_with_sigd_method + a = File.join(dir, "a.rb") + pristine = File.read(a) + snap = File.join(NilKill::TMP_DIR, "src-snapshot") + manifest = nil + isolated_env("NIL_KILL_TARGETS" => dir, "NIL_KILL_INSTRUMENTED_ROOT" => nil) do + NilKill::TracePlan.write(NilKill::TRACE_PLAN_PATH) + manifest = NilKill::SourceInstrumenter.new.run_in_place(snap) + end + rel = NilKill.rel(a) + expect(manifest).to include(rel) + # wrapped in place at the REAL path + expect(File.read(a)).to include("NilKillRuntimeTrace.record_source_method_call") + expect(File.read(a)).not_to eq(pristine) + # snapshot holds the exact pristine bytes + expect(File.binread(File.join(snap, rel))).to eq(pristine) + # linemap written under RUNTIME_DIR (where the child reads it) + expect(File.file?(File.join(NilKill::RUNTIME_DIR, ".nk-linemap.json"))).to be(true) + + NilKill.write_inplace_sentinel!(snap, manifest) + expect(JSON.parse(File.read(NilKill.inplace_sentinel_path))).to include("files" => manifest) + + expect(NilKill.restore_inplace_snapshot!).to be(true) + expect(File.read(a)).to eq(pristine) + expect(File.file?(NilKill.inplace_sentinel_path)).to be(false) + # idempotent: a second restore (ensure + trap + next-run all call it) + expect(NilKill.restore_inplace_snapshot!).to be(false) + ensure + FileUtils.remove_entry(dir) if dir && Dir.exist?(dir) + end + + it "ensure_src_restored! heals a crashed collect (sentinel present), else no-op" do + dir = corpus_with_sigd_method + a = File.join(dir, "a.rb") + pristine = File.read(a) + snap = File.join(NilKill::TMP_DIR, "src-snapshot") + isolated_env("NIL_KILL_TARGETS" => dir, "NIL_KILL_INSTRUMENTED_ROOT" => nil) do + NilKill::TracePlan.write(NilKill::TRACE_PLAN_PATH) + m = NilKill::SourceInstrumenter.new.run_in_place(snap) + # simulate a crash: sentinel on disk, src still wrapped, process dies + NilKill.write_inplace_sentinel!(snap, m) + end + expect(File.read(a)).not_to eq(pristine) # wrapped (crashed state) + + # next nil-kill process startup self-heals + expect { NilKill.ensure_src_restored! }.to output(/Restoring pristine sources/).to_stderr + expect(File.read(a)).to eq(pristine) + + # no sentinel -> silent no-op, no restore work + expect { NilKill.ensure_src_restored! }.not_to output.to_stderr + ensure + FileUtils.remove_entry(dir) if dir && Dir.exist?(dir) + end + + it "restore tolerates a not-yet-wrapped file (crash mid-wrap is still healed)" do + dir = corpus_with_sigd_method + snap = File.join(NilKill::TMP_DIR, "src-snapshot") + # sentinel lists a file that was never snapshotted (crash before its + # cp). restore must skip it (already pristine) and not blow up. + FileUtils.mkdir_p(snap) + NilKill.write_inplace_sentinel!(snap, ["src/never_wrapped.rb"]) + expect(NilKill.restore_inplace_snapshot!).to be(true) + expect(File.file?(NilKill.inplace_sentinel_path)).to be(false) + ensure + FileUtils.remove_entry(dir) if dir && Dir.exist?(dir) + end +end diff --git a/gems/nil-kill/spec/layout_spec.rb b/gems/nil-kill/spec/layout_spec.rb new file mode 100644 index 000000000..e3795f2a5 --- /dev/null +++ b/gems/nil-kill/spec/layout_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe "nil-kill package layout" do + it "keeps tools/nil-kill as a symlink to the gem executable" do + link = File.join(NilKill::ROOT, "tools", "nil-kill") + + expect(File.symlink?(link)).to be(true) + expect(File.readlink(link)).to eq("../gems/nil-kill/exe/nil-kill") + end + + it "loads doctor through the tools/nil-kill symlink under Bundler" do + _out, err, status = Open3.capture3("bundle", "exec", "tools/nil-kill", "doctor", chdir: NilKill::ROOT) + + expect(status).to be_success, err + end +end diff --git a/gems/nil-kill/spec/nil_kill_spec.rb b/gems/nil-kill/spec/nil_kill_spec.rb new file mode 100644 index 000000000..95c1b2a99 --- /dev/null +++ b/gems/nil-kill/spec/nil_kill_spec.rb @@ -0,0 +1,4152 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe NilKill do + describe ".sorbet_type union policy" do + it "emits T.any(...) for 2-3 class unions by default" do + expect(NilKill.sorbet_type(%w[String Integer])).to eq("T.any(Integer, String)") + expect(NilKill.sorbet_type(%w[Foo Bar Baz])).to eq("T.any(Bar, Baz, Foo)") + end + + it "falls back to T.untyped when union exceeds MAX_UNION_TYPES" do + expect(NilKill.sorbet_type(%w[A B C D])).to eq("T.untyped") + end + + it "wraps a 2-class union as T.nilable(T.any(...)) when NilClass is present" do + expect(NilKill.sorbet_type(%w[String Integer NilClass])).to eq("T.nilable(T.any(Integer, String))") + end + + it "honors NIL_KILL_UNION_POLICY=untyped explicit override" do + original = ENV["NIL_KILL_UNION_POLICY"] + ENV["NIL_KILL_UNION_POLICY"] = "untyped" + expect(NilKill.sorbet_type(%w[String Integer])).to eq("T.untyped") + ensure + ENV["NIL_KILL_UNION_POLICY"] = original + end + end + + describe ".strip_to_stdlib_owner" do + it "strips parametric stdlib container wrappers to their bare class name" do + expect(NilKill.strip_to_stdlib_owner("T::Array[String]")).to eq("Array") + expect(NilKill.strip_to_stdlib_owner("T::Hash[Symbol, T.untyped]")).to eq("Hash") + expect(NilKill.strip_to_stdlib_owner("T::Set[Integer]")).to eq("Set") + expect(NilKill.strip_to_stdlib_owner("T::Enumerable[Object]")).to eq("Enumerable") + end + + it "returns nil for non-container types so callers can detect no-op" do + expect(NilKill.strip_to_stdlib_owner("String")).to be_nil + expect(NilKill.strip_to_stdlib_owner("AST::Foo")).to be_nil + expect(NilKill.strip_to_stdlib_owner("T.untyped")).to be_nil + expect(NilKill.strip_to_stdlib_owner("")).to be_nil + expect(NilKill.strip_to_stdlib_owner(nil)).to be_nil + end + end + + describe "target filtering" do + it "excludes configured target subdirectories from files and path matches" do + Dir.mktmpdir("nil-kill-target-filter") do |dir| + kept_dir = File.join(dir, "src") + excluded_dir = File.join(kept_dir, "tools") + FileUtils.mkdir_p(excluded_dir) + kept = File.join(kept_dir, "kept.rb") + excluded = File.join(excluded_dir, "excluded.rb") + File.write(kept, "class Kept; end\n") + File.write(excluded, "class Excluded; end\n") + + isolated_env("NIL_KILL_TARGETS" => kept_dir, "NIL_KILL_EXCLUDE_TARGETS" => excluded_dir) do + expect(NilKill.target_files).to eq([kept]) + expect(NilKill.target_path?(kept)).to be(true) + expect(NilKill.target_path?(excluded)).to be(false) + end + end + end + end + + describe NilKill::SourceIndex do + it "infers helper parameter types from dispatcher case arms" do + Dir.mktmpdir("nil-kill-dispatcher") do |dir| + path = File.join(dir, "visitor.rb") + File.write(path, <<~RUBY) + class Visitor + def visit(node) + case node + when AST::Name + visit_name(node) + when AST::Call + visit_call(node) + end + end + + def visit_name(node) + node + end + + def visit_call(node) + node + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.dispatcher_inferences).to include( + a_hash_including("class" => "Visitor", "dispatcher" => "visit", "helper" => "visit_name", "type" => "AST::Name") + ) + expect(idx.dispatcher_inferences).to include( + a_hash_including("class" => "Visitor", "dispatcher" => "visit", "helper" => "visit_call", "type" => "AST::Call") + ) + end + end + + it "records return syntax and control shape for hygiene reporting" do + Dir.mktmpdir("nil-kill-return-hygiene-index") do |dir| + path = File.join(dir, "hygiene.rb") + File.write(path, <<~RUBY) + class HygieneIndex + extend T::Sig + + sig { returns(T.untyped) } + def branchless_implicit + "ok" + end + + sig { returns(T.untyped) } + def mixed_branching(flag) + return "early" if flag + "late" + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |origin, lookup| lookup[origin["method"]] = origin } + + expect(origins["branchless_implicit"]).to include( + "return_syntax" => "implicit", + "control_shape" => "branchless" + ) + expect(origins["mixed_branching"]).to include( + "return_syntax" => "mixed", + "control_shape" => "branching" + ) + end + end + end + + describe NilKill::FlowGraph do + it "indexes hash field reads by producer identity and field path" do + Dir.mktmpdir("nil-kill-flow-graph") do |dir| + path = File.join(dir, "records.rb") + File.write(path, <<~RUBY) + class Records + def pick + record = {c: "ok", p: 1} + record[:c] + end + end + RUBY + + idx = NilKill::SourceIndex.new(path) + lookup = idx.collection_index_lookups.find { |entry| entry["code"] == "record[:c]" } + graph = described_class.from_evidence("facts" => { + "existing_sigs" => [], + "unsigned_methods" => idx.methods, + "collection_index_lookups" => idx.collection_index_lookups, + }, "methods" => []) + + field_id = graph.hash_record_identity_for_lookup(lookup) + + expect(field_id).to include("hash_literal") + expect(field_id).to end_with("[:c]") + expect(graph.sorbet_type_for(field_id)).to eq("T.nilable(String)") + end + end + + it "represents call arguments, returns, forwarding, and struct fields as graph edges" do + evidence = { + "methods" => [], + "facts" => { + "existing_sigs" => [ + { "path" => "lib/example.rb", "line" => 1, "class" => "Example", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(String) }", "params" => [] }, + { "path" => "lib/example.rb", "line" => 5, "class" => "Example", "method" => "root", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }", "params" => [{ "name" => "record", "type" => "T.untyped" }] }, + ], + "unsigned_methods" => [], + "return_origins" => [ + { "path" => "lib/example.rb", "line" => 5, "class" => "Example", "method" => "root", "kind" => "instance", + "return_syntax" => "implicit", "candidate_type" => "T.untyped", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf", "line" => 6, "code" => "leaf" }] }, + ], + "param_origins" => [ + { "path" => "lib/example.rb", "line" => 9, "callee" => "root", "arg_kind" => "positional", "slot" => "0", + "origin_kind" => "static", "type" => "String", "code" => "\"ok\"" }, + ], + "struct_field_static" => [ + { "path" => "lib/example.rb", "line" => 12, "class" => "Example::Node", "field" => "name", + "type" => "String", "expression" => "\"node\"" }, + ], + }, + } + + graph = described_class.from_evidence(evidence) + + expect(graph.edges).to include(a_hash_including("kind" => "call_argument", "to" => "param:callee:root:positional:0")) + expect(graph.edges).to include(a_hash_including("kind" => "return_forward", "from" => "return:method_name:leaf")) + expect(graph.sorbet_type_for("struct_field:Example::Node:name")).to eq("String") + end + end + + describe NilKill::Report do + it "formats project paths as root-relative links when requested" do + report = described_class.new(["--with-links"]) + abs = File.join(NilKill::ROOT, "src", "ast", "parser.rb") + + line = report.format_report_line("- #{abs}:42 AutoConstraintCollector#walk Parser#error! Parser#stmt? parser issue T.nilable(String)") + + expect(line).to match(/\A- \[src\/ast\/parser\.rb:42\]\((?:\.\.\/)+src\/ast\/parser\.rb#L42\) `AutoConstraintCollector#walk` `Parser#error!` `Parser#stmt\?` parser issue T\.nilable\(String\)\z/) + expect(line).not_to include(NilKill::ROOT) + end + + it "--hygiene emits only the slot summary and action counts, skipping heavy sections" do + report = described_class.new(["--hygiene"]) + lines = [] + evidence = { + "target_dirs" => ["src"], + "target_exclude_dirs" => [], + "methods" => [], + "facts" => { + "existing_sigs" => [ + { "path" => "src/x.rb", "line" => 1, "class" => "X", "method" => "f", + "sig" => "sig { params(a: String).returns(T.untyped) }" }, + ], + "unsigned_methods" => [], + "tlet_sites" => [], + "struct_declarations" => [], + }, + "diagnostics" => { "sorbet_errors" => [], "nil_origins" => [] }, + } + actions = [ + { "kind" => "fix_sig_return", "confidence" => "high", "path" => "src/x.rb", "line" => 1, + "data" => { "type" => "String", "source" => "static_return_origin" } }, + { "kind" => "fix_sig_param", "confidence" => "review", "path" => "src/x.rb", "line" => 1, + "data" => { "name" => "y", "type" => "String", "source" => "static_param_backflow" } }, + ] + + lines = report.send(:build_header, evidence) + report.send(:append_hygiene_overview_summary, lines, evidence, actions) + + expect(lines.join("\n")).to include("## Hygiene Overview") + # The bulleted coverage subsections were consolidated into two + # tables; the hygiene overview now leads with those. + expect(lines.join("\n")).to include("### Type Soundness") + expect(lines.join("\n")).to include("### Untyped Cause Breakdown") + expect(lines.join("\n")).to include("## Auto-Fix Action Counts") + expect(lines.join("\n")).to include("HIGH (auto-applied): 1") + expect(lines.join("\n")).to include("REVIEW (manual or verified-loop): 1") + # Heavy sections must NOT be present + expect(lines.join("\n")).not_to include("### Param T.untyped Buckets") + expect(lines.join("\n")).not_to include("### Return Origin Pressure") + end + + it "reports per-method slot contribution counts in untyped slot buckets" do + report = described_class.new + evidence = { + "methods" => [ + { "source" => { "path" => "lib/example.rb", "line" => 1 }, "calls" => 0 }, + ], + "facts" => { + "existing_sigs" => [ + { "path" => "lib/example.rb", "line" => 1, "class" => "Example", "method" => "foo", + "sig" => "sig { params(a: T.untyped, b: T.untyped).returns(T.untyped) }", + "params" => [{ "name" => "a" }, { "name" => "b" }] }, + ], + }, + } + lines = [] + + report.send(:append_untyped_breakdown, lines, evidence) + + expect(lines).to include("### Param T.untyped Buckets") + expect(lines).to include("### Return T.untyped Buckets") + expect(lines).to include("### Param T.untyped Source Categories") + expect(lines).to include("### Return T.untyped Source Categories") + expect(lines).to include("### Param Unknown Expression Causes") + expect(lines).to include("### Return Unknown Expression Causes") + expect(lines).to include("- slot not observed: method was not hit: 2") + expect(lines).to include(" - 2 slots: lib/example.rb:1 Example#foo a; 0 call(s); observed no observed runtime type") + end + + it "links paths relative to a custom report output directory" do + report = described_class.new(["--with-links", "--output-path=gems/nil-kill"]) + src_abs = File.join(NilKill::ROOT, "src", "ast", "parser.rb") + gem_abs = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill.rb") + + expect(report.format_report_line("- #{src_abs}:42")).to eq("- [src/ast/parser.rb:42](../../src/ast/parser.rb#L42)") + expect(report.format_report_line("- #{gem_abs}:12")).to eq("- [gems/nil-kill/lib/nil_kill.rb:12](lib/nil_kill.rb#L12)") + end + + it "adds a toc, starts linked reports at hygiene, and truncates long bullet lists by default" do + report = described_class.new(["--with-links"]) + lines = [ + "# Nil Kill Report", + "", + "- Target dirs: src", + "- Methods indexed: 10", + "", + "## Project Prioritization", + "### Nil Source Fixes", + "- priority item", + "## Hygiene Overview", + "### Class And Instance Variable Slots", + "- ivar item", + "### Struct Field Slots", + "- struct item", + "### Signature Slots", + *Array.new(12) { |idx| "- item #{idx + 1}" }, + "### Return Hygiene", + "- Return slots indexed: 10", + "#### Control Shape", + "", + "- branchless: 5", + "Next bucket:", + *Array.new(12) { |idx| "- next #{idx + 1}" }, + "## Review Actions (1)", + "- review item", + ] + + prepared = report.prepare_linked_report(lines) + + expect(prepared[2]).to eq("## Table of Contents") + expect(prepared.index("## Project Prioritization")).to be < prepared.index("## Hygiene Overview") + expect(prepared.index("## Hygiene Overview")).to be < prepared.index("## Run Summary") + expect(prepared.index("## Run Summary")).to be > prepared.index("## Review Actions (1)") + expect(prepared.index("### Class And Instance Variable Slots")).to be < prepared.index("### Signature Slots") + expect(prepared.index("### Struct Field Slots")).to be < prepared.index("### Signature Slots") + expect(prepared.index("#### Control Shape")).to be > prepared.index("### Return Hygiene") + expect(prepared).to include("- [Hygiene Overview](#hygiene-overview)") + expect(prepared).to include("- [Project Prioritization](#project-prioritization)") + expect(prepared).to include("#### Control Shape") + expect(prepared).to include("- [Review Actions (1)](#review-actions-1)") + expect(prepared).not_to include("
More items") + expect(prepared).not_to include("- item 11") + expect(prepared).to include("- ... and 2 more (run with `--full` to see all)") + expect(prepared).to include("- ... and 2 more (run with `--full` to see all)") + expect(prepared).not_to include("
") + expect(prepared[prepared.index("## Review Actions (1)") - 1]).to eq("") + end + + it "keeps overflow bullets collapsed in full linked reports" do + report = described_class.new(["--with-links", "--full"]) + lines = [ + "# Nil Kill Report", + "", + "- Target dirs: src", + "## Signature Slots", + *Array.new(12) { |idx| "- item #{idx + 1}" }, + "## Review Actions (1)", + "- review item", + ] + + prepared = report.prepare_linked_report(lines, full: true) + + expect(prepared).to include("
More items") + expect(prepared).to include("- item 11") + expect(prepared).to include("
") + close_idx = prepared.rindex("
") + expect(prepared[close_idx + 1]).to eq("") + expect(prepared[close_idx + 2]).to eq("## Review Actions (1)") + end + + it "clusters similar hash shapes with pressure into one struct candidate" do + evidence = { + "facts" => { + "hash_shapes" => [ + { "path" => "src/diagnostics.rb", "line" => 10, "keys" => %w[category severity summary template], + "value_types" => %w[Symbol String String String], "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" }, + { "path" => "src/diagnostics.rb", "line" => 20, "keys" => %w[category cause fix_hint severity summary template], + "value_types" => %w[Symbol String String String String String], "code" => "{category: :lint, cause: \"c\", fix_hint: \"f\", severity: \"warning\", summary: \"s\", template: \"t\"}" }, + ], + "collection_index_lookups" => [ + { "path" => "src/use.rb", "line" => 30, "code" => "entry[:category]", "receiver" => "entry", "index" => ":category", + "lookup_type" => "T.nilable(Symbol)", "status" => "typed lookup", + "origin" => { "kind" => "hash literal", "path" => "src/diagnostics.rb", "line" => 10, + "name" => "entry", "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" } }, + { "path" => "src/use.rb", "line" => 31, "code" => "entry.fetch(:fix_hint)", "receiver" => "entry", "index" => ":fix_hint", + "lookup_type" => "T.nilable(String)", "status" => "typed lookup", + "origin" => { "kind" => "hash literal", "path" => "src/diagnostics.rb", "line" => 20, + "name" => "entry", "code" => "{category: :lint, cause: \"c\", fix_hint: \"f\", severity: \"warning\", summary: \"s\", template: \"t\"}" } }, + ], + "param_origins" => [], + "return_origins" => [], + "hash_record_blockers" => [ + { "path" => "src/use.rb", "line" => 32, "kind" => "dynamic_key", "code" => "entry[key]", + "receiver" => "entry", "message" => "dynamic hash-record key prevents struct accessor rewrite", + "origin" => { "kind" => "hash literal", "path" => "src/diagnostics.rb", "line" => 10, + "name" => "entry", "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" } }, + ], + "existing_sigs" => [], + "unsigned_methods" => [], + "struct_declarations" => [], + "struct_field_static" => [], + }, + "methods" => [], + } + report = described_class.allocate + report.instance_variable_set(:@evidence, evidence) + + rows = report.send(:hash_record_struct_candidates, evidence) + + expect(rows.size).to eq(1) + expect(rows.first["common_keys"]).to eq(%w[category severity summary template]) + expect(rows.first["optional_keys"]).to eq(%w[cause fix_hint]) + expect(rows.first["read_counts"]).to include("category" => 1, "fix_hint" => 1) + expect(rows.first["producers"]).to include( + a_hash_including("path" => "src/diagnostics.rb", "line" => 10, "keys" => %w[category severity summary template]) + ) + expect(rows.first["consumers"]).to include( + a_hash_including("path" => "src/use.rb", "line" => 31, "code" => "entry.fetch(:fix_hint)", "key" => "fix_hint") + ) + expect(rows.first["blockers"]).to include( + a_hash_including("path" => "src/use.rb", "line" => 32, "kind" => "dynamic_key") + ) + expect(rows.first["fields"]).to include( + { "name" => "fix_hint", "type" => "T.nilable(String)", "optional" => true } + ) + end + + it "does not merge similar hash keysets when discriminator field types conflict" do + evidence = { + "facts" => { + "hash_shapes" => [ + { "path" => "src/parser.rb", "line" => 10, "keys" => %w[kind value], + "value_types" => %w[Symbol AST::Node], "code" => "{kind: :when, value: node}" }, + { "path" => "src/lsp.rb", "line" => 20, "keys" => %w[kind value], + "value_types" => %w[String String], "code" => "{kind: \"markdown\", value: text}" }, + { "path" => "src/parser.rb", "line" => 30, "keys" => %w[kind value body], + "value_types" => %w[Symbol AST::Node T::Array[AST::Node]], "code" => "{kind: :if, value: node, body: body}" }, + ], + "collection_index_lookups" => [], + "param_origins" => [], + "return_origins" => [], + "hash_record_blockers" => [], + "existing_sigs" => [], + "unsigned_methods" => [], + "struct_declarations" => [], + "struct_field_static" => [], + }, + "methods" => [], + } + report = described_class.allocate + report.instance_variable_set(:@evidence, evidence) + + rows = report.send(:hash_record_struct_candidates, evidence) + + expect(rows.size).to eq(2) + expect(rows.map { |row| row["producers"].map { |producer| producer["path"] }.uniq }.map(&:sort)).to contain_exactly( + ["src/parser.rb"], + ["src/lsp.rb"], + ) + end + + it "does not merge similar hash keysets when parameterized collection field types conflict" do + evidence = { + "facts" => { + "hash_shapes" => [ + { "path" => "src/int_items.rb", "line" => 10, "keys" => %w[name items], + "value_types" => ["String", "T::Array[Integer]"], "code" => "{name: \"ids\", items: [1]}" }, + { "path" => "src/string_items.rb", "line" => 20, "keys" => %w[name items], + "value_types" => ["String", "T::Array[String]"], "code" => "{name: \"names\", items: [\"a\"]}" }, + ], + "collection_index_lookups" => [], + "param_origins" => [], + "return_origins" => [], + "hash_record_blockers" => [], + "existing_sigs" => [], + "unsigned_methods" => [], + "struct_declarations" => [], + "struct_field_static" => [], + }, + "methods" => [], + } + report = described_class.allocate + report.instance_variable_set(:@evidence, evidence) + + rows = report.send(:hash_record_struct_candidates, evidence) + + expect(rows.size).to eq(2) + expect(rows.map { |row| row["producers"].first["path"] }).to contain_exactly("src/int_items.rb", "src/string_items.rb") + end + end + + describe NilKill::Infer do + def infer_with_store + infer = described_class.allocate + infer.instance_variable_set(:@store, NilKill::Store.new) + infer + end + + describe "enrich_return_origins_with_receiver_inference!" do + def with_rbi_stub(infer, &block) + stub = Object.new + stub.define_singleton_method(:return_type) do |method, recv| + case [method.to_s, recv.to_s] + when %w[token AST::Foo] then "Token" + when %w[token AST::Bar] then "Token" + when %w[token AST::Mismatch] then "Symbol" + when %w[token AST::ReturnsNilable] then "T.nilable(Token)" + else nil + end + end + original = NilKill.method(:rbi_return_index) + NilKill.define_singleton_method(:rbi_return_index) { stub } + begin + block.call + ensure + NilKill.define_singleton_method(:rbi_return_index, original) + end + end + + it "narrows recv.method when callers consistently pass a class with a strong RBI return" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["param_origins"] = [ + { "callee" => "wrap", "slot" => "node", "origin_kind" => "static", "type" => "AST::Foo", + "path" => "src/c.rb", "line" => 1, "code" => "AST::Foo.new" }, + ] + store.facts["existing_sigs"] = [ + { "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).returns(T.untyped) }" }, + ] + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 6, "code" => "node.token" }], + "blockers" => ["untyped callee token"], + } + store.facts["return_origins"] = [origin] + + with_rbi_stub(infer) { infer.send(:enrich_return_origins_with_receiver_inference!) } + + expect(origin["sources"].first["kind"]).to eq("typed_call_inferred") + expect(origin["sources"].first["type"]).to eq("Token") + expect(origin["candidate_type"]).to eq("Token") + expect(origin["confidence"]).to eq("weak") + end + + it "narrows only when ALL callers pass classes that agree on the return type" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["param_origins"] = [ + { "callee" => "wrap", "slot" => "node", "origin_kind" => "static", "type" => "AST::Foo", + "path" => "src/c1.rb", "line" => 1, "code" => "AST::Foo.new" }, + { "callee" => "wrap", "slot" => "node", "origin_kind" => "static", "type" => "AST::Mismatch", + "path" => "src/c2.rb", "line" => 1, "code" => "AST::Mismatch.new" }, + ] + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 6, "code" => "node.token" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + with_rbi_stub(infer) { infer.send(:enrich_return_origins_with_receiver_inference!) } + + expect(origin["sources"].first["kind"]).to eq("call_untyped") + end + + it "skips T.nilable narrowings (cascade-prone)" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["param_origins"] = [ + { "callee" => "wrap", "slot" => "node", "origin_kind" => "static", "type" => "AST::ReturnsNilable", + "path" => "src/c.rb", "line" => 1, "code" => "AST::ReturnsNilable.new" }, + ] + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 6, "code" => "node.token" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + with_rbi_stub(infer) { infer.send(:enrich_return_origins_with_receiver_inference!) } + + expect(origin["sources"].first["kind"]).to eq("call_untyped") + end + + it "skips when receiver is not a known param of the enclosing method" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # No param_origins recorded for "wrap" -- no callsite evidence to drive inference. + store.facts["param_origins"] = [] + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 6, "code" => "node.token" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + with_rbi_stub(infer) { infer.send(:enrich_return_origins_with_receiver_inference!) } + + expect(origin["sources"].first["kind"]).to eq("call_untyped") + end + + it "rejects narrowing when runtime trace contradicts the inferred type" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["param_origins"] = [ + { "callee" => "wrap", "slot" => "node", "origin_kind" => "static", "type" => "AST::Foo", + "path" => "src/c.rb", "line" => 1, "code" => "AST::Foo.new" }, + ] + # Runtime evidence shows the method actually returns Hash, not Token. + rec = { "returns" => %w[Hash], "key" => ["Wrapper", "wrap", "instance", File.expand_path("src/x.rb", NilKill::ROOT), 5] } + store.methods["#{rec["key"].join("\0")}"] = rec + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 6, "code" => "node.token" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + with_rbi_stub(infer) { infer.send(:enrich_return_origins_with_receiver_inference!) } + + expect(origin["sources"].first["kind"]).to eq("call_untyped") + end + + it "ignores call shapes that don't lead with recv.method (chains, ConstClass.x)" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["param_origins"] = [ + { "callee" => "wrap", "slot" => "node", "origin_kind" => "static", "type" => "AST::Foo", + "path" => "src/c.rb", "line" => 1, "code" => "AST::Foo.new" }, + ] + store.facts["existing_sigs"] = [ + { "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).returns(T.untyped) }" }, + ] + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [ + { "kind" => "call_untyped", "callee" => "foo", "line" => 7, "code" => "node.foo.bar" }, + { "kind" => "call_untyped", "callee" => "z", "line" => 8, "code" => "Some::Class.z" }, + ], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + with_rbi_stub(infer) { infer.send(:enrich_return_origins_with_receiver_inference!) } + + origin["sources"].each { |s| expect(s["kind"]).to eq("call_untyped") } + end + + it "narrows recv.method(args) (method call with args is fine, args don't matter for return type)" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["param_origins"] = [ + { "callee" => "wrap", "slot" => "node", "origin_kind" => "static", "type" => "AST::Foo", + "path" => "src/c.rb", "line" => 1, "code" => "AST::Foo.new" }, + ] + store.facts["existing_sigs"] = [ + { "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).returns(T.untyped) }" }, + ] + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 6, "code" => "node.token(arg)" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + with_rbi_stub(infer) { infer.send(:enrich_return_origins_with_receiver_inference!) } + + expect(origin["sources"].first["kind"]).to eq("typed_call_inferred") + expect(origin["sources"].first["type"]).to eq("Token") + end + end + + it "plans structured hash-record cluster promotion actions from report candidates" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["hash_shapes"] = [ + { "path" => "src/diagnostics.rb", "line" => 10, "keys" => %w[category severity summary template], + "value_types" => %w[Symbol String String String], "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" }, + { "path" => "src/diagnostics.rb", "line" => 20, "keys" => %w[category cause fix_hint severity summary template], + "value_types" => %w[Symbol String String String String String], "code" => "{category: :lint, cause: \"c\", fix_hint: \"f\", severity: \"warning\", summary: \"s\", template: \"t\"}" }, + ] + store.facts["collection_index_lookups"] = [ + { "path" => "src/use.rb", "line" => 30, "code" => "entry[:category]", "receiver" => "entry", "index" => ":category", + "lookup_type" => "T.nilable(Symbol)", "status" => "typed lookup", + "origin" => { "kind" => "hash literal", "path" => "src/diagnostics.rb", "line" => 10, + "name" => "entry", "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" } }, + { "path" => "src/use.rb", "line" => 44, "code" => "record[:summary]", "receiver" => "record", "index" => ":summary", + "lookup_type" => "T.nilable(String)", "status" => "typed lookup", + "origin" => { "kind" => "method parameter", "path" => "src/use.rb", "line" => 40, + "name" => "record", "type" => "T::Hash[Symbol, T.untyped]" } }, + ] + store.facts["hash_record_blockers"] = [ + { "path" => "src/use.rb", "line" => 31, "kind" => "dynamic_key", "code" => "entry[key]", + "receiver" => "entry", "message" => "dynamic hash-record key prevents struct accessor rewrite", + "origin" => { "kind" => "hash literal", "path" => "src/diagnostics.rb", "line" => 10, + "name" => "entry", "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" } }, + ] + store.facts["return_origins"] = [ + { "path" => "src/diagnostics.rb", "line" => 8, "method" => "build_diagnostic", "class" => "Diagnostics", + "sources" => [{ "line" => 10, "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" }] }, + { "path" => "src/diagnostics.rb", "line" => 18, "method" => "build_diagnostics", "class" => "Diagnostics", + "sources" => [{ "line" => 10, "code" => "{category: :lint, severity: \"warning\", summary: \"s\", template: \"t\"}" }] }, + ] + store.facts["existing_sigs"] = [ + { "path" => "src/diagnostics.rb", "line" => 8, "method" => "build_diagnostic", "class" => "Diagnostics", + "sig" => "sig { returns(T::Hash[Symbol, T.untyped]) }" }, + { "path" => "src/diagnostics.rb", "line" => 18, "method" => "build_diagnostics", "class" => "Diagnostics", + "sig" => "sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }" }, + { "path" => "src/use.rb", "line" => 40, "method" => "render", "class" => "Use", + "sig" => "sig { params(record: T::Hash[Symbol, T.untyped]).returns(String) }" }, + ] + + infer.send(:propose_hash_record_cluster_actions) + + action = store.actions.find { |candidate| candidate["kind"] == "promote_hash_record_cluster_to_struct" } + expect(action).to include("confidence" => "review", "path" => "src/diagnostics.rb", "line" => 10) + expect(action.dig("data", "struct_name")).to eq("CategoryRecord") + expect(action.dig("data", "producers")).to include(a_hash_including("path" => "src/diagnostics.rb", "line" => 10)) + expect(action.dig("data", "consumers")).to include(a_hash_including("path" => "src/use.rb", "line" => 30, "key" => "category")) + expect(action.dig("data", "fields")).to include( + { "name" => "fix_hint", "type" => "T.nilable(String)", "optional" => true } + ) + expect(action.dig("data", "blockers")).to include( + "dynamic hash-record key prevents struct accessor rewrite at src/use.rb:31" + ) + expect(action.dig("data", "signatures")).to include( + a_hash_including("path" => "src/diagnostics.rb", "line" => 8, "kind" => "return", "type" => "CategoryRecord"), + a_hash_including("path" => "src/diagnostics.rb", "line" => 18, "kind" => "return", "type" => "T::Array[CategoryRecord]") + ) + expect(action.dig("data", "signatures")).not_to include( + a_hash_including("path" => "src/use.rb", "line" => 40, "kind" => "param", "name" => "record", "type" => "CategoryRecord") + ) + end + + it "does not treat test scratch under gems/tmp or gem spec fixtures as struct-name collisions" do + infer = infer_with_store + tmp_scratch = File.join(NilKill::ROOT, "gems", "tmp", "nil-kill-existing-struct-spec") + gem_spec = File.join(NilKill::ROOT, "gems", "nil-kill", "spec", "fixtures", "existing-struct-spec") + gem_lib = File.join(NilKill::ROOT, "gems", "nil-kill-existing-struct-spec", "lib") + FileUtils.mkdir_p(tmp_scratch) + FileUtils.mkdir_p(gem_spec) + FileUtils.mkdir_p(gem_lib) + begin + File.write(File.join(tmp_scratch, "tmp_struct.rb"), "class CollisionFixture < T::Struct\nend\n") + File.write(File.join(gem_spec, "fixture_struct.rb"), "class CollisionFixture < T::Struct\nend\n") + File.write(File.join(gem_lib, "real_gem_struct.rb"), "class CollisionFixture < T::Struct\nend\n") + + paths = infer.send(:hash_record_existing_struct_paths, "CollisionFixture") + + expect(paths).not_to include(a_string_including("gems/tmp")) + expect(paths).not_to include(a_string_including("gems/nil-kill/spec/fixtures")) + expect(paths).to include(a_string_matching(%r{gems/nil-kill-existing-struct-spec/lib/real_gem_struct\.rb\z})) + ensure + FileUtils.rm_rf(tmp_scratch) + FileUtils.rm_rf(gem_spec) + FileUtils.rm_rf(File.dirname(gem_lib)) + end + end + + it "proposes conservative generic param narrowing from runtime element evidence" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => { "items" => ["String"] }, + "param_kv" => {}, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(items: T::Array[T.untyped]).returns(T.untyped) }", + } + + infer.send(:validate_sig, rec, src) + actions = infer.instance_variable_get(:@store).actions + + expect(actions).to include( + a_hash_including( + "kind" => "narrow_generic_param", + "confidence" => "high", + "data" => a_hash_including("name" => "items", "type" => "T::Array[String]") + ) + ) + end + + it "keeps low-sample collection narrowing in review" do + infer = infer_with_store + rec = { + "calls" => 1, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => { "items" => ["String"] }, + "param_kv" => {}, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(items: T::Array[T.untyped]).returns(T.untyped) }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "narrow_generic_param", + "confidence" => "review", + "data" => a_hash_including("name" => "items", "type" => "T::Array[String]") + ) + ) + end + + it "keeps runtime-only param fixes in review instead of high auto-fix" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => { "name" => ["String"] }, + "params_by_name" => {}, + "param_elem" => {}, + "param_kv" => {}, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(name: T.untyped).void }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "fix_sig_param", + "confidence" => "review", + "data" => a_hash_including("name" => "name", "type" => "String") + ) + ) + end + + it "proposes param backflow fixes when static callsites agree" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "sink", "kind" => "instance", + "sig" => "sig { params(name: T.untyped).void }", "params" => [{ "name" => "name" }] }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 20, "callee" => "sink", "slot" => "name", + "origin_kind" => "static", "type" => "String", "code" => "\"Ada\"" }, + { "path" => "lib/caller.rb", "line" => 21, "callee" => "sink", "slot" => "0", + "origin_kind" => "typed_return", "type" => "String", "code" => "name_for(user)" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to include( + a_hash_including( + "kind" => "fix_sig_param", + "confidence" => "review", + "path" => "lib/example.rb", + "line" => 10, + "message" => include("static callsites prove param name is String"), + "data" => a_hash_including( + "name" => "name", + "type" => "String", + "source" => "static_param_backflow", + "callsite_count" => 2 + ) + ) + ) + end + + it "proposes per-class backflow for shared method names but still rejects unknown callsites" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/a.rb", "line" => 10, "class" => "A", "method" => "sink", "kind" => "instance", + "sig" => "sig { params(name: T.untyped).void }", "params" => [{ "name" => "name" }] }, + { "path" => "lib/b.rb", "line" => 10, "class" => "B", "method" => "sink", "kind" => "instance", + "sig" => "sig { params(name: T.untyped).void }", "params" => [{ "name" => "name" }] }, + { "path" => "lib/c.rb", "line" => 10, "class" => "C", "method" => "known", "kind" => "instance", + "sig" => "sig { params(value: T.untyped).void }", "params" => [{ "name" => "value" }] }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 20, "callee" => "sink", "slot" => "name", + "origin_kind" => "static", "type" => "String", "code" => "\"Ada\"" }, + { "path" => "lib/caller.rb", "line" => 21, "callee" => "known", "slot" => "value", + "origin_kind" => "unknown", "type" => nil, "code" => "dynamic_value" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + # B:34 relaxation: a shared name no longer blocks the group. Both + # A#sink and B#sink get a REVIEW (loop-gated) String proposal; the + # genuinely-unknown `known` callsite is still rejected. + sink_paths = store.actions.select { |a| a["data"]["name"] == "name" && a["data"]["type"] == "String" }.map { |a| a["path"] }.sort + expect(sink_paths).to eq(["lib/a.rb", "lib/b.rb"]) + expect(store.actions.any? { |a| a["data"]["name"] == "value" }).to be(false) + expect(store.actions).to all(a_hash_including("confidence" => "review")) + end + + it "rejects static param backflow candidates that do not satisfy the param protocol" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "stream_source?", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).returns(T::Boolean) }", + "params" => [{ "name" => "node" }], + "protocols" => { "node" => { "methods" => ["type_info"] } } }, + { "path" => "lib/ast.rb", "line" => 20, "class" => "AST::RangeLit", "method" => "type_info", "kind" => "instance", + "sig" => "sig { returns(Type) }", "params" => [] }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 30, "callee" => "stream_source?", "slot" => "node", + "origin_kind" => "static", "type" => "MIR::FieldGet", "code" => "node.left" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to be_empty + end + + it "rejects static param backflow candidates when the param protocol has unresolved forwarding gaps" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "direct_index_get", "kind" => "instance", + "sig" => "sig { params(ast_node: T.untyped).void }", + "params" => [{ "name" => "ast_node" }], + "protocols" => { "ast_node" => { "methods" => [], "gaps" => ["forwarded to direct_slice_backed_expr? at lib/example.rb:12"] } } }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 30, "callee" => "direct_index_get", "slot" => "ast_node", + "origin_kind" => "static", "type" => "Resolv::DNS::Name", "code" => "node.target" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to be_empty + end + + it "accepts a static param backflow candidate when ProtocolResolver follows a forwarded helper" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # `wrap(node)` forwards `node` to `inspect_node(node)`. + # `inspect_node(child)` calls child.token. + # The narrowing candidate AST::Foo defines `token` so the chain + # satisfies and the backflow should propose `node: AST::Foo`. + store.facts["existing_sigs"] = [ + { "path" => "lib/wrapper.rb", "line" => 10, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).void }", + "params" => [{ "name" => "node" }], + "protocols" => { "node" => { "methods" => [], "gaps" => ["forwarded to inspect_node slot 0 at lib/wrapper.rb:12"] } } }, + { "path" => "lib/wrapper.rb", "line" => 20, "class" => "Wrapper", "method" => "inspect_node", "kind" => "instance", + "sig" => "sig { params(child: T.untyped).void }", + "params" => [{ "name" => "child" }], + "protocols" => { "child" => { "methods" => ["token"], "gaps" => [] } } }, + { "path" => "lib/ast.rb", "line" => 5, "class" => "AST::Foo", "method" => "token", "kind" => "instance", + "sig" => "sig { returns(Token) }", "params" => [] }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 30, "callee" => "wrap", "slot" => "node", + "origin_kind" => "static", "type" => "AST::Foo", "code" => "AST::Foo.new" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to include( + a_hash_including( + "kind" => "fix_sig_param", + "data" => a_hash_including("name" => "node", "type" => "AST::Foo", "source" => "static_param_backflow") + ) + ) + end + + it "rejects a static param backflow candidate when the forwarded helper requires a method the candidate lacks" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # Same shape as accept-spec above but the candidate is Resolv::DNS::Name + # which does not define `token`. Resolver finds the missing method + # and the candidate is correctly rejected. + store.facts["existing_sigs"] = [ + { "path" => "lib/wrapper.rb", "line" => 10, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).void }", + "params" => [{ "name" => "node" }], + "protocols" => { "node" => { "methods" => [], "gaps" => ["forwarded to inspect_node slot 0 at lib/wrapper.rb:12"] } } }, + { "path" => "lib/wrapper.rb", "line" => 20, "class" => "Wrapper", "method" => "inspect_node", "kind" => "instance", + "sig" => "sig { params(child: T.untyped).void }", + "params" => [{ "name" => "child" }], + "protocols" => { "child" => { "methods" => ["token"], "gaps" => [] } } }, + # Resolv::DNS::Name exists in the index but does not have `token`. + { "path" => "vendor/resolv.rb", "line" => 1, "class" => "Resolv::DNS::Name", "method" => "to_s", "kind" => "instance", + "sig" => "sig { returns(String) }", "params" => [] }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 30, "callee" => "wrap", "slot" => "node", + "origin_kind" => "static", "type" => "Resolv::DNS::Name", "code" => "name_for(node)" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to be_empty + end + + it "blocks the chain when the forwarded helper is not in the method index" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/wrapper.rb", "line" => 10, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).void }", + "params" => [{ "name" => "node" }], + "protocols" => { "node" => { "methods" => [], "gaps" => ["forwarded to missing_helper slot 0 at lib/wrapper.rb:12"] } } }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 30, "callee" => "wrap", "slot" => "node", + "origin_kind" => "static", "type" => "AST::Foo", "code" => "AST::Foo.new" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to be_empty + end + + it "follows a two-hop forwarding chain via the resolver" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # wrap -> middle -> leaf, where leaf calls token on its param. + store.facts["existing_sigs"] = [ + { "path" => "lib/x.rb", "line" => 10, "class" => "X", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).void }", + "params" => [{ "name" => "node" }], + "protocols" => { "node" => { "methods" => [], "gaps" => ["forwarded to middle slot 0 at lib/x.rb:11"] } } }, + { "path" => "lib/x.rb", "line" => 20, "class" => "X", "method" => "middle", "kind" => "instance", + "sig" => "sig { params(arg: T.untyped).void }", + "params" => [{ "name" => "arg" }], + "protocols" => { "arg" => { "methods" => [], "gaps" => ["forwarded to leaf slot 0 at lib/x.rb:21"] } } }, + { "path" => "lib/x.rb", "line" => 30, "class" => "X", "method" => "leaf", "kind" => "instance", + "sig" => "sig { params(payload: T.untyped).void }", + "params" => [{ "name" => "payload" }], + "protocols" => { "payload" => { "methods" => ["token"], "gaps" => [] } } }, + { "path" => "lib/ast.rb", "line" => 5, "class" => "AST::Foo", "method" => "token", "kind" => "instance", + "sig" => "sig { returns(Token) }", "params" => [] }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 1, "callee" => "wrap", "slot" => "node", + "origin_kind" => "static", "type" => "AST::Foo", "code" => "AST::Foo.new" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to include( + a_hash_including("kind" => "fix_sig_param", "data" => a_hash_including("type" => "AST::Foo")) + ) + end + + it "uses ivar protocols when a param is captured to an ivar" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # initialize captures node to @node. Other class methods call @node.token. + # The ivar protocol carries the requirement back to the param. + store.facts["existing_sigs"] = [ + { "path" => "lib/x.rb", "line" => 10, "class" => "X", "method" => "initialize", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).void }", + "params" => [{ "name" => "node" }], + "protocols" => { "node" => { "methods" => [], "gaps" => ["captured in @node at lib/x.rb:11"] } } }, + { "path" => "lib/ast.rb", "line" => 5, "class" => "AST::Foo", "method" => "token", "kind" => "instance", + "sig" => "sig { returns(Token) }", "params" => [] }, + ] + store.facts["ivar_protocols"] = { "X\0@node" => ["token"] } + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 1, "callee" => "initialize", "slot" => "node", + "origin_kind" => "static", "type" => "AST::Foo", "code" => "AST::Foo.new" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to include( + a_hash_including("kind" => "fix_sig_param", "data" => a_hash_including("type" => "AST::Foo")) + ) + end + + it "blocks ivar capture when the ivar has no observed protocol" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/x.rb", "line" => 10, "class" => "X", "method" => "initialize", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).void }", + "params" => [{ "name" => "node" }], + "protocols" => { "node" => { "methods" => [], "gaps" => ["captured in @unobserved at lib/x.rb:11"] } } }, + ] + store.facts["ivar_protocols"] = {} + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 1, "callee" => "initialize", "slot" => "node", + "origin_kind" => "static", "type" => "AST::Foo", "code" => "AST::Foo.new" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to be_empty + end + + it "resolves a forwarding cycle without infinite recursion" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # foo -> bar -> foo cycle. Both forward only -- no direct methods. + # Resolver should converge (empty methods), but the chain is + # forwarding-only with no required methods, so the candidate is + # accepted (any class satisfies an empty protocol). + store.facts["existing_sigs"] = [ + { "path" => "lib/x.rb", "line" => 10, "class" => "X", "method" => "foo", "kind" => "instance", + "sig" => "sig { params(arg: T.untyped).void }", + "params" => [{ "name" => "arg" }], + "protocols" => { "arg" => { "methods" => [], "gaps" => ["forwarded to bar slot 0 at lib/x.rb:11"] } } }, + { "path" => "lib/x.rb", "line" => 20, "class" => "X", "method" => "bar", "kind" => "instance", + "sig" => "sig { params(arg: T.untyped).void }", + "params" => [{ "name" => "arg" }], + "protocols" => { "arg" => { "methods" => [], "gaps" => ["forwarded to foo slot 0 at lib/x.rb:21"] } } }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 1, "callee" => "foo", "slot" => "arg", + "origin_kind" => "static", "type" => "AST::Foo", "code" => "AST::Foo.new" }, + ] + + expect { infer.send(:propose_static_param_backflow_actions) }.not_to raise_error + expect(store.actions).to include( + a_hash_including("kind" => "fix_sig_param", "data" => a_hash_including("type" => "AST::Foo")) + ) + end + + it "rejects non-informative Object static param backflow candidates" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "verify", "kind" => "instance", + "sig" => "sig { params(node: T.untyped).void }", "params" => [{ "name" => "node" }] }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 30, "callee" => "verify", "slot" => "node", + "origin_kind" => "static", "type" => "T.nilable(Object)", "code" => "node.value" }, + ] + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).to be_empty + end + + it "promotes unambiguous forwarded-return chains to high-confidence return fixes" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + { "path" => "lib/example.rb", "line" => 20, "class" => "Example", "method" => "middle", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + { "path" => "lib/example.rb", "line" => 30, "class" => "Example", "method" => "root", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + ] + store.facts["return_origins"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "leaf", "kind" => "instance", + "candidate_type" => "String", "confidence" => "strong", + "sources" => [{ "kind" => "static", "type" => "String", "line" => 12, "code" => "\"ok\"" }], "blockers" => [] }, + { "path" => "lib/example.rb", "line" => 20, "class" => "Example", "method" => "middle", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf", "line" => 22, "code" => "leaf" }], "blockers" => ["untyped callee leaf"] }, + { "path" => "lib/example.rb", "line" => 30, "class" => "Example", "method" => "root", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "middle", "line" => 32, "code" => "middle" }], "blockers" => ["untyped callee middle"] }, + ] + + infer.send(:propose_forwarded_return_chain_actions) + + expect(store.actions).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => "lib/example.rb", + "line" => 20, + "data" => a_hash_including("type" => "String", "source" => "forwarded_return_chain") + ), + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => "lib/example.rb", + "line" => 30, + "data" => a_hash_including("type" => "String", "source" => "forwarded_return_chain") + ) + ) + end + + it "keeps ambiguous forwarded-return callees out of high-confidence fixes" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "A", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + { "path" => "lib/example.rb", "line" => 20, "class" => "B", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + { "path" => "lib/example.rb", "line" => 30, "class" => "Example", "method" => "root", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + ] + store.facts["return_origins"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "A", "method" => "leaf", "kind" => "instance", + "candidate_type" => "String", "confidence" => "strong", + "sources" => [{ "kind" => "static", "type" => "String", "line" => 12 }], "blockers" => [] }, + { "path" => "lib/example.rb", "line" => 20, "class" => "B", "method" => "leaf", "kind" => "instance", + "candidate_type" => "Integer", "confidence" => "strong", + "sources" => [{ "kind" => "static", "type" => "Integer", "line" => 22 }], "blockers" => [] }, + { "path" => "lib/example.rb", "line" => 30, "class" => "Example", "method" => "root", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf", "line" => 32 }], "blockers" => ["untyped callee leaf"] }, + ] + + infer.send(:propose_forwarded_return_chain_actions) + + expect(store.actions).not_to include( + a_hash_including("kind" => "fix_sig_return", "confidence" => "high", "path" => "lib/example.rb", "line" => 30) + ) + end + + it "keeps nilable forwarded-return chains as review-only fixes" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + { "path" => "lib/example.rb", "line" => 20, "class" => "Example", "method" => "root", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + ] + store.facts["return_origins"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "leaf", "kind" => "instance", + "candidate_type" => "T.nilable(Type)", "confidence" => "strong", + "sources" => [ + { "kind" => "static", "type" => "Type", "line" => 12 }, + { "kind" => "nil", "line" => 13 }, + ], + "blockers" => [] }, + { "path" => "lib/example.rb", "line" => 20, "class" => "Example", "method" => "root", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf", "line" => 22 }], "blockers" => ["untyped callee leaf"] }, + ] + + infer.send(:propose_forwarded_return_chain_actions) + + expect(store.actions).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "review", + "path" => "lib/example.rb", + "line" => 20, + "data" => a_hash_including("type" => "T.nilable(Type)", "source" => "forwarded_return_chain") + ) + ) + expect(store.actions).not_to include( + a_hash_including("kind" => "fix_sig_return", "confidence" => "high", "path" => "lib/example.rb", "line" => 20) + ) + end + + it "keeps duplicate forwarded-return method names ambiguous even when their sig types match" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "A", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(String) }" }, + { "path" => "lib/example.rb", "line" => 20, "class" => "B", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(String) }" }, + { "path" => "lib/example.rb", "line" => 30, "class" => "Example", "method" => "root", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + ] + store.facts["return_origins"] = [ + { "path" => "lib/example.rb", "line" => 30, "class" => "Example", "method" => "root", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf", "line" => 32 }], "blockers" => ["untyped callee leaf"] }, + ] + + infer.send(:propose_forwarded_return_chain_actions) + + expect(store.actions).not_to include( + a_hash_including("kind" => "fix_sig_return", "path" => "lib/example.rb", "line" => 30) + ) + end + + it "emits HIGH static-return-origin actions when all sources are static or RBI-backed" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + origin = { + "path" => "lib/example.rb", "line" => 8, "class" => "Example", "method" => "name", "kind" => "instance", + "candidate_type" => "String", "confidence" => "strong", + "sources" => [{ "kind" => "static", "type" => "String", "line" => 9, "code" => "\"ok\"" }], + "blockers" => [] + } + src = { "path" => "lib/example.rb", "line" => 8, "return_origin" => origin } + + infer.send(:propose_static_return_action, src, "sig { returns(T.untyped) }", nil) + + expect(store.actions).to include( + a_hash_including( + "kind" => "fix_sig_return", "confidence" => "high", "path" => "lib/example.rb", "line" => 8, + "data" => a_hash_including("type" => "String", "source" => "static_return_origin") + ) + ) + end + + it "demotes a bare heuristic static return (non-literal) to REVIEW unless runtime-corroborated" do + # Regression: Pprof::Profile#add_sample is `@samples << {...}` + # (Array#<<). The static origin heuristically guessed String with + # confidence strong and NO blockers; it was stamped HIGH and then + # failed `srb tc` ("Expected String, got Array"). A bare static + # source whose code is not self-evidently typed must not be HIGH + # without runtime corroboration. + infer = infer_with_store + store = infer.instance_variable_get(:@store) + origin = { + "path" => "lib/p.rb", "line" => 8, "candidate_type" => "String", "confidence" => "strong", + "sources" => [{ "kind" => "static", "type" => "String", "line" => 9, "code" => "@samples << { a: 1 }" }], + "blockers" => [] + } + src = { "path" => "lib/p.rb", "line" => 8, "return_origin" => origin } + + infer.send(:propose_static_return_action, src, "sig { returns(T.untyped) }", nil) + expect(store.actions).to include( + a_hash_including("kind" => "fix_sig_return", "confidence" => "review", "path" => "lib/p.rb") + ) + expect(store.actions).not_to include(a_hash_including("kind" => "fix_sig_return", "confidence" => "high")) + + # Same origin, but runtime observed the method returning String -> + # corroborated -> HIGH is now justified. + store.actions.clear + infer.send(:propose_static_return_action, src, "sig { returns(T.untyped) }", { "returns" => ["String"] }) + expect(store.actions).to include( + a_hash_including("kind" => "fix_sig_return", "confidence" => "high", "path" => "lib/p.rb") + ) + end + + it "emits REVIEW static-return-origin actions when at least one source is a non-RBI forwarded call" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + origin = { + "path" => "lib/example.rb", "line" => 8, "class" => "Example", "method" => "name", "kind" => "instance", + "candidate_type" => "String", "confidence" => "strong", + "sources" => [ + { "kind" => "static", "type" => "String", "line" => 9, "code" => "\"ok\"" }, + { "kind" => "typed_call", "type" => "String", "callee" => "user_defined_helper", "line" => 10, "code" => "user_defined_helper" }, + ], + "blockers" => ["forwarded source user_defined_helper"] + } + src = { "path" => "lib/example.rb", "line" => 8, "return_origin" => origin } + + infer.send(:propose_static_return_action, src, "sig { returns(T.untyped) }", nil) + + expect(store.actions).to include( + a_hash_including( + "kind" => "fix_sig_return", "confidence" => "review", "path" => "lib/example.rb", "line" => 8, + "data" => a_hash_including("type" => "String", "source" => "static_return_origin", + "blockers" => ["forwarded source user_defined_helper"]) + ) + ) + end + + it "rejects forwarded-return-chain candidates when runtime observed a class outside the proposed type" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "leaf", "kind" => "instance", + "sig" => "sig { returns(String) }" }, + { "path" => "lib/example.rb", "line" => 20, "class" => "Example", "method" => "root", "kind" => "instance", + "sig" => "sig { returns(T.untyped) }" }, + ] + store.facts["return_origins"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "leaf", "kind" => "instance", + "candidate_type" => "String", "confidence" => "strong", + "sources" => [{ "kind" => "static", "type" => "String", "line" => 12, "code" => "\"ok\"" }], "blockers" => [] }, + { "path" => "lib/example.rb", "line" => 20, "class" => "Example", "method" => "root", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf", "line" => 22, "code" => "leaf" }], "blockers" => ["untyped callee leaf"] }, + ] + rec = store.method_record(["Example", "root", "instance", File.expand_path("lib/example.rb", NilKill::ROOT), 20]) + rec["returns"] = %w[String Symbol] + + infer.send(:propose_forwarded_return_chain_actions) + + expect(store.actions).not_to include( + a_hash_including("kind" => "fix_sig_return", "path" => "lib/example.rb", "line" => 20) + ) + end + + it "rejects void return action when runtime observed a non-nil return" do + infer = infer_with_store + src = { "path" => "lib/example.rb", "line" => 8, "class" => "Example", "method" => "emit", "kind" => "instance", + "noreturn_candidate" => false } + rec = { "returns" => ["String"] } + unused = { infer.send(:method_location_key, src) => true } + + infer.send(:propose_void_return_action, src, "sig { returns(T.untyped) }", unused, rec) + + expect(infer.instance_variable_get(:@store).actions).to be_empty + end + + it "proposes runtime-void (REVIEW) when the method ran but never produced a usable return and static usage couldn't prove it" do + infer = infer_with_store + src = { "path" => "lib/example.rb", "line" => 8, "class" => "Example", "method" => "emit_fix!", "kind" => "instance", + "noreturn_candidate" => false } + rec = { "calls" => 45, "returns" => [] } # ran a lot, never a usable return + unused = {} # static usage scan could NOT prove it unused (name collision etc.) + + infer.send(:propose_void_return_action, src, "sig { returns(T.untyped) }", unused, rec) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including("kind" => "fix_sig_return", "confidence" => "review", + "data" => a_hash_including("type" => "void", "source" => "runtime_void")) + ) + end + + it "does not runtime-void a method whose return was observed usable at runtime" do + infer = infer_with_store + src = { "path" => "lib/example.rb", "line" => 8, "class" => "Example", "method" => "build", "kind" => "instance", + "noreturn_candidate" => false } + rec = { "calls" => 30, "returns" => %w[String] } + + infer.send(:propose_void_return_action, src, "sig { returns(T.untyped) }", {}, rec) + + expect(infer.instance_variable_get(:@store).actions).to be_empty + end + + it "rejects T.noreturn action when runtime observed any return" do + infer = infer_with_store + src = { "path" => "lib/example.rb", "line" => 8, "class" => "Example", "method" => "boom", "kind" => "instance", + "noreturn_candidate" => true } + rec = { "returns" => ["StandardError"] } + + infer.send(:propose_noreturn_action, src, "sig { returns(T.untyped) }", rec) + + expect(infer.instance_variable_get(:@store).actions).to be_empty + end + + it "rejects static_param_backflow narrowing when runtime observed a class outside the static candidate" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/example.rb", "line" => 10, "class" => "Example", "method" => "consume", "kind" => "instance", + "sig" => "sig { params(x: T.untyped).void }" }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/caller.rb", "line" => 5, "callee" => "consume", "slot" => "0", + "origin_kind" => "static", "type" => "Node", "code" => "Node.new" }, + { "path" => "lib/caller.rb", "line" => 6, "callee" => "consume", "slot" => "0", + "origin_kind" => "static", "type" => "Node", "code" => "Node.new" }, + ] + rec = store.method_record(["Example", "consume", "instance", File.expand_path("lib/example.rb", NilKill::ROOT), 10]) + rec["params_by_name"] = { "x" => %w[Node Symbol] } + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).not_to include( + a_hash_including("kind" => "fix_sig_param", "path" => "lib/example.rb") + ) + end + + it "rejects static_param_backflow narrowing for the FunctionContext :Any-symbol fallthrough pattern" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "path" => "lib/function_context.rb", "line" => 10, "class" => "FunctionContext", "method" => "initialize", + "kind" => "instance", "sig" => "sig { params(return_type: T.untyped).void }" }, + ] + store.facts["param_origins"] = [ + { "path" => "lib/annotator.rb", "line" => 5, "callee" => "initialize", "slot" => "return_type", + "origin_kind" => "static", "type" => "Type", "code" => "node.return_type" }, + { "path" => "lib/annotator.rb", "line" => 6, "callee" => "initialize", "slot" => "return_type", + "origin_kind" => "static", "type" => "Type", "code" => "node.return_type" }, + ] + rec = store.method_record(["FunctionContext", "initialize", "instance", + File.expand_path("lib/function_context.rb", NilKill::ROOT), 10]) + rec["params_by_name"] = { "return_type" => %w[Type Symbol] } + + infer.send(:propose_static_param_backflow_actions) + + expect(store.actions).not_to include( + a_hash_including("kind" => "fix_sig_param", "data" => a_hash_including("name" => "return_type", "type" => "Type")) + ) + end + + it "runtime_contradicts? rejects T::Array narrowings when runtime saw non-Array return classes" do + infer = infer_with_store + # Proposer wants `T.nilable(T::Array[T.untyped])`; runtime observed Hash returns. + rec = { "returns" => %w[Hash NilClass] } + expect(infer.send(:runtime_contradicts?, rec, :return, nil, "T.nilable(T::Array[T.untyped])")).to be(true) + end + + it "runtime_contradicts? accepts T::Array narrowings when runtime saw only Array (and nil)" do + infer = infer_with_store + rec = { "returns" => %w[Array NilClass] } + expect(infer.send(:runtime_contradicts?, rec, :return, nil, "T.nilable(T::Array[T.untyped])")).to be(false) + end + + it "runtime_contradicts? rejects T::Hash narrowings when runtime saw Array" do + infer = infer_with_store + rec = { "returns" => %w[Array] } + expect(infer.send(:runtime_contradicts?, rec, :return, nil, "T::Hash[T.untyped, T.untyped]")).to be(true) + end + + it "treats calls on a global-variable receiver as untyped (`$stderr.puts` is not assumed to return NilClass)" do + Dir.mktmpdir("nil-kill-global-recv", NilKill::ROOT) do |dir| + source = File.join(dir, "global_recv.rb") + File.write(source, <<~RUBY) + class Example + extend T::Sig + + sig { returns(T.untyped) } + def emit + $stderr.puts "event" + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + NilKill::Infer.new(["--no-sorbet"]).run + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + # The proposer must NOT emit a HIGH fix_sig_return -> NilClass: `$stderr` + # is dynamically reassignable, so the actual class of `$stderr.puts` at + # runtime is whatever the runtime has assigned -- can't be locked down + # statically. + nilclass_action = evidence["actions"].find do |action| + action["kind"] == "fix_sig_return" && + action["confidence"] == "high" && + action["path"].end_with?("/global_recv.rb") && + action.dig("data", "type") == "NilClass" + end + expect(nilclass_action).to be_nil + end + end + + it "unused_return scanner sees callers outside target_dirs when NIL_KILL_TARGETS is unset" do + original = ENV["NIL_KILL_TARGETS"] + ENV.delete("NIL_KILL_TARGETS") + begin + files = NilKill.usage_scan_files + spec_seen = files.any? { |f| f.include?("/gems/nil-kill/spec/") } || + files.any? { |f| f.include?("/spec/") } + expect(spec_seen).to be(true), "usage_scan_files should include spec/ when targets aren't constrained, got #{files.first(3)}..." + ensure + ENV["NIL_KILL_TARGETS"] = original + end + end + + it "unused_return scanner scopes to target_files when NIL_KILL_TARGETS is set (isolated_env behaviour)" do + Dir.mktmpdir("nil-kill-isolated-scan", NilKill::ROOT) do |dir| + File.write(File.join(dir, "lone.rb"), "# nothing\n") + isolated_env("NIL_KILL_TARGETS" => dir) do + files = NilKill.usage_scan_files + expect(files.size).to eq(1) + expect(files.first).to end_with("/lone.rb") + end + end + end + + it "sorbet-validates HIGH actions and downgrades the ones that break srb tc" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # Two HIGH actions; the bisection should isolate the one that fails. + store.actions << { "kind" => "fix_sig_return", "confidence" => "high", "path" => "src/clean.rb", + "line" => 5, "message" => "clean narrowing", "data" => { "type" => "String", "source" => "static_return_origin" } } + store.actions << { "kind" => "fix_sig_return", "confidence" => "high", "path" => "src/dirty.rb", + "line" => 7, "message" => "broken narrowing", "data" => { "type" => "String", "source" => "static_return_origin" } } + + # Stub IO: pretend file snapshot is empty, apply is a no-op, srb tc reports failure only when the dirty action is present. + allow(infer).to receive(:sorbet_validate_batch) do |actions, _snapshot| + actions.select { |a| a["path"] == "src/dirty.rb" } + end + + infer.send(:sorbet_validate_high_actions!) + + clean = store.actions.find { |a| a["path"] == "src/clean.rb" } + dirty = store.actions.find { |a| a["path"] == "src/dirty.rb" } + expect(clean["confidence"]).to eq("high") + expect(dirty["confidence"]).to eq("review") + expect(dirty["message"]).to start_with("[downgraded from high by sorbet pre-validate]") + end + + it "sorbet_validate_high_actions! is a no-op when there are no HIGH actions" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.actions << { "kind" => "fix_sig_return", "confidence" => "review", "path" => "src/r.rb", + "line" => 5, "data" => { "type" => "String" } } + expect(infer).not_to receive(:sorbet_validate_batch) + + infer.send(:sorbet_validate_high_actions!) + + expect(store.actions.first["confidence"]).to eq("review") + end + + it "uses nested runtime shape evidence for generic narrowing" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => { "items" => ["Hash"] }, + "param_kv" => {}, + "param_elem_shapes" => { + "items" => [ + { + "kind" => "hash", + "keys" => [{ "kind" => "class", "name" => "Symbol" }], + "values" => [ + { + "kind" => "array", + "elements" => [{ "kind" => "class", "name" => "String" }], + }, + ], + }, + ], + }, + "param_kv_shapes" => {}, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + "return_elem_shapes" => [], + "return_kv_shapes" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(items: T::Array[T.untyped]).returns(T.untyped) }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "narrow_generic_param", + "confidence" => "review", + "data" => a_hash_including("type" => "T::Array[T::Hash[Symbol, T::Array[String]]]") + ) + ) + end + + it "preserves nilable wrappers when narrowing collection generics" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => { "items" => ["String"] }, + "param_kv" => {}, + "returns" => ["Array"], + "return_elem" => ["String"], + "return_kv" => [[], []], + "return_elem_shapes" => [], + "return_kv_shapes" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(items: T.nilable(T::Array[T.untyped])).returns(T.nilable(T::Array[T.untyped])) }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "narrow_generic_param", + "confidence" => "high", + "data" => a_hash_including("from" => "T.nilable(T::Array[T.untyped])", "type" => "T.nilable(T::Array[String])") + ), + a_hash_including( + "kind" => "narrow_generic_return", + "confidence" => "high", + "data" => a_hash_including("from" => "T.nilable(T::Array[T.untyped])", "type" => "T.nilable(T::Array[String])") + ) + ) + end + + it "keeps stable nested container shape when value candidates are too broad" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => { "items" => ["Hash"] }, + "param_kv" => {}, + "param_elem_shapes" => { + "items" => [ + { + "kind" => "hash", + "keys" => [{ "kind" => "class", "name" => "Symbol" }], + "values" => (1..7).map { |idx| { "kind" => "class", "name" => "Value#{idx}" } }, + }, + ], + }, + "param_kv_shapes" => {}, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + "return_elem_shapes" => [], + "return_kv_shapes" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(items: T::Array[T.untyped]).returns(T.untyped) }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "narrow_generic_param", + "confidence" => "review", + "data" => a_hash_including("type" => "T::Array[T::Hash[Symbol, T.untyped]]") + ) + ) + end + + it "keeps broad union collection narrowing in review" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => {}, + "param_kv" => { "plan" => [["Symbol"], ["Array", "Set"]] }, + "param_elem_shapes" => {}, + "param_kv_shapes" => { + "plan" => [ + [{ "kind" => "class", "name" => "Symbol" }], + [ + { "kind" => "array", "elements" => [{ "kind" => "class", "name" => "String" }] }, + { "kind" => "set", "elements" => [{ "kind" => "class", "name" => "Integer" }] }, + ], + ], + }, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + "return_elem_shapes" => [], + "return_kv_shapes" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(plan: T::Hash[Symbol, T.untyped]).returns(T.untyped) }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "narrow_generic_param", + "confidence" => "review", + "data" => a_hash_including("type" => "T::Hash[Symbol, T.any(T::Array[String], T::Set[Integer])]") + ) + ) + end + + it "does not narrow generic params from polymorphic AST evidence" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => { "items" => ["AST::Name"] }, + "param_kv" => {}, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { params(items: T::Array[T.untyped]).returns(T.untyped) }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).not_to include( + a_hash_including("kind" => "narrow_generic_param") + ) + end + + it "turns Sorbet result-type errors into review widening feedback" do + infer = infer_with_store + output = <<~TEXT + lib/example.rb:12: Expected `String` but found `T.nilable(String)` for method result type https://srb.help/7005 + 12 | end + ^^^ + Expected `String` for result type of method `name`: + lib/example.rb:8: + 8 | sig { returns(String) } + TEXT + + feedback = infer.send(:parse_sorbet_feedback, output) + + expect(feedback).to include( + a_hash_including( + "code" => "7005", + "path" => "lib/example.rb", + "line" => 8, + "message" => include("widening return") + ) + ) + end + + it "keeps runtime-only return observations in review instead of high auto-fix" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => {}, + "param_kv" => {}, + "returns" => ["String"], + "return_elem" => [], + "return_kv" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { returns(T.untyped) }", + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "review", + "data" => a_hash_including("type" => "String") + ) + ) + end + + it "keeps return fixes in review even when runtime and static evidence agree" do + infer = infer_with_store + rec = { + "calls" => 50, + "params_ok" => {}, + "params_by_name" => {}, + "param_elem" => {}, + "param_kv" => {}, + "returns" => ["String"], + "return_elem" => [], + "return_kv" => [[], []], + } + src = { + "path" => "lib/example.rb", + "line" => 10, + "sig" => "sig { returns(T.untyped) }", + "return_origin" => { "confidence" => "strong", "candidate_type" => "String" }, + } + + infer.send(:validate_sig, rec, src) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "review", + "data" => a_hash_including("type" => "String") + ) + ) + end + + it "promotes unused T.untyped returns to verifiable void actions" do + Dir.mktmpdir("nil-kill-void", NilKill::ROOT) do |dir| + source = File.join(dir, "void_example.rb") + File.write(source, <<~RUBY) + class VoidExample + extend T::Sig + + sig { returns(T.untyped) } + def emit + puts "event" + end + + sig { returns(String) } + def caller + emit + "done" + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + expect(evidence["actions"]).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => rel, + "line" => 5, + "data" => a_hash_including("type" => "void", "source" => "unused_return") + ) + ) + end + end + + it "propagates unused return evidence backward through return-forwarding chains" do + Dir.mktmpdir("nil-kill-void-chain", NilKill::ROOT) do |dir| + source = File.join(dir, "void_chain_example.rb") + File.write(source, <<~RUBY) + class VoidChainExample + extend T::Sig + + sig { returns(T.untyped) } + def leaf_value + "event" + end + + sig { returns(T.untyped) } + def middle_value + leaf_value + end + + sig { returns(T.untyped) } + def top_value + middle_value + end + + sig { void } + def run + top_value + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + void_lines = evidence["actions"].select do |action| + action["kind"] == "fix_sig_return" && + action["confidence"] == "high" && + action["path"] == rel && + action.dig("data", "type") == "void" + end.map { |action| action["line"] } + + expect(void_lines).to include(5, 10, 15) + end + end + + it "treats explicit return forwarding as unused when the wrapper return is unused" do + Dir.mktmpdir("nil-kill-explicit-void-chain", NilKill::ROOT) do |dir| + source = File.join(dir, "explicit_void_chain_example.rb") + File.write(source, <<~RUBY) + class ExplicitVoidChainExample + extend T::Sig + + sig { returns(T.untyped) } + def explicit_leaf + "event" + end + + sig { returns(T.untyped) } + def explicit_wrapper + return explicit_leaf + end + + sig { void } + def run + explicit_wrapper + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + void_lines = evidence["actions"].select do |action| + action["kind"] == "fix_sig_return" && + action["confidence"] == "high" && + action["path"] == rel && + action.dig("data", "type") == "void" + end.map { |action| action["line"] } + + expect(void_lines).to include(5, 10) + end + end + + it "does not mark a return-forwarding chain void when the final value is used" do + Dir.mktmpdir("nil-kill-void-used-chain", NilKill::ROOT) do |dir| + source = File.join(dir, "used_chain_example.rb") + File.write(source, <<~RUBY) + class UsedChainExample + extend T::Sig + + sig { returns(T.untyped) } + def used_leaf + "event" + end + + sig { returns(T.untyped) } + def used_middle + used_leaf + end + + sig { returns(String) } + def run + value = used_middle + value.to_s + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + void_lines = evidence["actions"].select do |action| + action["kind"] == "fix_sig_return" && + action["confidence"] == "high" && + action["path"] == rel && + action.dig("data", "type") == "void" + end.map { |action| action["line"] } + + expect(void_lines).not_to include(5, 10) + end + end + + it "does not auto-promote ambiguous method names to void" do + Dir.mktmpdir("nil-kill-void-ambiguous", NilKill::ROOT) do |dir| + source = File.join(dir, "ambiguous_void_example.rb") + File.write(source, <<~RUBY) + class FirstAmbiguousVoid + extend T::Sig + + sig { returns(T.untyped) } + def duplicate_name + "first" + end + end + + class SecondAmbiguousVoid + extend T::Sig + + sig { returns(T.untyped) } + def duplicate_name + "second" + end + end + + class AmbiguousVoidRunner + extend T::Sig + + sig { params(target: T.untyped).void } + def run(target) + target.duplicate_name + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + expect(evidence["actions"]).not_to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => rel, + "data" => a_hash_including("type" => "void") + ) + ) + end + end + + it "promotes untyped returns forwarded through an already-void wrapper" do + Dir.mktmpdir("nil-kill-void-typed-wrapper", NilKill::ROOT) do |dir| + source = File.join(dir, "typed_void_wrapper_example.rb") + File.write(source, <<~RUBY) + class TypedVoidWrapperExample + extend T::Sig + + sig { returns(T.untyped) } + def leaf_event + "event" + end + + sig { void } + def run + leaf_event + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + expect(evidence["actions"]).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => rel, + "line" => 5, + "data" => a_hash_including("type" => "void", "source" => "unused_return") + ) + ) + end + end + + it "does not promote returns forwarded through typed value wrappers to void" do + Dir.mktmpdir("nil-kill-typed-value-wrapper", NilKill::ROOT) do |dir| + source = File.join(dir, "typed_value_wrapper_example.rb") + File.write(source, <<~RUBY) + class TypedValueWrapperExample + extend T::Sig + + sig { returns(T.untyped) } + def leaf_nil + nil + end + + sig { returns(NilClass) } + def wrapper_nil + return leaf_nil + end + + sig { void } + def run + wrapper_nil + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + expect(evidence["actions"]).not_to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => rel, + "line" => 5, + "data" => a_hash_including("type" => "void") + ) + ) + end + end + + it "promotes strong static stdlib nilable returns to high-confidence fixes" do + Dir.mktmpdir("nil-kill-stdlib-return", NilKill::ROOT) do |dir| + source = File.join(dir, "stdlib_return_example.rb") + File.write(source, <<~RUBY) + class StdlibReturnExample + extend T::Sig + + sig { params(lines: T::Array[String], ok: T::Boolean).returns(T.untyped) } + def maybe_join(lines, ok) + return nil unless ok + lines.join + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + expect(evidence["actions"]).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => rel, + "line" => 5, + "data" => a_hash_including("type" => "T.nilable(String)", "source" => "static_return_origin") + ) + ) + end + end + + it "promotes always-raising T.untyped returns to verifiable T.noreturn actions" do + Dir.mktmpdir("nil-kill-noreturn", NilKill::ROOT) do |dir| + source = File.join(dir, "noreturn_example.rb") + File.write(source, <<~RUBY) + class NoReturnExample + extend T::Sig + + sig { returns(T.untyped) } + def fail_now + raise "boom" + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + expect(evidence["actions"]).to include( + a_hash_including( + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => rel, + "line" => 5, + "data" => a_hash_including("type" => "T.noreturn", "source" => "noreturn_body") + ) + ) + end + end + + it "does not promote guard-clause methods with normal returns to T.noreturn" do + Dir.mktmpdir("nil-kill-noreturn-guard", NilKill::ROOT) do |dir| + source = File.join(dir, "noreturn_guard_example.rb") + File.write(source, <<~RUBY) + class NoReturnGuardExample + extend T::Sig + + sig { params(value: String).returns(T.untyped) } + def assert_prefix!(value) + return unless value.start_with?("!") + raise "bad" + end + end + RUBY + + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + end + + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rel = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + expect(evidence["actions"]).not_to include( + a_hash_including( + "kind" => "fix_sig_return", + "path" => rel, + "line" => 5, + "data" => a_hash_including("type" => "T.noreturn") + ) + ) + end + end + + it "does not auto-apply dead nil-check rewrites without separate proof" do + infer = infer_with_store + infer.instance_variable_get(:@store).facts["dead_nil_checks"] << { + "path" => "lib/example.rb", + "line" => 3, + "kind" => "nil_check", + "code" => "value.nil?", + "reason" => "value is provably non-nil", + } + + infer.send(:build_actions) + + expect(infer.instance_variable_get(:@store).actions).to include( + a_hash_including("kind" => "replace_dead_nil_check", "confidence" => "review") + ) + end + + describe "build_project_method_return_index" do + def stub_rbi_field_types(types) + original = NilKill::SourceIndex.method(:rbi_field_types) + NilKill::SourceIndex.define_singleton_method(:rbi_field_types) { types } + yield + ensure + NilKill::SourceIndex.define_singleton_method(:rbi_field_types, original) + end + + it "includes existing_sigs entries with strong returns" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "class" => "Wrapper", "method" => "wrap", + "sig" => "sig { params(node: T.untyped).returns(String) }" }, + ] + stub_rbi_field_types({}) do + index = infer.send(:build_project_method_return_index) + expect(index[["Wrapper", "wrap"]]).to eq("String") + end + end + + it "skips existing_sigs with T.untyped or empty returns" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "class" => "Wrapper", "method" => "untyped_wrap", + "sig" => "sig { params(node: T.untyped).returns(T.untyped) }" }, + ] + stub_rbi_field_types({}) do + index = infer.send(:build_project_method_return_index) + expect(index).not_to have_key(["Wrapper", "untyped_wrap"]) + end + end + + it "merges RBI struct-field accessor types" do + infer = infer_with_store + stub_rbi_field_types({ ["AST::Foo", "token"] => "Token", ["AST::Foo", "ignored"] => "T.untyped" }) do + index = infer.send(:build_project_method_return_index) + expect(index[["AST::Foo", "token"]]).to eq("Token") + expect(index).not_to have_key(["AST::Foo", "ignored"]) + end + end + + it "merges strong inferred returns from return_origins for methods existing_sigs missed" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["return_origins"] = [ + { "class" => "Helper", "method" => "summarize", "confidence" => "strong", + "candidate_type" => "String" }, + { "class" => "Helper", "method" => "weak", "confidence" => "strong", + "candidate_type" => "T::Array[T.untyped]" }, + { "class" => "Helper", "method" => "blocked_one", "confidence" => "blocked", + "candidate_type" => "T.untyped" }, + ] + stub_rbi_field_types({}) do + index = infer.send(:build_project_method_return_index) + expect(index[["Helper", "summarize"]]).to eq("String") + expect(index).not_to have_key(["Helper", "weak"]) + expect(index).not_to have_key(["Helper", "blocked_one"]) + end + end + + it "converges in a fixed-point loop: iter 1 narrows method_b, iter 2 narrows method_a via method_b" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # method_b's receiver: caller `caller_b` calls method_b(item) with item: AST::Foo. + # method_a's receiver: caller `caller_a` calls method_a(holder) with holder: Container. + # method_a's body does `holder.method_b(holder.item)` -- but the + # receiver-inference path only looks at `holder.method_b` as a + # call_untyped source. After iter 1, method_b's return becomes + # known. In iter 2, project_method_returns picks up method_b's + # newly-strong return and method_a's source narrows. + store.facts["param_origins"] = [ + { "callee" => "method_b", "slot" => "item", "origin_kind" => "static", "type" => "AST::Foo", + "path" => "src/cb.rb", "line" => 1, "code" => "AST::Foo.new" }, + { "callee" => "method_a", "slot" => "holder", "origin_kind" => "static", "type" => "Wrapper", + "path" => "src/ca.rb", "line" => 1, "code" => "Wrapper.new" }, + ] + store.facts["existing_sigs"] = [ + { "path" => "src/b.rb", "line" => 1, "class" => "Wrapper", "method" => "method_b", "kind" => "instance", + "sig" => "sig { params(item: T.untyped).returns(T.untyped) }" }, + { "path" => "src/a.rb", "line" => 1, "class" => "Wrapper", "method" => "method_a", "kind" => "instance", + "sig" => "sig { params(holder: T.untyped).returns(T.untyped) }" }, + ] + origin_b = { + "path" => "src/b.rb", "line" => 1, "class" => "Wrapper", "method" => "method_b", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 2, "code" => "item.token" }], + "blockers" => [], + } + origin_a = { + "path" => "src/a.rb", "line" => 1, "class" => "Wrapper", "method" => "method_a", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "method_b", "line" => 2, "code" => "holder.method_b" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin_b, origin_a] + + stub = Object.new + stub.define_singleton_method(:return_type) do |method, recv| + (method.to_s == "token" && recv.to_s == "AST::Foo") ? "Token" : nil + end + original = NilKill.method(:rbi_return_index) + NilKill.define_singleton_method(:rbi_return_index) { stub } + begin + infer.send(:enrich_return_origins_with_receiver_inference!) + ensure + NilKill.define_singleton_method(:rbi_return_index, original) + end + + # Iter 1: method_b narrows from token RBI lookup. + expect(origin_b["sources"].first["kind"]).to eq("typed_call_inferred") + expect(origin_b["sources"].first["type"]).to eq("Token") + # Iter 2: method_a narrows because Wrapper#method_b now has a strong + # return type that build_project_method_return_index sees. + expect(origin_a["sources"].first["kind"]).to eq("typed_call_inferred") + expect(origin_a["sources"].first["type"]).to eq("Token") + end + + it "stops early when an iteration produces zero new enrichments" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # No param_origins -> nothing can be narrowed. + store.facts["param_origins"] = [] + origin = { + "path" => "src/x.rb", "line" => 1, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "token", "line" => 1, "code" => "node.token" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + stub = Object.new + stub.define_singleton_method(:return_type) { |_method, _recv| nil } + original = NilKill.method(:rbi_return_index) + NilKill.define_singleton_method(:rbi_return_index) { stub } + begin + expect { infer.send(:enrich_return_origins_with_receiver_inference!) }.not_to raise_error + ensure + NilKill.define_singleton_method(:rbi_return_index, original) + end + + expect(origin["sources"].first["kind"]).to eq("call_untyped") + end + + it "matches project_method_returns via stripped container owner when receiver is T::Array[X]" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + # Caller passes a typed T::Array[Token] to `wrap(items)`. + store.facts["param_origins"] = [ + { "callee" => "wrap", "slot" => "items", "origin_kind" => "static", "type" => "T::Array[Token]", + "path" => "src/c.rb", "line" => 1, "code" => "tokens" }, + ] + store.facts["existing_sigs"] = [ + { "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "sig" => "sig { params(items: T.untyped).returns(T.untyped) }" }, + # A project method registered under bare "Array" (e.g. via a + # third-party-library RBI or struct field) -- the lookup must + # match this when the inferred receiver type is T::Array[Token]. + { "path" => "ext/array_ext.rb", "line" => 1, "class" => "Array", "method" => "freeze_tokens", + "sig" => "sig { returns(T::Array[Token]) }" }, + ] + origin = { + "path" => "src/x.rb", "line" => 5, "class" => "Wrapper", "method" => "wrap", "kind" => "instance", + "candidate_type" => "T.untyped", "confidence" => "blocked", + "sources" => [{ "kind" => "call_untyped", "callee" => "freeze_tokens", "line" => 6, "code" => "items.freeze_tokens" }], + "blockers" => [], + } + store.facts["return_origins"] = [origin] + + stub = Object.new + stub.define_singleton_method(:return_type) { |_method, _recv| nil } + original = NilKill.method(:rbi_return_index) + NilKill.define_singleton_method(:rbi_return_index) { stub } + begin + infer.send(:enrich_return_origins_with_receiver_inference!) + ensure + NilKill.define_singleton_method(:rbi_return_index, original) + end + + expect(origin["sources"].first["kind"]).to eq("typed_call_inferred") + expect(origin["sources"].first["type"]).to eq("T::Array[Token]") + end + + it "prefers existing_sigs return over inferred when both exist" do + infer = infer_with_store + store = infer.instance_variable_get(:@store) + store.facts["existing_sigs"] = [ + { "class" => "Cls", "method" => "m", + "sig" => "sig { returns(String) }" }, + ] + store.facts["return_origins"] = [ + { "class" => "Cls", "method" => "m", "confidence" => "strong", + "candidate_type" => "Integer" }, + ] + stub_rbi_field_types({}) do + index = infer.send(:build_project_method_return_index) + expect(index[["Cls", "m"]]).to eq("String") + end + end + end + end + + describe NilKill::Loop do + def loop_for_hash_records(limit: 1) + described_class.allocate.tap do |loop| + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@permanent_skip, []) + loop.instance_variable_set(:@z3_solver, nil) + loop.instance_variable_set(:@hash_record_limit, limit) + end + end + + def loop_for_signature_backflow(limit: 5) + described_class.allocate.tap do |loop| + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@permanent_skip, []) + loop.instance_variable_set(:@z3_solver, nil) + loop.instance_variable_set(:@signature_backflow_limit, limit) + end + end + + describe "collection-escape soundness gate" do + it "blocks a cluster whose producer constructs the record inside an array literal" do + Dir.mktmpdir("nil-kill-escape-gate") do |dir| + path = File.join(dir, "lowering.rb") + rel = path + File.write(path, <<~RUBY) + class L + def lower(node) + inner = build(node) + MIR::StructInit.new(node.union_name.to_s, [ + { name: node.variant_name.to_s, value: inner } + ]) + end + end + RUBY + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, NilKill::Store.new) + escaping = infer.send(:hash_record_producers_escaping_into_collection, + [{ "path" => rel, "line" => 5, "code" => "{ name: node.variant_name.to_s, value: inner }" }]) + expect(escaping).not_to be_empty + end + end + + it "blocks a producer pushed onto an array via <<" do + Dir.mktmpdir("nil-kill-append") do |dir| + path = File.join(dir, "parser.rb") + File.write(path, <<~RUBY) + class P + def parse + fields << { name: name, value: :wildcard, name_token: tok } + end + end + RUBY + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, NilKill::Store.new) + escaping = infer.send(:hash_record_producers_escaping_into_collection, + [{ "path" => path, "line" => 3, "code" => "{ name: name, value: :wildcard, name_token: tok }" }]) + expect(escaping).not_to be_empty + end + end + + it "blocks a producer bound to a local then stored via index-write" do + Dir.mktmpdir("nil-kill-idxwrite") do |dir| + path = File.join(dir, "pprof.rb") + File.write(path, <<~RUBY) + class Pprof + def intern_fn + f = { + id: @next_func_id, + name_idx: intern(name), + } + @functions[key] = f + f[:id] + end + end + RUBY + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, NilKill::Store.new) + # Hash literal spans lines 3-6; start line is 3. + escaping = infer.send(:hash_record_producers_escaping_into_collection, + [{ "path" => path, "line" => 3, + "code" => "{\n id: @next_func_id,\n name_idx: intern(name),\n }" }]) + expect(escaping).not_to be_empty + end + end + + it "does NOT block a producer bound to a local (confined, enumerable)" do + Dir.mktmpdir("nil-kill-confined") do |dir| + path = File.join(dir, "label.rb") + File.write(path, <<~RUBY) + class Example + def label + user = {name: "Ada", id: 1} + "\#{user[:name]}:\#{user.fetch(:id)}" + end + end + RUBY + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, NilKill::Store.new) + escaping = infer.send(:hash_record_producers_escaping_into_collection, + [{ "path" => path, "line" => 3, "code" => '{name: "Ada", id: 1}' }]) + expect(escaping).to be_empty + end + end + + it "flags a COHERENT escaping record as a real opportunity (hidden element type)" do + Dir.mktmpdir("nil-kill-gate-coherent") do |dir| + path = File.join(dir, "lowering.rb") + File.write(path, <<~RUBY) + class L + def lower(node) + MIR::StructInit.new(node.union_name.to_s, [ + { name: node.variant_name.to_s, value: inner } + ]) + end + end + RUBY + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, NilKill::Store.new) + row = { + "struct_name" => "NameRecord", "type_name" => "MIR::NameRecord", + "common_keys" => %w[name value], "optional_keys" => [], + "fields" => [{ "name" => "name", "type" => "String" }, { "name" => "value", "type" => "MIR::StructInit" }], + "producers" => [{ "path" => path, "line" => 4, "code" => "{ name: node.variant_name.to_s, value: inner }" }], + "collection_slots" => 2, + } + blockers = infer.send(:hash_record_cluster_blockers, row) + expect(blockers).to include(a_string_matching(/hidden element type.*element-typed-collection rewrite/)) + expect(blockers).not_to include(a_string_matching(/not a struct candidate/)) + end + end + + it "flags a HETEROGENEOUS escaping cluster as not a struct candidate" do + Dir.mktmpdir("nil-kill-gate-hetero") do |dir| + path = File.join(dir, "lowering.rb") + File.write(path, <<~RUBY) + class L + def lower(node) + site_rows << { id: 1, a: x } + end + end + RUBY + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, NilKill::Store.new) + # 1 common key, 6 optional, mostly T.any -> divergent shapes merged. + row = { + "struct_name" => "AllocsRecord", "type_name" => "AllocsRecord", + "common_keys" => %w[id], "optional_keys" => %w[a b c d e f], + "fields" => [ + { "name" => "id", "type" => "Integer" }, + { "name" => "a", "type" => "T.any(String, Symbol)" }, + { "name" => "b", "type" => "T.untyped" }, + { "name" => "c", "type" => "T.untyped" }, + { "name" => "d", "type" => "T.untyped" }, + { "name" => "e", "type" => "String" }, + { "name" => "f", "type" => "T.untyped" }, + ], + "producers" => [{ "path" => path, "line" => 3, "code" => "{ id: 1, a: x }" }], + "collection_slots" => 4, + } + blockers = infer.send(:hash_record_cluster_blockers, row) + expect(blockers).to include(a_string_matching(/heterogeneous collection.*not a struct candidate/)) + expect(blockers).not_to include(a_string_matching(/hidden element type/)) + end + end + end + + describe "retry_with_useless_tcast_cleanup restores snapshot on verify exception (Bug 3)" do + it "restores files when verify raises (e.g. --verify-spec-subset with empty cmd fallback)" do + Dir.mktmpdir("nil-kill-tcast-crash") do |dir| + path = File.join(dir, "sample.rb") + File.write(path, "ORIGINAL\n") + + loop = described_class.allocate + loop.instance_variable_set(:@verify_spec_subset, false) + loop.instance_variable_set(:@verify_cmd, []) # forces verify crash + # Stub apply_actions and apply_useless_tcast_feedback to record they ran. + apply_calls = 0 + loop.define_singleton_method(:verify) { |**| raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" } + stub_apply = Class.new do + define_method(:initialize) { |_| } + define_method(:apply_actions) do |_actions| + apply_calls += 1 + File.write(path, "MODIFIED\n") + 1 + end + end + stub_const = stub_apply + loop.define_singleton_method(:apply_useless_tcast_feedback) { |_, _| 0 } + + orig = NilKill::Apply + NilKill.send(:remove_const, :Apply) + NilKill.const_set(:Apply, stub_const) + begin + snapshot = { path => "ORIGINAL\n" } + action = { "kind" => "promote_hash_record_to_struct", "path" => path } + result = loop.send(:retry_with_useless_tcast_cleanup, action, snapshot, "stub output") + expect(result).to eq(0) + expect(File.read(path)).to eq("ORIGINAL\n") # snapshot restored + expect(apply_calls).to be > 0 # apply ran before crash + ensure + NilKill.send(:remove_const, :Apply) + NilKill.const_set(:Apply, orig) + end + end + end + end + + describe "hash-record promoter respects forward refs (Bug 2)" do + it "inserts the new struct AFTER same-file constant-assigned types it references" do + Dir.mktmpdir("nil-kill-forward-ref") do |dir| + path = File.join(dir, "mir.rb") + File.write(path, <<~RUBY) + module MIR + # Inserted struct would forward-reference MIR::StructInit (defined + # below as a constant assignment, not a class). Promoter must + # detect this and place the new struct after. + + StructInit = Struct.new(:zig_type, :fields) + end + RUBY + lines = File.readlines(path) + # allocate (not .new) -- insert_hash_record_struct is a pure + # transform; Apply#initialize calls Store.read which aborts + # (SystemExit) when this example's tmp has no evidence.json, + # which otherwise leaks a non-zero process exit despite the + # suite reporting 0 failures. + apply = NilKill::Apply.allocate + data = { + "struct_name" => "NameRecord", + "type_name" => "MIR::NameRecord", + "scope" => ["MIR"], + "struct_path" => path, + "fields" => [ + { "name" => "name", "type" => "String" }, + { "name" => "value", "type" => "MIR::StructInit" }, + ], + "nested_structs" => [], + } + + changed = apply.send(:insert_hash_record_struct, lines, data) + expect(changed).to be(true) + + struct_init_line = lines.find_index { |l| l.include?("StructInit = Struct.new") } + name_record_line = lines.find_index { |l| l.include?("class NameRecord") } + expect(struct_init_line).not_to be_nil + expect(name_record_line).not_to be_nil + expect(name_record_line).to be > struct_init_line + end + end + end + + describe "verify treats rspec load failures as failure (Bug 1)" do + def loop_with_fake_verify(stdout:, stderr:, exit_status: 0) + loop = described_class.allocate + loop.instance_variable_set(:@verify_spec_subset, false) + loop.instance_variable_set(:@verify_cmd, ["echo", "fake"]) + loop.define_singleton_method(:verify) do |actions: nil| + combined = stdout + stderr + patterns = NilKill::Loop::RSPEC_LOAD_FAILURE_PATTERNS + ok = exit_status.zero? && patterns.none? { |re| re.match?(combined) } + [ok, combined] + end + loop + end + + it "fails verify when output reports errors outside of examples even on exit 0" do + loop = loop_with_fake_verify( + stdout: "0 examples, 0 failures, 5 errors occurred outside of examples\n", + stderr: "", + exit_status: 0, + ) + ok, _ = loop.verify + expect(ok).to be(false) + end + + it "fails verify when output reports `An error occurred while loading ./spec/`" do + loop = loop_with_fake_verify( + stdout: "An error occurred while loading ./spec/foo_spec.rb.\nFailure/Error: require_relative\n", + stderr: "", + exit_status: 0, + ) + ok, _ = loop.verify + expect(ok).to be(false) + end + + it "succeeds when output is clean and exit is 0" do + loop = loop_with_fake_verify( + stdout: "42 examples, 0 failures\n", + stderr: "", + exit_status: 0, + ) + ok, _ = loop.verify + expect(ok).to be(true) + end + end + + it "selects unblocked review hash-record promotions for verified loop application" do + loop = loop_for_hash_records + evidence = { + "actions" => [ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", + "path" => "src/users.rb", "line" => 10, "message" => "plan UserRecord", + "data" => { "pressure" => { "total" => 5 }, "blockers" => [] } }, + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", + "path" => "src/blocked.rb", "line" => 20, "message" => "plan BlockedRecord", + "data" => { "pressure" => { "total" => 50 }, "blockers" => ["dynamic hash-record key prevents struct accessor rewrite"] } }, + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/other.rb", "line" => 30, "message" => "review only", + "data" => {} }, + ], + } + + actions = loop.send(:hash_record_review_actions, evidence) + + expect(actions).to contain_exactly( + a_hash_including("kind" => "promote_hash_record_cluster_to_struct", "path" => "src/users.rb") + ) + end + + it "limits review hash-record promotions by pressure" do + loop = loop_for_hash_records(limit: 1) + evidence = { + "actions" => [ + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", + "path" => "src/low.rb", "line" => 10, "message" => "low", + "data" => { "pressure" => { "total" => 1 }, "blockers" => [] } }, + { "kind" => "promote_hash_record_cluster_to_struct", "confidence" => "review", + "path" => "src/high.rb", "line" => 20, "message" => "high", + "data" => { "pressure" => { "total" => 9 }, "blockers" => [] } }, + ], + } + + actions = loop.send(:hash_record_review_actions, evidence) + + expect(actions.map { |action| action["path"] }).to eq(["src/high.rb"]) + end + + it "selects static param backflow review actions for verified loop application" do + loop = loop_for_signature_backflow + evidence = { + "actions" => [ + { "kind" => "fix_sig_param", "confidence" => "review", + "path" => "src/typed.rb", "line" => 10, "message" => "static callsites prove param node is Node", + "data" => { "name" => "node", "type" => "Node", "source" => "static_param_backflow", "callsite_count" => 3 } }, + { "kind" => "fix_sig_param", "confidence" => "review", + "path" => "src/runtime.rb", "line" => 20, "message" => "runtime only", + "data" => { "name" => "node", "type" => "Node", "source" => "runtime", "callsite_count" => 30 } }, + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/return.rb", "line" => 30, "message" => "not a param", + "data" => { "type" => "Node", "source" => "static_param_backflow" } }, + ], + } + + actions = loop.send(:signature_backflow_review_actions, evidence) + + expect(actions).to contain_exactly( + a_hash_including("kind" => "fix_sig_param", "path" => "src/typed.rb") + ) + end + + it "limits static param backflow review actions by callsite count" do + loop = loop_for_signature_backflow(limit: 1) + evidence = { + "actions" => [ + { "kind" => "fix_sig_param", "confidence" => "review", + "path" => "src/low.rb", "line" => 10, "message" => "low", + "data" => { "name" => "node", "type" => "Node", "source" => "static_param_backflow", "callsite_count" => 1 } }, + { "kind" => "fix_sig_param", "confidence" => "review", + "path" => "src/high.rb", "line" => 20, "message" => "high", + "data" => { "name" => "node", "type" => "Node", "source" => "static_param_backflow", "callsite_count" => 9 } }, + ], + } + + actions = loop.send(:signature_backflow_review_actions, evidence) + + expect(actions.map { |action| action["path"] }).to eq(["src/high.rb"]) + end + + describe NilKill::SpecDependencyIndex do + it "finds spec files that transitively require a changed src file" do + Dir.mktmpdir("nil-kill-dep-index", NilKill::ROOT) do |dir| + FileUtils.mkdir_p(File.join(dir, "src")) + FileUtils.mkdir_p(File.join(dir, "spec")) + File.write(File.join(dir, "src", "leaf.rb"), "") + File.write(File.join(dir, "src", "middle.rb"), "require_relative \"leaf\"\n") + File.write(File.join(dir, "src", "top.rb"), "require_relative \"middle\"\n") + spec_path = File.join(dir, "spec", "top_spec.rb") + File.write(spec_path, "require_relative \"../src/top\"\n") + unrelated_spec = File.join(dir, "spec", "unrelated_spec.rb") + File.write(unrelated_spec, "") + + isolated_env("NIL_KILL_TARGETS" => dir) do + NilKill::SpecDependencyIndex.reset! + index = NilKill::SpecDependencyIndex.build + specs = index.specs_depending_on([File.join(dir, "src", "leaf.rb")]) + expect(specs).to include(spec_path) + expect(specs).not_to include(unrelated_spec) + end + end + ensure + NilKill::SpecDependencyIndex.reset! + end + + it "returns spec files for non-existent paths as empty" do + Dir.mktmpdir("nil-kill-dep-empty", NilKill::ROOT) do |dir| + isolated_env("NIL_KILL_TARGETS" => dir) do + NilKill::SpecDependencyIndex.reset! + index = NilKill::SpecDependencyIndex.build + expect(index.specs_depending_on([File.join(dir, "does_not_exist.rb")])).to eq([]) + end + end + ensure + NilKill::SpecDependencyIndex.reset! + end + end + + def loop_for_narrow_tlet(limit: 0) + described_class.allocate.tap do |loop| + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@permanent_skip, []) + loop.instance_variable_set(:@z3_solver, nil) + loop.instance_variable_set(:@narrow_tlet_limit, limit) + end + end + + it "selects narrow_tlet REVIEW actions for verified-loop application" do + loop = loop_for_narrow_tlet + evidence = { + "actions" => [ + { "kind" => "narrow_tlet", "confidence" => "review", + "path" => "src/a.rb", "line" => 5, "message" => "narrow T.let to String", + "data" => { "type" => "String" } }, + { "kind" => "narrow_tlet", "confidence" => "high", + "path" => "src/b.rb", "line" => 10, "message" => "already high", + "data" => { "type" => "Integer" } }, + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/c.rb", "line" => 15, "message" => "wrong kind", + "data" => { "type" => "String", "source" => "static_return_origin" } }, + ], + } + + actions = loop.send(:narrow_tlet_review_actions, evidence) + + expect(actions.map { |a| a["path"] }).to contain_exactly("src/a.rb") + end + + it "narrow_tlet skips actions already in high_actions, @skipped, or rejected by z3 preflight" do + loop = loop_for_narrow_tlet + action = { "kind" => "narrow_tlet", "confidence" => "review", + "path" => "src/x.rb", "line" => 1, "message" => "x", + "data" => { "type" => "String" } } + evidence = { "actions" => [action] } + + expect(loop.send(:narrow_tlet_review_actions, evidence, [action])).to be_empty + + loop.instance_variable_set(:@skipped, Set.new([loop.send(:fingerprint, action)])) + expect(loop.send(:narrow_tlet_review_actions, evidence)).to be_empty + + loop.instance_variable_set(:@skipped, Set.new) + stub_solver = Object.new + def stub_solver.preflight_rejection(_action); "candidate uses bare generic collection type"; end + loop.instance_variable_set(:@z3_solver, stub_solver) + expect(loop.send(:narrow_tlet_review_actions, evidence)).to be_empty + end + + def loop_for_narrow_generic(limit: 0) + described_class.allocate.tap do |loop| + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@permanent_skip, []) + loop.instance_variable_set(:@z3_solver, nil) + loop.instance_variable_set(:@narrow_generic_limit, limit) + end + end + + it "selects narrow_generic_* REVIEW actions with collection_runtime source" do + loop = loop_for_narrow_generic + evidence = { + "actions" => [ + { "kind" => "narrow_generic_param", "confidence" => "review", + "path" => "src/a.rb", "line" => 10, "message" => "narrow param", + "data" => { "name" => "items", "from" => "T::Array[T.untyped]", "type" => "T::Array[String]", "source" => "collection_runtime" } }, + { "kind" => "narrow_generic_return", "confidence" => "review", + "path" => "src/b.rb", "line" => 20, "message" => "narrow return", + "data" => { "from" => "T::Hash[T.untyped, T.untyped]", "type" => "T::Hash[Symbol, String]", "source" => "collection_runtime" } }, + { "kind" => "narrow_generic_param", "confidence" => "high", + "path" => "src/c.rb", "line" => 30, "message" => "already high", + "data" => { "name" => "x", "from" => "T::Array[T.untyped]", "type" => "T::Array[String]", "source" => "collection_runtime" } }, + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/d.rb", "line" => 40, "message" => "wrong kind", + "data" => { "type" => "String", "source" => "forwarded_return_chain" } }, + { "kind" => "narrow_generic_param", "confidence" => "review", + "path" => "src/e.rb", "line" => 50, "message" => "wrong source", + "data" => { "name" => "y", "from" => "T::Array[T.untyped]", "type" => "T::Array[String]", "source" => "other" } }, + ], + } + + actions = loop.send(:narrow_generic_review_actions, evidence) + + expect(actions.map { |a| a["path"] }).to contain_exactly("src/a.rb", "src/b.rb") + end + + it "narrow_generic skips actions already in high_actions, @skipped, or rejected by z3 preflight" do + loop = loop_for_narrow_generic + action = { "kind" => "narrow_generic_param", "confidence" => "review", + "path" => "src/x.rb", "line" => 1, "message" => "x", + "data" => { "name" => "x", "from" => "T::Array[T.untyped]", "type" => "T::Array[String]", "source" => "collection_runtime" } } + evidence = { "actions" => [action] } + + # already in high_actions + expect(loop.send(:narrow_generic_review_actions, evidence, [action])).to be_empty + # in @skipped + loop.instance_variable_set(:@skipped, Set.new([loop.send(:fingerprint, action)])) + expect(loop.send(:narrow_generic_review_actions, evidence)).to be_empty + # z3 preflight reject + loop.instance_variable_set(:@skipped, Set.new) + stub_solver = Object.new + def stub_solver.preflight_rejection(_action); "candidate uses bare generic collection type"; end + loop.instance_variable_set(:@z3_solver, stub_solver) + expect(loop.send(:narrow_generic_review_actions, evidence)).to be_empty + end + + def loop_for_return_backflow(limit: 5) + described_class.allocate.tap do |loop| + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@permanent_skip, []) + loop.instance_variable_set(:@z3_solver, nil) + loop.instance_variable_set(:@return_backflow_limit, limit) + end + end + + it "selects forwarded-return-chain review actions for verified loop application" do + loop = loop_for_return_backflow + evidence = { + "actions" => [ + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/forwarded.rb", "line" => 10, "message" => "forwarded chain", + "data" => { "type" => "String", "source" => "forwarded_return_chain", "chain" => ["a", "b", "c"] } }, + { "kind" => "fix_sig_return", "confidence" => "high", + "path" => "src/high.rb", "line" => 20, "message" => "high already auto-applied", + "data" => { "type" => "void", "source" => "unused_return" } }, + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/runtime.rb", "line" => 30, "message" => "runtime only", + "data" => { "type" => "String" } }, + { "kind" => "fix_sig_param", "confidence" => "review", + "path" => "src/param.rb", "line" => 40, "message" => "not a return", + "data" => { "name" => "x", "type" => "String", "source" => "static_param_backflow" } }, + { "kind" => "promote_hash_record_to_struct", "confidence" => "review", + "path" => "src/hash.rb", "line" => 50, "message" => "not a return", + "data" => {} }, + ], + } + + actions = loop.send(:return_backflow_review_actions, evidence) + + expect(actions).to contain_exactly( + a_hash_including("kind" => "fix_sig_return", "path" => "src/forwarded.rb") + ) + end + + it "selects static-return-origin review actions" do + loop = loop_for_return_backflow + evidence = { + "actions" => [ + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/static.rb", "line" => 10, "message" => "static origin", + "data" => { "type" => "Node", "source" => "static_return_origin", "blockers" => ["x"] } }, + ], + } + + actions = loop.send(:return_backflow_review_actions, evidence) + + expect(actions).to contain_exactly( + a_hash_including("kind" => "fix_sig_return", "path" => "src/static.rb", + "data" => a_hash_including("source" => "static_return_origin")) + ) + end + + it "limits review return-backflow actions by chain length" do + loop = loop_for_return_backflow(limit: 1) + evidence = { + "actions" => [ + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/short.rb", "line" => 10, "message" => "short chain", + "data" => { "type" => "String", "source" => "forwarded_return_chain", "chain" => ["a"] } }, + { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/long.rb", "line" => 20, "message" => "long chain", + "data" => { "type" => "String", "source" => "forwarded_return_chain", "chain" => ["a", "b", "c", "d"] } }, + ], + } + + actions = loop.send(:return_backflow_review_actions, evidence) + + expect(actions.map { |action| action["path"] }).to eq(["src/long.rb"]) + end + + it "skips return-backflow actions already in high_actions" do + loop = loop_for_return_backflow + review_action = { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/dupe.rb", "line" => 10, "message" => "dupe", + "data" => { "type" => "String", "source" => "forwarded_return_chain", "chain" => ["a"] } } + existing = { "kind" => "fix_sig_return", "confidence" => "high", + "path" => "src/dupe.rb", "line" => 10, "message" => "dupe", + "data" => { "type" => "String", "source" => "forwarded_return_chain", "chain" => ["a"] } } + + actions = loop.send(:return_backflow_review_actions, { "actions" => [review_action] }, [existing]) + + expect(actions).to be_empty + end + + it "skips return-backflow actions present in @skipped or permanent_skip" do + loop = loop_for_return_backflow + action = { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/skip.rb", "line" => 10, "message" => "skipped", + "data" => { "type" => "String", "source" => "forwarded_return_chain", "chain" => ["a"] } } + + loop.instance_variable_set(:@skipped, Set.new([loop.send(:fingerprint, action)])) + expect(loop.send(:return_backflow_review_actions, { "actions" => [action] })).to be_empty + + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@permanent_skip, [{ "kind" => "fix_sig_return", "path" => "src/skip.rb" }]) + expect(loop.send(:return_backflow_review_actions, { "actions" => [action] })).to be_empty + end + + it "skips return-backflow actions rejected by z3 preflight" do + loop = loop_for_return_backflow + action = { "kind" => "fix_sig_return", "confidence" => "review", + "path" => "src/z3reject.rb", "line" => 10, "message" => "rejected", + "data" => { "type" => "T.any(A, B, C, D)", "source" => "forwarded_return_chain", "chain" => ["a"] } } + + stub_solver = Object.new + def stub_solver.preflight_rejection(_action) = "candidate union exceeds cutoff" + loop.instance_variable_set(:@z3_solver, stub_solver) + + expect(loop.send(:return_backflow_review_actions, { "actions" => [action] })).to be_empty + end + + it "snapshots every file touched by a cross-file hash-record action" do + loop = loop_for_hash_records + action = { "kind" => "promote_hash_record_cluster_to_struct", "path" => "src/producer.rb", "line" => 1, + "data" => { + "producers" => [{ "path" => "src/producer.rb", "line" => 1 }], + "consumers" => [{ "path" => "src/consumer.rb", "line" => 2 }], + "signatures" => [{ "path" => "src/signature.rb", "line" => 3 }], + } } + + paths = loop.send(:snapshot_paths_for_action, action) + + expect(paths).to contain_exactly("src/producer.rb", "src/consumer.rb", "src/signature.rb") + end + + it "retries rejected return fixes as nilable when Sorbet reports nilable evidence" do + loop = described_class.allocate + output = <<~TEXT + lib/example.rb:12: Expected `String` but found `T.nilable(String)` for method result type https://srb.help/7005 + 12 | end + ^^^ + Expected `String` for result type of method `name`: + lib/example.rb:8: + 8 | sig { returns(String) } + TEXT + action = { + "kind" => "fix_sig_return", + "confidence" => "high", + "path" => "lib/example.rb", + "line" => 8, + "message" => "existing sig return is T.untyped; observed String", + "data" => { "type" => "String" }, + } + + fallback = loop.send(:nilable_widening_fallback, action, output) + + expect(fallback).to include( + "kind" => "fix_sig_return", + "path" => "lib/example.rb", + "line" => 8, + "data" => a_hash_including("type" => "T.nilable(String)") + ) + end + end + + describe NilKill::Report do + describe "struct_field_candidates" do + it "skips slots with any uninferrable static record (has_unknown_static)" do + report = described_class.new + runtime = [] + static = [ + { "class" => "AST::ConcurrentOp", "field" => "op", "type" => "AST::EachOp", "expression" => "each_op" }, + { "class" => "AST::ConcurrentOp", "field" => "op", "type" => nil, "expression" => "inner_op" }, + ] + + candidates = report.struct_field_candidates(runtime, static) + + expect(candidates.find { |c| c["class"] == "AST::ConcurrentOp" && c["field"] == "op" }).to be_nil + end + + it "still emits when all static records are inferrable" do + report = described_class.new + runtime = [] + static = [ + { "class" => "Capabilities::Conflict", "field" => "message", "type" => "String", "expression" => "\"x\"" }, + { "class" => "Capabilities::Conflict", "field" => "message", "type" => "String", "expression" => "\"y\"" }, + ] + + candidates = report.struct_field_candidates(runtime, static) + + slot = candidates.find { |c| c["class"] == "Capabilities::Conflict" && c["field"] == "message" } + expect(slot).not_to be_nil + expect(slot["type"]).to eq("String") + end + + it "skips T.nilable candidates at any nesting depth" do + report = described_class.new + runtime = [] + static = [ + { "class" => "Example", "field" => "maybe", "type" => "T.nilable(String)", "expression" => "x" }, + ] + + candidates = report.struct_field_candidates(runtime, static) + + expect(candidates.find { |c| c["class"] == "Example" && c["field"] == "maybe" }).to be_nil + end + + it "skips weak-collection candidates (T::Array[T.untyped] etc.)" do + report = described_class.new + runtime = [] + static = [ + { "class" => "Example", "field" => "items", "type" => "T::Array[T.untyped]", "expression" => "[]" }, + ] + + candidates = report.struct_field_candidates(runtime, static) + + expect(candidates.find { |c| c["class"] == "Example" && c["field"] == "items" }).to be_nil + end + end + + describe "add_struct_field_sig (verified-loop struct-rbi)" do + it "applies via the Apply handler: update / insert / append / idempotent" do + ap = NilKill::Apply.allocate + lines = ["# typed: true\n", "\n", "class AST::Foo\n", + " sig { returns(T.untyped) }\n", " def token; end\n", "end\n", "\n"] + act = ->(c, f, t) { { "kind" => "add_struct_field_sig", "line" => 1, + "data" => { "class" => c, "field" => f, "type" => t } } } + expect(ap.send(:apply_add_struct_field_sig, lines, act.("AST::Foo", "token", "Token"))).to be(true) + expect(ap.send(:apply_add_struct_field_sig, lines, act.("AST::Foo", "name", "String"))).to be(true) + expect(ap.send(:apply_add_struct_field_sig, lines, act.("AST::Bar", "id", "Integer"))).to be(true) + expect(ap.send(:apply_add_struct_field_sig, lines, act.("AST::Foo", "token", "Token"))).to be(false) + out = lines.join + expect(out).to include("class AST::Foo\n sig { returns(Token) }\n def token; end") + expect(out).to include(" sig { returns(String) }\n def name; end") + expect(out).to include("class AST::Bar\n sig { returns(Integer) }\n def id; end\nend") + end + + + it "the loop selector picks unskipped REVIEW add_struct_field_sig actions" do + loop = NilKill::Loop.allocate + loop.instance_variable_set(:@skipped, Set.new) + loop.instance_variable_set(:@permanent_skip, []) + ev = { "actions" => [ + { "kind" => "add_struct_field_sig", "confidence" => "review", "path" => "sorbet/rbi/ast-struct-fields.rbi", + "line" => 1, "message" => "type X#f as Y", "data" => { "class" => "X", "field" => "f", "type" => "Y" } }, + { "kind" => "fix_sig_return", "confidence" => "review", "path" => "src/a.rb", "line" => 1, "data" => {} }, + ] } + picked = loop.send(:struct_rbi_review_actions, ev) + expect(picked.map { |a| a["kind"] }).to eq(["add_struct_field_sig"]) + end + end + + describe "untyped_cause_table" do + it "classifies each category's untyped slots into the five causes with reconciling denominators" do + report = described_class.new + evidence = { + # Rec#a has a concrete add_struct_field_sig action -> genuinely + # PropagationGap. Rec#b has none -> honest NoEvidence (its RHS + # is itself untyped; the transitive wall). + "actions" => [ + { "kind" => "add_struct_field_sig", "data" => { "class" => "Rec", "field" => "a" } }, + ], + "methods" => [ + # param x observed single String (Refused/Pending) but return + # NOT observed at runtime so it falls through to the + # call_untyped source -> PropagationGap. + { "source" => { "path" => "src/a.rb", "line" => 1 }, "path" => "src/a.rb", "line" => 1, + "calls" => 5, "params_ok" => { "x" => ["String"] }, "params_by_name" => { "x" => ["String"] }, + "returns" => [] }, + { "source" => { "path" => "src/a.rb", "line" => 9 }, "path" => "src/a.rb", "line" => 9, + "calls" => 0, "params_ok" => {}, "params_by_name" => {}, "returns" => [] }, + ], + "facts" => { + "existing_sigs" => [ + # x: single observed runtime type -> Refused/Pending. + # return: forwards to g(), and g HAS a concrete sig return + # program-wide -> far-end resolvable -> PropagationGap. + { "path" => "src/a.rb", "line" => 1, "class" => "A", "method" => "f", + "sig" => "sig { params(x: T.untyped).returns(T.untyped) }", + "return_origin" => { "sources" => [{ "kind" => "call_untyped", "callee" => "g" }], "blockers" => [] } }, + # g: concrete sig return -> seeds the program return index. + { "path" => "src/a.rb", "line" => 5, "class" => "A", "method" => "g", + "sig" => "sig { returns(String) }", "return_origin" => {} }, + # never hit, no callsites -> NoEvidence (param) ; return AndNode -> NotImplemented + { "path" => "src/a.rb", "line" => 9, "class" => "A", "method" => "h", + "sig" => "sig { params(y: T.untyped).returns(T.untyped) }", + "return_origin" => { "sources" => [], "blockers" => ["unknown return expression AndNode at src/a.rb:9"] } }, + # return forwards to an unresolvable callee -> transitive + # wall -> honest NoEvidence (NOT PropagationGap). + { "path" => "src/a.rb", "line" => 14, "class" => "A", "method" => "k", + "sig" => "sig { returns(T.untyped) }", + "return_origin" => { "sources" => [{ "kind" => "call_untyped", "callee" => "mystery" }], "blockers" => [] } }, + ], + "param_origins" => [], + "struct_declarations" => [ + { "path" => "src/a.rb", "line" => 20, "class" => "Rec", "fields" => %w[a b] }, + ], + "struct_field_static" => [ + { "class" => "Rec", "field" => "a", "type" => "T.untyped", "expression" => "param_x", "path" => "src/a.rb", "line" => 20 }, + ], + "tlet_sites" => [ + { "tlet" => true, "type" => "T.untyped", "path" => "src/a.rb", "line" => 30, "name" => "@z" }, + ], + "ivar_param_origins" => {}, + "collection_runtime" => [], + }, + } + # struct_rbi_types reads generated RBI off disk; stub to the + # synthetic declared fields so the test is hermetic. + report.define_singleton_method(:struct_rbi_types) do + { %w[Rec a] => "T.untyped", %w[Rec b] => "T.untyped" } + end + # Isolate cause classification from the unused-return (void) + # heuristic, which flags everything as unused under sparse + # synthetic evidence and would mask the call_untyped path. + report.define_singleton_method(:unused_return_method_names) { |_| [] } + + table = report.untyped_cause_table(evidence) + + # Param inputs: x (Refused/Pending, single observed String) + y (NoEvidence, never hit, no callsites) + expect(table["Param inputs"]["Refused/Pending"]).to eq(1) + expect(table["Param inputs"]["NoEvidence"]).to eq(1) + # Returns: f -> g() resolvable program-wide -> PropagationGap ; + # h -> AndNode (not statically modelled, no runtime) and + # k -> mystery() untyped anywhere -> both honest NoEvidence + # (NotImplemented category removed; h/k are real evidence gaps). + expect(table["Returns"]["PropagationGap"]).to eq(1) + expect(table["Returns"]["NoEvidence"]).to eq(2) + # Struct/ivar: @z T.let untyped -> Refused/Pending ; Rec#a expr param -> PropagationGap ; Rec#b no static -> NoEvidence + expect(table["Struct/class fields & ivars"]["Refused/Pending"]).to eq(1) + expect(table["Struct/class fields & ivars"]["PropagationGap"]).to eq(1) + expect(table["Struct/class fields & ivars"]["NoEvidence"]).to eq(1) + # denominator == sum of the six causes per row + table.each_value do |counts| + NilKill::Report::UNTYPED_CAUSES.each { |c| counts[c] ||= 0 } + end + expect(NilKill::Report::UNTYPED_CAUSES.sum { |c| table["Struct/class fields & ivars"][c] }).to eq(3) + end + end + + describe "untyped_evidence_gaps (residual NoEvidence broken out by why)" do + it "partitions NoEvidence into unseen / discarded_return / only_nil with locations" do + report = described_class.new + evidence = { + "methods" => [ + # run -> executed, return value never traced -> discarded_return + { "source" => { "path" => "src/a.rb", "line" => 1 }, "calls" => 9, + "params_by_name" => {}, "params_ok" => {}, "returns" => [] }, + # only-nil param + { "source" => { "path" => "src/a.rb", "line" => 20 }, "calls" => 5, + "params_by_name" => { "x" => ["NilClass"] }, "params_ok" => { "x" => ["NilClass"] }, "returns" => [] }, + ], + "actions" => [], + "facts" => { + # collect_coverage present (a real collect) but not covering + # A#dead's body -> dead's param is honestly "unseen", and the + # never_run hard-precondition is satisfied (no raise). + "collect_coverage" => { "src/other.rb" => [1] }, + "param_origins" => [], "struct_declarations" => [], "tlet_sites" => [], + "struct_field_runtime" => [], "ivar_runtime" => [], "collection_runtime" => [], + "return_origins" => [], + "existing_sigs" => [ + { "path" => "src/a.rb", "line" => 1, "class" => "A", "method" => "run", + "sig" => "sig { returns(T.untyped) }", + "return_origin" => { "sources" => [{ "kind" => "call_untyped", "callee" => "mystery" }], "blockers" => [] } }, + { "path" => "src/a.rb", "line" => 20, "class" => "A", "method" => "take", + "sig" => "sig { params(x: T.untyped).returns(String) }" }, + # never executed (no runtime record) + { "path" => "src/a.rb", "line" => 40, "class" => "A", "method" => "dead", + "sig" => "sig { params(z: T.untyped).returns(T.untyped) }", + "return_origin" => { "sources" => [{ "kind" => "call_untyped", "callee" => "mystery" }], "blockers" => [] } }, + ], + }, + } + + gaps = report.send(:untyped_evidence_gaps, evidence) + txt = ->(r) { gaps[r].map { |g| g["text"] } } + expect(txt.("discarded_return")).to eq(["src/a.rb:1 `A#run` return"]) + expect(gaps["discarded_return"].map { |g| g["cat"] }).to eq(["Returns"]) + expect(txt.("only_nil")).to eq(["src/a.rb:20 `A#take` param `x`"]) + # A#dead never ran and the collect did not cover it -> unseen + # (the one honest, actionable no-evidence bucket). + expect(txt.("unseen")).to eq(["src/a.rb:40 `A#dead` param `z`"]) + end + + it "labels a pruned block/Proc param arg_untraced, NOT never_run (the H1b mis-attribution)" do + report = described_class.new + evidence = { + "methods" => [], # method pruned by TracePlan -> no runtime record at all + "actions" => [], + "facts" => { + "param_origins" => [], "struct_declarations" => [], "tlet_sites" => [], + "struct_field_runtime" => [], "ivar_runtime" => [], "collection_runtime" => [], + "return_origins" => [], + "existing_sigs" => [ + { "path" => "src/p.rb", "line" => 5, "end_line" => 9, "class" => "P", "method" => "suffix", + "sig" => "sig { params(block: T.untyped).returns(Prism::Token) }" }, + ], + }, + } + + gaps = report.send(:untyped_evidence_gaps, evidence) + # Even with rec.nil? (pruned) and no collect coverage, a block + # param is arg_untraced -- it is NOT unseen/never_run/dead. + expect(gaps["arg_untraced"].map { |g| g["text"] }).to eq(["src/p.rb:5 `P#suffix` param `block`"]) + expect(gaps["unseen"]).to be_empty + expect(gaps["never_run"]).to be_empty + end + + it "collect-run coverage is the SOLE signal: body ran here but no record -> collect_ran_untraced (tracer bug)" do + report = described_class.new + ev = { "facts" => { "collect_coverage" => { "src/m.rb" => [10, 11, 12] } } } + # def spans 9..15, collect-run coverage shows interior lines + # executed -> the method DID run this collect yet has no record + # == an unambiguous tracer bug (no foreign baseline can mask it). + expect(report.send(:never_run_reason, ev, "src/m.rb", 9, 15)).to eq("collect_ran_untraced") + # def at 40..45, NOT in this collect's coverage. There is no + # second workload to "cover it elsewhere": it is genuinely + # unseen by the (superset) collect workload. + expect(report.send(:never_run_reason, ev, "src/m.rb", 40, 45)).to eq("unseen") + end + + it "does NOT treat a defined-but-never-called method as collect_ran (def-line coverage is not body execution)" do + report = described_class.new + # Ruby Coverage marks the `def` line (9) the moment the method + # is defined at file load -- even if never called. Only the def + # line is covered; the body (10..14) never ran. + ev = { "facts" => { "collect_coverage" => { "src/m.rb" => [9] } } } + # Must NOT be collect_ran_untraced (would falsely accuse the + # tracer). With collect_coverage present but no body line, the + # method was simply not reached -> unseen (NOT untraced_covered; + # that category no longer exists). + expect(report.send(:never_run_reason, ev, "src/m.rb", 9, 15)).to eq("unseen") + # A genuinely body-covered method (line 11 inside 9..15) still + # counts as ran -> real tracer defect. Fresh instance: the + # collect-coverage index is memoized per Report. + report2 = described_class.new + ev2 = { "facts" => { "collect_coverage" => { "src/m.rb" => [9, 11] } } } + expect(report2.send(:never_run_reason, ev2, "src/m.rb", 9, 15)).to eq("collect_ran_untraced") + end + + it "closed tree: collect_coverage is the only source; untraced_covered cannot be emitted" do + report = described_class.new + # 1. cc present + body ran -> collect_ran_untraced + run_ev = { "facts" => { "collect_coverage" => { "src/m.rb" => [11] } } } + expect(report.send(:never_run_reason, run_ev, "src/m.rb", 9, 15)).to eq("collect_ran_untraced") + # 2. cc present + body NOT run anywhere -> unseen + miss = described_class.new + miss_ev = { "facts" => { "collect_coverage" => { "src/other.rb" => [3] } } } + expect(miss.send(:never_run_reason, miss_ev, "src/m.rb", 9, 15)).to eq("unseen") + # 3. cc absent entirely -> never_run (degenerate; a real collect + # makes this impossible -- cli aborts on zero Coverage). + none = described_class.new + expect(none.send(:never_run_reason, { "facts" => {} }, "src/m.rb", 9, 15)).to eq("never_run") + # The foreign SimpleCov baseline is GONE -- the deletion itself + # is regression-tested so it can never silently come back. + expect(described_class::EVIDENCE_GAP_REASONS).not_to have_key("untraced_covered") + expect(report.respond_to?(:simplecov_covered_files, true)).to be(false) + expect(described_class.const_defined?(:SIMPLECOV_RESULTSET)).to be(false) + # collect_ran_untraced and never_run are NOT report columns -- + # they are hard failures, so a tracer regression or a no-collect + # run can never be a silently-dropped row or a misread "dead". + expect(described_class::EVIDENCE_GAP_REASONS).not_to have_key("collect_ran_untraced") + expect(described_class::EVIDENCE_GAP_REASONS).not_to have_key("never_run") + expect(described_class::EVIDENCE_GAP_HARD.keys).to contain_exactly("collect_ran_untraced", "never_run") + base_facts = { + "param_origins" => [], "struct_declarations" => [], "tlet_sites" => [], + "struct_field_runtime" => [], "ivar_runtime" => [], "collection_runtime" => [], + "return_origins" => [], + } + sig = { "path" => "src/d.rb", "line" => 3, "end_line" => 7, "class" => "D", + "method" => "go", "sig" => "sig { params(z: T.untyped).returns(String) }" } + # no collect_coverage at all -> never_run is DROPPED (no signal), + # NOT raised (would break unit tests) and NOT a column. + no_cov = { "methods" => [], "actions" => [], + "facts" => base_facts.merge("existing_sigs" => [sig]) } + g = nil + expect { g = described_class.new.send(:untyped_evidence_gaps, no_cov) }.not_to raise_error + expect(g).not_to have_key("never_run") + # body ran in the collect but no record -> collect_ran_untraced -> RAISES + ran = { "methods" => [], "actions" => [], + "facts" => base_facts.merge("existing_sigs" => [sig], + "collect_coverage" => { "src/d.rb" => [5] }) } + expect { described_class.new.send(:untyped_evidence_gaps, ran) } + .to raise_error(/collect_ran_untraced .* tracer\/trace-plan regression/) + # No (path, lo, hi) over a present cc can ever yield it. + [[9, 15], [40, 45], [1, 2]].each do |lo, hi| + %w[src/m.rb src/x.rb].each do |p| + inst = described_class.new + expect(inst.send(:never_run_reason, run_ev, p, lo, hi)).not_to eq("untraced_covered") + end + end + end + end + + describe "classify_collection_untyped! (owner-identity join, not sig line)" do + it "matches collection runtime by owner identity, not the sig/decl line" do + report = described_class.new + bucket = Hash.new(0) + evidence = { + "facts" => { + "tlet_sites" => [], "struct_declarations" => [], + "existing_sigs" => [ + { "path" => "src/p.rb", "line" => 10, "end_line" => 40, "class" => "P", "method" => "run", + "sig" => "sig { params(items: T::Array[T.untyped]).returns(T::Array[T.untyped]) }" }, + ], + # Runtime recorded at the MUTATION site (line 27), NOT the + # sig line 10 -- the old [path,line,name] join missed this. + "collection_runtime" => [ + { "owner_kind" => "method_param", "name" => "items", "path" => "#{NilKill::ROOT}/src/p.rb", + "line" => 27, "elem_classes" => ["String"], "elem_shapes" => [] }, + { "owner_kind" => "method_return", "name" => "run", "path" => "#{NilKill::ROOT}/src/p.rb", + "line" => 38, "elem_classes" => %w[String Symbol Integer Float Hash Array TrueClass FalseClass], + "elem_shapes" => [] }, + ], + }, + } + + report.send(:classify_collection_untyped!, bucket, evidence) + + # items: single observed elem String -> Refused/Pending (was NoEvidence) + expect(bucket["Refused/Pending"]).to eq(1) + # run return: many elem classes -> Heterogeneous (was NoEvidence) + expect(bucket["Heterogeneous"]).to eq(1) + expect(bucket["NoEvidence"]).to eq(0) + end + + it "uses method-boundary element evidence for read-only params (no mutation hook)" do + report = described_class.new + bucket = Hash.new(0) + evidence = { + # No collection_runtime at all -- the param is only READ, so + # the mutation hooks never fired. Element classes come from + # the call-boundary capture in the method record. + "methods" => [ + { "source" => { "path" => "src/q.rb", "line" => 5 }, + "param_elem" => { "xs" => ["Symbol"] }, "return_elem" => [] }, + ], + "facts" => { + "tlet_sites" => [], "struct_declarations" => [], "collection_runtime" => [], + "existing_sigs" => [ + { "path" => "src/q.rb", "line" => 5, "end_line" => 20, "class" => "Q", "method" => "scan", + "sig" => "sig { params(xs: T::Array[T.untyped]).returns(T.untyped) }" }, + ], + }, + } + + report.send(:classify_collection_untyped!, bucket, evidence) + expect(bucket["Refused/Pending"]).to eq(1) # single boundary elem Symbol + expect(bucket["NoEvidence"]).to eq(0) + end + end + + describe "classify_struct_ivar_untyped! (consults struct/ivar runtime)" do + it "classifies observed struct fields by runtime evidence, not blanket NoEvidence" do + report = described_class.new + bucket = Hash.new(0) + evidence = { + "actions" => [{ "kind" => "add_struct_field_sig", "data" => { "class" => "Rec", "field" => "prop" } }], + "facts" => { + "tlet_sites" => [], + "struct_declarations" => [ + { "class" => "Rec", "fields" => %w[one nilonly many pair prop dead] }, + ], + "struct_field_runtime" => [ + { "class" => "Rec", "field" => "one", "classes" => ["Type"] }, + { "class" => "Rec", "field" => "nilonly", "classes" => ["NilClass"] }, + { "class" => "Rec", "field" => "many", + "classes" => %w[AST::While AST::ForRange AST::FuncCall AST::Identifier + AST::BinaryOp AST::Literal AST::CallNode AST::StructLit] }, + ], + "ivar_runtime" => [ + { "class" => "Rec", "name" => "@pair", "classes" => %w[String Integer] }, + ], + }, + } + + report.send(:classify_struct_ivar_untyped!, bucket, evidence) + + expect(bucket["Refused/Pending"]).to eq(2) # one (single Type) + nilonly (only nil -> void) + expect(bucket["Heterogeneous"]).to eq(1) # many (> MAX_UNION_TYPES) + expect(bucket["WeakEvidence"]).to eq(1) # pair (String|Integer) + expect(bucket["PropagationGap"]).to eq(1) # prop (add_struct_field_sig action, no runtime) + expect(bucket["NoEvidence"]).to eq(1) # dead (no runtime, no action) + end + end + + describe "classify_return_untyped_cause (runtime evidence not discarded)" do + def classify(report, method, rec) + report.send(:build_program_return_index!, { "facts" => { "return_origins" => [] } }) + report.send(:classify_return_untyped_cause, method, rec, [].to_set) + end + + it "marks an executed return observed only as nil as Refused/Pending (void), not NoEvidence" do + report = described_class.new + method = { "method" => "noop!", "return_origin" => { + "sources" => [{ "kind" => "call_untyped", "callee" => "mystery" }], "blockers" => [] } } + rec = { "calls" => 12, "returns" => ["NilClass"] } + expect(classify(report, method, rec)).to eq("Refused/Pending") + end + + it "uses observed runtime classes (WeakEvidence) when the static origin is an untyped forwarded call" do + report = described_class.new + method = { "method" => "build", "return_origin" => { + "sources" => [{ "kind" => "call_untyped", "callee" => "mystery" }], "blockers" => [] } } + rec = { "calls" => 30, "returns" => %w[Array Hash NilClass] } + # was NoEvidence (transitive wall short-circuit discarded runtime) + expect(classify(report, method, rec)).to eq("WeakEvidence") + end + + it "is Heterogeneous when runtime observed many concrete return types" do + report = described_class.new + method = { "method" => "lower", "return_origin" => { + "sources" => [{ "kind" => "call_untyped", "callee" => "mystery" }], "blockers" => [] } } + rec = { "calls" => 99, + "returns" => %w[AST::While AST::ForRange AST::FuncCall AST::Identifier + AST::BinaryOp AST::Literal AST::CallNode AST::StructLit] } + expect(classify(report, method, rec)).to eq("Heterogeneous") + end + + it "stays NoEvidence when never executed and no resolvable static origin" do + report = described_class.new + method = { "method" => "dead", "return_origin" => { + "sources" => [{ "kind" => "call_untyped", "callee" => "mystery" }], "blockers" => [] } } + rec = { "calls" => 0, "returns" => [] } + expect(classify(report, method, rec)).to eq("NoEvidence") + end + end + + describe "guard_collapse_rows" do + it "ranks slots by is_a?(Type) guard count and joins the outlier producers" do + report = described_class.new + evidence = { + "facts" => { + "type_normalizers" => [ + { "class" => "Foo", "method" => "lower", "path" => "src/x.rb", "line" => 11, + "code" => "t = type.is_a?(Type) ? type : Type.new(type)" }, + { "class" => "Foo", "method" => "lower", "path" => "src/x.rb", "line" => 19, + "code" => "rt = type.is_a?(Type) ? type : Type.new(type)" }, + { "class" => "Foo", "method" => "lower", "path" => "src/x.rb", "line" => 27, + "code" => "type.is_a?(Type) or raise" }, + { "class" => "Bar", "method" => "take", "path" => "src/y.rb", "line" => 4, + "code" => "x.is_a?(Type) ? x : Type.new(x)" }, + ], + "existing_sigs" => [ + { "class" => "Foo", "method" => "lower", "path" => "src/x.rb", "line" => 10, + "sig" => "sig { params(type: T.untyped).returns(T.untyped) }", + "params" => [{ "name" => "type" }] }, + ], + "unsigned_methods" => [ + { "class" => "Bar", "method" => "take", "path" => "src/y.rb", "line" => 3, + "sig" => "", "params" => [{ "name" => "x" }] }, + ], + "param_origins" => + ([{ "callee" => "lower", "slot" => "type", "origin_kind" => "static", + "type" => "Type", "path" => "src/a.rb", "line" => 1, "code" => "ty" }] * 8) + + [{ "callee" => "lower", "slot" => "type", "origin_kind" => "static", + "type" => "Symbol", "path" => "src/b.rb", "line" => 42, "code" => ":raw" }, + { "callee" => "lower", "slot" => "type", "origin_kind" => "static", + "type" => "Symbol", "path" => "src/b.rb", "line" => 88, "code" => ":sym" }] + + [{ "callee" => "take", "slot" => "x", "origin_kind" => "static", + "type" => "Type", "path" => "src/c.rb", "line" => 5, "code" => "tt" }], + }, + } + + rows = report.send(:guard_collapse_rows, evidence) + + expect(rows.size).to eq(2) + top = rows.first + expect(top["method"]).to eq("Foo#lower") + expect(top["slot"]).to eq("type") + expect(top["slot_kind"]).to eq("param") + expect(top["guards"]).to eq(3) + expect(top["dominant"]).to eq("Type") + expect(top["dominant_share"]).to be_within(0.001).of(0.8) + expect(top["producers"]).to eq(10) + expect(top["outliers"]).to contain_exactly( + { "type" => "Symbol", "loc" => "src/b.rb:42", "code" => ":raw" }, + { "type" => "Symbol", "loc" => "src/b.rb:88", "code" => ":sym" } + ) + # Ranked by guard count: Bar#take (1 guard) sorts below Foo#lower (3). + expect(rows.last["method"]).to eq("Bar#take") + expect(rows.last["guards"]).to eq(1) + end + + it "falls back to the origin call's runtime return classes when the receiver is a local" do + report = described_class.new + evidence = { + "methods" => [ + # annotate observed returning only Type -> singleton -> the + # union is unjustified; guards on its consumers collapse. + { "class" => "Annotator", "method" => "annotate", "returns" => ["Type"] }, + # decode observed returning Type AND Symbol -> tighten that + # contract (members listed, no collapse verdict). + { "class" => "Lexer", "method" => "decode", "returns" => %w[Type Symbol] }, + ], + "facts" => { + "type_normalizers" => [ + { "class" => "C", "method" => "a", "path" => "src/c.rb", "line" => 9, + "code" => "ti.is_a?(Type) ? ti : Type.new(ti)", + "origin_kind" => "call", "origin_name" => "annotate" }, + { "class" => "C", "method" => "a", "path" => "src/c.rb", "line" => 14, + "code" => "ti.is_a?(Type) ? ti : Type.new(ti)", + "origin_kind" => "call", "origin_name" => "annotate" }, + { "class" => "D", "method" => "b", "path" => "src/d.rb", "line" => 3, + "code" => "tk.is_a?(Type) ? tk : Type.new(tk)", + "origin_kind" => "call", "origin_name" => "decode" }, + ], + "existing_sigs" => [], "unsigned_methods" => [], "param_origins" => [], + }, + } + + rows = report.send(:guard_collapse_rows, evidence) + + annotate_row = rows.find { |r| r["method"] == "C#a" } + expect(annotate_row["guards"]).to eq(2) + expect(annotate_row["via"]).to eq("returns of annotate()") + expect(annotate_row["members"]).to eq(["Type"]) + expect(annotate_row["dominant"]).to eq("Type") + expect(annotate_row["dominant_share"]).to eq(1.0) + + decode_row = rows.find { |r| r["method"] == "D#b" } + expect(decode_row["via"]).to eq("returns of decode()") + expect(decode_row["members"]).to contain_exactly("Type", "Symbol") + expect(decode_row["dominant"]).to be_nil + end + + it "attributes an attr-accessor contract via the like-named ivar's runtime classes" do + # `.type_info` is an attr_reader-style accessor with no traced + # `def` (rt_returns empty); it must fall back to the @type_info + # ivar runtime class set the collector now records, aggregated + # globally by name across declaring classes. + report = described_class.new + evidence = { + "methods" => [], + "facts" => { + "existing_sigs" => [], "unsigned_methods" => [], "param_origins" => [], + "ivar_runtime" => [ + { "class" => "AST::VarDecl", "name" => "@type_info", "classes" => ["Type"] }, + { "class" => "AST::BindExpr", "name" => "@type_info", "classes" => %w[Type Symbol] }, + ], + "type_normalizers" => [ + { "class" => "MIRLowering", "method" => "lower_get_index", "path" => "src/m.rb", "line" => 9, + "code" => "ti.is_a?(Type)", "origin_kind" => "attr", "origin_name" => "type_info" }, + { "class" => "EscapeAnalysis", "method" => "per_fn_scan!", "path" => "src/e.rb", "line" => 5, + "code" => "ti.is_a?(Type)", "origin_kind" => "attr", "origin_name" => "type_info" }, + ], + }, + } + + rows = report.send(:guard_collapse_rows, evidence) + ti_rows = rows.select { |r| r["origin_name"] == "type_info" } + # Per-(class,method) rows: each guarded receiver attributed via + # the like-named ivar's runtime class set. + expect(ti_rows.size).to eq(2) + expect(ti_rows.map { |r| r["via"] }.uniq).to eq(["@type_info assignments"]) + expect(ti_rows.first["members"]).to contain_exactly("Type", "Symbol") + + # Renderer aggregates by canonical contract across methods: + # 2 guards collapse on the single `.type_info` accessor. + lines = [] + report.send(:append_union_decomplexity, lines, evidence) + ti_line = lines.find { |l| l.include?("`.type_info`") && l.include?("guards collapse") } + expect(ti_line).to include("2 guards collapse") + expect(ti_line).to include("via @type_info assignments (runtime) {Type, Symbol}") + end + end + + describe "node_alias_candidate_rows" do + it "buckets a single-namespace Heterogeneous param under its node alias" do + report = described_class.new + ast = %w[AST::While AST::ForRange AST::FuncCall AST::Identifier AST::BinaryOp + AST::Literal AST::CallNode AST::StructLit] + evidence = { + "methods" => [ + { "source" => { "path" => "src/a.rb", "line" => 10 }, + "calls" => 50, "params_ok" => { "node" => ast }, "params_by_name" => { "node" => ast } }, + # mixed AST + MIR -> spans 2 namespaces -> NOT a single-alias row + { "source" => { "path" => "src/b.rb", "line" => 3 }, + "calls" => 50, "params_ok" => { "n" => %w[AST::While MIR::Alloc AST::FuncCall AST::Identifier AST::BinaryOp AST::Literal] }, + "params_by_name" => {} }, + ], + "facts" => { + "param_origins" => [], + "existing_sigs" => [ + { "path" => "src/a.rb", "line" => 10, "class" => "Walker", "method" => "visit", + "sig" => "sig { params(node: T.untyped).returns(T.untyped) }" }, + { "path" => "src/b.rb", "line" => 3, "class" => "Lower", "method" => "go", + "sig" => "sig { params(n: T.untyped).returns(T.untyped) }" }, + ], + }, + } + + by_ns, total = report.send(:node_alias_candidate_rows, evidence) + expect(total).to eq(2) + expect(by_ns.keys).to eq(["AST"]) + expect(by_ns["AST"]).to contain_exactly( + a_hash_including("loc" => "src/a.rb:10", "method" => "Walker#visit", "param" => "node", "classes" => 8) + ) + end + end + + it "classifies return usage with the same graph used for void promotion" do + Dir.mktmpdir("nil-kill-return-hygiene-report", NilKill::ROOT) do |dir| + source = File.join(dir, "hygiene_report.rb") + File.write(source, <<~RUBY) + class HygieneReport + extend T::Sig + + sig { returns(T.untyped) } + def unused_leaf + "event" + end + + sig { returns(T.untyped) } + def unused_wrapper + return unused_leaf + end + + sig { returns(T.untyped) } + def used_leaf + "value" + end + + sig { returns(String) } + def used_caller + value = used_leaf + value.to_s + end + + sig { void } + def run + unused_wrapper + end + end + RUBY + + rows = nil + isolated_env("NIL_KILL_TARGETS" => dir) do + expect { NilKill::Infer.new(["--no-sorbet"]).run }.to output(/Nil Kill Report/).to_stdout + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + rows = described_class.allocate.send(:return_hygiene_rows, evidence).each_with_object({}) do |row, lookup| + lookup[row["method"]] = row + end + end + + expect(rows["unused_leaf"]).to include( + "usage" => "unused via return-forwarding", + "fixability" => "auto-fixable: void" + ) + expect(rows["unused_wrapper"]).to include( + "usage" => "unused via return-forwarding", + "source_kind" => "explicit/direct forwarded return", + "fixability" => "auto-fixable: void" + ) + expect(rows["used_leaf"]).to include("usage" => "used as value") + expect(rows["run"]).to include("usage" => "declared void", "fixability" => "addressed: void") + end + end + + it "breaks return sources into actionable hygiene buckets" do + report = described_class.allocate + + expect(report.send(:return_hygiene_source_kind, { + "return_syntax" => "implicit", + "sources" => [{ "kind" => "static", "type" => "String", "code" => "\"ok\"" }], + })).to eq("literal/static") + + expect(report.send(:return_hygiene_source_kind, { + "return_syntax" => "implicit", + "sources" => [{ "kind" => "typed_call", "callee" => "join", "type" => "String", "code" => "items.join", "stdlib" => true }], + })).to eq("Ruby stdlib call") + + expect(report.send(:return_hygiene_source_kind, { + "return_syntax" => "implicit", + "sources" => [ + { "kind" => "typed_call", "callee" => "join", "type" => "String", "code" => "items.join", "stdlib" => true }, + { "kind" => "call_untyped", "callee" => "fallback", "code" => "fallback" }, + ], + })).to eq("mixed sources") + + expect(report.send(:return_hygiene_source_kind, { + "return_syntax" => "explicit", + "sources" => [{ "kind" => "call_untyped", "callee" => "other", "code" => "other" }], + })).to eq("explicit/direct forwarded return") + + expect(report.send(:return_hygiene_source_kind, { + "return_syntax" => "mixed", + "sources" => [{ "kind" => "call_untyped", "callee" => "other", "code" => "other" }], + })).to eq("mixed/direct forwarded return") + + expect(report.send(:return_hygiene_source_kind, { + "return_syntax" => "implicit", + "sources" => [{ "kind" => "unknown", "code" => "items[name]" }], + })).to eq("collection lookup") + + expect(report.send(:return_hygiene_source_kind, { + "return_syntax" => "implicit", + "sources" => [{ "kind" => "ivar_read", "code" => "@value" }], + })).to eq("struct/class field or instance variable") + end + + it "reports strong, weak, untyped, and nilable coverage inside each hygiene bucket" do + report = described_class.allocate + lines = [] + rows = [ + { "source_kind" => "literal/static", "return_type" => "String" }, + { "source_kind" => "literal/static", "return_type" => "T.untyped" }, + { "source_kind" => "literal/static", "return_type" => "T::Hash[T.untyped, T.untyped]" }, + { "source_kind" => "Ruby stdlib call", "return_type" => "T.nilable(T::Boolean)" }, + ] + + report.send(:append_hygiene_bucket_lines, lines, "Return source kind", rows, "source_kind", rows.size) + + expect(lines).to include( + "- literal/static: total 3 (75.0%) of all returns; strong 1 (33.3%); weak 1 (33.3%); untyped 1 (33.3%); nilable 0 (0.0%) within row", + "- Ruby stdlib call: total 1 (25.0%) of all returns; strong 1 (100.0%); weak 0 (0.0%); untyped 0 (0.0%); nilable 1 (100.0%) within row" + ) + end + + it "splits addressed return fixability by type strength" do + report = described_class.allocate + + expect(report.send(:return_hygiene_fixability, "String", "used as value", "literal/static")).to eq("addressed: strong") + expect(report.send(:return_hygiene_fixability, "T::Hash[T.untyped, T.untyped]", "used as value", "collection lookup")).to eq("addressed: weak") + expect(report.send(:return_hygiene_fixability, "T.nilable(T.untyped)", "used as value", "unknown source")).to eq("addressed: untyped") + end + + it "builds primary evidence reasons for weak and untyped signature slots" do + report = described_class.allocate + evidence = { + "methods" => [ + { "source" => { "path" => "src/example.rb", "line" => 3 }, "calls" => 2, + "params_ok" => { "name" => ["String"] }, "params_by_name" => {}, "returns" => ["String"] }, + { "source" => { "path" => "src/example.rb", "line" => 9 }, "calls" => 1, + "params_ok" => {}, "params_by_name" => {}, "returns" => [] }, + ], + "facts" => { + "existing_sigs" => [ + { "path" => "src/example.rb", "line" => 3, "class" => "Example", "method" => "save", "kind" => "instance", + "sig" => "sig { params(name: T.untyped, items: T::Array[T.untyped]).returns(T.untyped) }", + "params" => [{ "name" => "name" }, { "name" => "items" }], + "return_origin" => { "path" => "src/example.rb", "line" => 3, "class" => "Example", "method" => "save", "kind" => "instance", + "sources" => [{ "kind" => "call_untyped", "callee" => "build", "code" => "build" }] } }, + { "path" => "src/example.rb", "line" => 9, "class" => "Example", "method" => "load", "kind" => "instance", + "sig" => "sig { returns(T::Hash[Symbol, T.untyped]) }", "params" => [] }, + ], + "param_origins" => [ + { "path" => "src/caller.rb", "line" => 12, "callee" => "save", "slot" => "name", "origin_kind" => "static", "type" => "String", "code" => "\"Ada\"" }, + ], + "return_origins" => [], + }, + "actions" => [ + { "kind" => "fix_sig_param", "confidence" => "review", "path" => "src/example.rb", "line" => 3, + "data" => { "name" => "name", "type" => "String" } }, + ], + } + + rows = report.send(:signature_slot_evidence_rows, evidence) + by_slot = rows.each_with_object({}) { |row, lookup| lookup[[row["slot_kind"], row["slot"]]] = row } + + expect(by_slot[["param", "name"]]).to include( + "strength" => "untyped", + "primary_reason" => "candidate: runtime-only param observation" + ) + expect(by_slot[["param", "name"]]["example"]).to include("candidate action fix_sig_param") + expect(by_slot[["param", "items"]]).to include( + "strength" => "weak", + "primary_reason" => "weak declared type: array element evidence needed" + ) + expect(by_slot[["return", "return"]]).to include( + "strength" => "weak", + "primary_reason" => "weak declared type: hash key/value evidence needed" + ) + end + end + + describe NilKill::GuardedAutocorrect do + it "restores Sorbet autocorrect removals of defensive safe navigation" do + Dir.mktmpdir("nil-kill-autocorrect") do |dir| + path = File.join(dir, "example.rb") + File.write(path, "value&.name\n") + autocorrect = described_class.new([]) + snapshot = { path => [{ line: 1, content: "value&.name\n" }] } + + File.write(path, "value.name\n") + + expect(autocorrect.send(:restore_safe_navigation, snapshot)).to eq(1) + expect(File.read(path)).to eq("value&.name\n") + end + end + + it "restores known bogus did-you-mean autocorrect replacements" do + Dir.mktmpdir("nil-kill-autocorrect") do |dir| + path = File.join(dir, "example.rb") + original = ["node.class.module_alias\n"] + File.write(path, "node.class.module_eval\n") + autocorrect = described_class.new([]) + + expect(autocorrect.send(:restore_bogus_replacements, path => original)).to eq(1) + expect(File.read(path)).to eq("node.class.module_alias\n") + end + end + end + + describe NilKill::StructRBI do + it "can generate a complete struct RBI without the legacy generator" do + generator = described_class.allocate + facts = { + "struct_declarations" => [ + { "class" => "Example::Node", "line" => 1, "fields" => ["name", "items"] }, + ], + } + candidates = [ + { "class" => "Example::Node", "field" => "name", "type" => "String" }, + ] + + rbi = generator.send(:generate_complete, facts, candidates) + + expect(rbi).to include("class Example::Node") + expect(rbi).to include("sig { returns(String) }\n def name; end") + expect(rbi).to include("sig { returns(T.untyped) }\n def items; end") + end + + it "preserves existing RBI field types in complete mode when no new candidate exists" do + generator = described_class.allocate + allow(generator).to receive(:existing_rbi_types).and_return({ ["Example::Node", "items"] => "T::Array[String]" }) + facts = { + "struct_declarations" => [ + { "class" => "Example::Node", "line" => 1, "fields" => ["items"] }, + ], + } + + rbi = generator.send(:generate_complete, facts, []) + + expect(rbi).to include("sig { returns(T::Array[String]) }\n def items; end") + end + + it "extracts offending method names from srb tc 'Got X originating from' blocks" do + generator = described_class.allocate + srb_output = <<~SRB + src/mir/mir_lowering.rb:5708: Comparison between `String` and `Symbol(:Any)` is always false https://srb.help/7046 + 5708 | msg_str = if raw.nil? || raw == :Any || raw.empty? + ^^ + Got `String` originating from: + src/mir/mir_lowering.rb:5707: + 5707 | raw = node.message + ^^^^^^^^^^^^ + + src/foo.rb:10: Method `[]` does not exist on `NilClass` https://srb.help/7003 + 10 | bound_var = node.capabilities.first[:var_node] + Got `T.nilable(...)` originating from: + src/foo.rb:9: + 9 | cap = node.capabilities.first + ^^^^^^^^^^^^^^^^^^^^^^^ + SRB + + methods = generator.send(:extract_offending_methods, srb_output) + + expect(methods).to include("message") + expect(methods).to include("capabilities") + expect(methods).to include("first") + end + + it "falls back to T.untyped for blocklisted fields in complete mode" do + generator = described_class.allocate + generator.instance_variable_set(:@blocklist, Set.new(["message"])) + allow(generator).to receive(:existing_rbi_types).and_return({}) + facts = { + "struct_declarations" => [ + { "class" => "Example::Panic", "line" => 1, "fields" => ["message", "code"] }, + ], + } + candidates = [ + { "class" => "Example::Panic", "field" => "message", "type" => "String" }, + { "class" => "Example::Panic", "field" => "code", "type" => "Integer" }, + ] + + rbi = generator.send(:generate_complete, facts, candidates) + + # Blocklisted "message" reverts to T.untyped; other fields unaffected. + expect(rbi).to include("sig { returns(T.untyped) }\n def message; end") + expect(rbi).to include("sig { returns(Integer) }\n def code; end") + end + end + + describe NilKill::CLI do + describe "#targets_changed_since_collect (git-aware staleness)" do + let(:cli) { described_class.new([]) } + + around do |ex| + Dir.mktmpdir("nk-meta") { |d| @meta = File.join(d, "collect-meta.json"); ex.run } + end + + before { allow(cli).to receive(:collect_meta_path).and_return(@meta) } + + it "is :unknown when no collect metadata exists (caller falls back to mtime)" do + expect(cli.send(:targets_changed_since_collect)).to eq(:unknown) + end + + it "is :unknown when git is unavailable" do + File.write(@meta, JSON.generate("head" => "abc", "dirty" => "")) + allow(cli).to receive(:git_capture).and_return(nil) + expect(cli.send(:targets_changed_since_collect)).to eq(:unknown) + end + + it "is false (fresh) when HEAD and working-tree status match the collect -- a touched mtime does NOT trip it" do + File.write(@meta, JSON.generate("head" => "deadbee", "dirty" => " M src/x.rb\n")) + allow(cli).to receive(:git_capture) do |*a| + a.first == "rev-parse" ? "deadbee\n" : " M src/x.rb\n" + end + expect(cli.send(:targets_changed_since_collect)).to be(false) + end + + it "reports the changed commits + files when src/ moved since the collect" do + File.write(@meta, JSON.generate("head" => "oldsha1", "dirty" => "")) + allow(cli).to receive(:git_capture) do |*a| + case a.first + when "rev-parse" then "newsha2\n" + when "status" then "" + when "diff" then "src/ast/parser.rb\nsrc/mir/mir_lowering.rb\n" + end + end + meta_h, cur_h, files = cli.send(:targets_changed_since_collect) + expect(meta_h).to eq("oldsha1") + expect(cur_h).to eq("newsha2") + expect(files).to contain_exactly("src/ast/parser.rb", "src/mir/mir_lowering.rb") + end + + it "is fresh when HEAD moved but ZERO target files changed (commits only touched non-src)" do + File.write(@meta, JSON.generate("head" => "oldsha1", "dirty" => "")) + allow(cli).to receive(:git_capture) do |*a| + case a.first + when "rev-parse" then "newsha2\n" + when "status" then "" + when "diff" then "" # no src/ files differ between the two shas + end + end + expect(cli.send(:targets_changed_since_collect)).to be(false) + end + + it "detects an uncommitted working-tree change even at the same HEAD" do + File.write(@meta, JSON.generate("head" => "samesha", "dirty" => "")) + allow(cli).to receive(:git_capture) do |*a| + a.first == "rev-parse" ? "samesha\n" : " M src/ast/ast.rb\n" + end + _, _, files = cli.send(:targets_changed_since_collect) + expect(files).to eq(["src/ast/ast.rb"]) + end + end + end +end diff --git a/gems/nil-kill/spec/report_evidence_gap_unit_spec.rb b/gems/nil-kill/spec/report_evidence_gap_unit_spec.rb new file mode 100644 index 000000000..443bc5c4a --- /dev/null +++ b/gems/nil-kill/spec/report_evidence_gap_unit_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true +# +# Unit matrix for the gap-classification primitives that have no other +# coverage: collect_coverage_index, collect_ran? (open-interval +# bounds), untraceable_arg_kind?, and never_run_reason's lo-nil +# short-circuit. The closed never_run_reason tree itself is covered in +# nil_kill_spec.rb. + +require_relative "spec_helper" + +RSpec.describe NilKill::Report, "evidence-gap primitives" do + subject(:report) { described_class.new } + + describe "#collect_coverage_index" do + it "converts the collect_coverage dict to per-path Sets of Integers" do + ev = { "facts" => { "collect_coverage" => { "src/a.rb" => %w[3 5], "src/b.rb" => [7] } } } + idx = report.send(:collect_coverage_index, ev) + expect(idx["src/a.rb"]).to eq(Set[3, 5]) + expect(idx["src/b.rb"]).to eq(Set[7]) + expect(idx["src/a.rb"]).to all(be_a(Integer)) + end + + it "returns nil when collect_coverage is absent or empty" do + expect(report.send(:collect_coverage_index, { "facts" => {} })).to be_nil + expect(described_class.new.send(:collect_coverage_index, { "facts" => { "collect_coverage" => {} } })).to be_nil + end + + it "memoizes per Report instance" do + ev = { "facts" => { "collect_coverage" => { "src/a.rb" => [1] } } } + first = report.send(:collect_coverage_index, ev) + ev["facts"]["collect_coverage"]["src/a.rb"] = [9] + expect(report.send(:collect_coverage_index, ev)).to equal(first) + end + end + + describe "#collect_ran? (open interval, def line and end excluded)" do + let(:idx) { { "f" => Set[10, 12, 15] } } + + it "is false when idx is nil" do + expect(report.send(:collect_ran?, nil, "f", 1, 9)).to be(false) + end + + it "is false when the file has no coverage entry" do + expect(report.send(:collect_ran?, idx, "other", 1, 99)).to be(false) + end + + it "excludes the def line (lower bound)" do + expect(report.send(:collect_ran?, { "f" => Set[10] }, "f", 10, 15)).to be(false) + end + + it "excludes the trailing end line (upper bound)" do + expect(report.send(:collect_ran?, { "f" => Set[15] }, "f", 10, 15)).to be(false) + end + + it "is true for a strictly interior covered line" do + expect(report.send(:collect_ran?, idx, "f", 9, 16)).to be(true) + end + + it "is false for a 1-2 line body (empty interior, hi == lo+1)" do + expect(report.send(:collect_ran?, { "f" => Set[10, 11] }, "f", 10, 11)).to be(false) + end + + it "defaults hi to lo when hi is nil (no interior -> false)" do + expect(report.send(:collect_ran?, { "f" => Set[10] }, "f", 10, nil)).to be(false) + end + end + + describe "#untraceable_arg_kind?" do + it "is true for block-ish names and Proc-ish types, false for a real slot" do + expect(report.send(:untraceable_arg_kind?, "block", "T.untyped")).to be(true) + expect(report.send(:untraceable_arg_kind?, "blk", "T.untyped")).to be(true) + expect(report.send(:untraceable_arg_kind?, "*rest", "T.untyped")).to be(true) + expect(report.send(:untraceable_arg_kind?, "&b", "T.untyped")).to be(true) + expect(report.send(:untraceable_arg_kind?, "on_block", "T.untyped")).to be(true) + expect(report.send(:untraceable_arg_kind?, "cb_blk", "T.untyped")).to be(true) + expect(report.send(:untraceable_arg_kind?, "f", "T.proc.void")).to be(true) + expect(report.send(:untraceable_arg_kind?, "f", "Proc")).to be(true) + expect(report.send(:untraceable_arg_kind?, "f", "T.nilable(Proc)")).to be(true) + # the negative: a real typeable positional is NOT excused + expect(report.send(:untraceable_arg_kind?, "value", "Integer")).to be(false) + end + end + + describe "#never_run_reason lo-nil short-circuit" do + it "never returns collect_ran_untraced when lo is nil (struct-field caller)" do + ev = { "facts" => { "collect_coverage" => { "src/m.rb" => [10, 11] } } } + expect(report.send(:never_run_reason, ev, "src/m.rb")).to eq("unseen") + end + end +end diff --git a/gems/nil-kill/spec/return_cascade_spec.rb b/gems/nil-kill/spec/return_cascade_spec.rb new file mode 100644 index 000000000..4532fbccb --- /dev/null +++ b/gems/nil-kill/spec/return_cascade_spec.rb @@ -0,0 +1,370 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe NilKill::Report do + it "ranks roots by transitive return and param impact" do + report = described_class.new + allow(report).to receive(:return_usage_by_name).and_return({}) + + origins = [ + { + "path" => "src/example.rb", "line" => 10, "class" => "Example", "method" => "base", + "sources" => [{ "kind" => "call_untyped", "callee" => "mystery", "line" => 11 }], + "blockers" => [], + }, + { + "path" => "src/example.rb", "line" => 20, "class" => "Example", "method" => "mid", + "sources" => [{ "kind" => "call_untyped", "callee" => "base", "line" => 21 }], + "blockers" => [], + }, + { + "path" => "src/example.rb", "line" => 30, "class" => "Example", "method" => "top", + "sources" => [{ "kind" => "call_untyped", "callee" => "mid", "line" => 31 }], + "blockers" => [], + }, + { + "path" => "src/example.rb", "line" => 40, "class" => "Example", "method" => "sibling", + "sources" => [{ "kind" => "call_untyped", "callee" => "mystery", "line" => 41 }], + "blockers" => [], + }, + ] + evidence = { + "methods" => origins.map do |origin| + { "source" => { "method" => origin["method"], "class" => origin["class"], "path" => origin["path"], "line" => origin["line"] } } + end, + "facts" => { + "param_origins" => [ + { "origin_kind" => "untyped_return", "source_method" => "mid", "path" => "src/use.rb", "line" => 5, "callee" => "consume", "slot" => "0" }, + { "origin_kind" => "untyped_return", "source_method" => "top", "path" => "src/use.rb", "line" => 6, "callee" => "consume", "slot" => "1" }, + ], + }, + } + + pressure = report.return_cascade_pressure(origins, evidence).to_h + + expect(pressure.fetch("untyped callee mystery")["returns"].size).to eq(4) + expect(pressure.fetch("untyped callee mystery")["direct"].size).to eq(2) + expect(pressure.fetch("untyped callee mystery")["cascade"].size).to eq(2) + expect(pressure.fetch("untyped callee mystery")["params"].size).to eq(2) + expect(pressure.fetch("untyped callee base")["returns"].size).to eq(2) + end + + it "does not cascade through ambiguous method names" do + report = described_class.new + allow(report).to receive(:return_usage_by_name).and_return({}) + + origins = [ + { + "path" => "src/a.rb", "line" => 10, "class" => "A", "method" => "value", + "sources" => [{ "kind" => "nil", "line" => 11 }], + "blockers" => [], + }, + { + "path" => "src/b.rb", "line" => 20, "class" => "B", "method" => "value", + "sources" => [{ "kind" => "nil", "line" => 21 }], + "blockers" => [], + }, + { + "path" => "src/use.rb", "line" => 30, "class" => "Use", "method" => "consumer", + "sources" => [{ "kind" => "call_untyped", "callee" => "value", "line" => 31 }], + "blockers" => [], + }, + ] + evidence = { + "methods" => origins.map do |origin| + { "source" => { "method" => origin["method"], "class" => origin["class"], "path" => origin["path"], "line" => origin["line"] } } + end, + "facts" => { + "param_origins" => [ + { "origin_kind" => "untyped_return", "source_method" => "value", "path" => "src/use.rb", "line" => 40, "callee" => "consume", "slot" => "0" }, + ], + }, + } + + pressure = report.return_cascade_pressure(origins, evidence).to_h + + expect(pressure.fetch("nil return at src/a.rb:11")["returns"].size).to eq(1) + expect(pressure.fetch("nil return at src/a.rb:11")["cascade"].size).to eq(0) + expect(pressure.fetch("nil return at src/a.rb:11")["params"].size).to eq(0) + end + + it "reports forwarded-return blocker pressure by callee status" do + report = described_class.new + origins = [ + { "path" => "src/a.rb", "line" => 10, "class" => "A", "method" => "wrapper", "kind" => "instance", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf" }] }, + { "path" => "src/a.rb", "line" => 20, "class" => "A", "method" => "other_wrapper", "kind" => "instance", + "sources" => [{ "kind" => "call_untyped", "callee" => "leaf" }] }, + { "path" => "src/a.rb", "line" => 30, "class" => "A", "method" => "ambiguous_wrapper", "kind" => "instance", + "sources" => [{ "kind" => "call_untyped", "callee" => "dup_name" }] }, + { "path" => "src/a.rb", "line" => 40, "class" => "A", "method" => "same_type_ambiguous_wrapper", "kind" => "instance", + "sources" => [{ "kind" => "call_untyped", "callee" => "dup_same_type" }] }, + ] + evidence = { + "facts" => { + "existing_sigs" => [ + { "method" => "leaf", "sig" => "sig { returns(String) }" }, + { "method" => "dup_name", "sig" => "sig { returns(T.untyped) }" }, + { "method" => "dup_name", "sig" => "sig { returns(T.untyped) }" }, + { "method" => "dup_same_type", "sig" => "sig { returns(String) }" }, + { "method" => "dup_same_type", "sig" => "sig { returns(String) }" }, + ], + "return_origins" => [ + { "method" => "dup_name", "candidate_type" => "String" }, + { "method" => "dup_name", "candidate_type" => "Integer" }, + ], + "param_origins" => [ + { "origin_kind" => "untyped_return", "source_method" => "leaf", "path" => "src/use.rb", "line" => 40, "callee" => "sink", "slot" => "0" }, + ], + }, + } + + pressure = report.forwarded_return_blocker_pressure(origins, evidence) + + expect(pressure.fetch("leaf")).to include("status" => "typed signature String") + expect(pressure.fetch("leaf")["returns"].size).to eq(2) + expect(pressure.fetch("leaf")["params"].size).to eq(1) + expect(pressure.fetch("dup_name")).to include("status" => "ambiguous method name") + expect(pressure.fetch("dup_same_type")).to include("status" => "unresolved forwarded callee") + end + + it "categorizes untyped return sources for report triage" do + report = described_class.new + + expect(report.untyped_return_source_category( + "return_origin" => { "sources" => [{ "kind" => "ivar_read" }] } + )).to eq("untyped instance variable") + expect(report.untyped_return_source_category( + "return_origin" => { "sources" => [{ "kind" => "call_untyped", "callee" => "fetch" }] } + )).to eq("untyped forwarded return") + expect(report.untyped_return_source_category( + "return_origin" => { "candidate_type" => "T::Array[T.untyped]", "sources" => [{ "kind" => "static", "type" => "T::Array[T.untyped]" }] } + )).to eq("untyped struct/array/collection value") + expect(report.untyped_return_source_category( + "return_origin" => { "sources" => [{ "kind" => "static", "type" => "String" }] } + )).to eq("untyped literal/static expression") + end + + it "categorizes untyped param sources for report triage" do + report = described_class.new + + expect(report.untyped_param_source_category([ + { "origin_kind" => "unknown", "code" => "@cached" }, + ])).to eq("untyped instance variable") + expect(report.untyped_param_source_category([ + { "origin_kind" => "untyped_return", "code" => "build_value" }, + ])).to eq("untyped forwarded return") + expect(report.untyped_param_source_category([ + { "origin_kind" => "static", "type" => "T::Array[T.untyped]", "code" => "[]" }, + ])).to eq("untyped struct/array/collection value") + expect(report.untyped_param_source_category([ + { "origin_kind" => "static", "type" => "String", "code" => "\"x\"" }, + ])).to eq("untyped literal/static expression") + end + + it "attributes singular unknown expression causes and separates mixed causes" do + report = described_class.new + + expect(report.unknown_expression_bucket(["forwarded return build"])).to eq("unknown forwarded return build") + expect(report.unknown_expression_bucket(["instance variable @cached"])).to eq("unknown instance variable @cached") + expect(report.unknown_expression_bucket(["struct/array/collection value Array"])).to eq("unknown struct/array/collection value Array") + expect(report.unknown_expression_bucket(["literal/static expression class constant String"])).to eq("unknown literal/static expression class constant String") + expect(report.unknown_expression_bucket(["operation CallNode", "forwarded return build"])).to eq("unknown forwarded return build") + expect(report.unknown_expression_bucket(["operation RangeNode", "literal/static expression Integer"])).to eq("unknown operation RangeNode") + expect(report.unknown_expression_bucket(["forwarded return build", "instance variable @cached"])).to eq("unknown expression with multiple unknown types") + end + + it "only reports param unknown causes for untyped parameter slots" do + report = described_class.new + slots = report.untyped_param_slot_keys([ + { "method" => "target", "sig" => "sig { params(value: T.untyped, typed: String).void }" }, + ]) + + expect(report.untyped_param_origin?({ "callee" => "target", "slot" => "value" }, slots)).to eq(true) + expect(report.untyped_param_origin?({ "callee" => "target", "slot" => "0" }, slots)).to eq(true) + expect(report.untyped_param_origin?({ "callee" => "target", "slot" => "typed" }, slots)).to eq(false) + expect(report.untyped_param_origin?({ "callee" => "other", "slot" => "value" }, slots)).to eq(false) + end + + it "excludes already resolved signature slots from callsite pressure" do + report = described_class.new + report.instance_variable_set(:@evidence, { + "facts" => { + "existing_sigs" => [ + { "path" => "src/a.rb", "line" => 10, "params" => [ + { "name" => "already_nilable", "type" => "T.nilable(String)" }, + { "name" => "needs_nilable", "type" => "String" }, + { "name" => "already_typed", "type" => "String" }, + { "name" => "needs_union", "type" => "T.untyped" }, + ] }, + ], + "unsigned_methods" => [], + }, + }) + + nil_pressure = report.send(:callsite_pressure, [ + { "kind" => "nil_param_observed", "path" => "src/a.rb", "line" => 10, "data" => { + "name" => "already_nilable", "callsites" => { "src/root.rb:1:NilClass" => 10 }, + } }, + { "kind" => "nil_param_observed", "path" => "src/a.rb", "line" => 10, "data" => { + "name" => "needs_nilable", "callsites" => { "src/root.rb:2:NilClass" => 5 }, + } }, + ], "nil_param_observed") + + union_pressure = report.send(:callsite_pressure, [ + { "kind" => "union_observed", "path" => "src/a.rb", "line" => 10, "data" => { + "name" => "already_typed", "callsites" => { "src/root.rb:3:String" => 10 }, + } }, + { "kind" => "union_observed", "path" => "src/a.rb", "line" => 10, "data" => { + "name" => "needs_union", "callsites" => { "src/root.rb:4:String" => 5 }, + } }, + ], "union_observed") + + expect(nil_pressure.keys).to eq(["src/root.rb:2"]) + expect(union_pressure.keys).to eq(["src/root.rb:4"]) + end + + it "summarizes weak collection slots and runtime candidates" do + report = described_class.new + evidence = { + "methods" => [ + { + "source" => { "path" => "src/a.rb", "line" => 10 }, + "calls" => 5, + "param_elem" => { "items" => ["String"] }, + "param_kv" => { "map" => [["String"], ["Integer"]] }, + "param_elem_shapes" => {}, + "param_kv_shapes" => { + "map" => [ + [{ "kind" => "class", "name" => "String" }], + [ + { + "kind" => "array", + "elements" => [{ "kind" => "class", "name" => "Integer" }], + }, + ], + ], + }, + "return_elem" => ["Symbol"], + "return_kv" => [[], []], + "return_elem_shapes" => [], + "return_kv_shapes" => [[], []], + }, + ], + "facts" => { + "existing_sigs" => [ + { "path" => "src/a.rb", "line" => 10, "class" => "A", "method" => "m", + "sig" => "sig { params(items: T::Array[T.untyped], map: T::Hash[T.untyped, T.untyped]).returns(T::Array[T.untyped]) }" }, + ], + }, + } + + slots = report.collection_signature_slots(evidence) + candidates = slots.filter_map { |slot| report.collection_slot_candidate(slot) } + + expect(slots.map { |slot| [slot["slot_kind"], slot["slot"], slot.dig("info", "kind"), slot.dig("info", "weak")] }).to include( + ["param", "items", "array", true], + ["param", "map", "hash", true], + ["return", "return", "array", true] + ) + expect(candidates.map { |candidate| [candidate["slot"], candidate["candidate"]] }).to include( + ["items", "T::Array[String]"], + ["map", "T::Hash[String, T::Array[Integer]]"], + ["return", "T::Array[Symbol]"] + ) + end + + it "ranks weak collection blockers by affected slots and mutation provenance" do + report = described_class.new + evidence = { + "methods" => [ + { + "source" => { "path" => "src/a.rb", "line" => 10 }, + "calls" => 12, + "param_elem" => { "items" => ["Hash"] }, + "param_kv" => {}, + "param_elem_shapes" => { + "items" => [ + { + "kind" => "hash", + "keys" => [{ "kind" => "class", "name" => "Symbol" }], + "values" => (1..6).map { |idx| { "kind" => "class", "name" => "Value#{idx}" } }, + }, + ], + }, + "param_kv_shapes" => {}, + "return_elem" => ["Hash"], + "return_kv" => [[], []], + "return_elem_shapes" => [ + { + "kind" => "hash", + "keys" => [{ "kind" => "class", "name" => "Symbol" }], + "values" => (1..6).map { |idx| { "kind" => "class", "name" => "Value#{idx}" } }, + }, + ], + "return_kv_shapes" => [[], []], + }, + ], + "facts" => { + "existing_sigs" => [ + { "path" => "src/a.rb", "line" => 10, "class" => "A", "method" => "m", + "sig" => "sig { params(items: T::Array[T.untyped]).returns(T::Array[T.untyped]) }" }, + ], + "collection_runtime" => [ + { "owner_kind" => "method_param", "name" => "items", "path" => "src/a.rb", "line" => 10, + "kind" => "array", "calls" => 12, "mutation_sites" => { "src/build.rb:20" => 7 } }, + { "owner_kind" => "method_return", "name" => "m", "path" => "src/a.rb", "line" => 10, + "kind" => "array", "calls" => 12, "mutation_sites" => { "src/build.rb:20" => 5 } }, + ], + }, + } + + slots = report.collection_signature_slots(evidence) + pressure = report.collection_blocker_pressure(evidence, slots) + labels = pressure.keys + + expect(labels).to include(a_string_including("method_param items array at src/a.rb:10; candidate still contains T.untyped")) + expect(labels).to include(a_string_including("method_return m array at src/a.rb:10; candidate still contains T.untyped")) + expect(pressure.values.map { |data| data["slots"].size }).to all(eq(1)) + expect(pressure.values.map { |data| data["mutation_sites"].fetch("src/build.rb:20") }).to include(7, 5) + end + + it "ranks hash maps acting as structs by downstream slot pressure" do + report = described_class.new + evidence = { + "facts" => { + "collection_index_lookups" => [ + { "path" => "src/caps.rb", "line" => 10, "code" => "c[:capability]", "receiver" => "c", "index" => ":capability", + "receiver_type" => nil, "status" => "unknown receiver type", "origin" => { "kind" => "local variable", "name" => "c" } }, + { "path" => "src/caps.rb", "line" => 11, "code" => "c[:var_node]", "receiver" => "c", "index" => ":var_node", + "receiver_type" => "T::Hash[Symbol, T.untyped]", "status" => "weak collection receiver", "origin" => { "kind" => "local variable", "name" => "c" } }, + { "path" => "src/params.rb", "line" => 20, "code" => "param[:name]", "receiver" => "param", "index" => ":name", + "receiver_type" => nil, "status" => "unknown receiver type", "origin" => { "kind" => "method parameter", "name" => "param" } }, + ], + "param_origins" => [ + { "path" => "src/use.rb", "line" => 30, "callee" => "sink", "slot" => "0", "code" => "c[:capability]" }, + ], + "return_origins" => [ + { "sources" => [{ "line" => 40, "code" => "c[:var_node]" }] }, + ], + }, + } + + rows = report.hash_record_struct_pressure(evidence) + + expect(rows.first).to include( + "label" => "local hash record c at src/caps.rb", + "keys" => %w[capability var_node], + "collection_slots" => 2, + "param_slots" => 1, + "return_slots" => 1, + "total_pressure" => 4 + ) + expect(rows[1]).to include( + "label" => "method parameter hash record param", + "keys" => ["name"], + "total_pressure" => 1 + ) + end +end diff --git a/gems/nil-kill/spec/runtime_trace_spec.rb b/gems/nil-kill/spec/runtime_trace_spec.rb new file mode 100644 index 000000000..f6a850630 --- /dev/null +++ b/gems/nil-kill/spec/runtime_trace_spec.rb @@ -0,0 +1,668 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe "nil-kill runtime trace" do + it "captures method returns, T.let values, and struct fields in a subprocess" do + Dir.mktmpdir("nil-kill-runtime", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "ostruct" + require "sorbet-runtime" + + Pair = Struct.new(:name, :items) + + class Worker + extend T::Sig + + sig { params(values: T::Array[T.untyped]).returns(String) } + def call(values) + label = T.let("ok", T.untyped) + Pair.new(label, values) + OpenStruct.new(count: values.length) + label + end + end + + Worker.new.call(["a", "b"]) + RUBY + + trace_tmp = File.join(dir, "trace-tmp") + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TMP_DIR" => trace_tmp, + "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{tracer}", + } + + _out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", source, chdir: NilKill::ROOT) + + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + tlet_events = Dir.glob(File.join(trace_dir, "tlets-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + struct_events = Dir.glob(File.join(trace_dir, "structs-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + + expect(method_events).to include(a_hash_including("class" => "Worker", "method" => "call", "returns" => include("String"))) + expect(method_events).to include(a_hash_including("class" => "Worker", "method" => "call", "param_elem" => a_hash_including("values" => include("String")))) + expect(tlet_events).to include(a_hash_including("classes" => include("String"))) + expect(struct_events).to include(a_hash_including("class" => "Pair", "field" => "name", "classes" => include("String"))) + end + end + + it "captures collection mutations and source-instrumented ivar assignments in a subprocess" do + Dir.mktmpdir("nil-kill-runtime-ivar", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + require "set" + + class Worker + extend T::Sig + + sig { params(items: T::Array[T.untyped], map: T::Hash[T.untyped, T.untyped]).returns(T::Hash[T.untyped, T.untyped]) } + def call(items, map) + @items = [] + @seen = Set.new + items << "runtime" + items << {name: ["nested"]} + map[:name] = 1 + map[:nested] = {ok: ["yes"]} + @items << :ivar_item + @seen.add(:seen) + map + end + end + + Worker.new.call([], {}) + RUBY + + instrumented = File.join(dir, "sample.instrumented.rb") + FileUtils.mkdir_p(File.dirname(instrumented)) + File.write(instrumented, NilKill::SourceInstrumenter.new.instrument_file(source)) + + trace_tmp = File.join(dir, "trace-tmp") + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TMP_DIR" => trace_tmp, + "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{tracer}", + } + + _out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", instrumented, chdir: NilKill::ROOT) + + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + collection_events = Dir.glob(File.join(trace_dir, "collections-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + + expect(method_events).to include(a_hash_including( + "class" => "Worker", + "method" => "call", + "param_elem" => a_hash_including("items" => include("String", "Hash")), + "param_kv" => a_hash_including("map" => [include("Symbol"), include("Integer", "Hash")]), + "param_elem_shapes" => a_hash_including("items" => include( + a_hash_including( + "kind" => "hash", + "keys" => include(a_hash_including("kind" => "class", "name" => "Symbol")), + "values" => include(a_hash_including( + "kind" => "array", + "elements" => include(a_hash_including("kind" => "class", "name" => "String")) + )) + ) + )), + "param_kv_shapes" => a_hash_including("map" => [ + [], + include(a_hash_including( + "kind" => "hash", + "keys" => include(a_hash_including("kind" => "class", "name" => "Symbol")), + "values" => include(a_hash_including( + "kind" => "array", + "elements" => include(a_hash_including("kind" => "class", "name" => "String")) + )) + )) + ]) + )) + expect(collection_events).to include(a_hash_including( + "owner_kind" => "method_param", + "name" => "items", + "kind" => "array", + "elem_shapes" => include(a_hash_including( + "kind" => "hash", + "keys" => include(a_hash_including("kind" => "class", "name" => "Symbol")), + "values" => include(a_hash_including( + "kind" => "array", + "elements" => include(a_hash_including("kind" => "class", "name" => "String")) + )) + )) + )) + expect(collection_events).to include(a_hash_including("owner_kind" => "ivar", "name" => "@items", "kind" => "array", "elem_classes" => include("Symbol"))) + expect(collection_events).to include(a_hash_including("owner_kind" => "ivar", "name" => "@seen", "kind" => "set", "elem_classes" => include("Symbol"))) + items_owner = collection_events.find { |event| event["owner_kind"] == "method_param" && event["name"] == "items" } + expect(items_owner.fetch("mutation_sites").keys).to include(a_string_matching(/sample\.instrumented\.rb:\d+\z/)) + end + end + + it "source-instruments only trace-plan methods when method TracePoint collection is disabled" do + Dir.mktmpdir("nil-kill-runtime-source-plan", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + + class Worker + extend T::Sig + + sig { params(value: String).returns(String) } + def typed(value) + value + end + + sig { params(value: T.untyped).returns(T.untyped) } + def untyped(value) + value + end + end + + worker = Worker.new + worker.typed("typed") + worker.untyped("untyped") + RUBY + + methods = NilKill::SourceIndex.new(source).methods.each_with_object({}) { |method, lookup| lookup[method["method"]] = method } + plan = { + "methods" => { + ["Worker", "typed", "instance", File.expand_path(source), methods.fetch("typed")["line"]].join("\0") => { + "sample" => false, + "params" => { "value" => false }, + "return" => false, + }, + ["Worker", "untyped", "instance", File.expand_path(source), methods.fetch("untyped")["line"]].join("\0") => { + "sample" => true, + "params" => { "value" => true }, + "return" => true, + }, + }, + } + FileUtils.mkdir_p(NilKill::TMP_DIR) + File.write(NilKill::TRACE_PLAN_PATH, JSON.pretty_generate(plan)) + + instrumented = File.join(dir, "sample.instrumented.rb") + FileUtils.mkdir_p(File.dirname(instrumented)) + instrumented_source = NilKill::SourceInstrumenter.new.instrument_file(source) + File.write(instrumented, instrumented_source) + + expect(instrumented_source).to include('record_source_method_call("Worker", "untyped"') + expect(instrumented_source).not_to include('record_source_method_call("Worker", "typed"') + + trace_tmp = File.join(dir, "trace-tmp") + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => trace_tmp, + "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{tracer}", + } + + _out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", instrumented, chdir: NilKill::ROOT) + + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + expect(method_events).to include(a_hash_including("class" => "Worker", "method" => "untyped", "returns" => include("String"))) + expect(method_events).not_to include(a_hash_including("class" => "Worker", "method" => "typed")) + end + end + + it "instruments a one-line def (def f; ...; end), anchoring the suffix on the end keyword" do + Dir.mktmpdir("nil-kill-oneline", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + + class Worker + extend T::Sig + + sig { returns(T.untyped) } + def oneline; @v = T.let(@v, T.untyped); end + end + + Worker.new.oneline + RUBY + + methods = NilKill::SourceIndex.new(source).methods.each_with_object({}) { |m, h| h[m["method"]] = m } + plan = { + "methods" => { + ["Worker", "oneline", "instance", File.expand_path(source), methods.fetch("oneline")["line"]].join("\0") => { + "sample" => true, "params" => {}, "return" => true + }, + }, + } + FileUtils.mkdir_p(NilKill::TMP_DIR) + File.write(NilKill::TRACE_PLAN_PATH, JSON.pretty_generate(plan)) + + instrumented_source = NilKill::SourceInstrumenter.new.instrument_file(source) + expect(instrumented_source).to include('record_source_method_call("Worker", "oneline"') + + instrumented = File.join(dir, "sample.instrumented.rb") + File.write(instrumented, instrumented_source) + trace_tmp = File.join(dir, "trace-tmp") + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => trace_tmp, "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{tracer}", + } + _out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", instrumented, chdir: NilKill::ROOT) + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |p| File.readlines(p, chomp: true).map { |l| JSON.parse(l) } } + expect(method_events).to include(a_hash_including("class" => "Worker", "method" => "oneline")) + end + end + + it "records a method punted to the TracePoint fallback (ensure in body)" do + Dir.mktmpdir("nil-kill-tp-fallback", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + + class Worker + extend T::Sig + + # `ensure` -> contains_ensure? -> instrumenter punts this to + # the TracePoint fallback instead of a source wrapper. + sig { params(value: T.untyped).returns(T.untyped) } + def guarded(value) + value.to_s + ensure + nil + end + end + + Worker.new.guarded(42) + RUBY + + methods = NilKill::SourceIndex.new(source).methods.each_with_object({}) { |m, h| h[m["method"]] = m } + # target_dirs MUST be present and match NIL_KILL_TARGETS or the + # runtime discards the whole plan (trace_plan target-guard) -> + # the TracePoint fallback never installs. This is exactly the + # faithful shape TracePlan.write produces in a real collect. + plan = { + "target_dirs" => [dir], + "methods" => { + ["Worker", "guarded", "instance", File.expand_path(source), methods.fetch("guarded")["line"]].join("\0") => { + "sample" => true, "params" => { "value" => true }, "return" => true + }, + }, + } + FileUtils.mkdir_p(NilKill::TMP_DIR) + File.write(NilKill::TRACE_PLAN_PATH, JSON.pretty_generate(plan)) + + instrumented_source = NilKill::SourceInstrumenter.new.instrument_file(source) + # Punted: NO source wrapper emitted ... + expect(instrumented_source).not_to include('record_source_method_call("Worker", "guarded"') + # ... and the method is registered in the TracePoint fallback plan. + reloaded = JSON.parse(File.read(NilKill::TRACE_PLAN_PATH)) + expect(reloaded["tracepoint_methods"].keys.any? { |k| k.split("\0")[1] == "guarded" }).to be(true) + + instrumented = File.join(dir, "sample.instrumented.rb") + File.write(instrumented, instrumented_source) + trace_tmp = File.join(dir, "trace-tmp") + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_tmp) + FileUtils.mkdir_p(trace_tmp) + # Faithful to real collect: the trace plan (now carrying + # tracepoint_methods) and the runtime dumps share one TMP_DIR. + FileUtils.cp(NilKill::TRACE_PLAN_PATH, File.join(trace_tmp, "trace-plan.json")) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => trace_tmp, "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{tracer}", + } + _out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", instrumented, chdir: NilKill::ROOT) + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |p| File.readlines(p, chomp: true).map { |l| JSON.parse(l) } } + expect(method_events).to include(a_hash_including( + "class" => "Worker", "method" => "guarded", + "params_by_name" => a_hash_including("value" => include("Integer")) + )) + end + end + + it "does not crash when a loaded module overrides the singleton .name" do + Dir.mktmpdir("nil-kill-runtime-evil-name", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + + # Mirrors REXML::Functions, which defines `.name` as an XPath DSL + # method. The targeted-definition TracePoint(:end) fires for this + # module's `end` and must NOT invoke this override. + module Hostile + def self.name(*) + raise "singleton .name must never be called by the tracer" + end + end + + class Worker + extend T::Sig + + sig { params(value: T.untyped).returns(T.untyped) } + def untyped(value) + value + end + end + + Worker.new.untyped("ok") + RUBY + + methods = NilKill::SourceIndex.new(source).methods.each_with_object({}) { |method, lookup| lookup[method["method"]] = method } + plan = { + "methods" => { + ["Worker", "untyped", "instance", File.expand_path(source), methods.fetch("untyped")["line"]].join("\0") => { + "sample" => true, + "params" => { "value" => true }, + "return" => true, + }, + }, + } + FileUtils.mkdir_p(NilKill::TMP_DIR) + File.write(NilKill::TRACE_PLAN_PATH, JSON.pretty_generate(plan)) + + instrumented = File.join(dir, "sample.instrumented.rb") + FileUtils.mkdir_p(File.dirname(instrumented)) + File.write(instrumented, NilKill::SourceInstrumenter.new.instrument_file(source)) + + trace_tmp = File.join(dir, "trace-tmp") + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => trace_tmp, + "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{tracer}", + } + + _out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", instrumented, chdir: NilKill::ROOT) + + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + expect(method_events).to include(a_hash_including("class" => "Worker", "method" => "untyped", "returns" => include("String"))) + end + end + + it "source-instrumented methods record both explicit and implicit return paths" do + Dir.mktmpdir("nil-kill-runtime-source-returns", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + + class Worker + extend T::Sig + + sig { params(flag: T::Boolean).returns(T.untyped) } + def mixed(flag) + return "explicit" if flag + :implicit + end + end + + worker = Worker.new + worker.mixed(true) + worker.mixed(false) + RUBY + + method = NilKill::SourceIndex.new(source).methods.fetch(0) + plan = { + "methods" => { + ["Worker", "mixed", "instance", File.expand_path(source), method["line"]].join("\0") => { + "sample" => true, + "params" => { "flag" => false }, + "return" => true, + }, + }, + } + FileUtils.mkdir_p(NilKill::TMP_DIR) + File.write(NilKill::TRACE_PLAN_PATH, JSON.pretty_generate(plan)) + + instrumented = File.join(dir, "sample.instrumented.rb") + FileUtils.mkdir_p(File.dirname(instrumented)) + File.write(instrumented, NilKill::SourceInstrumenter.new.instrument_file(source)) + + trace_tmp = File.join(dir, "trace-tmp") + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => trace_tmp, + "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{tracer}", + } + + _out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", instrumented, chdir: NilKill::ROOT) + + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + expect(method_events).to include(a_hash_including( + "class" => "Worker", + "method" => "mixed", + "returns" => include("String", "Symbol"), + "ok_calls" => 2 + )) + end + end + + it "uses targeted TracePoint fallback for source-instrumented methods with ensure" do + Dir.mktmpdir("nil-kill-runtime-source-ensure", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + + class Worker + extend T::Sig + + sig { params(value: T.untyped).returns(T.untyped) } + def guarded(value) + value + ensure + @done = true + end + end + + Worker.new.guarded("ensured") + RUBY + + method = NilKill::SourceIndex.new(source).methods.fetch(0) + plan = { + "version" => 1, + "target_dirs" => [File.expand_path(dir)], + "methods" => { + ["Worker", "guarded", "instance", File.expand_path(source), method["line"]].join("\0") => { + "sample" => true, + "params" => { "value" => true }, + "return" => true, + }, + }, + } + FileUtils.mkdir_p(NilKill::TMP_DIR) + File.write(NilKill::TRACE_PLAN_PATH, JSON.pretty_generate(plan)) + + instrumented_root = File.join(dir, "instrumented") + instrumented = File.join(instrumented_root, Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s) + FileUtils.mkdir_p(File.dirname(instrumented)) + instrumented_source = NilKill::SourceInstrumenter.new.instrument_file(source) + File.write(instrumented, instrumented_source) + + trace_plan = JSON.parse(File.read(NilKill::TRACE_PLAN_PATH)) + expect(instrumented_source).not_to include('record_source_method_call("Worker", "guarded"') + expect(trace_plan.fetch("tracepoint_methods").keys).to eq(plan.fetch("methods").keys) + + trace_tmp = File.join(dir, "trace-tmp") + FileUtils.mkdir_p(trace_tmp) + File.write(File.join(trace_tmp, "trace-plan.json"), JSON.pretty_generate(trace_plan)) + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => trace_tmp, + "NIL_KILL_TARGETS" => dir, + "NIL_KILL_INSTRUMENTED_ROOT" => instrumented_root, + "RUBYOPT" => "-rbundler/setup -r#{tracer}", + } + + _out, err, status = Open3.capture3(env, "ruby", instrumented, chdir: NilKill::ROOT) + + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + expect(method_events).to include(a_hash_including( + "class" => "Worker", + "method" => "guarded", + "params_by_name" => a_hash_including("value" => include("String")), + "returns" => include("String"), + "ok_calls" => 1 + )) + end + end + + it "source-wraps (does NOT punt) methods with lambda-local returns" do + Dir.mktmpdir("nil-kill-runtime-source-lambda-return", NilKill::ROOT) do |dir| + source = File.join(dir, "sample.rb") + File.write(source, <<~RUBY) + require "sorbet-runtime" + + class Worker + extend T::Sig + + sig { params(value: T.untyped).returns(T.untyped) } + def lambda_return(value) + fn = lambda do |item| + return nil if item.nil? + item + end + fn.call(value) + end + end + + Worker.new.lambda_return("lambda") + RUBY + + method = NilKill::SourceIndex.new(source).methods.fetch(0) + plan = { + "version" => 1, + "target_dirs" => [File.expand_path(dir)], + "methods" => { + ["Worker", "lambda_return", "instance", File.expand_path(source), method["line"]].join("\0") => { + "sample" => true, + "params" => { "value" => true }, + "return" => true, + }, + }, + } + FileUtils.mkdir_p(NilKill::TMP_DIR) + File.write(NilKill::TRACE_PLAN_PATH, JSON.pretty_generate(plan)) + + instrumented_root = File.join(dir, "instrumented") + instrumented = File.join(instrumented_root, Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s) + FileUtils.mkdir_p(File.dirname(instrumented)) + instrumented_source = NilKill::SourceInstrumenter.new.instrument_file(source) + File.write(instrumented, instrumented_source) + + trace_plan = JSON.parse(File.read(NilKill::TRACE_PLAN_PATH)) + # A lambda-local `return` returns from the lambda, not the method + # -- it never reaches the wrapper's catch, so the method is safely + # SOURCE-WRAPPED (deterministic inline record), NOT punted to the + # unreliable multi-process TracePoint fallback. + expect(instrumented_source).to include('record_source_method_call("Worker", "lambda_return"') + expect(trace_plan.fetch("tracepoint_methods", {})).to be_empty + + trace_tmp = File.join(dir, "trace-tmp") + FileUtils.mkdir_p(trace_tmp) + File.write(File.join(trace_tmp, "trace-plan.json"), JSON.pretty_generate(trace_plan)) + trace_dir = File.join(trace_tmp, "runtime") + FileUtils.rm_rf(trace_dir) + tracer = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => trace_tmp, + "NIL_KILL_TARGETS" => dir, + "NIL_KILL_INSTRUMENTED_ROOT" => instrumented_root, + "RUBYOPT" => "-rbundler/setup -r#{tracer}", + } + + _out, err, status = Open3.capture3(env, "ruby", instrumented, chdir: NilKill::ROOT) + + expect(status).to be_success, err + method_events = Dir.glob(File.join(trace_dir, "methods-*.jsonl")).flat_map { |path| File.readlines(path, chomp: true).map { |line| JSON.parse(line) } } + expect(method_events).to include(a_hash_including( + "class" => "Worker", + "method" => "lambda_return", + "params_by_name" => a_hash_including("value" => include("String")), + "returns" => include("String"), + "ok_calls" => 1 + )) + end + end + + it "maps instrumented line numbers back to src line numbers across a modified ivar-write line" do + Dir.mktmpdir("nil-kill-linemap", NilKill::ROOT) do |dir| + src = File.join(dir, "sample.rb") + # @x = ... is a MODIFIED (rewritten) line; a naive line-equality + # map drifts permanently after it. The method below it must still + # map instrumented lines back into its real src def-range. + File.write(src, <<~RUBY) + class Foo + def initialize + @x = compute_value + end + + def target(value) + result = value.to_s + result.upcase + end + + def compute_value + 42 + end + end + RUBY + + old = ENV["NIL_KILL_TARGETS"] + ENV["NIL_KILL_TARGETS"] = dir + begin + instrumented, map = NilKill::SourceInstrumenter.new.instrument_file_with_map(src) + ensure + ENV["NIL_KILL_TARGETS"] = old + end + + src_lines = File.read(src).lines.map(&:chomp) + def_idx = src_lines.index { |l| l.include?("def target(value)") } + target_def_src = def_idx + 1 + target_end_src = src_lines[def_idx..].index { |l| l.strip == "end" } + def_idx + 1 + + target_instr_lines = instrumented.lines.each_index.select do |i| + instrumented.lines[i].include?("result.upcase") || instrumented.lines[i].include?("def target(value)") + end.map { |i| i + 1 } + + expect(target_instr_lines).not_to be_empty + target_instr_lines.each do |il| + expect(map[il]).to be_between(target_def_src, target_end_src), + "instrumented line #{il} mapped to src #{map[il]}, outside target's src range #{target_def_src}..#{target_end_src}" + end + end + end +end diff --git a/gems/nil-kill/spec/sorbet_feedback_spec.rb b/gems/nil-kill/spec/sorbet_feedback_spec.rb new file mode 100644 index 000000000..d790fd262 --- /dev/null +++ b/gems/nil-kill/spec/sorbet_feedback_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe "nil-kill Sorbet feedback parsing" do + def infer + NilKill::Infer.allocate.tap { |instance| instance.instance_variable_set(:@store, NilKill::Store.new) } + end + + def fixture(name) + File.read(File.join(__dir__, "fixtures", "sorbet", name)) + end + + it "parses 7002 argument widening feedback at the signature location" do + feedback = infer.send(:parse_sorbet_feedback, fixture("7002.txt")) + + expect(feedback).to include(a_hash_including( + "code" => "7002", + "path" => "lib/example.rb", + "line" => 8, + "arg" => "name", + "expected" => "String", + "found" => "T.nilable(String)" + )) + end + + it "parses 7005 result widening feedback at the signature location" do + feedback = infer.send(:parse_sorbet_feedback, fixture("7005.txt")) + + expect(feedback).to include(a_hash_including( + "code" => "7005", + "path" => "lib/example.rb", + "line" => 8, + "message" => include("widening return") + )) + end + + it "parses 7034 safe-navigation feedback at the origin location" do + feedback = infer.send(:parse_sorbet_feedback, fixture("7034.txt")) + + expect(feedback).to include(a_hash_including( + "code" => "7034", + "path" => "lib/example.rb", + "line" => 18, + "site_line" => 25 + )) + end + + it "strips ANSI color while parsing nil origins" do + output = "\e[31mlib/example.rb:25: Method `name` does not exist on `NilClass` https://srb.help/7003\e[0m\n" \ + " lib/origin.rb:4:\n" + + origins = infer.send(:parse_nil_origins, output) + + expect(origins).to eq([{ "origin" => "lib/origin.rb:4", "count" => 1 }]) + end +end diff --git a/gems/nil-kill/spec/source_index_spec.rb b/gems/nil-kill/spec/source_index_spec.rb new file mode 100644 index 000000000..c82e5c1fa --- /dev/null +++ b/gems/nil-kill/spec/source_index_spec.rb @@ -0,0 +1,2251 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe NilKill::SourceIndex do + it "indexes methods, sigs, dead nil checks, structs, tuples, hashes, and normalizers" do + Dir.mktmpdir("nil-kill-source-index") do |dir| + path = File.join(dir, "sample.rb") + File.write(path, <<~RUBY) + class Example + extend T::Sig + + Node = Struct.new(:token, :items) + + sig { params(reason: String).returns(String) } + def run(reason) + tuple = [:name, 1] + shape = {name: "x", value: 1} + node = Node.new("tok", []) + t = reason.is_a?(Type) ? reason : Type.new(reason) + reason&.upcase + reason.nil? + t.to_s + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.methods).to include(a_hash_including( + "class" => "Example", + "method" => "run", + "has_sig" => true, + "non_nil_params" => include("reason") + )) + expect(idx.dead_nil_checks).to include(a_hash_including("kind" => "safe_nav", "code" => "reason&.upcase")) + expect(idx.dead_nil_checks).to include(a_hash_including("kind" => "nil_check", "code" => "reason.nil?")) + expect(idx.struct_declarations).to include(a_hash_including("class" => "Example::Node", "fields" => %w[token items])) + expect(idx.struct_field_static).to include(a_hash_including("class" => "Example::Node", "field" => "token", "type" => "String")) + expect(idx.tuple_arrays).to include(a_hash_including("types" => %w[Symbol Integer])) + expect(idx.hash_shapes).to include(a_hash_including("keys" => %w[name value])) + expect(idx.type_normalizers).to include(a_hash_including("method" => "run", "code" => include("is_a?(Type)"))) + end + end + + it "records splat / double-splat / block params as untraceable (def-side, not sig-side)" do + Dir.mktmpdir("nil-kill-untraceable") do |dir| + path = File.join(dir, "sample.rb") + File.write(path, <<~RUBY) + class Example + extend T::Sig + + sig { params(node: Integer, type_kwargs: T.untyped).returns(T.nilable(Integer)) } + def lift(node, **type_kwargs) + node + end + + sig { params(items: T.untyped, blk: T.untyped).returns(T.untyped) } + def each_item(*items, &blk) + items + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.methods).to include(a_hash_including( + "method" => "lift", + "untraceable_params" => ["type_kwargs"] + )) + expect(idx.methods).to include(a_hash_including( + "method" => "each_item", + "untraceable_params" => contain_exactly("items", "blk") + )) + end + end + + it "attributes a type-normalizer to its method even after a nested block" do + Dir.mktmpdir("nil-kill-normalizer-method") do |dir| + path = File.join(dir, "sample.rb") + File.write(path, <<~RUBY) + class Coercer + def normalize(input) + if input.respond_to?(:each) + input.each { |x| x } + end + t = input.is_a?(Type) ? input : Type.new(input) + t + end + + def from_call(node) + ti = annotate(node) + ti.is_a?(Type) ? ti : Type.new(ti) + end + + def from_ivar + @cached.is_a?(Type) ? @cached : Type.new(@cached) + end + + private_class_method def self.prefixed(arg) + t = arg + t.is_a?(Type) ? t : Type.new(t) + end + end + RUBY + + idx = described_class.new(path) + + # The normalizer sits AFTER a nested if/end; the old bare-`end` + # reset mis-attributed it to method=nil. It must bind to #normalize, + # and `input` resolves to a param origin (no in-method assignment). + expect(idx.type_normalizers).to include( + a_hash_including("class" => "Coercer", "method" => "normalize", + "code" => include("input.is_a?(Type)"), "origin_kind" => "param", "origin_name" => "input") + ) + # ti = annotate(node) -> one-hop call origin (join target = annotate's returns). + expect(idx.type_normalizers).to include( + a_hash_including("method" => "from_call", "origin_kind" => "call", "origin_name" => "annotate") + ) + # @cached.is_a?(Type) -> ivar origin. + expect(idx.type_normalizers).to include( + a_hash_including("method" => "from_ivar", "origin_kind" => "ivar", "origin_name" => "@cached") + ) + # `private_class_method def self.prefixed` -- the modifier prefix + # used to leave method blank for the whole def. + expect(idx.type_normalizers).to include( + a_hash_including("method" => "prefixed", "code" => include("t.is_a?(Type)")) + ) + expect(idx.type_normalizers.map { |n| n["method"] }).not_to include(nil) + end + end + + it "records dispatcher unions when multiple case arms share a helper" do + Dir.mktmpdir("nil-kill-dispatcher-union") do |dir| + path = File.join(dir, "visitor.rb") + File.write(path, <<~RUBY) + class Visitor + def visit(node) + case node + when AST::Name, AST::Call + visit_expr(node) + end + end + + def visit_expr(node) + node + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.dispatcher_inferences).to include(a_hash_including( + "helper" => "visit_expr", + "type" => "T.any(AST::Call, AST::Name)", + "classes" => %w[AST::Call AST::Name] + )) + end + end + + it "propagates array hash element shapes through map results and keyword constructor fields" do + described_class.reset_global_shape_indexes + + Dir.mktmpdir("nil-kill-constructor-field-shapes") do |dir| + path = File.join(dir, "constructor_field_shapes.rb") + File.write(path, <<~RUBY) + class SignatureBox + extend T::Sig + + attr_reader :params + + sig { params(params: T::Array[T::Hash[Symbol, T.untyped]]).void } + def initialize(params:) + @params = params + params.each { |param| param[:name] } + end + end + + class Consumer + extend T::Sig + + sig { params(raw: T::Array[T::Hash[Symbol, T.untyped]]).void } + def run(raw) + normalized = raw.map do |param| + {name: "arg", type: :Any} + end + signature = SignatureBox.new(params: normalized) + signature.params.each do |param| + param[:type] + end + end + end + RUBY + + idx = described_class.new(path) + lookup = idx.collection_index_lookups.find { |entry| entry["code"] == "param[:type]" && entry["line"] > 20 } + + expect(lookup).to include("origin" => a_hash_including("kind" => "local hash shape")) + expect(lookup.dig("origin", "shape", "keys").keys).to include("name", "type") + end + ensure + described_class.reset_global_shape_indexes + end + + it "attributes chained first/last array element hash reads to the element shape" do + described_class.reset_global_shape_indexes + + Dir.mktmpdir("nil-kill-chained-array-element-shapes") do |dir| + path = File.join(dir, "chained_array_element_shapes.rb") + File.write(path, <<~RUBY) + class ChainedArrayElementShapes + def run + steps = [{expr: "x", binding: nil}] + [steps.first[:expr], steps.last[:binding]] + end + end + RUBY + + idx = described_class.new(path) + first_lookup = idx.collection_index_lookups.find { |entry| entry["code"] == "steps.first[:expr]" } + last_lookup = idx.collection_index_lookups.find { |entry| entry["code"] == "steps.last[:binding]" } + + expect(first_lookup).to include("origin" => a_hash_including("kind" => "local hash shape")) + expect(first_lookup.dig("origin", "shape", "keys").keys).to include("expr", "binding") + expect(last_lookup).to include("origin" => a_hash_including("kind" => "local hash shape")) + end + ensure + described_class.reset_global_shape_indexes + end + + it "uses a unique field array element shape when the receiver type is unknown" do + described_class.reset_global_shape_indexes + + Dir.mktmpdir("nil-kill-unique-field-array-shape") do |dir| + path = File.join(dir, "unique_field_array_shape.rb") + File.write(path, <<~RUBY) + class StepBox + attr_reader :steps + + def initialize(steps:) + @steps = steps + end + end + + class UniqueFieldArrayShape + def build + StepBox.new(steps: [{expr: "x", binding: nil}]) + end + + def run(box) + out = [] + box.steps.each do |step| + out << step + end + out.each do |step| + step[:expr] + end + end + end + RUBY + + idx = described_class.new(path) + lookup = idx.collection_index_lookups.find { |entry| entry["code"] == "step[:expr]" } + + expect(lookup).to include("origin" => a_hash_including("kind" => "local hash shape")) + expect(lookup.dig("origin", "shape", "keys").keys).to include("expr", "binding") + end + ensure + described_class.reset_global_shape_indexes + end + + it "propagates block element hash shapes into forwarded method params" do + described_class.reset_global_shape_indexes + + Dir.mktmpdir("nil-kill-forwarded-block-record-param") do |dir| + path = File.join(dir, "forwarded_block_record_param.rb") + File.write(path, <<~RUBY) + class ForwardedBlockRecordParam + extend T::Sig + + sig { params(step: T::Hash[Symbol, T.untyped]).void } + def consume(step) + step[:expr] + forward(step) + end + + def forward(value) + value + end + + sig { params(stmts: T::Array[T.untyped]).void } + def run(stmts) + flat_steps = [] + stmts.each do |stmt| + flat_steps << {expr: stmt, binding: nil} + end + flat_steps.each do |step| + consume(step) + end + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["hash_record_blockers"].concat(idx.hash_record_blockers) + store.facts["param_origins"].concat(idx.param_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_cluster_actions) + + action = store.actions.find do |candidate| + Array(candidate.dig("data", "signatures")).any? { |signature| signature["method"] == "consume" } + end + expect(action.dig("data", "fields").map { |field| field["name"] }).to include("expr", "binding") + expect(action.dig("data", "signatures")).to include(a_hash_including( + "method" => "consume", + "name" => "step", + "type" => a_string_matching(/Record\z/) + )) + expect(idx.collection_index_lookups.find { |lookup| lookup["code"] == "step[:expr]" }.dig("origin", "shape", "keys").keys).to include("expr", "binding") + end + ensure + described_class.reset_global_shape_indexes + end + + it "plans constructor keyword signatures from local array element record shapes" do + described_class.reset_global_shape_indexes + + Dir.mktmpdir("nil-kill-constructor-array-record-signature") do |dir| + path = File.join(dir, "constructor_array_record_signature.rb") + File.write(path, <<~RUBY) + class Signature + extend T::Sig + + sig { params(params: T::Array[T::Hash[Symbol, T.untyped]]).void } + def initialize(params:) + @params = params + params.each { |param| param[:name] } + end + end + + class Builder + def run(raw) + normalized = raw.map do |param| + {name: "arg", type: :Any} + end + Signature.new(params: normalized) + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["param_origins"].concat(idx.param_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_cluster_actions) + + action = store.actions.find do |candidate| + Array(candidate.dig("data", "signatures")).any? { |signature| signature["method"] == "initialize" } + end + expect(action.dig("data", "signatures")).to include(a_hash_including( + "method" => "initialize", + "name" => "params", + "from" => "T::Array[T::Hash[Symbol, T.untyped]]", + "type" => a_string_matching(/\AT::Array\[.*Record\]\z/) + )) + end + ensure + described_class.reset_global_shape_indexes + end + + it "keeps hash record keys when a field value type is unknown" do + Dir.mktmpdir("nil-kill-unknown-record-field-key") do |dir| + path = File.join(dir, "unknown_record_field_key.rb") + File.write(path, <<~RUBY) + class UnknownRecordFieldKey + def consume(step) + step[:expr] + end + + def run(value) + consume({expr: value.unknown_call, binding: nil}) + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["param_origins"].concat(idx.param_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_cluster_actions) + + action = store.actions.find do |candidate| + Array(candidate.dig("data", "consumers")).any? { |consumer| consumer["code"] == "step[:expr]" } + end + expect(action.dig("data", "fields").map { |field| field["name"] }).to include("expr", "binding") + expect(action.dig("data", "blockers")).to include("field expr needs type evidence; currently unknown") + end + end + + it "classifies static return origins and records return-to-param flows" do + Dir.mktmpdir("nil-kill-return-origins") do |dir| + path = File.join(dir, "returns.rb") + File.write(path, <<~RUBY) + class ReturnOrigins + extend T::Sig + + sig { returns(String) } + def typed_name + "name" + end + + sig { returns(T.untyped) } + def strong_literal + "literal" + end + + sig { returns(T.untyped) } + def from_typed_callee + typed_name + end + + sig { returns(T.untyped) } + def blocked_nil(flag) + return nil if flag + unknown_value + end + + def sink(value) + value + end + + def caller + sink(from_typed_callee) + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |origin, h| h[origin["method"]] = origin } + + expect(origins["strong_literal"]).to include( + "confidence" => "strong", + "candidate_type" => "String" + ) + expect(origins["from_typed_callee"]).to include( + "confidence" => "strong", + "candidate_type" => "String" + ) + expect(origins["blocked_nil"]).to include( + "confidence" => "blocked", + "candidate_type" => "T.untyped" + ) + expect(origins["blocked_nil"]["sources"]).to include(a_hash_including("kind" => "nil")) + + expect(idx.param_origins).to include(a_hash_including( + "callee" => "sink", + "slot" => "0", + "origin_kind" => "typed_return", + "source_method" => "from_typed_callee" + )) + end + end + + it "infers static return origins through Prism control-flow and local expression nodes" do + Dir.mktmpdir("nil-kill-prism-return-gaps") do |dir| + path = File.join(dir, "prism_return_gaps.rb") + File.write(path, <<~RUBY) + class PrismReturnGaps + extend T::Sig + + sig { params(flag: T::Boolean).returns(T.untyped) } + def branch_string(flag) + if flag + "yes" + else + "no" + end + end + + sig { params(flag: T::Boolean).returns(T.untyped) } + def branch_nilable(flag) + if flag + "yes" + else + nil + end + end + + sig { params(flag: T::Boolean).returns(T.untyped) } + def branch_without_else(flag) + if flag + "yes" + end + end + + sig { params(flag: T::Boolean).returns(T.untyped) } + def unless_without_else(flag) + unless flag + "yes" + end + end + + sig { returns(T.untyped) } + def explicit_false + return false + end + + sig { returns(T.untyped) } + def returned_local + name = "Ada" + name + end + + sig { params(flag: T::Boolean).returns(T.untyped) } + def branch_local(flag) + if flag + name = "Ada" + else + name = "Grace" + end + name + end + + sig { params(flag: T::Boolean).returns(T.untyped) } + def conflicting_local(flag) + if flag + value = "Ada" + else + value = 1 + end + value + end + + sig { returns(T.untyped) } + def range_literal + 1..3 + end + + sig { returns(T.untyped) } + def interpolated + name = "Ada" + "hello \#{name}" + end + + sig { returns(T.untyped) } + def parenthesized + ("Ada") + end + + sig { params(name: T.nilable(String)).returns(T.untyped) } + def fallback(name) + name || "Ada" + end + + sig { params(items: T::Array[String]).returns(T.untyped) } + def loop_fallthrough(items) + while items.any? + items.pop + end + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["branch_string"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["branch_nilable"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["branch_without_else"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["unless_without_else"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["explicit_false"]).to include("confidence" => "strong", "candidate_type" => "T::Boolean") + expect(origins["returned_local"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["branch_local"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["conflicting_local"]).to include("confidence" => "blocked", "candidate_type" => "T.untyped") + expect(origins["conflicting_local"]["blockers"].join("\n")).to include("LocalVariableReadNode") + expect(origins["range_literal"]).to include("confidence" => "strong", "candidate_type" => "Range") + expect(origins["interpolated"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["parenthesized"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["fallback"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["loop_fallthrough"]).to include("confidence" => "strong", "candidate_type" => "NilClass") + end + end + + it "uses RBI return types for receiver calls when source sigs are absent" do + previous = NilKill.instance_variable_get(:@rbi_return_index) + fake_index = Object.new + def fake_index.return_type(name, _receiver_type = nil) + name == "include?" ? "T::Boolean" : nil + end + NilKill.instance_variable_set(:@rbi_return_index, fake_index) + + Dir.mktmpdir("nil-kill-rbi-return") do |dir| + path = File.join(dir, "rbi_return.rb") + File.write(path, <<~RUBY) + class RbiReturn + extend T::Sig + + sig { params(items: T::Array[String]).returns(T.untyped) } + def has_name(items) + items.include?("name") + end + end + RUBY + + idx = described_class.new(path) + origin = idx.return_origins.find { |entry| entry["method"] == "has_name" } + + expect(origin).to include( + "confidence" => "strong", + "candidate_type" => "T::Boolean" + ) + end + ensure + NilKill.instance_variable_set(:@rbi_return_index, previous) + end + + it "parses multiline RBI return signatures" do + Dir.mktmpdir("nil-kill-rbi-index") do |dir| + path = File.join(dir, "core.rbi") + File.write(path, <<~RBI) + class Array + sig { returns(::T.untyped) } + def include?; end + + sig do + params( + arg0: T.untyped, + blk: T.proc.params(arg0: T.untyped).returns(BasicObject), + ) + .returns(T::Boolean) + end + def include?(arg0); end + end + RBI + + idx = NilKill::RbiReturnIndex.new + idx.load_path(path) + + expect(idx.return_type("include?")).to eq("T::Boolean") + end + end + + it "filters out ambiguous stdlib RBI owners (Resolv, URI, Net) from bare-receiver fallback" do + Dir.mktmpdir("nil-kill-rbi-resolv") do |dir| + path = File.join(dir, "resolv_stub.rbi") + File.write(path, <<~RBI) + class Resolv::DNS::Name + sig { returns(Resolv::DNS::Name) } + def name; end + end + + class MyProject::Node + sig { returns(String) } + def label; end + end + RBI + + idx = NilKill::RbiReturnIndex.new + idx.load_path(path) + + # Bare-receiver fallback for `name` would previously pull + # `Resolv::DNS::Name` -> wrong narrowing for project fields. Now it + # filters that owner out, so the result is nil (no useful candidate). + expect(idx.return_type("name")).to be_nil + # Non-stdlib classes still resolve normally. + expect(idx.return_type("label", "MyProject::Node")).to eq("String") + end + end + + it "normalizes Sorbet RBI boolean overload return variants" do + Dir.mktmpdir("nil-kill-rbi-boolean-index") do |dir| + path = File.join(dir, "boolean_core.rbi") + File.write(path, <<~RBI) + class BasicObject + sig { returns(T::Boolean) } + def !; end + end + + class FalseClass + sig { returns(TrueClass) } + def !; end + end + + class TrueClass + sig { returns(FalseClass) } + def !; end + end + RBI + + idx = NilKill::RbiReturnIndex.new + idx.load_path(path) + + expect(idx.return_type("!")).to eq("T::Boolean") + end + end + + it "keeps generic collection shape from Sorbet RBI returns" do + Dir.mktmpdir("nil-kill-rbi-generic-collections") do |dir| + path = File.join(dir, "enumerable_core.rbi") + File.write(path, <<~RBI) + module Enumerable + sig do + type_parameters(:U) + .params(blk: T.proc.params(arg0: Elem).returns(T.type_parameter(:U))) + .returns(T::Array[T.type_parameter(:U)]) + end + sig { returns(T::Enumerator[Elem]) } + def map(&blk); end + end + RBI + + idx = NilKill::RbiReturnIndex.new + idx.load_path(path) + + expect(idx.return_type("map", "T::Array[String]")).to eq("T::Array[T.untyped]") + end + end + + it "uses receiver types to disambiguate RBI return signatures" do + previous = NilKill.instance_variable_get(:@rbi_return_index) + fake_index = Object.new + def fake_index.return_type(name, receiver_type = nil) + return "String" if name == "join" && receiver_type == "T::Array[String]" + nil + end + NilKill.instance_variable_set(:@rbi_return_index, fake_index) + + Dir.mktmpdir("nil-kill-rbi-receiver") do |dir| + path = File.join(dir, "rbi_receiver.rb") + File.write(path, <<~RUBY) + class RbiReceiver + extend T::Sig + + sig { params(items: T::Array[String]).returns(T.untyped) } + def csv(items) + items.join(",") + end + end + RUBY + + idx = described_class.new(path) + origin = idx.return_origins.find { |entry| entry["method"] == "csv" } + + expect(origin).to include( + "confidence" => "strong", + "candidate_type" => "String" + ) + end + ensure + NilKill.instance_variable_set(:@rbi_return_index, previous) + end + + it "uses Sorbet RBI return types for obvious core methods" do + Dir.mktmpdir("nil-kill-core-returns") do |dir| + path = File.join(dir, "core_returns.rb") + File.write(path, <<~RUBY) + class CoreReturns + extend T::Sig + + sig { params(items: T::Array[String], value: String).returns(T.untyped) } + def has_any(items, value) + items.any? { |item| item == value } + end + + sig { params(items: T::Array[String]).returns(T.untyped) } + def csv(items) + items.join(",") + end + + sig { params(value: T.untyped).returns(T.untyped) } + def negated(value) + !value + end + + sig { params(value: T.untyped).returns(T.untyped) } + def stringified(value) + value.to_s + end + + sig { params(value: String).returns(T.untyped) } + def len(value) + value.length + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["has_any"]).to include("confidence" => "strong", "candidate_type" => "T::Boolean") + expect(origins["csv"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["negated"]).to include("confidence" => "strong", "candidate_type" => "T::Boolean") + expect(origins["stringified"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["len"]).to include("confidence" => "strong", "candidate_type" => "Integer") + end + end + + it "propagates typed collection index lookups into return origins" do + Dir.mktmpdir("nil-kill-collection-index-return") do |dir| + path = File.join(dir, "collection_index_return.rb") + File.write(path, <<~RUBY) + class CollectionIndexReturn + extend T::Sig + + sig { params(items: T::Array[String]).returns(T.untyped) } + def first_item(items) + items[0] + end + + sig { params(items: T::Array[String]).returns(T.untyped) } + def first_two(items) + items[0..1] + end + + sig { params(map: T::Hash[Symbol, Integer]).returns(T.untyped) } + def count_for(map) + map[:count] + end + + sig { params(items: T::Array[T.untyped], map: T::Hash[Symbol, T.untyped]).returns(T.untyped) } + def weak_lookup(items, map) + [items[0], map[:count]] + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["first_item"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["first_two"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["count_for"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(Integer)") + expect(origins["weak_lookup"]).to include("confidence" => "weak", "candidate_type" => "T::Array[T.untyped]") + end + end + + it "propagates typed collection and core call results into param origins" do + Dir.mktmpdir("nil-kill-collection-param-origin") do |dir| + path = File.join(dir, "collection_param_origin.rb") + File.write(path, <<~RUBY) + class CollectionParamOrigin + extend T::Sig + + sig { params(value: T.untyped).void } + def sink(value); end + + sig { params(items: T::Array[String], map: T::Hash[Symbol, Integer], names: T::Set[String]).void } + def call(items, map, names) + sink(items[0]) + sink(map[:count]) + sink(items.length) + sink(items.include?("x")) + sink(names.include?("x")) + end + end + RUBY + + idx = described_class.new(path) + origins = idx.param_origins.select { |entry| entry["callee"] == "sink" }.each_with_object({}) do |entry, h| + h[entry["code"]] = entry + end + + expect(origins.fetch("items[0]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(String)") + expect(origins.fetch("map[:count]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(Integer)") + expect(origins.fetch("items.length")).to include("origin_kind" => "typed_return", "type" => "Integer") + expect(origins.fetch("items.include?(\"x\")")).to include("origin_kind" => "typed_return", "type" => "T::Boolean") + expect(origins.fetch("names.include?(\"x\")")).to include("origin_kind" => "typed_return", "type" => "T::Boolean") + end + end + + it "infers static return origins for Ruby iterator and collection mutation calls" do + Dir.mktmpdir("nil-kill-iterator-return-origin") do |dir| + path = File.join(dir, "iterator_return_origin.rb") + File.write(path, <<~RUBY) + class IteratorReturnOrigin + extend T::Sig + + sig { params(items: T::Array[String]).returns(T.untyped) } + def each_items(items) + items.each { |item| item.to_s } + end + + sig { params(map: T::Hash[Symbol, Integer]).returns(T.untyped) } + def each_pair_map(map) + map.each_pair { |key, value| [key, value] } + end + + sig { params(items: T::Array[Integer]).returns(T.untyped) } + def mapped(items) + items.map { |item| item.to_s } + end + + sig { params(items: T::Array[T.nilable(String)]).returns(T.untyped) } + def filtered(items) + items.filter_map { |item| item } + end + + sig { params(items: T::Array[T.nilable(String)]).returns(T.untyped) } + def compacted(items) + items.compact + end + + sig { params(items: T::Array[String]).returns(T.untyped) } + def selected(items) + items.select { |item| item.length > 1 } + end + + sig { params(items: T::Array[String]).returns(T.untyped) } + def appended(items) + items << "Ada" + end + + sig { params(items: T::Array[String], more: T::Array[String]).returns(T.untyped) } + def concatenated(items, more) + items.concat(more) + end + + sig { params(items: T.untyped).returns(T.untyped) } + def unknown_each(items) + items.each { |item| item } + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["each_items"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["each_pair_map"]).to include("confidence" => "strong", "candidate_type" => "T::Hash[Symbol, Integer]") + expect(origins["mapped"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["filtered"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["compacted"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["selected"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["appended"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["concatenated"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["unknown_each"]).to include("confidence" => "blocked", "candidate_type" => "T.untyped") + end + end + + it "infers local accumulator collection types from static mutations" do + Dir.mktmpdir("nil-kill-accumulator-return-origin") do |dir| + path = File.join(dir, "accumulator_return_origin.rb") + File.write(path, <<~RUBY) + class AccumulatorReturnOrigin + extend T::Sig + + sig { returns(T.untyped) } + def array_append + out = [] + out << "Ada" + out + end + + sig { returns(T.untyped) } + def array_push + out = [] + out.push(1) + out + end + + sig { returns(T.untyped) } + def array_concat + out = [] + out.concat(["Ada"]) + out + end + + sig { returns(T.untyped) } + def array_nilable + out = [] + out << "Ada" + out << nil + out + end + + sig { returns(T.untyped) } + def array_conflict + out = [] + out << "Ada" + out << 1 + out + end + + sig { returns(T.untyped) } + def hash_assign + out = {} + out[:name] = "Ada" + out + end + + sig { returns(T.untyped) } + def hash_non_mutating_merge + out = {} + out.merge(name: "Ada") + out + end + + sig { returns(T.untyped) } + def hash_mutating_merge + out = {} + out.merge!(name: "Ada") + out + end + + sig { returns(T.untyped) } + def set_add + out = Set.new + out.add(:name) + out + end + + sig { params(node: String, out: T::Array[T.untyped]).returns(T.untyped) } + def default_accumulator(node, out = []) + out << node + out + end + + sig { params(node: String, children: T::Array[String], out: T::Array[T.untyped]).returns(T.untyped) } + def recursive_accumulator(node, children, out = []) + out << node + children.each { |child| recursive_accumulator(child, [], out) } + out + end + + sig { returns(T.untyped) } + def poisoned_accumulator + out = [] + out << "Ada" + mutate(out) + out + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["array_append"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["array_push"]).to include("confidence" => "strong", "candidate_type" => "T::Array[Integer]") + expect(origins["array_concat"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["array_nilable"]).to include("confidence" => "strong", "candidate_type" => "T::Array[T.nilable(String)]") + expect(origins["array_conflict"]).to include("confidence" => "weak", "candidate_type" => "T::Array[T.untyped]") + expect(origins["hash_assign"]).to include("confidence" => "strong", "candidate_type" => "T::Hash[Symbol, String]") + expect(origins["hash_non_mutating_merge"]).to include("confidence" => "weak", "candidate_type" => "T::Hash[T.untyped, T.untyped]") + expect(origins["hash_mutating_merge"]).to include("confidence" => "strong", "candidate_type" => "T::Hash[Symbol, String]") + expect(origins["set_add"]).to include("confidence" => "strong", "candidate_type" => "T::Set[Symbol]") + expect(origins["default_accumulator"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["recursive_accumulator"]).to include("confidence" => "strong", "candidate_type" => "T::Array[String]") + expect(origins["poisoned_accumulator"]).to include("confidence" => "blocked", "candidate_type" => "T.untyped") + end + end + + it "propagates static hash record shapes into index lookups" do + Dir.mktmpdir("nil-kill-hash-shape-return-origin") do |dir| + path = File.join(dir, "hash_shape_return_origin.rb") + File.write(path, <<~RUBY) + class HashShapeReturnOrigin + extend T::Sig + + sig { returns(T.untyped) } + def local_hash_lookup + entry = {expr: "Ada", count: 1} + entry[:expr] + end + + sig { returns(T.untyped) } + def aliased_hash_lookup + entry = {expr: "Ada"} + alias_entry = entry + alias_entry[:expr] + end + + sig { params(flag: T::Boolean).returns(T.untyped) } + def branch_hash_lookup(flag) + if flag + entry = {expr: "Ada"} + else + entry = {expr: "Grace"} + end + entry[:expr] + end + + sig { params(flag: T::Boolean).returns(T.untyped) } + def branch_hash_conflict(flag) + if flag + entry = {expr: "Ada"} + else + entry = {expr: 1} + end + entry[:expr] + end + + sig { returns(T.untyped) } + def array_of_records + records = [{expr: "Ada"}, {expr: "Grace"}] + records.map { |entry| entry[:expr] } + end + + sig { returns(T.untyped) } + def make_entry + {expr: "Ada", token: :name} + end + + sig { params(target: T.untyped, flag: T::Boolean).returns(T.untyped) } + def assign_entry(target, flag) + return unless flag + target.entry = {expr: "Ada", token: :name} + end + + sig { returns(T.untyped) } + def forwarded_hash_lookup + entry = make_entry + entry[:token] + end + + sig { returns(T.untyped) } + def caller_before_callee + helper_defined_later({expr: "Ada"}) + end + + sig { params(entry: T.untyped).returns(T.untyped) } + def helper_defined_later(entry) + entry[:expr] + end + + sig { returns(T.untyped) } + def escaped_hash_lookup + entry = {expr: "Ada"} + mutate(entry) + entry[:expr] + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["local_hash_lookup"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["aliased_hash_lookup"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["branch_hash_lookup"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["branch_hash_conflict"]).to include("confidence" => "blocked", "candidate_type" => "T.untyped") + expect(origins["array_of_records"]).to include("confidence" => "strong", "candidate_type" => "T::Array[T.nilable(String)]") + expect(origins["make_entry"]).to include("confidence" => "weak", "candidate_type" => "T::Hash[T.untyped, T.untyped]") + expect(origins["make_entry"]["hash_shape"]).to include("keys" => include("expr" => ["String"], "token" => ["Symbol"])) + expect(origins["assign_entry"]["hash_shape"]).to include("keys" => include("expr" => ["String"], "token" => ["Symbol"])) + expect(origins["forwarded_hash_lookup"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(Symbol)") + expect(origins["caller_before_callee"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["helper_defined_later"]).to include("confidence" => "strong", "candidate_type" => "T.nilable(String)") + expect(origins["escaped_hash_lookup"]).to include("confidence" => "blocked", "candidate_type" => "T.untyped") + end + end + + it "plans review actions to promote local hash records with literal reads to structs" do + Dir.mktmpdir("nil-kill-hash-record-struct-action") do |dir| + path = File.join(dir, "hash_record_struct_action.rb") + File.write(path, <<~RUBY) + class HashRecordStructAction + def label + entry = {name: "Ada", id: 1} + "\#{entry[:name]}:\#{entry.fetch(:id)}" + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_struct_actions) + + action = store.actions.find { |candidate| candidate["kind"] == "promote_hash_record_to_struct" } + expect(action).to include("confidence" => "review", "path" => NilKill.rel(path), "line" => 3) + expect(action.dig("data", "struct_name")).to eq("EntryRecord") + expect(action.dig("data", "fields")).to contain_exactly( + { "name" => "name", "type" => "String" }, + { "name" => "id", "type" => "Integer" }, + ) + expect(action.dig("data", "read_rewrites")).to contain_exactly( + { "line" => 4, "code" => "entry[:name]", "replacement" => "entry.name" }, + { "line" => 4, "code" => "entry.fetch(:id)", "replacement" => "entry.id" }, + ) + end + end + + it "uses nested hash-record array field shapes to unblock struct fields" do + Dir.mktmpdir("nil-kill-hash-record-nested-field-action") do |dir| + path = File.join(dir, "hash_record_nested_field_action.rb") + File.write(path, <<~RUBY) + class HashRecordNestedFieldAction + def label + plan = {name: "compile", steps: [{expr: "load", id: 1}]} + plan[:steps].first[:expr] + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_struct_actions) + + action = store.actions.find { |candidate| candidate["kind"] == "promote_hash_record_to_struct" && candidate.dig("data", "struct_name") == "PlanRecord" } + expect(action).not_to be_nil + expect(action.dig("data", "blockers")).to be_empty + expect(action.dig("data", "fields")).to include( + a_hash_including("name" => "steps", "type" => "T::Array[StepsRecord]") + ) + expect(action.dig("data", "nested_structs")).to include( + a_hash_including("struct_name" => "StepsRecord", "fields" => include( + { "name" => "expr", "type" => "String" }, + { "name" => "id", "type" => "Integer" }, + )) + ) + end + end + + it "plans fetch symbol and string hash-record consumer rewrites" do + Dir.mktmpdir("nil-kill-hash-record-fetch-rewrites") do |dir| + path = File.join(dir, "hash_record_fetch_rewrites.rb") + File.write(path, <<~RUBY) + class HashRecordFetchRewrites + def label + entry = {name: "Ada", id: 1} + "\#{entry.fetch(:name)}:\#{entry.fetch("id")}" + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_struct_actions) + + action = store.actions.find { |candidate| candidate["kind"] == "promote_hash_record_to_struct" } + expect(action.dig("data", "read_rewrites")).to contain_exactly( + { "line" => 4, "code" => "entry.fetch(:name)", "replacement" => "entry.name" }, + { "line" => 4, "code" => "entry.fetch(\"id\")", "replacement" => "entry.id" }, + ) + end + end + + it "plans review actions to promote same-file returned hash records with literal reads to structs" do + Dir.mktmpdir("nil-kill-return-hash-record-struct-action") do |dir| + path = File.join(dir, "return_hash_record_struct_action.rb") + File.write(path, <<~RUBY) + class ReturnHashRecordStructAction + def build_user + {name: "Ada", id: 1} + end + + def label + user = build_user + "\#{user[:name]}:\#{user.fetch(:id)}" + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["return_origins"].concat(idx.return_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_struct_actions) + + action = store.actions.find { |candidate| candidate["kind"] == "promote_hash_record_to_struct" } + expect(action).to include("confidence" => "review", "path" => NilKill.rel(path), "line" => 3) + expect(action["message"]).to include("returned by build_user") + expect(action.dig("data", "struct_name")).to eq("UserRecord") + expect(action.dig("data", "fields")).to contain_exactly( + { "name" => "name", "type" => "String" }, + { "name" => "id", "type" => "Integer" }, + ) + expect(action.dig("data", "read_rewrites")).to contain_exactly( + { "line" => 8, "code" => "user[:name]", "replacement" => "user.name" }, + { "line" => 8, "code" => "user.fetch(:id)", "replacement" => "user.id" }, + ) + expect(action.dig("data", "producer")).to include("method" => "build_user", "line" => 2) + end + end + + it "plans signature rewrites when a record literal producer is passed directly to a callee" do + Dir.mktmpdir("nil-kill-direct-producer-param-signature") do |dir| + path = File.join(dir, "direct_producer_param_signature.rb") + File.write(path, <<~RUBY) + class DirectProducerParamSignature + extend T::Sig + + sig { params(step: T::Hash[Symbol, T.untyped]).void } + def sink(step) + step[:expr] + end + + def caller + sink({expr: "x", binding: nil}) + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["param_origins"].concat(idx.param_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_cluster_actions) + + action = store.actions.find do |candidate| + Array(candidate.dig("data", "signatures")).any? { |signature| signature["method"] == "sink" } + end + expect(action.dig("data", "consumers")).to include(a_hash_including( + "code" => "step[:expr]", + "origin" => a_hash_including("kind" => "method parameter", "shape" => a_hash_including("poisoned" => false)) + )) + expect(action.dig("data", "signatures")).to include(a_hash_including( + "kind" => "param", + "name" => "step", + "from" => "T::Hash[Symbol, T.untyped]", + "type" => "BindingRecord", + "method" => "sink" + )) + end + end + + it "preserves nilability when rewriting hash-record return signatures" do + infer = NilKill::Infer.allocate + + expect(infer.send(:hash_record_signature_target, "T::Hash[Symbol, T.untyped]", "AllocRecord")).to eq("AllocRecord") + expect(infer.send(:hash_record_signature_target, "T.nilable(T::Hash[T.untyped, T.untyped])", "AllocRecord")).to eq("T.nilable(AllocRecord)") + expect(infer.send(:hash_record_signature_target, "T::Array[T::Hash[Symbol, T.untyped]]", "AllocRecord")).to eq("T::Array[AllocRecord]") + expect(infer.send(:hash_record_signature_target, "T.nilable(T::Array[T::Hash[Symbol, T.untyped]])", "AllocRecord")).to eq("T.nilable(T::Array[AllocRecord])") + end + + it "plans local record helper param rewrites through aliases" do + Dir.mktmpdir("nil-kill-local-record-helper-param-alias") do |dir| + path = File.join(dir, "local_record_helper_param_alias.rb") + File.write(path, <<~RUBY) + class LocalRecordHelperParamAlias + extend T::Sig + + sig { params(dims: T::Hash[Symbol, T.nilable(Symbol)]).void } + def helper(dims) + dims[:sync] + end + + def run + dims = {ownership: nil, sync: nil} + other = dims + helper(other) + dims[:ownership] + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["param_origins"].concat(idx.param_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_struct_actions) + + action = store.actions.find { |candidate| candidate.dig("data", "struct_name") == "DimsRecord" } + expect(action.dig("data", "signatures")).to include(a_hash_including( + "kind" => "param", + "name" => "dims", + "from" => "T::Hash[Symbol, T.nilable(Symbol)]", + "type" => "DimsRecord", + "method" => "helper" + )) + expect(action.dig("data", "read_rewrites")).to include( + a_hash_including("code" => "dims[:sync]", "replacement" => "dims.sync"), + a_hash_including("code" => "dims[:ownership]", "replacement" => "dims.ownership") + ) + end + end + + it "blocks local helper param promotion when the helper uses dynamic keys or mutates the record" do + Dir.mktmpdir("nil-kill-local-record-helper-param-blockers") do |dir| + path = File.join(dir, "local_record_helper_param_blockers.rb") + File.write(path, <<~RUBY) + class LocalRecordHelperParamBlockers + extend T::Sig + + sig { params(dims: T::Hash[Symbol, T.nilable(Symbol)], key: Symbol).void } + def helper(dims, key) + dims[key] + dims[:sync] = nil + end + + def run(key) + dims = {ownership: nil, sync: nil} + helper(dims, key) + dims[:ownership] + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["hash_record_blockers"].concat(idx.hash_record_blockers) + store.facts["param_origins"].concat(idx.param_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_struct_actions) + + action = store.actions.find { |candidate| candidate.dig("data", "struct_name") == "DimsRecord" } + expect(action.dig("data", "blockers")).to include( + a_string_including("dynamic hash-record key prevents struct accessor rewrite"), + a_string_including("shape-changing hash-record mutation prevents broad struct rewrite") + ) + end + end + + it "uses member calls on hash-record fields as protocol evidence for field types" do + Dir.mktmpdir("nil-kill-hash-record-field-member-protocol") do |dir| + path = File.join(dir, "hash_record_field_member_protocol.rb") + File.write(path, <<~RUBY) + module AST + module Locatable + def token; end + def full_type; end + end + end + + class FieldMemberProtocol + extend T::Sig + + sig { params(step: T::Hash[Symbol, T.untyped]).void } + def sink(step) + step[:expr].full_type + step[:expr].token + end + + def caller(expr) + sink({expr: expr, binding: nil}) + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["existing_sigs"].concat(idx.methods.select { |method| method["has_sig"] }) + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["hash_record_member_calls"].concat(idx.hash_record_member_calls) + store.facts["param_origins"].concat(idx.param_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_cluster_actions) + + action = store.actions.find { |candidate| candidate.dig("data", "struct_name") == "BindingRecord" } + expect(action.dig("data", "type_name")).to eq("AST::BindingRecord") + expect(action.dig("data", "scope")).to eq(["AST"]) + expr_field = action.dig("data", "fields").find { |field| field["name"] == "expr" } + expect(expr_field).to include( + "type" => "AST::Locatable", + "required_members" => contain_exactly("full_type", "token") + ) + end + end + + it "does not plan returned hash-record promotion for dynamic keys" do + Dir.mktmpdir("nil-kill-return-hash-record-dynamic-key") do |dir| + path = File.join(dir, "return_hash_record_dynamic_key.rb") + File.write(path, <<~RUBY) + class ReturnHashRecordDynamicKey + def build_user + {name: "Ada", id: 1} + end + + def label(key) + user = build_user + user[key] + end + end + RUBY + + idx = described_class.new(path) + store = NilKill::Store.new + store.facts["hash_shapes"].concat(idx.hash_shapes) + store.facts["collection_index_lookups"].concat(idx.collection_index_lookups) + store.facts["return_origins"].concat(idx.return_origins) + infer = NilKill::Infer.allocate + infer.instance_variable_set(:@store, store) + + infer.send(:propose_hash_record_struct_actions) + + expect(store.actions).not_to include(a_hash_including("kind" => "promote_hash_record_to_struct")) + end + end + + it "records explicit hash-record blockers for dynamic keys and mutations" do + Dir.mktmpdir("nil-kill-hash-record-blockers") do |dir| + path = File.join(dir, "hash_record_blockers.rb") + File.write(path, <<~RUBY) + class HashRecordBlockers + def label(key) + entry = {name: "Ada", id: 1} + entry[key] + entry[:id] = 2 + entry.merge!(name: "Grace") + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.hash_record_blockers).to include( + a_hash_including("kind" => "dynamic_key", "code" => "entry[key]", "receiver" => "entry"), + a_hash_including("kind" => "mutation", "code" => "entry[:id] = 2", "receiver" => "entry"), + a_hash_including("kind" => "mutation", "code" => "entry.merge!(name: \"Grace\")", "receiver" => "entry") + ) + end + end + + it "propagates hash record shapes through cross-file attribute writers" do + Dir.mktmpdir("nil-kill-attribute-shape-origin") do |dir| + described_class.reset_global_shape_indexes + producer = File.join(dir, "producer.rb") + consumer = File.join(dir, "consumer.rb") + File.write(producer, <<~RUBY) + class Producer + def attach(node) + clauses = [] + clauses << {expr: "Ada", source: :pre} + node.pre_clauses = clauses + end + end + RUBY + File.write(consumer, <<~RUBY) + class Consumer + extend T::Sig + + sig { params(value: T.untyped).void } + def sink(value); end + + sig { params(fn_node: T.untyped).void } + def consume(fn_node) + fn_node.pre_clauses.each do |entry| + sink(entry[:expr]) + end + end + end + RUBY + + described_class.new(producer) + idx = described_class.new(consumer) + origin = idx.param_origins.find { |entry| entry["callee"] == "sink" && entry["code"] == "entry[:expr]" } + + expect(origin).to include("origin_kind" => "typed_return", "type" => "T.nilable(String)") + expect(idx.collection_index_lookups).to include(a_hash_including( + "code" => "entry[:expr]", + "receiver_type" => "T::Hash[T.untyped, T.untyped]", + "lookup_type" => "T.nilable(String)", + "status" => "typed lookup" + )) + ensure + described_class.reset_global_shape_indexes + end + end + + it "learns hash-record array element schemas from callsites for param and return inference" do + Dir.mktmpdir("nil-kill-hash-record-array-param-shape") do |dir| + path = File.join(dir, "hash_record_array_param_shape.rb") + File.write(path, <<~RUBY) + class HashRecordArrayParamShape + extend T::Sig + + sig { params(value: T.untyped).void } + def sink(value); end + + sig { void } + def caller + consume_caps([{capability: :VIEW, var_node: "source"}]) + cap_names([{capability: :SNAPSHOT, var_node: "cache"}]) + end + + sig { params(caps: T::Array[T.untyped]).void } + def consume_caps(caps) + caps.each do |c| + sink(c[:capability]) + sink(c[:var_node]) + end + end + + sig { params(caps: T::Array[T.untyped]).returns(T.untyped) } + def cap_names(caps) + caps.map { |cap| cap[:capability] } + end + end + RUBY + + idx = described_class.new(path) + origins = idx.param_origins.select { |entry| entry["callee"] == "sink" }.each_with_object({}) do |entry, h| + h[entry["code"]] = entry + end + returns = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins.fetch("c[:capability]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(Symbol)") + expect(origins.fetch("c[:var_node]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(String)") + expect(returns.fetch("cap_names")).to include("confidence" => "strong", "candidate_type" => "T::Array[T.nilable(Symbol)]") + end + end + + it "propagates hash-record array element schemas through forwarded method returns" do + Dir.mktmpdir("nil-kill-forwarded-hash-record-array-shape") do |dir| + path = File.join(dir, "forwarded_hash_record_array_shape.rb") + File.write(path, <<~RUBY) + class ForwardedHashRecordArrayShape + extend T::Sig + + sig { params(value: T.untyped).void } + def sink(value); end + + sig { returns(T.untyped) } + def build_caps + [{capability: :VIEW, var_node: "source"}] + end + + sig { void } + def caller + consume_caps(build_caps) + end + + sig { params(caps: T::Array[T.untyped]).void } + def consume_caps(caps) + caps.each do |c| + sink(c[:capability]) + sink(c[:var_node]) + end + end + end + RUBY + + idx = described_class.new(path) + origins = idx.param_origins.select { |entry| entry["callee"] == "sink" }.each_with_object({}) do |entry, h| + h[entry["code"]] = entry + end + returns = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(returns.fetch("build_caps")).to include("confidence" => "weak", "candidate_type" => "T::Array[T::Hash[T.untyped, T.untyped]]") + expect(origins.fetch("c[:capability]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(Symbol)") + expect(origins.fetch("c[:var_node]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(String)") + end + end + + it "flows hash-record arrays through struct constructor fields and accessors" do + Dir.mktmpdir("nil-kill-struct-field-record-flow") do |dir| + described_class.reset_global_shape_indexes + path = File.join(dir, "struct_field_record_flow.rb") + File.write(path, <<~RUBY) + module AST + WithBlock = Struct.new(:token, :capabilities, :body) + end + + class StructFieldRecordFlow + extend T::Sig + + sig { returns(AST::WithBlock) } + def build_node + caps = [{capability: :VIEW, var_node: "source"}] + AST::WithBlock.new(nil, caps, []) + end + + sig { params(value: T.untyped).void } + def sink(value); end + + sig { void } + def consume + node = build_node + node.capabilities.each do |c| + sink(c[:capability]) + sink(c[:var_node]) + end + end + end + RUBY + + idx = described_class.new(path) + origins = idx.param_origins.select { |entry| entry["callee"] == "sink" }.each_with_object({}) do |entry, h| + h[entry["code"]] = entry + end + + expect(origins.fetch("c[:capability]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(Symbol)") + expect(origins.fetch("c[:var_node]")).to include("origin_kind" => "typed_return", "type" => "T.nilable(String)") + ensure + described_class.reset_global_shape_indexes + end + end + + it "flows scalar struct constructor field evidence into accessor calls" do + Dir.mktmpdir("nil-kill-struct-field-scalar-flow") do |dir| + described_class.reset_global_shape_indexes + path = File.join(dir, "struct_field_scalar_flow.rb") + File.write(path, <<~RUBY) + module Lexer + Token = Struct.new(:type, :value) + end + + class StructFieldScalarFlow + extend T::Sig + + sig { returns(Lexer::Token) } + def token + Lexer::Token.new(:VAR_ID, "name") + end + + sig { returns(T.untyped) } + def token_value + token.value + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins.fetch("token_value")).to include("confidence" => "strong", "candidate_type" => "String") + ensure + described_class.reset_global_shape_indexes + end + end + + it "preserves record array evidence through fallback expressions" do + Dir.mktmpdir("nil-kill-record-fallback-flow") do |dir| + described_class.reset_global_shape_indexes + path = File.join(dir, "record_fallback_flow.rb") + File.write(path, <<~RUBY) + module AST + WithBlock = Struct.new(:capabilities) + end + + class RecordFallbackFlow + extend T::Sig + + sig { returns(AST::WithBlock) } + def build_node + AST::WithBlock.new([{capability: :VIEW, alias_mutable: true}]) + end + + sig { params(value: T.untyped).void } + def sink(value); end + + sig { void } + def consume + node = build_node + caps = node.capabilities || [] + caps.select { |cap| cap[:alias_mutable] }.each do |cap| + sink(cap[:capability]) + end + end + end + RUBY + + idx = described_class.new(path) + origin = idx.param_origins.find { |entry| entry["callee"] == "sink" && entry["code"] == "cap[:capability]" } + lookup = idx.collection_index_lookups.find { |entry| entry["code"] == "cap[:capability]" } + + expect(origin).to include("origin_kind" => "typed_return", "type" => "T.nilable(Symbol)") + expect(lookup).to include("lookup_type" => "T.nilable(Symbol)", "status" => "typed lookup") + ensure + described_class.reset_global_shape_indexes + end + end + + it "types assignment expressions from the assigned RHS" do + Dir.mktmpdir("nil-kill-assignment-return") do |dir| + path = File.join(dir, "assignment_return.rb") + File.write(path, <<~RUBY) + class AssignmentReturn + extend T::Sig + + sig { returns(T.untyped) } + def attr_set + self.name = "Ada" + end + + sig { params(items: T::Array[T.untyped]).returns(T.untyped) } + def index_set(items) + items[0] = 1 + end + + def name=(value) + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["attr_set"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["index_set"]).to include("confidence" => "strong", "candidate_type" => "Integer") + end + end + + it "propagates local receiver types into RBI return lookup" do + previous = NilKill.instance_variable_get(:@rbi_return_index) + fake_index = Object.new + def fake_index.return_type(name, receiver_type = nil) + return "String" if name == "join" && receiver_type == "T::Array[T.untyped]" + return "T::Array[T.untyped]" if name == "map" && receiver_type == "T::Array[T.untyped]" + nil + end + NilKill.instance_variable_set(:@rbi_return_index, fake_index) + + Dir.mktmpdir("nil-kill-local-receiver") do |dir| + path = File.join(dir, "local_receiver.rb") + File.write(path, <<~RUBY) + class LocalReceiver + extend T::Sig + + sig { returns(T.untyped) } + def joined + parts = [] + parts.join("") + end + + sig { params(args: T::Array[T.untyped]).returns(T.untyped) } + def fallback_join(args) + (args || []).map { |arg| arg }.join(", ") + end + + sig { params(items: T::Array[String]).returns(T.untyped) } + def slice_join(items) + slice = items[0..1] + slice.join + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |entry, h| h[entry["method"]] = entry } + + expect(origins["joined"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["fallback_join"]).to include("confidence" => "strong", "candidate_type" => "String") + expect(origins["slice_join"]).to include("confidence" => "strong", "candidate_type" => "String") + end + ensure + NilKill.instance_variable_set(:@rbi_return_index, previous) + end + + it "uses RBI NilClass returns for bare Kernel-style calls" do + previous = NilKill.instance_variable_get(:@rbi_return_index) + fake_index = Object.new + def fake_index.return_type(name, _receiver_type = nil) + name == "puts" ? "NilClass" : nil + end + NilKill.instance_variable_set(:@rbi_return_index, fake_index) + + Dir.mktmpdir("nil-kill-rbi-kernel") do |dir| + path = File.join(dir, "rbi_kernel.rb") + File.write(path, <<~RUBY) + class RbiKernel + extend T::Sig + + sig { returns(T.untyped) } + def log + puts("ok") + end + end + RUBY + + idx = described_class.new(path) + origin = idx.return_origins.find { |entry| entry["method"] == "log" } + + expect(origin).to include( + "confidence" => "strong", + "candidate_type" => "NilClass" + ) + end + ensure + NilKill.instance_variable_set(:@rbi_return_index, previous) + end + + it "types class constants used as argument values" do + Dir.mktmpdir("nil-kill-class-constant-arg") do |dir| + path = File.join(dir, "class_constant_arg.rb") + File.write(path, <<~RUBY) + class ClassConstantArg + extend T::Sig + + sig { params(value: T.untyped).void } + def takes(value); end + + sig { void } + def call + takes(String) + end + end + RUBY + + idx = described_class.new(path) + origin = idx.param_origins.find { |entry| entry["callee"] == "takes" && entry["code"] == "String" } + + expect(origin).to include( + "origin_kind" => "static", + "type" => "T.class_of(String)" + ) + end + end + + it "attributes collection index lookups to visible local and ivar origins" do + Dir.mktmpdir("nil-kill-index-origin") do |dir| + path = File.join(dir, "index_origin.rb") + File.write(path, <<~RUBY) + class IndexOrigin + extend T::Sig + + sig { params(items: T::Array[T.untyped]).returns(T.untyped) } + def initialize(items) + @items = items + end + + sig { params(values: T::Hash[String, T.untyped]).returns(T.untyped) } + def fetch(values) + local = { "name" => 1 } + [values["name"], local["name"], @items[0]] + end + end + RUBY + + idx = described_class.new(path) + by_receiver = idx.collection_index_lookups.each_with_object({}) { |lookup, h| h[lookup["receiver"]] = lookup } + + expect(by_receiver.fetch("values")).to include( + "status" => "weak collection receiver", + "receiver_type" => "T::Hash[String, T.untyped]" + ) + expect(by_receiver.fetch("values").fetch("origin")).to include( + "kind" => "method parameter", + "name" => "values" + ) + + expect(by_receiver.fetch("local").fetch("origin")).to include( + "kind" => "hash literal", + "hash_key_types" => ["String"], + "hash_value_types" => ["Integer"] + ) + + expect(by_receiver.fetch("@items").fetch("origin")).to include( + "kind" => "method parameter", + "name" => "@items", + "alias_of" => "items" + ) + end + end + + it "does not treat Sorbet generic type syntax as collection index lookup" do + Dir.mktmpdir("nil-kill-type-index") do |dir| + path = File.join(dir, "type_index.rb") + File.write(path, <<~RUBY) + class TypeIndex + extend T::Sig + + sig { params(items: T::Array[String]).returns(T::Hash[String, Integer]) } + def call(items) + {"first" => items[0]} + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.collection_index_lookups.map { |lookup| lookup["code"] }).to eq(["items[0]"]) + end + end + + it "does not propose NilClass T.let sites for nil ivar placeholders" do + Dir.mktmpdir("nil-kill-nil-ivar") do |dir| + path = File.join(dir, "nil_ivar.rb") + File.write(path, <<~RUBY) + class NilIvar + def initialize + @cached = nil + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.tlet_sites).not_to include(a_hash_including("name" => "@cached", "candidate_type" => "NilClass")) + end + end + + it "does not treat nil assignments as non-nil safe-navigation proof" do + Dir.mktmpdir("nil-kill-nil-local") do |dir| + path = File.join(dir, "nil_local.rb") + File.write(path, <<~RUBY) + class NilLocal + extend T::Sig + + sig { params(value: T.untyped).returns(T.untyped) } + def maybe(value) + value = nil unless value.is_a?(String) + value&.upcase + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.dead_nil_checks).not_to include(a_hash_including("kind" => "safe_nav", "code" => "value&.upcase")) + end + end + + it "does not treat branch-dependent locals as dead nil checks" do + Dir.mktmpdir("nil-kill-branch-local") do |dir| + path = File.join(dir, "branch_local.rb") + File.write(path, <<~RUBY) + class BranchLocal + extend T::Sig + + sig { params(flag: T::Boolean).returns(T::Boolean) } + def maybe(flag) + reason = nil + reason = :present if flag + reason.nil? + end + end + RUBY + + idx = described_class.new(path) + + expect(idx.dead_nil_checks).not_to include(a_hash_including("kind" => "nil_check", "code" => "reason.nil?")) + end + end + + describe "operator handlers in propagated_core_return_type" do + def expression_returns(code) + Dir.mktmpdir("nil-kill-op-handlers") do |dir| + path = File.join(dir, "sample.rb") + File.write(path, "class Sample\n def m\n #{code}\n end\nend\n") + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |o, h| h[o["method"]] = o } + origins["m"]&.fetch("candidate_type", nil) + end + end + + it "returns T::Boolean for comparison operators" do + expect(expression_returns("1 == 2")).to eq("T::Boolean") + expect(expression_returns("1 != 2")).to eq("T::Boolean") + expect(expression_returns("1 < 2")).to eq("T::Boolean") + expect(expression_returns("1 >= 2")).to eq("T::Boolean") + end + + it "returns T.nilable(Integer) for <=>" do + expect(expression_returns("1 <=> 2")).to eq("T.nilable(Integer)") + end + + it "returns Integer for hash" do + expect(expression_returns('"x".hash')).to eq("Integer") + end + + it "returns String for inspect" do + expect(expression_returns("1.inspect")).to eq("String") + end + + it "preserves String receiver type for +" do + expect(expression_returns('"a" + "b"')).to eq("String") + end + + it "preserves Integer receiver type for +" do + expect(expression_returns("1 + 2")).to eq("Integer") + end + + it "preserves receiver type through freeze and dup" do + expect(expression_returns('"x".freeze')).to eq("String") + expect(expression_returns("1.dup")).to eq("Integer") + end + end + + describe "cross-file noreturn propagation" do + it "registers methods detected as noreturn for use by callers in other files" do + described_class.reset_global_shape_indexes + Dir.mktmpdir("nil-kill-noreturn-prop") do |dir| + path = File.join(dir, "raise_helper.rb") + File.write(path, <<~RUBY) + class RaiseHelper + def bang! + raise "boom" + end + end + RUBY + described_class.new(path) + end + expect(described_class.noreturn_methods).to include("bang!") + end + + it "treats T.absurd as a noreturn call" do + described_class.reset_global_shape_indexes + Dir.mktmpdir("nil-kill-tabsurd") do |dir| + path = File.join(dir, "exhaustive.rb") + File.write(path, <<~RUBY) + class Exhaustive + def visit + T.absurd(:never) + end + end + RUBY + described_class.new(path) + end + expect(described_class.noreturn_methods).to include("visit") + end + + it "propagates noreturn through registered callee names" do + described_class.reset_global_shape_indexes + # Simulate the cross-file world: register a helper, then verify a + # caller whose only path ends in that helper is noreturn. + described_class.register_noreturn_method("bang!") + Dir.mktmpdir("nil-kill-prop-caller") do |dir| + path = File.join(dir, "caller.rb") + File.write(path, <<~RUBY) + class Caller + def doit + bang! + end + end + RUBY + idx = described_class.new(path) + rec = idx.methods.find { |m| m["method"] == "doit" } + expect(rec["noreturn_candidate"]).to be(true) + end + end + end + + describe "instance variable typing in expression_type" do + it "uses T.let-declared ivar types when reading the ivar" do + Dir.mktmpdir("nil-kill-ivar-tlet-read") do |dir| + path = File.join(dir, "ivar_tlet.rb") + File.write(path, <<~RUBY) + class IvarTlet + extend T::Sig + + def initialize(name) + @name = T.let(name, String) + end + + def get_name + @name + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |origin, h| h[origin["method"]] = origin } + + expect(origins["get_name"]).to include( + "confidence" => "strong", + "candidate_type" => "String" + ) + end + end + + it "scopes ivar T.let types per-class so unrelated classes do not bleed" do + Dir.mktmpdir("nil-kill-ivar-tlet-scope") do |dir| + path = File.join(dir, "ivar_tlet_scope.rb") + File.write(path, <<~RUBY) + class A + def initialize(n) + @x = T.let(n, String) + end + + def read_x + @x + end + end + + class B + def read_x + @x + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |origin, h| h[[origin["class"], origin["method"]]] = origin } + + expect(origins[["A", "read_x"]]).to include("candidate_type" => "String") + expect(origins[["B", "read_x"]]["candidate_type"]).to eq("T.untyped").or eq(nil) + end + end + + it "returns no useful type for unrecorded or T.untyped-typed ivars" do + Dir.mktmpdir("nil-kill-ivar-untyped") do |dir| + path = File.join(dir, "ivar_untyped.rb") + File.write(path, <<~RUBY) + class IvarUntyped + def initialize(n) + @y = T.let(n, T.untyped) + end + + def read_y + @y + end + + def read_unset + @never_set + end + end + RUBY + + idx = described_class.new(path) + origins = idx.return_origins.each_with_object({}) { |origin, h| h[origin["method"]] = origin } + + expect(origins["read_y"]["candidate_type"]).to eq("T.untyped") + expect(origins["read_unset"]["candidate_type"]).to eq("T.untyped") + end + end + end +end diff --git a/gems/nil-kill/spec/spec_helper.rb b/gems/nil-kill/spec/spec_helper.rb new file mode 100644 index 000000000..110dc7c93 --- /dev/null +++ b/gems/nil-kill/spec/spec_helper.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +if ENV["NIL_KILL_COVERAGE"] == "1" + require "simplecov" + + begin + require "simplecov-cobertura" + cobertura_available = true + rescue LoadError + cobertura_available = false + end + + SimpleCov.command_name "nil-kill" + SimpleCov.coverage_dir "coverage/nil-kill" + SimpleCov.print_error_status = false + SimpleCov.minimum_coverage 0 + SimpleCov.start do + enable_coverage :branch + track_files "gems/nil-kill/lib/**/*.rb" + add_filter "/gems/nil-kill/spec/" + add_group "nil-kill", "gems/nil-kill/lib" + + if cobertura_available + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter, + ]) + end + end +end + +require "fileutils" +require "open3" +require "tmpdir" +require "stringio" + +nil_kill_root = File.expand_path("../../..", __dir__) +ENV["NIL_KILL_TMP_DIR"] ||= File.join(nil_kill_root, "tmp", "nil-kill-spec", Process.pid.to_s) + +require_relative "../lib/nil_kill" + +module NilKillSpecHelpers + NIL_KILL_PATH_CONSTANTS = %i[ + TMP_DIR RUNTIME_DIR INSTRUMENTED_DIR EVIDENCE_PATH REPORT_PATH TRACE_PLAN_PATH SORBET_PAYLOAD_DIR + ].freeze + + def reset_nil_kill_tmp_paths!(tmp_dir) + root = NilKill::ROOT + paths = { + TMP_DIR: File.expand_path(tmp_dir, root), + RUNTIME_DIR: File.join(File.expand_path(tmp_dir, root), "runtime"), + INSTRUMENTED_DIR: File.join(File.expand_path(tmp_dir, root), "instrumented"), + EVIDENCE_PATH: File.join(File.expand_path(tmp_dir, root), "evidence.json"), + REPORT_PATH: File.join(File.expand_path(tmp_dir, root), "report.md"), + TRACE_PLAN_PATH: File.join(File.expand_path(tmp_dir, root), "trace-plan.json"), + SORBET_PAYLOAD_DIR: File.join(File.expand_path(tmp_dir, root), "sorbet-payload"), + } + old_verbose = $VERBOSE + $VERBOSE = nil + paths.each do |name, value| + NilKill.send(:remove_const, name) if NilKill.const_defined?(name, false) + NilKill.const_set(name, value) + end + ensure + $VERBOSE = old_verbose + end + + def repo_tmp_file(name, body) + dir = File.join(NilKill::ROOT, "tmp", "nil-kill-spec-#{Process.pid}-#{object_id}") + FileUtils.mkdir_p(dir) + path = File.join(dir, name) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, body) + [path, Pathname.new(path).relative_path_from(Pathname.new(NilKill::ROOT)).to_s] + end + + def isolated_env(vars) + old = vars.each_key.to_h { |key| [key, ENV[key]] } + vars.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + yield + ensure + old.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end +end + +RSpec.configure do |config| + config.include NilKillSpecHelpers + + config.around do |example| + old_tmp = ENV["NIL_KILL_TMP_DIR"] + tmp = File.join(nil_kill_root, "tmp", "nil-kill-spec", Process.pid.to_s, example.id.gsub(/[^\w.-]+/, "_")) + ENV["NIL_KILL_TMP_DIR"] = tmp + reset_nil_kill_tmp_paths!(tmp) + example.run + ensure + old_tmp.nil? ? ENV.delete("NIL_KILL_TMP_DIR") : ENV["NIL_KILL_TMP_DIR"] = old_tmp + reset_nil_kill_tmp_paths!(old_tmp || File.join(nil_kill_root, "tmp", "nil-kill-spec", Process.pid.to_s)) + end +end + +Dir[File.join(__dir__, "support", "*.rb")].sort.each { |f| require f } diff --git a/gems/nil-kill/spec/support/mini_collect.rb b/gems/nil-kill/spec/support/mini_collect.rb new file mode 100644 index 000000000..9017fb453 --- /dev/null +++ b/gems/nil-kill/spec/support/mini_collect.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true +# +# Shared, production-faithful mini-collect harness for the tracer +# matrix and the zero-gap guarantee/invariant specs. +# +# B1 reality: there is no parallel instrumented tree and no +# require-redirect any more. A collect instruments the target source +# IN PLACE (one copy, at the real path, always wrapped). The harness +# does exactly that against a throwaway tmp corpus dir, runs a driver +# under the tracer, and returns the runtime records + Coverage. A red +# case is a genuine tracer/architecture gap. + +require "tmpdir" + +module MiniCollect + TRACER = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + + def in_tmp(&blk) + Dir.mktmpdir("nk-cap", NilKill::ROOT, &blk) + end + + def lib(dir, body, name = "lib.rb") + p = File.join(dir, name) + FileUtils.mkdir_p(File.dirname(p)) + File.write(p, body) + p + end + + # Instrument `dir` IN PLACE, run `driver_src` under the tracer with + # NIL_KILL_TARGETS=dir and NO instrumented-root redirect. The child's + # NIL_KILL_TMP_DIR is the spec's per-example TMP_DIR so the linemap + # (written by run_in_place to RUNTIME_DIR) and the trace plan are the + # same files the child reads. Returns runtime records + Coverage. + # instrument: false is the NEGATIVE CONTROL -- skip in-place wrapping + # so the tracer's source-wrap recorder never fires while Ruby + # Coverage still marks bodies executed. The invariant MUST then fail + # (proving it has teeth, not vacuously green). + def mini_collect(dir, lib_rel, driver_src, extra_files: {}, instrument: true) + FileUtils.mkdir_p(NilKill::RUNTIME_DIR) + snapshot = File.join(NilKill::TMP_DIR, "src-snapshot") + isolated_env("NIL_KILL_TARGETS" => dir, "NIL_KILL_INSTRUMENTED_ROOT" => nil) do + NilKill::TracePlan.write(NilKill::TRACE_PLAN_PATH) + NilKill::SourceInstrumenter.new.run_in_place(snapshot) if instrument + end + extra_files.each do |rel, body| + p = File.join(dir, rel) + FileUtils.mkdir_p(File.dirname(p)) + File.write(p, body) + end + driver = File.join(dir, "driver.rb") + File.write(driver, driver_src) + env = { + "NIL_KILL_TRACE" => "1", + "NIL_KILL_TRACE_METHODS" => "0", + "NIL_KILL_TMP_DIR" => NilKill::TMP_DIR, + "NIL_KILL_TARGETS" => dir, + "RUBYOPT" => "-r#{TRACER}", + } + out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", driver, chdir: NilKill::ROOT) + rd = NilKill::RUNTIME_DIR + glob = lambda do |k| + Dir.glob(File.join(rd, "#{k}-*.jsonl")).flat_map do |p| + File.readlines(p, chomp: true).map { |l| JSON.parse(l) } + end + end + # In-place: the wrapped source IS the file at its real path (the + # corpus dir is throwaway tmp, so it is left wrapped -- no restore + # needed). instr_lib is that wrapped content for assertions. + instr_lib_path = File.join(dir, lib_rel.to_s) + { + status: status, out: out, err: err, dir: dir, + methods: glob.call("methods"), structs: glob.call("structs"), + ivars: glob.call("ivars"), collections: glob.call("collections"), + tlets: glob.call("tlets"), coverage: glob.call("coverage"), + instr_lib: (File.file?(instr_lib_path) ? File.read(instr_lib_path) : ""), + } + end + + # mini_collect + the real in-process Infer/Report tail (no Sorbet: + # fast + offline). Returns the collect result plus :evidence (parsed + # evidence.json), :report (a NilKill::Report) and :report_md. + def full_collect(dir, driver_src, extra_files: {}, instrument: true) + r = mini_collect(dir, "lib.rb", driver_src, extra_files: extra_files, instrument: instrument) + isolated_env("NIL_KILL_TARGETS" => dir, "NIL_KILL_INSTRUMENTED_ROOT" => nil) do + capture_stdout { NilKill::Infer.new(["--no-sorbet"]).run } + end + evidence = JSON.parse(File.read(NilKill::EVIDENCE_PATH)) + r.merge( + evidence: evidence, + report: NilKill::Report.new, + report_md: (File.read(NilKill::REPORT_PATH) if File.file?(NilKill::REPORT_PATH)), + ) + end + + def capture_stdout + old = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = old + end + + # The exact predicate report.rb uses for collect_ran_untraced, run + # over the collect's OWN evidence: rel-path -> Set(covered src lines). + def collect_coverage_index(evidence) + cc = evidence.dig("facts", "collect_coverage") + return {} unless cc.is_a?(Hash) + cc.each_with_object({}) { |(p, lines), h| h[p.to_s] = Array(lines).map(&:to_i).to_set } + end +end + +RSpec.configure { |c| c.include MiniCollect } diff --git a/gems/nil-kill/spec/trace_plan_spec.rb b/gems/nil-kill/spec/trace_plan_spec.rb new file mode 100644 index 000000000..7991a5332 --- /dev/null +++ b/gems/nil-kill/spec/trace_plan_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe NilKill::TracePlan do + def plan_entry(method) + tp = described_class.allocate + tp.instance_variable_set(:@methods, {}) + tp.send(:add_method, method) + tp.instance_variable_get(:@methods).values.first + end + + # The dangerous regression this whole investigation chased: if + # TracePlan ever prunes a method that HAS a typeable (untyped + # positional) slot, that method silently gets no runtime record and + # inflates NoEvidence with a method that actually ran. Guard it. + it "samples a method whose positional param is T.untyped (must not be pruned)" do + e = plan_entry( + "class" => "C", "method" => "run", "kind" => "instance", "line" => 1, + "path" => "src/c.rb", + "sig" => "sig { params(x: T.untyped).returns(String) }", + "params" => [{ "name" => "x", "type" => "T.untyped" }] + ) + expect(e["sample"]).to be(true) + end + + it "samples a method whose return is T.untyped even if params are typed" do + e = plan_entry( + "class" => "C", "method" => "calc", "kind" => "instance", "line" => 1, + "path" => "src/c.rb", + "sig" => "sig { params(x: Integer).returns(T.untyped) }", + "params" => [{ "name" => "x", "type" => "Integer" }] + ) + expect(e["sample"]).to be(true) + end + + it "prunes a method that is fully typed (nothing to learn) -- expected, not a bug" do + e = plan_entry( + "class" => "C", "method" => "typed", "kind" => "instance", "line" => 1, + "path" => "src/c.rb", + "sig" => "sig { params(x: Integer).returns(String) }", + "params" => [{ "name" => "x", "type" => "Integer" }] + ) + expect(e["sample"]).to be(false) + end + + it "prunes a method whose ONLY untyped slot is a block param (block is ~always Proc; acceptable). The report must label such a slot arg_untraced, not never_run." do + e = plan_entry( + "class" => "C", "method" => "suffix", "kind" => "instance", "line" => 1, + "path" => "src/c.rb", + # SourceIndex omits the block param from `params`, so only the + # typed positionals are seen -> sample=false (pruned, no record). + "sig" => "sig { params(type: Symbol, value: String, block: T.untyped).returns(Prism::Token) }", + "params" => [{ "name" => "type", "type" => "Symbol" }, { "name" => "value", "type" => "String" }] + ) + expect(e["sample"]).to be(false) + end +end diff --git a/gems/nil-kill/spec/tracer_capability_spec.rb b/gems/nil-kill/spec/tracer_capability_spec.rb new file mode 100644 index 000000000..817983bd0 --- /dev/null +++ b/gems/nil-kill/spec/tracer_capability_spec.rb @@ -0,0 +1,438 @@ +# frozen_string_literal: true +# +# Tracer-capability matrix -- a self-contained MINI-COLLECT per case, +# faithful to production B1: TracePlan.write -> instrument every target +# file IN PLACE (one wrapped copy at the real path, .nk-linemap.json) +# -> a driver that loads the real src -> run under the tracer. There +# is no parallel tree and no require-redirect: every load mechanism +# loads the wrapped code. A red case is a genuine tracer gap. The +# shared harness lives in spec/support/mini_collect.rb. + +require_relative "spec_helper" + +RSpec.describe "nil-kill tracer capability matrix" do + # mini_collect / in_tmp / lib come from MiniCollect (spec/support). + + # ---- METHOD SHAPES --------------------------------------------------- + + it "plain + endless def" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def calc(v) = v.to_s + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.calc(7)\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "calc", + "params_by_name" => a_hash_including("v" => include("Integer")), + "returns" => include("String") + )) + end + end + + it "one-line classic def" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def calc(v); v.to_s; end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.calc(9)\n)) + expect(r[:methods]).to include(a_hash_including("class" => "W", "method" => "calc", "returns" => include("String"))) + end + end + + it "method with ensure (punted -> TracePoint fallback)" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def calc(v) + v.to_s + ensure + nil + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.calc(11)\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "calc", + "params_by_name" => a_hash_including("v" => include("Integer")), + "returns" => include("String") + )) + end + end + + it "return inside an iterator block: SOURCE-WRAPPED (not punted), records the returned value" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(xs: T.untyped).returns(T.untyped) } + def first(xs) + xs.each { |x| return x.to_s if x } + "" + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.first([41])\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:instr_lib]).to include('record_source_method_call("W", "first"') # NOT punted + expect(r[:instr_lib]).to include("throw __nil_kill_return_tag, NilKillRuntimeTrace.record_source_method_return") # block return rewritten + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "first", "returns" => include("String") + )) + end + end + + it "nested-block return (each{ map{ return } }): records the returned value" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(xs: T.untyped).returns(T.untyped) } + def deep(xs) + xs.each { |row| row.map { |c| return c.to_s if c } } + :none + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.deep([[7]])\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:methods]).to include(a_hash_including("class" => "W", "method" => "deep", "returns" => include("String"))) + end + end + + it "proc{ return } (non-local): records the returned value" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def go(v) + p = proc { return v.to_s } + p.call + "unreached" + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.go(9)\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:methods]).to include(a_hash_including("class" => "W", "method" => "go", "returns" => include("String"))) + end + end + + it "lambda{ return } (LOCAL): wrapped (not punted), records call+params+method return" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def run(v) + f = lambda { return v } + f.call.to_s + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.run(5)\n)) + expect(r[:status]).to be_success, r[:err] + # lambda-local return -> still SOURCE-WRAPPED (it does not escape + # the wrapper) and NOT rewritten to a throw. + expect(r[:instr_lib]).to include('record_source_method_call("W", "run"') + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "run", + "params_by_name" => a_hash_including("v" => include("Integer")), + "returns" => include("String") + )) + end + end + + it "loader gap: a file reached via plain `require` ($LOAD_PATH) is still wrapped" do + in_tmp do |dir| + libd = File.join(dir, "libd") + FileUtils.mkdir_p(libd) + File.write(File.join(libd, "widget.rb"), <<~RUBY) + require "sorbet-runtime" + class Widget + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def calc(v) = v.to_s + end + RUBY + # Driver uses a PLAIN require (bare name via $LOAD_PATH), the gap. + r = mini_collect(dir, File.join("libd", "widget.rb"), + %($LOAD_PATH.unshift #{libd.inspect}\nrequire "widget"\nWidget.new.calc(7)\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:methods]).to include(a_hash_including( + "class" => "Widget", "method" => "calc", + "params_by_name" => a_hash_including("v" => include("Integer")), + "returns" => include("String") + )) + end + end + + it "class method def self.f" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def self.calc(v) = v.to_s + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.calc(3)\n)) + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "calc", "kind" => "class", + "params_by_name" => a_hash_including("v" => include("Integer")) + )) + end + end + + it "private method" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + def go(v) = helper(v) + private + sig { params(v: T.untyped).returns(T.untyped) } + def helper(v) = v.to_s + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.go(8)\n)) + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "helper", "returns" => include("String") + )) + end + end + + it "method that raises: records call params" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def boom(v) + raise ArgumentError, "x" if v + v + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nbegin; W.new.boom(1); rescue ArgumentError; end\n)) + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "boom", + "params_by_name" => a_hash_including("v" => include("Integer")) + )) + end + end + + it "**kwargs/*splat/&block params: method still records (params arg_untraced by design)" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(a: T.untyped, rest: T.untyped, kw: T.untyped, blk: T.untyped).returns(T.untyped) } + def mix(a, *rest, **kw, &blk) + a.to_s + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.mix(1, 2, x: 3) { }\n)) + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "mix", + "params_by_name" => a_hash_including("a" => include("Integer")), + "returns" => include("String") + )) + end + end + + it "multi-line signature def" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(a: T.untyped, b: T.untyped).returns(T.untyped) } + def add( + a, + b + ) + (a + b).to_s + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.add(2, 3)\n)) + expect(r[:methods]).to include(a_hash_including("class" => "W", "method" => "add", "returns" => include("String"))) + end + end + + it "recursive method" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(n: T.untyped).returns(T.untyped) } + def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2) + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.fib(6)\n)) + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "fib", "params_by_name" => a_hash_including("n" => include("Integer")) + )) + end + end + + it "recursive method invoked from inside a host .each do..end block (collect_bg_blocks shape)" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(node: T.untyped, acc: T.untyped).returns(T.untyped) } + def walk(node, acc) + case node + when Array then node.each { |n| walk(n, acc) } + when Hash then node.each_pair { |_, v| walk(v, acc) } + else acc << node + end + end + sig { params(tree: T.untyped).returns(T.untyped) } + def run(tree) + out = [] + [tree].each { |t| walk(t, out) } # caller invokes walk from inside .each + out + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.run([{a: [1]}, 2])\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "walk", + "params_by_name" => a_hash_including("node" => include("Array")) + )) + end + end + + it "method called ONLY from a forked child (multi-process)" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def calc(v) = v.to_s + end + RUBY + r = mini_collect(dir, "lib.rb", <<~RUBY) + require_relative "lib" + pid = fork { W.new.calc(123) } + Process.wait(pid) + RUBY + expect(r[:status]).to be_success, r[:err] + expect(r[:methods]).to include(a_hash_including( + "class" => "W", "method" => "calc", "params_by_name" => a_hash_including("v" => include("Integer")) + )) + end + end + + # ---- __FILE__-RELATIVE RESOURCE READ (the transpiler ENOENT class) -- + + it "instrumented file's __FILE__/__dir__-relative resource read still works" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class Reader + extend T::Sig + ASSET = File.join(File.dirname(__FILE__), "asset.txt") + sig { returns(T.untyped) } + def load = File.read(ASSET) + end + RUBY + File.write(File.join(dir, "asset.txt"), "PAYLOAD-OK") + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nReader.new.load\n)) + expect(r[:status]).to be_success, "ENOENT from instrumented __FILE__: #{r[:err]}" + expect(r[:methods]).to include(a_hash_including( + "class" => "Reader", "method" => "load", "returns" => include("String") + )) + end + end + + # ---- STRUCT / IVAR / COLLECTION / T.let ---------------------------- + + it "Struct field with NO strong static type: runtime-records field classes" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + Pair = Struct.new(:a, :b) + class W + extend T::Sig + sig { params(x: T.untyped, y: T.untyped).returns(T.untyped) } + def make(x, y) = Pair.new(x, y) + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.make("abc", 7)\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:structs]).to include(a_hash_including("class" => "Pair", "field" => "a", "classes" => include("String"))) + expect(r[:structs]).to include(a_hash_including("class" => "Pair", "field" => "b", "classes" => include("Integer"))) + end + end + + it "ivar collection element: records" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(xs: T.untyped).returns(T.untyped) } + def go(xs) + @bag = [] + @bag << :sym + xs << "str" + @bag + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.go([])\n)) + expect(r[:collections]).to include(a_hash_including("owner_kind" => "ivar", "name" => "@bag", "elem_classes" => include("Symbol"))) + end + end + + it "T.let with untyped type: records the runtime class (line-shift safe)" do + in_tmp do |dir| + lib(dir, <<~RUBY) + require "sorbet-runtime" + class W + extend T::Sig + sig { params(v: T.untyped).returns(T.untyped) } + def go(v) + x = T.let(v, T.untyped) + x + end + end + RUBY + r = mini_collect(dir, "lib.rb", %(require_relative "lib"\nW.new.go("hi")\n)) + expect(r[:status]).to be_success, r[:err] + expect(r[:tlets]).to include(a_hash_including("classes" => include("String"))) + end + end +end diff --git a/gems/nil-kill/spec/z3_solver_spec.rb b/gems/nil-kill/spec/z3_solver_spec.rb new file mode 100644 index 000000000..37742ce87 --- /dev/null +++ b/gems/nil-kill/spec/z3_solver_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require_relative "../lib/nil_kill/z3_solver" + +RSpec.describe NilKill::Z3Solver do + def solver_for(source) + dir = Dir.mktmpdir("nil-kill-z3", File.join(NilKill::ROOT, "tmp")) + path = File.join(dir, "sample.rb") + File.write(path, source) + rel = Pathname.new(path).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + evidence = { "facts" => { "existing_sigs" => [] }, "methods" => [] } + [described_class.new(evidence, [path]), rel] + end + + it "rejects candidates with bare generic collection constants" do + solver, rel = solver_for(<<~RUBY) + class Example + sig { returns(T::Hash[T.untyped, T.untyped]) } + def table + {} + end + end + RUBY + + action = { + "kind" => "narrow_generic_return", + "path" => rel, + "line" => 3, + "data" => { "type" => "T::Hash[String, Array]" }, + } + + expect(solver.preflight_rejection(action)).to eq("candidate uses bare generic collection type") + end + + it "rejects candidates with broad unions before verification" do + solver, rel = solver_for(<<~RUBY) + class Example + sig { returns(T.untyped) } + def value + something + end + end + RUBY + + action = { + "kind" => "fix_sig_return", + "path" => rel, + "line" => 3, + "data" => { "type" => "T.any(Float, Hash, Integer, String)" }, + } + + expect(solver.preflight_rejection(action)).to eq("candidate union exceeds cutoff") + end + + it "rejects candidates with broad nested unions before verification" do + solver, rel = solver_for(<<~RUBY) + class Example + sig { returns(T::Hash[T.untyped, T.untyped]) } + def value + {} + end + end + RUBY + + action = { + "kind" => "narrow_generic_return", + "path" => rel, + "line" => 3, + "data" => { "type" => "T::Hash[Symbol, T.any(Float, Hash, Integer, String)]" }, + } + + expect(solver.preflight_rejection(action)).to eq("candidate union exceeds cutoff") + end + + it "rejects array returns inferred from tuple-like array literals" do + solver, rel = solver_for(<<~RUBY) + class Example + sig { returns(T.untyped) } + def tuple + return [base, ownership, sync] + end + end + RUBY + + action = { + "kind" => "fix_sig_return", + "path" => rel, + "line" => 3, + "data" => { "type" => "T::Array[T.nilable(String)]" }, + } + + expect(solver.preflight_rejection(action)).to eq("array candidate conflicts with tuple-like return shape") + end + + it "rejects symbol-key hash candidates when the method reads distinct fixed keys" do + solver, rel = solver_for(<<~RUBY) + class Example + sig { params(snapshot: T::Hash[Symbol, T.untyped]).void } + def restore(snapshot) + snapshot[:node_states].each {} + target_count = snapshot[:edge_count] + end + end + RUBY + + action = { + "kind" => "narrow_generic_param", + "path" => rel, + "line" => 3, + "data" => { + "name" => "snapshot", + "type" => "T::Hash[Symbol, T.any(Integer, T::Hash[String, String])]", + }, + } + + expect(solver.preflight_rejection(action)).to eq("hash candidate collapses per-key symbol shape") + end + + it "rejects container candidates that conflict with protocol calls on the receiver" do + solver, rel = solver_for(<<~RUBY) + class Example + sig { params(node: T.untyped).void } + def walk(node) + node.class.members.each {} + end + end + RUBY + + action = { + "kind" => "fix_sig_param", + "path" => rel, + "line" => 3, + "data" => { + "name" => "node", + "type" => "T::Hash[Symbol, String]", + }, + } + + expect(solver.preflight_rejection(action)).to eq("container candidate conflicts with receiver protocol use") + end +end diff --git a/gems/nil-kill/spec/zero_evidence_gap_guarantee_spec.rb b/gems/nil-kill/spec/zero_evidence_gap_guarantee_spec.rb new file mode 100644 index 000000000..18872f023 --- /dev/null +++ b/gems/nil-kill/spec/zero_evidence_gap_guarantee_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true +# +# THE GATE. A hermetic end-to-end collect over a fixture corpus that +# exercises every load path (plain require, require_relative, +# Kernel#load, autoload, absolute require, a spawned-ruby subprocess, +# the recursive-from-.each collect_bg_blocks shape, an ensure-punt, an +# endless def, splat/kwsplat/block slots, a Struct field, a T.let, a +# collection). It asserts the report's "Untyped Evidence Gaps" table +# has EXACTLY ZERO `collect_ran_untraced` and `untraced_covered`, and +# that every traceable sampled method produced a runtime record. A red +# here means the architecture regressed. + +require_relative "spec_helper" + +RSpec.describe "zero-gap end-to-end guarantee", :zero_gap_guarantee do + corpus = File.join(__dir__, "fixtures", "zero_gap_corpus") + + # Class#method for every sampled (T.untyped-slot) method the workload + # calls. arg-only-untraceable slots (handle's *rest/**kw/&blk) still + # produce a `handle` record via its real `opts` slot. + EXPECTED = [ + %w[PlainReq transform], %w[RelReq calc], %w[KernelLoad handle], + %w[AutoLib one_line], %w[AbsReq walk], %w[AbsReq run], + %w[SubProc in_child], %w[EnsurePunt guarded], %w[StructColl build], + ].freeze + + around do |example| + Dir.mktmpdir("nk-zero-gap", NilKill::ROOT) do |dir| + Dir.glob(File.join(corpus, "*_lib.rb")).each { |f| FileUtils.cp(f, dir) } + @r = full_collect(dir, File.read(File.join(corpus, "workload.rb"))) + example.run + end + end + + # Lazily classify: untyped_evidence_gaps RAISES if a collect_ran_untraced + # or never_run ever appears (they are hard failures, not columns), so + # calling it here is itself the zero-tracer-bug / real-collect assertion. + def gaps + @gaps ||= @r[:report].send(:untyped_evidence_gaps, @r[:evidence]) + end + + it "the collect child completes under the tracer for every load path" do + expect(@r[:status]).to be_success, @r[:err] + end + + it "produces collect_coverage facts (Coverage + linemap wired through)" do + cc = @r[:evidence].dig("facts", "collect_coverage") + expect(cc).to be_a(Hash) + expect(cc).not_to be_empty + end + + it "records EVERY traceable method, whatever load path reached it" do + by = @r[:methods].group_by { |m| [m["class"], m["method"]] } + missing = EXPECTED.reject do |cls, meth| + recs = by[[cls, meth]] + recs && recs.sum { |m| m["calls"].to_i }.positive? + end + expect(missing).to be_empty, + "no runtime record for: #{missing.map { |c, m| "#{c}##{m}" }.join(", ")} " \ + "(a load path bypassed in-place recording). methods seen: " \ + "#{by.keys.map { |c, m| "#{c}##{m}" }.sort.join(", ")}" + end + + it "GUARANTEE: no collect_ran_untraced / never_run -- classification does not raise on the real corpus" do + # The in-place tracer recorded everything that ran and the collect + # produced Coverage; if either were false, untyped_evidence_gaps + # would RAISE here (hard failure, not a silently-zero column). + expect { gaps }.not_to raise_error + expect(gaps.keys & %w[collect_ran_untraced never_run untraced_covered]).to eq([]) + end + + it "GUARANTEE: the forbidden states are NOT columns -- they are hard failures" do + expect(NilKill::Report::EVIDENCE_GAP_REASONS.keys) + .not_to include("collect_ran_untraced", "never_run", "untraced_covered") + expect(NilKill::Report::EVIDENCE_GAP_HARD.keys) + .to contain_exactly("collect_ran_untraced", "never_run") + lines = [] + @r[:report].send(:append_untyped_evidence_gaps, lines, @r[:evidence]) + header = lines.find { |l| l.start_with?("| |") } + expect(header).not_to include("collect ran untraced") if header + expect(header).not_to include("untraced covered") if header + expect(header).not_to include("never run") if header + expect(gaps.keys - NilKill::Report::EVIDENCE_GAP_REASONS.keys).to eq([]) + end + + it "block/splat/kwsplat slots are arg_untraced, never a forbidden state" do + expect(gaps["arg_untraced"].map { |g| g["text"] }).to include(a_string_matching(/`(rest|kw|blk)`/)) + expect(gaps.fetch("collect_ran_untraced", [])).to eq([]) + expect(gaps.fetch("never_run", [])).to eq([]) + end +end diff --git a/sorbet/config b/sorbet/config index 764f46564..14c784d5b 100644 --- a/sorbet/config +++ b/sorbet/config @@ -13,6 +13,7 @@ --ignore=tools/ --ignore=gems/decomplex/ --ignore=gems/fix-cache/ +--ignore=gems/nil-kill/ # Suppress dead-code/unreachable warnings on defensive guards. # The autogen tracer captures observation-tight types, so methods' diff --git a/sorbet/rbi/ast-struct-fields.rbi b/sorbet/rbi/ast-struct-fields.rbi index 2d99c2702..e6c915c56 100644 --- a/sorbet/rbi/ast-struct-fields.rbi +++ b/sorbet/rbi/ast-struct-fields.rbi @@ -1,18 +1,8 @@ # typed: true # frozen_string_literal: true -# -# AUTO-GENERATED. Do not edit by hand. Regenerate with: -# bundle exec ruby tools/gen_struct_fields_rbi.rb > sorbet/rbi/ast-struct-fields.rbi -# -# Sorbet auto-types `Struct.new(:foo, :bar)` accessors as T.untyped, -# which masks nil-safety errors. This shim declares typed sigs for -# each Struct field so dead `&.` and `.nil?` checks become 7034 -# signals. -# -# Type policy is encoded in tools/gen_struct_fields_rbi.rb's -# TYPE_POLICY table. Initial pass tightens only :token (the most -# common attr). Other fields default to T.untyped and can be -# ratcheted up by extending the policy. + +# AUTO-GENERATED by bundle exec tools/nil-kill struct-rbi --complete. +# Re-run nil-kill infer/collect before regenerating. class AST::AllOp sig { returns(T.nilable(Token)) } @@ -58,7 +48,7 @@ class AST::Assignment end class AST::AverageOp - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def expression; end @@ -78,7 +68,7 @@ class AST::BenchmarkStmt def token; end sig { returns(T.untyped) } def expression; end - sig { returns(T.untyped) } + sig { returns(Integer) } def iterations; end end @@ -102,7 +92,7 @@ class AST::BgBlock end class AST::BgStreamBlock - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T::Array[T.untyped]) } def body; end @@ -151,9 +141,9 @@ end class AST::CallSiteOverride sig { returns(Token) } def token; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def kind; end - sig { returns(T.untyped) } + sig { returns(Integer) } def n; end sig { returns(T.untyped) } def inner; end @@ -212,7 +202,7 @@ class AST::ConcurrentOp end class AST::ContinueNode - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end end @@ -231,7 +221,7 @@ class AST::CopyNode end class AST::CountOp - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def expression; end @@ -277,12 +267,12 @@ class AST::EnumDef def name; end sig { returns(T.untyped) } def variants; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def visibility; end end class AST::ExternFnDecl - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def name; end @@ -292,12 +282,12 @@ class AST::ExternFnDecl def return_type; end sig { returns(T.untyped) } def from_module; end - sig { returns(T.untyped) } + sig { returns(T::Hash[Symbol, T.untyped]) } def effects; end end class AST::ExternStructDecl - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def name; end @@ -325,7 +315,7 @@ class AST::ForEach def body; end sig { returns(T.untyped) } def deferred_drops; end - sig { returns(T.untyped) } + sig { returns(T::Boolean) } def is_mutable; end end @@ -338,7 +328,7 @@ class AST::ForRange def start_expr; end sig { returns(T.untyped) } def end_expr; end - sig { returns(T.untyped) } + sig { returns(T::Boolean) } def inclusive; end sig { returns(T::Array[T.untyped]) } def body; end @@ -383,7 +373,7 @@ class AST::FunctionDef def catch_clauses; end sig { returns(T.untyped) } def default_catch; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def visibility; end sig { returns(T.untyped) } def deferred_drops; end @@ -398,10 +388,6 @@ class AST::GetField def target; end sig { returns(T.untyped) } def field; end - sig { returns(T.untyped) } - def is_assignment_lhs; end - sig { params(value: T.untyped).returns(T.untyped) } - def is_assignment_lhs=(value); end end class AST::GetIndex @@ -418,7 +404,7 @@ class AST::HashLit def token; end sig { returns(T::Hash[T.untyped, T.untyped]) } def pairs; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def storage; end end @@ -430,7 +416,7 @@ class AST::Identifier end class AST::IfBind - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def bindings; end @@ -480,7 +466,7 @@ class AST::LambdaLit def captures; end sig { returns(T.untyped) } def body; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def storage; end sig { returns(T.untyped) } def deferred_drops; end @@ -514,18 +500,18 @@ class AST::ListLit def token; end sig { returns(T::Array[T.untyped]) } def items; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def storage; end end class AST::Literal sig { returns(Token) } def token; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def type; end sig { returns(T.untyped) } def value; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def storage; end end @@ -549,7 +535,7 @@ class AST::MatchStatement end class AST::MaxOp - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def expression; end @@ -567,7 +553,7 @@ class AST::MethodCall end class AST::MinOp - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def expression; end @@ -595,14 +581,14 @@ class AST::OptionalUnwrap end class AST::OrBreak - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end end class AST::OrExit - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def kind; end sig { returns(T.untyped) } def error_name; end @@ -611,17 +597,17 @@ class AST::OrExit end class AST::OrPass - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end end class AST::OrPrune - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end end class AST::OrRaise - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end end @@ -650,7 +636,7 @@ class AST::ProfileStmt end class AST::Program - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T::Array[T.untyped]) } def statements; end @@ -668,13 +654,13 @@ class AST::Raise end class AST::RangeLit - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def start; end sig { returns(T.untyped) } def finish; end - sig { returns(T.untyped) } + sig { returns(T::Boolean) } def inclusive; end end @@ -708,7 +694,7 @@ class AST::RequireNode def path; end sig { returns(T.untyped) } def namespace; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def kind; end end @@ -727,7 +713,7 @@ class AST::ReturnNode end class AST::SelectOp - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def expression; end @@ -799,7 +785,7 @@ class AST::StructDef def name; end sig { returns(T.untyped) } def fields; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def visibility; end sig { returns(T::Array[T.untyped]) } def type_params; end @@ -823,7 +809,7 @@ class AST::StructPattern def token; end sig { returns(T.untyped) } def fields; end - sig { returns(T.untyped) } + sig { returns(T::Boolean) } def partial; end end @@ -832,14 +818,14 @@ class AST::StubDecl def token; end sig { returns(T.untyped) } def function_name; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def kind; end sig { returns(T.untyped) } def value; end end class AST::SumOp - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def expression; end @@ -873,7 +859,7 @@ class AST::TestBlock def name; end sig { returns(T.untyped) } def setup; end - sig { returns(T.untyped) } + sig { returns(T::Array[AST::WhenBlock]) } def whens; end end @@ -916,20 +902,20 @@ class AST::UnionDef def name; end sig { returns(T.untyped) } def variants; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def visibility; end end class AST::UnionVariantLit - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def union_name; end - sig { returns(T.untyped) } + sig { returns(String) } def variant_name; end sig { returns(T.untyped) } def fields; end - sig { returns(T.untyped) } + sig { returns(Symbol) } def storage; end end @@ -943,13 +929,13 @@ end class AST::VarDecl sig { returns(Token) } def token; end - sig { returns(T.untyped) } + sig { returns(String) } def name; end sig { returns(T.untyped) } def type; end sig { returns(T.untyped) } def value; end - sig { returns(T.untyped) } + sig { returns(T::Boolean) } def mutable; end end @@ -960,14 +946,14 @@ class AST::WhenBlock def description; end sig { returns(T.untyped) } def setup; end - sig { returns(T.untyped) } + sig { returns(T::Array[AST::TestThat]) } def tests; end sig { returns(T.untyped) } def benchmarks; end end class AST::WhereOp - sig { returns(Token) } + sig { returns(Lexer::Token) } def token; end sig { returns(T.untyped) } def expression; end @@ -1026,82 +1012,2029 @@ class AST::YieldExpr def expr; end end -class MIR::Alloc - sig { returns(Token) } - def token; end - sig { returns(T.untyped) } - def name; end +class AutoConstraintCollector::Slot sig { returns(T.untyped) } def kind; end sig { returns(T.untyped) } - def alloc; end + def fn_name; end + sig { returns(T.untyped) } + def index; end + sig { returns(T.untyped) } + def decl_node; end + sig { returns(T.untyped) } + def sources; end + sig { returns(T.untyped) } + def shape; end + sig { returns(T.untyped) } + def auto_token; end end -class MIR::Drop - sig { returns(Token) } - def token; end +class AutoUnifier::Ambiguity sig { returns(T.untyped) } - def name; end + def slot; end sig { returns(T.untyped) } - def kind; end + def observed_types; end sig { returns(T.untyped) } - def alloc; end + def sources; end +end + +class AutoUnifier::Resolution sig { returns(T.untyped) } - def has_moved_guard; end + def slot; end sig { returns(T.untyped) } - def type_info; end + def type; end sig { returns(T.untyped) } - def resource_close_zig; end + def sources; end +end + +class AutoUnifier::Result sig { returns(T.untyped) } - def source_node; end + def resolved; end + sig { returns(T.untyped) } + def ambiguous; end + sig { returns(T.untyped) } + def unresolved; end end -class MIR::FieldCleanup - sig { returns(Token) } - def token; end +class BinaryOpResult sig { returns(T.untyped) } - def target_name; end + def type; end sig { returns(T.untyped) } - def field; end + def left_coercion; end sig { returns(T.untyped) } - def alloc; end + def right_coercion; end + sig { returns(T.untyped) } + def storage; end + sig { returns(T.untyped) } + def error; end end -class MIR::Promote - sig { returns(Token) } - def token; end +class Capabilities::Conflict + sig { returns(T::Array[T.untyped]) } + def set_a; end + sig { returns(T::Array[T.untyped]) } + def set_b; end sig { returns(T.untyped) } - def name; end + def message; end +end + +class CapabilityHelper::CaptureAnalysis + sig { returns(T.untyped) } + def has_local; end + sig { returns(T.untyped) } + def has_rc; end + sig { returns(T.untyped) } + def has_shared; end + sig { returns(T.untyped) } + def has_sharded; end + sig { returns(T.untyped) } + def has_affine_locked; end + sig { returns(T.untyped) } + def has_outer_ref; end + sig { returns(T.untyped) } + def has_non_escaping_capture; end + sig { returns(T.untyped) } + def captures; end + sig { returns(T.untyped) } + def capture_symbols; end + sig { returns(T.untyped) } + def close_patterns; end + sig { returns(T.untyped) } + def pointer_captures; end + sig { returns(T.untyped) } + def string_captures; end + sig { returns(T.untyped) } + def resource_captures; end sig { returns(T.untyped) } + def site_moved; end + sig { returns(T.untyped) } + def site_copied; end + sig { returns(T.untyped) } + def strategies; end + sig { returns(T.untyped) } + def heap_promote_names; end + sig { returns(T.untyped) } + def move_mark_names; end + sig { returns(T.untyped) } + def alloc_mark_entries; end +end + +class CaptureStrategy::ByValue + sig { returns(String) } def zig_type; end + sig { returns(String) } + def ctx_init_name; end +end + +class CaptureStrategy::CaptureSiteInfo sig { returns(T.untyped) } - def strategy; end + def copied_names; end sig { returns(T.untyped) } - def fields; end + def moved_names; end +end + +class CaptureStrategy::FreshHeapCopy + sig { returns(String) } + def zig_type; end + sig { returns(String) } + def ctx_init_name; end + sig { returns(Symbol) } + def alloc_sym; end +end + +class CaptureStrategy::MoveInto + sig { returns(String) } + def zig_type; end + sig { returns(String) } + def ctx_init_name; end + sig { returns(String) } + def source_name; end +end + +class CaptureStrategy::RcClone + sig { returns(String) } + def zig_type; end + sig { returns(String) } + def ctx_init_name; end +end + +class CaptureStrategy::Refuse + sig { returns(Symbol) } + def reason; end + sig { returns(String) } + def owner_name; end +end + +class CompilerFrontend::Result sig { returns(T.untyped) } - def elem_type; end + def ast; end + sig { returns(SemanticAnnotator) } + def annotator; end + sig { returns(T.untyped) } + def fn_nodes; end + sig { returns(T.untyped) } + def fn_sigs; end + sig { returns(T.untyped) } + def struct_schemas; end + sig { returns(T.untyped) } + def enum_schemas; end + sig { returns(T.untyped) } + def union_schemas; end + sig { returns(T.untyped) } + def moved_guard_info; end end -class MIR::ReassignCleanup - sig { returns(Token) } - def token; end +class FiberCtxBuilder::CaptureSpec sig { returns(T.untyped) } def name; end + sig { returns(String) } + def field_type_zig; end sig { returns(T.untyped) } - def alloc; end + def init_value_zig; end + sig { returns(T.any(MIR::AddressOf, MIR::Ident)) } + def init_value_mir; end + sig { returns(T.untyped) } + def dupe_decl_zig; end + sig { returns(T.untyped) } + def body_cleanup_zig; end end -class MIR::Return - sig { returns(Token) } - def token; end +class FiberCtxBuilder::Result sig { returns(T.untyped) } - def escaped_vars; end + def specs; end + sig { returns(T.untyped) } + def capture_map; end + sig { returns(T.untyped) } + def capture_symbols; end end -class MIR::SuppressCleanup - sig { returns(Token) } - def token; end +class FixableHelper::AnchorToken + sig { returns(Integer) } + def line; end + sig { returns(Integer) } + def column; end +end + +class FsmOps::AddrOf + sig { returns(T.untyped) } + def expr; end +end + +class FsmOps::AllocExpr + sig { returns(String) } + def elem_type; end + sig { returns(FsmOps::LocalRef) } + def count; end +end + +class FsmOps::ArgRef + sig { returns(Integer) } + def idx; end +end + +class FsmOps::AssignField + sig { returns(String) } + def field; end + sig { returns(T.untyped) } + def value; end +end + +class FsmOps::BinOp + sig { returns(String) } + def op; end + sig { returns(MIR::FieldGet) } + def left; end + sig { returns(MIR::Lit) } + def right; end +end + +class FsmOps::CallExpr + sig { returns(String) } + def fn; end + sig { returns(T.untyped) } + def args; end + sig { returns(T::Boolean) } + def is_try; end +end + +class FsmOps::DeferFreeField + sig { returns(String) } + def field; end +end + +class FsmOps::ErrDeferCall + sig { returns(String) } + def fn; end + sig { returns(T::Array[FsmOps::StateField]) } + def args; end +end + +class FsmOps::ErrDeferFreeField + sig { returns(String) } + def field; end +end + +class FsmOps::IfFieldSubLtZeroReturnCall + sig { returns(String) } + def field; end + sig { returns(String) } + def sub; end + sig { returns(String) } + def return_fn; end + sig { returns(T::Array[FsmOps::SubField]) } + def return_args; end +end + +class FsmOps::IntCast + sig { returns(String) } + def zig_type; end + sig { returns(T.untyped) } + def expr; end +end + +class FsmOps::IoSubmit + sig { returns(Symbol) } + def verb; end + sig { returns(T.untyped) } + def waiter; end sig { returns(T.untyped) } + def extra_args; end +end + +class FsmOps::LetConst + sig { returns(String) } + def name; end + sig { returns(String) } + def zig_type; end + sig { returns(FsmOps::IntCast) } + def value; end +end + +class FsmOps::LocalRef + sig { returns(String) } + def name; end +end + +class FsmOps::SliceUntilIntCast + sig { returns(FsmOps::StateField) } + def base; end + sig { returns(FsmOps::SubField) } + def end_expr; end +end + +class FsmOps::StateField + sig { returns(String) } + def name; end +end + +class FsmOps::StateFieldDecl + sig { returns(String) } def name; end + sig { returns(String) } + def zig_type; end + sig { returns(String) } + def init_zig; end end +class FsmOps::StmtCall + sig { returns(String) } + def fn; end + sig { returns(T.untyped) } + def args; end + sig { returns(T::Boolean) } + def is_try; end +end + +class FsmOps::SubField + sig { returns(FsmOps::StateField) } + def base; end + sig { returns(String) } + def name; end +end + +class FsmOps::ZigLit + sig { returns(String) } + def zig; end +end + +class FsmTransform::Liveness::Result + sig { returns(T.untyped) } + def cross_segment_vars; end +end + +class FsmTransform::Segments::CondBranch + sig { returns(T.untyped) } + def cond_ast; end + sig { returns(Integer) } + def then_index; end + sig { returns(Integer) } + def else_index; end +end + +class FsmTransform::Segments::Done + sig { returns(T.untyped) } + def _; end +end + +class FsmTransform::Segments::Goto + sig { returns(Integer) } + def target_index; end +end + +class FsmTransform::Segments::IoSuspend + sig { returns(T.untyped) } + def call_node; end + sig { returns(T.untyped) } + def stdlib_def; end + sig { returns(T.untyped) } + def result_var; end + sig { returns(T.untyped) } + def next_index; end +end + +class FsmTransform::Segments::LockSuspend + sig { returns(T.untyped) } + def with_node; end + sig { returns(T.untyped) } + def cap; end + sig { returns(T.untyped) } + def prior_caps; end + sig { returns(T.untyped) } + def post_acquire_idx; end + sig { returns(T.untyped) } + def next_index; end +end + +class FsmTransform::Segments::LoopBack + sig { returns(Integer) } + def target_index; end +end + +class FsmTransform::Segments::NextSuspend + sig { returns(T.untyped) } + def promise_ast; end + sig { returns(T.untyped) } + def result_var; end + sig { returns(T.untyped) } + def next_index; end +end + +class FsmTransform::Segments::Segment + sig { returns(Integer) } + def index; end + sig { returns(T::Array[T.untyped]) } + def stmts; end + sig { returns(T.untyped) } + def tail; end +end + +class LSP::Analyzer::Result + sig { returns(T.untyped) } + def findings; end + sig { returns(T.untyped) } + def fatal_error; end +end + +class LSP::Analyzer::SyntheticFinding + sig { returns(T.untyped) } + def level; end + sig { returns(T.untyped) } + def message; end + sig { returns(T.untyped) } + def token; end + sig { returns(T.untyped) } + def category; end + sig { returns(T.untyped) } + def fixes; end +end + +class LSP::Analyzer::SyntheticToken + sig { returns(T.untyped) } + def line; end + sig { returns(T.untyped) } + def column; end + sig { returns(T.untyped) } + def value; end +end + +class LSP::DocumentStore::Document + sig { returns(T.untyped) } + def uri; end + sig { returns(T.untyped) } + def text; end + sig { returns(T.untyped) } + def version; end +end + +class Lexer::Token + sig { returns(Symbol) } + def type; end + sig { returns(T.untyped) } + def value; end + sig { returns(Integer) } + def line; end + sig { returns(Integer) } + def column; end +end + +class LockHelper::LockEdge + sig { returns(T.untyped) } + def held; end + sig { returns(T.untyped) } + def acquired; end + sig { returns(T.untyped) } + def site_token; end + sig { returns(T.untyped) } + def fn_name; end + sig { returns(T.untyped) } + def opted_out; end +end + +class MIR::AddressOf + sig { returns(T.untyped) } + def expr; end +end + +class MIR::Alloc + sig { returns(Token) } + def token; end + sig { returns(String) } + def name; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::AllocMark + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def alloc; end + sig { returns(T.untyped) } + def type_info; end +end + +class MIR::AllocSlice + sig { returns(T.untyped) } + def elem_type; end + sig { returns(T.untyped) } + def len; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::AllocatorRef + sig { returns(Symbol) } + def kind; end +end + +class MIR::ArrayInit + sig { returns(String) } + def elem_type; end + sig { returns(String) } + def count; end + sig { returns(T.untyped) } + def items; end +end + +class MIR::BatchWindowFlush + sig { returns(String) } + def window; end + sig { returns(String) } + def batch_var; end + sig { returns(String) } + def elem_zig; end + sig { returns(String) } + def result_var; end + sig { returns(T.untyped) } + def value_expr; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::BatchWindowPush + sig { returns(String) } + def window; end + sig { returns(MIR::Ident) } + def item_expr; end + sig { returns(String) } + def batch_var; end + sig { returns(String) } + def elem_zig; end + sig { returns(String) } + def result_var; end + sig { returns(T.untyped) } + def value_expr; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::BgBlock + sig { returns(String) } + def code; end + sig { returns(T.untyped) } + def captures; end + sig { returns(T.untyped) } + def run_body; end + sig { returns(T.untyped) } + def fsm_structure; end +end + +class MIR::BinOp + sig { returns(T.untyped) } + def op; end + sig { returns(T.untyped) } + def left; end + sig { returns(T.untyped) } + def right; end +end + +class MIR::BlockExpr + sig { returns(T.untyped) } + def label; end + sig { returns(T.untyped) } + def body; end +end + +class MIR::BreakStmt + sig { returns(T.untyped) } + def label; end + sig { returns(T.untyped) } + def value; end +end + +class MIR::Call + sig { returns(T.untyped) } + def callee; end + sig { returns(T.untyped) } + def args; end + sig { returns(T.any(T::Boolean, T::Hash[T.untyped, T.untyped])) } + def try_wrap; end + sig { returns(T.untyped) } + def heap_provenance; end +end + +class MIR::CapWrap + sig { returns(T.untyped) } + def inner; end + sig { returns(T.untyped) } + def zig_base; end + sig { returns(Symbol) } + def strategy; end + sig { returns(T.untyped) } + def sync_fn; end + sig { returns(T.untyped) } + def sync_type; end + sig { returns(T.untyped) } + def own_fn; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::Cast + sig { returns(T.untyped) } + def expr; end + sig { returns(T.untyped) } + def target_type; end + sig { returns(Symbol) } + def method; end +end + +class MIR::CatchWrapper + sig { returns(String) } + def code; end + sig { returns(T.untyped) } + def error_reassigns; end + sig { returns(T.untyped) } + def clause_bodies; end + sig { returns(T.untyped) } + def clause_meta; end + sig { returns(T.untyped) } + def has_default; end +end + +class MIR::Cleanup + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def cleanup_entry; end +end + +class MIR::Comment + sig { returns(String) } + def text; end +end + +class MIR::Comptime + sig { returns(T.any(MIR::InlineBc, MIR::InlineZig)) } + def expr; end +end + +class MIR::ConcatStr + sig { returns(T.untyped) } + def parts; end + sig { returns(Symbol) } + def alloc; end + sig { returns(T.untyped) } + def rt_expr; end +end + +class MIR::Conditional + sig { returns(T.any(MIR::BinOp, MIR::Ident)) } + def cond; end + sig { returns(T.any(MIR::BinOp, MIR::Cast, MIR::Ident)) } + def then_val; end + sig { returns(T.any(MIR::BinOp, MIR::Ident, MIR::Lit)) } + def else_val; end +end + +class MIR::ContainerInit + sig { returns(T.untyped) } + def zig_type; end + sig { returns(Symbol) } + def strategy; end + sig { returns(T.untyped) } + def alloc; end + sig { returns(T.untyped) } + def capacity; end +end + +class MIR::ContinueStmt + sig { returns(T.untyped) } + def unused; end +end + +class MIR::DeepCopy + sig { returns(T.untyped) } + def source; end + sig { returns(T.untyped) } + def zig_type; end + sig { returns(T.untyped) } + def elem_type; end + sig { returns(Symbol) } + def strategy; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::DeferStmt + sig { returns(T.untyped) } + def body; end +end + +class MIR::Deref + sig { returns(T.any(MIR::Deref, MIR::FieldGet, MIR::Ident)) } + def expr; end +end + +class MIR::DestroyPtr + sig { returns(T.any(MIR::FieldGet, MIR::Ident)) } + def ptr; end + sig { returns(T.any(MIR::Ident, Symbol)) } + def alloc; end +end + +class MIR::DoBlock + sig { returns(String) } + def code; end + sig { returns(T.untyped) } + def branch_bodies; end +end + +class MIR::Drop + sig { returns(Token) } + def token; end + sig { returns(String) } + def name; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def alloc; end + sig { returns(T::Boolean) } + def has_moved_guard; end + sig { returns(T.untyped) } + def type_info; end + sig { returns(T.untyped) } + def resource_close_zig; end + sig { returns(T.untyped) } + def source_node; end +end + +class MIR::DupeSlice + sig { returns(T.untyped) } + def source; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::EnumDef + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def variants; end + sig { returns(T.untyped) } + def visibility; end +end + +class MIR::ErrCleanup + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def cleanup_entry; end +end + +class MIR::ErrDeferStmt + sig { returns(T.untyped) } + def body; end +end + +class MIR::EscapePromote + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def zig_type; end + sig { returns(Symbol) } + def strategy; end + sig { returns(T.untyped) } + def data; end + sig { returns(String) } + def rt_expr; end + sig { returns(T.untyped) } + def elem_type; end +end + +class MIR::ExprStmt + sig { returns(T.untyped) } + def expr; end + sig { returns(T.untyped) } + def discard; end +end + +class MIR::FieldCleanup + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def target_name; end + sig { returns(String) } + def field; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::FieldCleanupMark + sig { returns(T.untyped) } + def target_name; end + sig { returns(String) } + def field; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::FieldDef + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def zig_type; end + sig { returns(T.untyped) } + def default; end +end + +class MIR::FieldGet + sig { returns(T.untyped) } + def object; end + sig { returns(T.untyped) } + def field; end +end + +class MIR::FnDef + sig { returns(T.untyped) } + def name; end + sig { returns(T.any(T::Array[MIR::Param], T::Array[T.untyped])) } + def params; end + sig { returns(T.untyped) } + def ret_type; end + sig { returns(T.untyped) } + def body; end + sig { returns(T.untyped) } + def visibility; end + sig { returns(T::Boolean) } + def can_fail; end + sig { returns(T.untyped) } + def comptime_params; end +end + +class MIR::FnRef + sig { returns(T.untyped) } + def name; end +end + +class MIR::ForStmt + sig { returns(T.untyped) } + def iter; end + sig { returns(T.untyped) } + def capture; end + sig { returns(T.untyped) } + def body; end + sig { returns(T.untyped) } + def index_capture; end + sig { returns(T.untyped) } + def mark_per_iter; end + sig { returns(T.untyped) } + def tight; end +end + +class MIR::FrameRestore + sig { returns(String) } + def rt_expr; end +end + +class MIR::FrameSave + sig { returns(String) } + def rt_expr; end +end + +class MIR::FreeSlice + sig { returns(T.any(MIR::FieldGet, MIR::Ident)) } + def slice; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::FreezeExpr + sig { returns(MIR::FieldGet) } + def inner; end + sig { returns(T.untyped) } + def zig_base; end +end + +class MIR::FsmB1Body + sig { returns(T.untyped) } + def blk_label; end + sig { returns(T.untyped) } + def ctx_struct; end + sig { returns(T.untyped) } + def spawn_setup; end +end + +class MIR::FsmB1CtxStruct + sig { returns(T.untyped) } + def type_name; end + sig { returns(T.untyped) } + def promise_zig; end + sig { returns(T.untyped) } + def captures_decl_zig; end + sig { returns(T.untyped) } + def run_body; end +end + +class MIR::FsmCtxStruct + sig { returns(T.untyped) } + def type_name; end + sig { returns(T.untyped) } + def promise_zig; end + sig { returns(T.untyped) } + def captures_decl_zig; end + sig { returns(T.untyped) } + def state_decls; end + sig { returns(T.untyped) } + def promoted_field_decls; end + sig { returns(T.untyped) } + def step0; end + sig { returns(T.untyped) } + def step1; end + sig { returns(T.untyped) } + def resume_fn; end +end + +class MIR::FsmDispatch + sig { returns(T.untyped) } + def ctx_id; end + sig { returns(T::Enumerator[[T.untyped, Integer]]) } + def arms; end + sig { returns(T::Boolean) } + def uses_loop_label; end +end + +class MIR::FsmGenericBody + sig { returns(T.untyped) } + def blk_label; end + sig { returns(MIR::FsmGenericCtxStruct) } + def ctx_struct; end + sig { returns(MIR::FsmSpawnSetup) } + def spawn_setup; end +end + +class MIR::FsmGenericCtxStruct + sig { returns(T.untyped) } + def type_name; end + sig { returns(T.untyped) } + def promise_zig; end + sig { returns(T.untyped) } + def captures_decl_zig; end + sig { returns(T.untyped) } + def extra_field_decls; end + sig { returns(T.untyped) } + def promoted_field_decls; end + sig { returns(T::Array[MIR::FsmMemberFn]) } + def member_fns; end + sig { returns(MIR::FsmDispatch) } + def resume_fn_zig; end + sig { returns(T.untyped) } + def destroy_extra_zig; end +end + +class MIR::FsmIoBody + sig { returns(T.untyped) } + def blk_label; end + sig { returns(T.untyped) } + def ctx_struct; end + sig { returns(T.untyped) } + def spawn_setup; end +end + +class MIR::FsmMemberFn + sig { returns(T.untyped) } + def fn_name; end + sig { returns(T.untyped) } + def ctx_id; end + sig { returns(T.untyped) } + def bg_rt; end + sig { returns(String) } + def rt_suppress_zig; end + sig { returns(T.untyped) } + def body_stmts; end + sig { returns(T.untyped) } + def extra_prologue_zig; end +end + +class MIR::FsmSpawnSetup + sig { returns(T.untyped) } + def alloc_var; end + sig { returns(String) } + def alloc_expr_zig; end + sig { returns(T.untyped) } + def promise_var; end + sig { returns(T.untyped) } + def promise_zig; end + sig { returns(T.untyped) } + def promoted_decls_zig; end + sig { returns(T.untyped) } + def ctx_var; end + sig { returns(T.untyped) } + def ctx_type; end + sig { returns(String) } + def ctx_init_zig; end + sig { returns(String) } + def spawn_call_zig; end + sig { returns(T.untyped) } + def rt_name; end + sig { returns(T.untyped) } + def profile_site_id; end + sig { returns(Integer) } + def profile_dispatch_id; end + sig { returns(String) } + def profile_site_comment; end +end + +class MIR::FsmStateArm + sig { returns(T.untyped) } + def index; end + sig { returns(T.untyped) } + def pre_body_skip; end + sig { returns(T.untyped) } + def pre_body_zig; end + sig { returns(T.untyped) } + def body_fn_name; end + sig { returns(T.untyped) } + def err_cleanups; end + sig { returns(T.untyped) } + def tail; end +end + +class MIR::FsmStep + sig { returns(T.untyped) } + def index; end + sig { returns(T.untyped) } + def ctx_id; end + sig { returns(T.untyped) } + def bg_rt; end + sig { returns(T.untyped) } + def rt_suppress_zig; end + sig { returns(T.untyped) } + def body_stmts; end +end + +class MIR::FsmStructure + sig { returns(T.untyped) } + def captures; end + sig { returns(T.untyped) } + def state_fields; end + sig { returns(T.untyped) } + def steps; end + sig { returns(T.untyped) } + def finalize_cleanups; end + sig { returns(T.untyped) } + def ctx_id; end + sig { returns(T.untyped) } + def result_aliases_finalized; end +end + +class MIR::FsmTailCondJump + sig { returns(T.noreturn) } + def cond_zig; end + sig { returns(Integer) } + def then_step; end + sig { returns(Integer) } + def else_step; end +end + +class MIR::FsmTailCondSkip + sig { returns(T.untyped) } + def cond_zig; end + sig { returns(T.untyped) } + def skip_step; end +end + +class MIR::FsmTailDone + sig { returns(T.untyped) } + def _; end +end + +class MIR::FsmTailJump + sig { returns(Integer) } + def next_step; end +end + +class MIR::FsmTailLockTry + sig { returns(T.untyped) } + def try_method; end + sig { returns(T.untyped) } + def lock_field_ref; end + sig { returns(T.untyped) } + def ok_step; end + sig { returns(T.untyped) } + def wait_step; end + sig { returns(T.untyped) } + def error_step; end +end + +class MIR::FsmTailRegisterYield + sig { returns(T.untyped) } + def next_step; end + sig { returns(T.untyped) } + def register_zig; end + sig { returns(T.untyped) } + def yield_reason; end +end + +class MIR::FsmTailRetryOrError + sig { returns(T.untyped) } + def retries; end + sig { returns(T.untyped) } + def retry_step; end + sig { returns(T.untyped) } + def fail_step; end +end + +class MIR::FsmTailWokenCheck + sig { returns(T.untyped) } + def ok_step; end + sig { returns(T.untyped) } + def error_step; end +end + +class MIR::FsmTailYield + sig { returns(T.untyped) } + def next_step; end + sig { returns(T.untyped) } + def yield_reason; end +end + +class MIR::HasField + sig { returns(T.untyped) } + def expr; end + sig { returns(T.untyped) } + def field; end +end + +class MIR::HeapCreate + sig { returns(T.untyped) } + def zig_type; end + sig { returns(T.any(MIR::DeepCopy, MIR::ItemsAccess, MIR::StructInit)) } + def init; end + sig { returns(Symbol) } + def alloc; end + sig { returns(String) } + def label; end +end + +class MIR::Ident + sig { returns(T.untyped) } + def name; end +end + +class MIR::IfBindStmt + sig { returns(T.untyped) } + def bindings; end + sig { returns(T::Array[T.any(T.untyped, T.untyped)]) } + def then_body; end + sig { returns(T.untyped) } + def else_body; end +end + +class MIR::IfChain + sig { returns(T::Enumerator[T.untyped]) } + def branches; end + sig { returns(T.untyped) } + def default_body; end +end + +class MIR::IfOptional + sig { returns(T.untyped) } + def optional; end + sig { returns(String) } + def capture; end + sig { returns(T.untyped) } + def then_expr; end + sig { returns(MIR::Lit) } + def else_expr; end +end + +class MIR::IfStmt + sig { returns(T.untyped) } + def cond; end + sig { returns(T.untyped) } + def then_body; end + sig { returns(T.untyped) } + def else_body; end +end + +class MIR::Import + sig { returns(T.untyped) } + def alias_name; end + sig { returns(String) } + def module_path; end + sig { returns(T.untyped) } + def member; end +end + +class MIR::IndexGet + sig { returns(T.untyped) } + def object; end + sig { returns(T.untyped) } + def index; end +end + +class MIR::IndexInsert + sig { returns(MIR::Ident) } + def map; end + sig { returns(MIR::Ident) } + def key_expr; end + sig { returns(MIR::Ident) } + def value_expr; end + sig { returns(String) } + def key_zig_type; end + sig { returns(String) } + def elem_zig_type; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::InlineBc + sig { returns(T.untyped) } + def op; end + sig { returns(T.untyped) } + def args; end + sig { returns(T.untyped) } + def stdlib_def; end +end + +class MIR::InlineZig + sig { returns(T.untyped) } + def code; end + sig { returns(String) } + def reason; end + sig { returns(T.untyped) } + def ownership_contract; end + sig { returns(T.untyped) } + def stdlib_def; end + sig { returns(T.untyped) } + def allocs; end + sig { returns(T.untyped) } + def target_var; end +end + +class MIR::ItemsAccess + sig { returns(T.untyped) } + def expr; end + sig { returns(T::Boolean) } + def safe; end +end + +class MIR::IterRange + sig { returns(T.untyped) } + def start; end + sig { returns(T.untyped) } + def end_val; end +end + +class MIR::LambdaExpr + sig { returns(MIR::FnDef) } + def fn_def; end + sig { returns(T.untyped) } + def captures; end +end + +class MIR::Let + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def init; end + sig { returns(T.untyped) } + def mutable; end + sig { returns(T.untyped) } + def annotation; end + sig { returns(T.untyped) } + def suppression; end +end + +class MIR::ListItems + sig { returns(T.untyped) } + def list; end +end + +class MIR::ListLength + sig { returns(T.untyped) } + def expr; end +end + +class MIR::Lit + sig { returns(T.untyped) } + def value; end +end + +class MIR::MakeList + sig { returns(T.untyped) } + def elem_type; end + sig { returns(T.untyped) } + def items; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::MethodCall + sig { returns(T.untyped) } + def receiver; end + sig { returns(T.untyped) } + def method; end + sig { returns(T.untyped) } + def args; end + sig { returns(T::Boolean) } + def try_wrap; end +end + +class MIR::MoveMark + sig { returns(T.untyped) } + def name; end +end + +class MIR::MutualThunkTrampoline + sig { returns(T.untyped) } + def fn_name; end + sig { returns(String) } + def ret_zig; end + sig { returns(T.untyped) } + def variants; end + sig { returns(T.untyped) } + def initial_variant; end + sig { returns(T::Array[String]) } + def initial_fields; end + sig { returns(T.untyped) } + def arms; end + sig { returns(String) } + def yield_line; end +end + +class MIR::Noop + sig { returns(String) } + def reason; end +end + +class MIR::OptionalUnwrap + sig { returns(T.untyped) } + def expr; end +end + +class MIR::Orelse + sig { returns(T.untyped) } + def expr; end + sig { returns(T.untyped) } + def fallback; end +end + +class MIR::Panic + sig { returns(String) } + def message; end +end + +class MIR::Param + sig { returns(T.untyped) } + def name; end + sig { returns(String) } + def zig_type; end + sig { returns(T.any(T::Boolean, T::Hash[T.untyped, T.untyped])) } + def pointer_passed; end +end + +class MIR::Pipeline + sig { returns(AST::BinaryOp) } + def ast_node; end + sig { returns(T.untyped) } + def inner; end + sig { returns(T.untyped) } + def source_type; end + sig { returns(T.untyped) } + def stages; end + sig { returns(T.untyped) } + def sink; end + sig { returns(T.untyped) } + def sink_alloc; end +end + +class MIR::PolymorphicMutate + sig { returns(String) } + def cell_zig; end + sig { returns(String) } + def rt; end + sig { returns(T.untyped) } + def alias_zig; end + sig { returns(String) } + def bare_t_zig; end + sig { returns(T.untyped) } + def body; end +end + +class MIR::PolymorphicMutateFlow + sig { returns(String) } + def cell_zig; end + sig { returns(String) } + def rt; end + sig { returns(T.untyped) } + def alias_zig; end + sig { returns(String) } + def bare_t_zig; end + sig { returns(String) } + def ret_zig; end + sig { returns(T.untyped) } + def body; end + sig { returns(T.untyped) } + def guard_cond; end + sig { returns(T.untyped) } + def guard_fail_body; end +end + +class MIR::Program + sig { returns(T.untyped) } + def items; end +end + +class MIR::Promote + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def zig_type; end + sig { returns(Symbol) } + def strategy; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def elem_type; end +end + +class MIR::PubConst + sig { returns(String) } + def name; end + sig { returns(String) } + def value; end +end + +class MIR::RangeLit + sig { returns(T.untyped) } + def start; end + sig { returns(T.untyped) } + def end_val; end + sig { returns(T.untyped) } + def elem_type; end +end + +class MIR::RawBc + sig { returns(T.untyped) } + def template; end + sig { returns(T.untyped) } + def args; end + sig { returns(T.untyped) } + def stdlib_def; end +end + +class MIR::RawZig + sig { returns(T.untyped) } + def code; end + sig { returns(String) } + def reason; end + sig { returns(T.untyped) } + def ownership_contract; end + sig { returns(T.untyped) } + def stdlib_def; end +end + +class MIR::RcDowngrade + sig { returns(T.untyped) } + def source; end + sig { returns(T.untyped) } + def zig_base; end + sig { returns(String) } + def func; end +end + +class MIR::RcRetain + sig { returns(T.untyped) } + def source; end + sig { returns(String) } + def zig_base; end + sig { returns(String) } + def func; end +end + +class MIR::ReassignCleanup + sig { returns(Token) } + def token; end + sig { returns(String) } + def name; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::ReassignMark + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::ReassignWithCleanup + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def value; end + sig { returns(String) } + def zig_type; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::Return + sig { returns(Token) } + def token; end + sig { returns(T::Array[String]) } + def escaped_vars; end +end + +class MIR::ReturnMark + sig { returns(T::Array[String]) } + def escaped_vars; end +end + +class MIR::ReturnStmt + sig { returns(T.untyped) } + def value; end +end + +class MIR::ScopeBlock + sig { returns(T.untyped) } + def body; end +end + +class MIR::Set + sig { returns(T.untyped) } + def target; end + sig { returns(T.untyped) } + def value; end + sig { returns(T.untyped) } + def needs_field_cleanup; end +end + +class MIR::ShardedMapGet + sig { returns(MIR::Deref) } + def target; end + sig { returns(T.untyped) } + def key; end + sig { returns(T.untyped) } + def shard_idx; end + sig { returns(T.untyped) } + def shard_key; end + sig { returns(Symbol) } + def map_kind; end + sig { returns(T.untyped) } + def stdlib_def; end + sig { returns(T.untyped) } + def key_zig; end + sig { returns(T.untyped) } + def val_zig; end + sig { returns(T.untyped) } + def resolved_allocs; end + sig { returns(Symbol) } + def template_kind; end +end + +class MIR::ShardedMapPut + sig { returns(MIR::Deref) } + def target; end + sig { returns(T.untyped) } + def key; end + sig { returns(MIR::InlineZig) } + def value; end + sig { returns(T.untyped) } + def shard_idx; end + sig { returns(T.untyped) } + def shard_key; end + sig { returns(T.untyped) } + def map_kind; end + sig { returns(T.untyped) } + def stdlib_def; end + sig { returns(T.untyped) } + def key_zig; end + sig { returns(T.untyped) } + def val_zig; end + sig { returns(T.untyped) } + def resolved_allocs; end + sig { returns(Symbol) } + def template_kind; end +end + +class MIR::SharePromote + sig { returns(T.untyped) } + def source; end + sig { returns(String) } + def zig_base; end + sig { returns(Symbol) } + def alloc; end +end + +class MIR::SliceExpr + sig { returns(T.untyped) } + def target; end + sig { returns(T.any(MIR::Cast, MIR::Ident, MIR::Lit)) } + def start; end + sig { returns(T.untyped) } + def end_expr; end + sig { returns(T.untyped) } + def elem_type; end +end + +class MIR::SnapshotMultiTxn + sig { returns(String) } + def cells_tuple; end + sig { returns(String) } + def rt; end + sig { returns(String) } + def alloc; end + sig { returns(String) } + def alias_decls; end + sig { returns(T.untyped) } + def body; end + sig { returns(String) } + def conflict_action; end + sig { returns(T.untyped) } + def retries; end + sig { returns(T.untyped) } + def with_label; end +end + +class MIR::SnapshotRead + sig { returns(String) } + def cell_unwrap; end + sig { returns(String) } + def rt; end + sig { returns(T.untyped) } + def alias_zig; end + sig { returns(String) } + def guard_var; end + sig { returns(T.untyped) } + def body; end +end + +class MIR::SnapshotTransaction + sig { returns(String) } + def cell_unwrap; end + sig { returns(String) } + def rt; end + sig { returns(String) } + def alloc; end + sig { returns(T.untyped) } + def alias_zig; end + sig { returns(String) } + def bare_t_zig; end + sig { returns(T.untyped) } + def body; end + sig { returns(String) } + def conflict_action; end + sig { returns(T.untyped) } + def retries; end + sig { returns(T.untyped) } + def with_label; end + sig { returns(T.untyped) } + def is_atomic_ptr; end +end + +class MIR::SoaFieldAccess + sig { returns(MIR::Ident) } + def soa_expr; end + sig { returns(T.untyped) } + def field_name; end +end + +class MIR::Sort + sig { returns(T.untyped) } + def elem_type; end + sig { returns(MIR::FieldGet) } + def items_expr; end + sig { returns(T.untyped) } + def key_a; end + sig { returns(T.untyped) } + def key_b; end +end + +class MIR::StreamSpawn + sig { returns(T.untyped) } + def captures; end + sig { returns(T.untyped) } + def body; end +end + +class MIR::StreamYield + sig { returns(T.untyped) } + def value; end +end + +class MIR::StructDef + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def methods; end + sig { returns(T.untyped) } + def visibility; end +end + +class MIR::StructInit + sig { returns(T.untyped) } + def zig_type; end + sig { returns(T.any(T::Array[T.untyped], T::Array[T::Hash[T.untyped, T.untyped]])) } + def fields; end +end + +class MIR::Suppress + sig { returns(T.untyped) } + def name; end +end + +class MIR::SuppressCleanup + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end +end + +class MIR::SuspendDescriptor + sig { returns(T.untyped) } + def setup_stmts; end + sig { returns(T.untyped) } + def bind_stmts; end + sig { returns(T.any(MIR::FsmTailRegisterYield, MIR::FsmTailYield)) } + def tail; end + sig { returns(T.any(T::Array[String], T::Array[T.untyped])) } + def ctx_field_decls; end + sig { returns(String) } + def result_var; end + sig { returns(T.untyped) } + def result_zig_type; end +end + +class MIR::SwitchStmt + sig { returns(T.untyped) } + def subject; end + sig { returns(T.untyped) } + def arms; end + sig { returns(T.untyped) } + def default_body; end +end + +class MIR::TailCall + sig { returns(T.untyped) } + def callee; end + sig { returns(T.untyped) } + def args; end +end + +class MIR::TestDef + sig { returns(String) } + def name; end + sig { returns(T.untyped) } + def body; end +end + +class MIR::ThunkTrampoline + sig { returns(T.untyped) } + def fn_name; end + sig { returns(String) } + def ret_zig; end + sig { returns(T::Array[String]) } + def param_field_decls; end + sig { returns(T::Array[String]) } + def param_init_fields; end + sig { returns(T.untyped) } + def base_cases; end + sig { returns(T.untyped) } + def recurse_arg_inits; end + sig { returns(T.untyped) } + def combine_lhs_zig; end + sig { returns(T.untyped) } + def op_zig; end + sig { returns(String) } + def yield_line; end +end + +class MIR::TransferMark + sig { returns(String) } + def name; end + sig { returns(Symbol) } + def target; end +end + +class MIR::TryCatch + sig { returns(T.untyped) } + def expr; end + sig { returns(T.untyped) } + def catch_body; end + sig { returns(T.untyped) } + def capture; end + sig { returns(T.untyped) } + def heap_provenance; end +end + +class MIR::TryExpr + sig { returns(T.untyped) } + def expr; end +end + +class MIR::TryOrPanic + sig { returns(T.untyped) } + def expr; end + sig { returns(T.untyped) } + def panic_msg; end +end + +class MIR::TypeAlias + sig { returns(T.untyped) } + def name; end + sig { returns(String) } + def target; end +end + +class MIR::TypeSentinel + sig { returns(Symbol) } + def extreme; end + sig { returns(T.untyped) } + def zig_type; end +end + +class MIR::UnaryOp + sig { returns(String) } + def op; end + sig { returns(T.untyped) } + def operand; end +end + +class MIR::Undef + sig { returns(T.untyped) } + def zig_type; end +end + +class MIR::UnionTypeDef + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def variants; end + sig { returns(T.untyped) } + def visibility; end +end + +class MIR::UnionVariantGet + sig { returns(T.untyped) } + def object; end + sig { returns(String) } + def variant; end + sig { returns(String) } + def zig_type; end +end + +class MIR::WeakUpgrade + sig { returns(T.untyped) } + def source; end + sig { returns(T.untyped) } + def zig_base; end + sig { returns(String) } + def func; end +end + +class MIR::WhileStmt + sig { returns(T.untyped) } + def cond; end + sig { returns(T.untyped) } + def body; end + sig { returns(T.untyped) } + def capture; end + sig { returns(T.untyped) } + def update; end + sig { returns(T.untyped) } + def mark_per_iter; end + sig { returns(T.untyped) } + def tight; end +end + +class MIR::WithMatchDispatch + sig { returns(String) } + def cell_zig; end + sig { returns(T.untyped) } + def arms; end +end + +class MIRPass::WalkCtx + sig { returns(T.untyped) } + def bindings; end + sig { returns(T.untyped) } + def promo; end +end + +class ModuleImporter::CompiledModule + sig { returns(AST::Program) } + def ast; end + sig { returns(T.untyped) } + def global_scope; end + sig { returns(T.untyped) } + def transpiled_body; end + sig { returns(String) } + def source_dir; end + sig { returns(T::Hash[Symbol, Schemas::StructSchema]) } + def struct_schemas; end + sig { returns(T::Hash[Symbol, Schemas::UnionSchema]) } + def union_schemas; end + sig { returns(T.untyped) } + def enum_schemas; end + sig { returns(T.untyped) } + def type_defs; end +end + +class OwnershipDataflow::DataflowStep + sig { returns(T.untyped) } + def state; end + sig { returns(T.untyped) } + def consumed; end +end + +class OwnershipDataflow::OwnerEntry + sig { returns(T.untyped) } + def state; end + sig { returns(T.untyped) } + def allocator; end + sig { returns(T.untyped) } + def needs_cleanup; end +end + +class OwnershipGraph::Edge + sig { returns(T.untyped) } + def from; end + sig { returns(T.untyped) } + def to; end + sig { returns(T.untyped) } + def kind; end +end + +class OwnershipGraph::Node + sig { returns(T.untyped) } + def path; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def state; end + sig { returns(T.untyped) } + def type_info; end + sig { returns(T.untyped) } + def scope_depth; end + sig { returns(T.untyped) } + def line; end + sig { returns(T.untyped) } + def move_line; end + sig { returns(T.untyped) } + def move_col; end + sig { returns(T.untyped) } + def move_action; end + sig { returns(T.untyped) } + def move_consumer_param_type; end +end + +class TestLowering::TestThatEnv + sig { returns(T.untyped) } + def ctx; end + sig { returns(T.untyped) } + def when_block; end + sig { returns(T.untyped) } + def when_desc; end + sig { returns(T.untyped) } + def tag_suffix; end + sig { returns(T.untyped) } + def stub_mir; end + sig { returns(T.untyped) } + def when_setup_mir; end + sig { returns(T.untyped) } + def when_before_each_mir; end + sig { returns(T.untyped) } + def when_after_each_mir; end + sig { returns(T.untyped) } + def let_ast_map; end +end + +class ThunkTransform::RecursiveSplitter::MutualPlan + sig { returns(T.untyped) } + def base_cases; end + sig { returns(T.untyped) } + def target_fn; end + sig { returns(T.untyped) } + def target_args; end + sig { returns(T.untyped) } + def final_return; end +end + +class ThunkTransform::RecursiveSplitter::MutualThunkPlan + sig { returns(T.untyped) } + def cycle_fns; end + sig { returns(T.untyped) } + def own_plan; end +end + +class ThunkTransform::RecursiveSplitter::Plan + sig { returns(T.untyped) } + def base_cases; end + sig { returns(T.untyped) } + def combine_lhs; end + sig { returns(T.untyped) } + def combine_op; end + sig { returns(T.untyped) } + def recurse_args; end + sig { returns(T.untyped) } + def final_return; end +end diff --git a/sorbet/rbi/nil-kill-structs.rbi b/sorbet/rbi/nil-kill-structs.rbi index 0c1f1dbb5..fe1390c44 100644 --- a/sorbet/rbi/nil-kill-structs.rbi +++ b/sorbet/rbi/nil-kill-structs.rbi @@ -1,7 +1,7 @@ # typed: true # frozen_string_literal: true -# AUTO-GENERATED by tools/nil-kill.rb struct-rbi. +# AUTO-GENERATED by bundle exec tools/nil-kill struct-rbi. # Re-run nil-kill infer/collect before regenerating. class Capabilities::Conflict diff --git a/src/annotator-helpers/auto_inference.rb b/src/annotator-helpers/auto_inference.rb index 9410a6e01..9b3ee65a7 100644 --- a/src/annotator-helpers/auto_inference.rb +++ b/src/annotator-helpers/auto_inference.rb @@ -274,7 +274,7 @@ def empty_hash_lit?(node) node.is_a?(AST::HashLit) && node.pairs.empty? end - sig { params(decl_node: T.untyped).returns(T.untyped) } + sig { params(decl_node: T.untyped).void } def register_list_shape_slot(decl_node) slot_id = [:list_element, decl_node.object_id] @slots[slot_id] ||= Slot.new( @@ -285,7 +285,7 @@ def register_list_shape_slot(decl_node) @local_decls[decl_node.name] = slot_id if @local_decls end - sig { params(decl_node: T.untyped).returns(T.untyped) } + sig { params(decl_node: T.untyped).void } def register_map_shape_slots(decl_node) key_id = [:map_key, decl_node.object_id] val_id = [:map_value, decl_node.object_id] diff --git a/src/annotator-helpers/capabilities.rb b/src/annotator-helpers/capabilities.rb index 528bf173a..0e783868b 100644 --- a/src/annotator-helpers/capabilities.rb +++ b/src/annotator-helpers/capabilities.rb @@ -636,7 +636,7 @@ def alias_mutated?(alias_name) # @param node [AST::WithBlock] the WITH block (for error reporting) # @param cap [Hash] the capability entry { :capability, :var_node, :alias } # @param expanded [Array] accumulator for resolved capabilities - sig { params(node: AST::WithBlock, cap: T::Hash[Symbol, T.untyped], expanded: T::Array[T::Hash[T.untyped, T.untyped]]).returns(T.untyped) } + sig { params(node: AST::WithBlock, cap: T::Hash[Symbol, T.untyped], expanded: T::Array[T::Hash[Symbol, T.untyped]]).returns(T.untyped) } def acquire_capability!(node, cap, expanded) T.bind(self, SemanticAnnotator) rescue nil var_node = cap[:var_node] @@ -1026,7 +1026,7 @@ def captures_outer_variables?(body, locally_bound) private # One recursive walk that checks each outer-scope identifier for ALL properties. - sig { params(nodes: T::Array[T.untyped], locally_bound: T::Set[String], result: CapabilityHelper::CaptureAnalysis, is_parallel: T.nilable(T::Boolean)).returns(T::Array[T.untyped]) } + sig { params(nodes: T::Array[T.untyped], locally_bound: T::Set[String], result: CapabilityHelper::CaptureAnalysis, is_parallel: T.nilable(T::Boolean)).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def _unified_capture_walk(nodes, locally_bound, result, is_parallel) T.bind(self, SemanticAnnotator) rescue nil @capability_audit = T.let(@capability_audit, T.untyped) @@ -1314,7 +1314,7 @@ def _bg_walk(node, scope, locally_bound) module CapabilityAudit extend T::Sig - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) } def capability_audit_init! T.bind(self, SemanticAnnotator) rescue nil @capability_audit = T.let({}, T.untyped) @@ -1360,12 +1360,12 @@ def audit_mark_mutated(var_name) # No longer needed — audit marking is handled by _unified_capture_walk. # Kept as a no-op for call-site compatibility. - sig { params(body_exprs: T.untyped, is_parallel: T.untyped).returns(T.untyped) } + sig { params(body_exprs: T.untyped, is_parallel: T.untyped).void } def audit_mark_bg_captures(body_exprs, is_parallel) T.bind(self, SemanticAnnotator) rescue nil end - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) } def finalize_capability_audit! T.bind(self, SemanticAnnotator) rescue nil @capability_audit = T.let(@capability_audit, T.untyped) diff --git a/src/annotator-helpers/effects.rb b/src/annotator-helpers/effects.rb index 33fc6e250..d64d6bfde 100644 --- a/src/annotator-helpers/effects.rb +++ b/src/annotator-helpers/effects.rb @@ -100,7 +100,7 @@ def effects_init! end # Called at the start of visit_FunctionDef to prepare a fresh effect set. - sig { params(fn_name: String).returns(T::Set[T.untyped]) } + sig { params(fn_name: String).returns(T::Set[Symbol]) } def effects_begin_function(fn_name) T.bind(self, SemanticAnnotator) rescue nil @fn_direct_effects = T.let(@fn_direct_effects, T.untyped) @@ -161,7 +161,7 @@ def current_conditional_depth # Record a call site's context so transitive propagation can promote the # callee's SUSPENDS effects. Worst-case merge across multiple call sites. - sig { params(callee_name: String).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(callee_name: String).returns(T.nilable(T::Hash[Symbol, T::Boolean])) } def record_call_site(callee_name) T.bind(self, SemanticAnnotator) rescue nil @call_site_context = T.let(@call_site_context, T.untyped) @@ -182,7 +182,7 @@ def record_call_site(callee_name) # length as node.args). compute_effects! reads this to resolve callee # CONTENTION_MAYBE / BLOCKING_MAYBE into concrete effects when the # families are concrete, or keeps them MAYBE when polymorphism propagates. - sig { params(callee_name: String, arg_family_sets: T::Array[T::Set[T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } + sig { params(callee_name: String, arg_family_sets: T::Array[T::Set[Symbol]]).returns(T.nilable(T::Array[T::Array[T::Set[Symbol]]])) } def record_call_arg_families(callee_name, arg_family_sets) T.bind(self, SemanticAnnotator) rescue nil @call_site_arg_families = T.let(@call_site_arg_families, T.untyped) @@ -326,7 +326,7 @@ def resolve_maybe_effects(callee_set, caller_name, callee_name) # Merge callee's effects into caller, applying context-sensitive # SUSPENDS promotion based on the call site's loop/cond bits. - sig { params(caller_set: T::Set[Symbol], callee_set: T::Set[Symbol], site_ctx: T.nilable(T::Hash[Symbol, T.untyped])).returns(T::Set[Symbol]) } + sig { params(caller_set: T::Set[Symbol], callee_set: T::Set[Symbol], site_ctx: T.nilable(T::Hash[Symbol, T::Boolean])).returns(T::Set[Symbol]) } def inherit_effects_from_callee(caller_set, callee_set, site_ctx) T.bind(self, SemanticAnnotator) rescue {} in_loop = site_ctx && site_ctx[:loop] @@ -667,7 +667,7 @@ def enumerate_fsm_suspend_points! end end - sig { params(node: T.untyped, fn_node: T.untyped, points: T::Array[T::Hash[T.untyped, T.untyped]]).returns(T.untyped) } + sig { params(node: T.untyped, fn_node: T.untyped, points: T::Array[T::Hash[Symbol, T.untyped]]).returns(T.untyped) } def scan_suspend_points(node, fn_node, points) T.bind(self, SemanticAnnotator) rescue nil case node @@ -795,7 +795,7 @@ def bg_spawn_form_for(callee_names, has_fnptr) # Walk a BG body and collect its suspend points using the same rules as # enumerate_fsm_suspend_points!, but anchored to the BgBlock scope. - sig { params(bg_node: T.untyped).returns(T::Array[T::Hash[T.untyped, T.untyped]]) } + sig { params(bg_node: T.untyped).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def collect_bg_suspend_points(bg_node) T.bind(self, SemanticAnnotator) rescue nil points = [] @@ -1085,7 +1085,7 @@ def scan_for_calls(node) # Post-pass: detect indirect mutual recursion in the call graph. # DFS reachability: for each function F, walk F's callees transitively # and report an error if F is reachable from itself. - sig { returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { returns(T.nilable(T::Hash[String, T::Set[String]])) } def check_indirect_reentrancy! T.bind(self, SemanticAnnotator) rescue nil @call_graph = T.let(@call_graph, T.untyped) diff --git a/src/annotator-helpers/fixable_helpers.rb b/src/annotator-helpers/fixable_helpers.rb index bb141f088..f63ade491 100644 --- a/src/annotator-helpers/fixable_helpers.rb +++ b/src/annotator-helpers/fixable_helpers.rb @@ -709,7 +709,7 @@ def emit_capture_immutable_as_mutable_error!(node, cap_name, owner_scope) # `RETURNS` annotation. :auto fix inserts `RETURNS :Any ` immediately # before the function's `->` arrow so the compiler knows to accept # the polymorphic return. - sig { params(fn_node: AST::FunctionDef, found_returns: T::Array[T.untyped]).returns(T.untyped) } + sig { params(fn_node: AST::FunctionDef, found_returns: T::Array[T.untyped]).void } def emit_ambiguous_return_error!(fn_node, found_returns) T.bind(self, SemanticAnnotator) rescue nil arrow = fn_node.arrow_token @@ -802,7 +802,7 @@ def emit_return_borrowed_no_copy_error!(node) # - The enclosing function may need `RETURNS !T` (the WITH acquire # can fail). The user's next compile catches that with its own # fixable error. - sig { params(node: T.untyped, code: Symbol, name: T.untyped, field: T.untyped, cap: T.untyped, perm: T.untyped).returns(T.untyped) } + sig { params(node: AST::GetField, code: Symbol, name: T.untyped, field: String, cap: String, perm: String).void } def emit_cap_field_needs_with!(node, code, name:, field:, cap:, perm:) T.bind(self, SemanticAnnotator) rescue nil @source_code = T.let(@source_code, T.untyped) @@ -844,7 +844,7 @@ def emit_cap_field_needs_with!(node, code, name:, field:, cap:, perm:) # the var's own name with `_v` appended (matches the convention used # by emit_cap_field_needs_with!). Falls back to plain `error!` # when no var token is locatable. - sig { params(node: T.untyped, missing_caps: T.untyped).returns(T.untyped) } + sig { params(node: AST::WithBlock, missing_caps: T.untyped).returns(T.untyped) } def emit_with_guard_all_bindings_need_as!(node, missing_caps) T.bind(self, SemanticAnnotator) rescue nil edits = [] @@ -879,7 +879,7 @@ def emit_with_guard_all_bindings_need_as!(node, missing_caps) # The actual mutation site stays in place — the user reviews and # decides whether dropping MUTABLE is the right call (vs removing # the mutation, vs moving it outside the guarded WITH). - sig { params(node: T.untyped, names: T.untyped, verb: T.untyped).returns(T.untyped) } + sig { params(node: AST::WithBlock, names: T.untyped, verb: String).returns(T.untyped) } def emit_with_guard_mutable_mutated!(node, names, verb) T.bind(self, SemanticAnnotator) rescue nil @source_code = T.let(@source_code, T.untyped) @@ -931,7 +931,7 @@ def emit_with_guard_mutable_mutated!(node, names, verb) # - x has no sync (plain or otherwise) -> :auto fix inserts # `@writeLocked` at the declaration. # Falls back to plain `error!` when no fix is locatable. - sig { params(node: T.untyped, name: T.untyped, var_node: T.untyped).returns(T.untyped) } + sig { params(node: AST::WithBlock, name: T.untyped, var_node: T.untyped).returns(T.untyped) } def emit_with_read_needs_write_lock!(node, name, var_node) T.bind(self, SemanticAnnotator) rescue nil syn = cap_var_sync(var_node) @@ -959,7 +959,7 @@ def emit_with_read_needs_write_lock!(node, name, var_node) # (multiowned / shared / locked / writeLocked). Each is shown with # a one-line semantic difference. Falls through to plain `error!` # when no fixes are locatable. - sig { params(node: T.untyped, name: T.untyped).returns(T.untyped) } + sig { params(node: AST::WithBlock, name: T.untyped).void } def emit_with_cannot_infer_cap!(node, name) T.bind(self, SemanticAnnotator) rescue nil candidates = [ @@ -989,7 +989,7 @@ def emit_with_cannot_infer_cap!(node, name) # declaration line. Only handles single-line decls with a `: T` # annotation — bare-inferred declarations fall back to plain # `error!`. - sig { params(node: T.untyped, name: String, got: T.untyped).returns(T.untyped) } + sig { params(node: AST::WithBlock, name: String, got: T.untyped).returns(T.untyped) } def emit_with_materialized_needs_tense!(node, name, got) T.bind(self, SemanticAnnotator) rescue nil @source_code = T.let(@source_code, T.untyped) @@ -1047,7 +1047,7 @@ def emit_with_restrict_immutable_error!(node, var_node) # in `!`. :auto fix appends `!` immediately after the function name. # Falls back to plain error! when the name token isn't available # (e.g. synthesized fns). - sig { params(fn_node: AST::FunctionDef).returns(T.untyped) } + sig { params(fn_node: AST::FunctionDef).void } def emit_style_mutable_param_needs_bang!(fn_node) T.bind(self, SemanticAnnotator) rescue nil name = fn_node.name @@ -1104,7 +1104,7 @@ def emit_can_smash_unsupported_error!(node) # the value is a literal whose source span is precisely known # (Literal nodes carry a token for the start; the value's textual # length is known from the parsed token's value). - sig { params(node: T.untyped, target_type: T.untyped, value_type: Symbol).returns(T.untyped) } + sig { params(node: T.untyped, target_type: T.untyped, value_type: Symbol).void } def emit_type_mismatch_assign_error!(node, target_type, value_type) T.bind(self, SemanticAnnotator) rescue nil kw = { got: value_type, expected: target_type } @@ -1247,7 +1247,7 @@ def build_decl_cap_replace_fix(name, old_sigil, new_sigil, description: nil, con # generated per candidate via `build_decl_cap_insert_fix`. Falls # back to plain `error!` (registry-formatted) when no candidate # is locatable (e.g. WITH target is a GetField / param). - sig { params(node: T.untyped, name: T.untyped, code: Symbol, candidates: T::Array[T.untyped], confidence: Symbol, kw: T.untyped).returns(T.untyped) } + sig { params(node: AST::WithBlock, name: T.untyped, code: Symbol, candidates: T::Array[T.untyped], confidence: Symbol, kw: T.untyped).returns(T.untyped) } def emit_with_cap_mismatch!(node, name, code, candidates, confidence: :auto, **kw) T.bind(self, SemanticAnnotator) rescue nil fixes = candidates.filter_map do |c| diff --git a/src/annotator-helpers/function_analysis.rb b/src/annotator-helpers/function_analysis.rb index 232dd853f..8326339ac 100644 --- a/src/annotator-helpers/function_analysis.rb +++ b/src/annotator-helpers/function_analysis.rb @@ -64,7 +64,7 @@ def analyze_routine(node, body, declared_return, is_implicit) return_type end - sig { params(params: T::Array[T.untyped], return_type: Symbol).returns(FunctionSignature) } + sig { params(params: T::Array[T::Hash[Symbol, T.untyped]], return_type: Symbol).returns(FunctionSignature) } def build_lambda_signature(params, return_type) T.bind(self, SemanticAnnotator) rescue nil normalized_params = params.map do |param| @@ -715,7 +715,7 @@ def verify_lifetime_source!(node, source_node) end end - sig { params(node: T.untyped).returns(T.nilable(T::Array[T.untyped])) } + sig { params(node: T.untyped).returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } def declare_and_verify_params(node) T.bind(self, SemanticAnnotator) rescue nil node.params.each do |param| @@ -868,7 +868,7 @@ def declare_captures(node) end end - sig { params(node: T.untyped, found_returns: T::Array[T.untyped], declared_return: T.nilable(Type)).returns(T.untyped) } + sig { params(node: T.untyped, found_returns: T::Array[T::Hash[Symbol, T.nilable(Symbol)]], declared_return: T.nilable(Type)).void } def verify_returns(node, found_returns, declared_return) T.bind(self, SemanticAnnotator) rescue nil if found_returns.size > 1 @@ -1003,7 +1003,7 @@ def reject_arg_type_matches?(arg, kind) pred.call(type) end - sig { params(definitions: T::Array[T.untyped], args: T::Array[T.untyped]).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(definitions: T::Array[T.untyped], args: T::Array[T.untyped]).returns(T.nilable(T::Hash[Symbol, T.untyped])) } def find_matching_intrinsic(definitions, args) T.bind(self, SemanticAnnotator) rescue nil definitions.find do |config| diff --git a/src/annotator-helpers/function_context.rb b/src/annotator-helpers/function_context.rb index f39b13b21..1a1d7801d 100644 --- a/src/annotator-helpers/function_context.rb +++ b/src/annotator-helpers/function_context.rb @@ -12,7 +12,7 @@ class FunctionContext :loop_depth, :conditional_depth, :returns, :stack_vars_bytes # accumulated bytes for stack-local variables - sig { params(name: String, return_type: T.untyped, lifetime: T.nilable(T::Array[T.untyped]), type_params: T::Array[Symbol]).void } + sig { params(name: String, return_type: T.untyped, lifetime: T.nilable(T::Array[String]), type_params: T::Array[Symbol]).void } def initialize(name:, return_type:, lifetime: nil, type_params: []) @name = name @return_type = return_type diff --git a/src/annotator-helpers/function_signature.rb b/src/annotator-helpers/function_signature.rb index b70ac959f..3cc6b4eec 100644 --- a/src/annotator-helpers/function_signature.rb +++ b/src/annotator-helpers/function_signature.rb @@ -62,7 +62,7 @@ def self.sync_from_function_def!(sig, fn) sig end - sig { params(params: T::Array[T::Hash[T.untyped, T.untyped]], return_type: T.untyped, return_lifetime: T.untyped, visibility: T.nilable(Symbol), type_params: T.nilable(T::Array[T.untyped]), reentrant: T::Boolean, extern: T::Boolean, module_alias: T.nilable(String), extern_effects: T.nilable(T::Hash[Symbol, T.untyped]), fn_type_params: T.nilable(T::Array[Symbol]), owner_type: T.nilable(String), owner_type_params: T.nilable(T::Array[T.untyped]), intrinsic: T::Boolean, zig_pattern: T.nilable(String)).void } + sig { params(params: T::Array[T::Hash[Symbol, T.untyped]], return_type: T.untyped, return_lifetime: T.untyped, visibility: T.nilable(Symbol), type_params: T.nilable(T::Array[Symbol]), reentrant: T::Boolean, extern: T::Boolean, module_alias: T.nilable(String), extern_effects: T.nilable(T::Hash[Symbol, Symbol]), fn_type_params: T.nilable(T::Array[Symbol]), owner_type: T.nilable(String), owner_type_params: T.nilable(T::Array[T.untyped]), intrinsic: T::Boolean, zig_pattern: T.nilable(String)).void } def initialize(params:, return_type:, return_lifetime: nil, visibility: nil, type_params: nil, reentrant: false, extern: false, module_alias: nil, extern_effects: nil, diff --git a/src/annotator-helpers/generic_analysis.rb b/src/annotator-helpers/generic_analysis.rb index 43424b8ab..974e96d20 100644 --- a/src/annotator-helpers/generic_analysis.rb +++ b/src/annotator-helpers/generic_analysis.rb @@ -503,7 +503,7 @@ def shared_call_capability_display(type) # Build a concrete copy of a generic function signature with all type params # replaced by their inferred concrete types. - sig { params(signature: FunctionSignature, subst: T::Hash[Symbol, T.untyped]).returns(FunctionSignature) } + sig { params(signature: FunctionSignature, subst: T::Hash[Symbol, Symbol]).returns(FunctionSignature) } def substitute_type_params(signature, subst) T.bind(self, SemanticAnnotator) rescue nil FunctionSignature.new( diff --git a/src/annotator-helpers/lock_helper.rb b/src/annotator-helpers/lock_helper.rb index 1173e6972..c522c0c88 100644 --- a/src/annotator-helpers/lock_helper.rb +++ b/src/annotator-helpers/lock_helper.rb @@ -79,7 +79,7 @@ def rank_of_cap(cap) T.must(@lock_type_ranks)[t] end - sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } + sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } def record_lock_clause_site!(node, expanded_capabilities) T.bind(self, SemanticAnnotator) rescue nil return unless node.lock_error_clause @@ -97,7 +97,7 @@ def record_lock_clause_site!(node, expanded_capabilities) # same-name; does not chase aliases or cross function boundaries (Phase # 2 handles cross-function type-level cycles). Opt-outs downgrade to a # [Note]. - sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } + sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } def check_nested_lock_reacquire!(node, expanded_capabilities) T.bind(self, SemanticAnnotator) rescue nil @held_locks = T.let(@held_locks, T.untyped) @@ -126,7 +126,7 @@ def check_nested_lock_reacquire!(node, expanded_capabilities) # cycle detection covers those). POSSIBLE_DEADLOCK / POSSIBLE_LOCK_CYCLE # on the inner WITH downgrades the error to a [Note] so the risk is # visible but not blocking. - sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } + sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } def check_lock_rank_ordering!(node, expanded_capabilities) T.bind(self, SemanticAnnotator) rescue nil @held_lock_types = T.let(@held_lock_types, T.untyped) @@ -175,7 +175,7 @@ def lock_identity_of(cap) # the programmer put the opt-out at the site that reads most naturally # — the outer holder, the inner acquire, or both — and each form has # the same suppression effect on the cycle graph. - sig { params(fn_name: String, cap: T::Hash[Symbol, T.untyped], held_stack: T::Array[T::Hash[T.untyped, T.untyped]], escape: T.nilable(T::Hash[Symbol, T.untyped])).returns(T.nilable(T::Array[T.untyped])) } + sig { params(fn_name: String, cap: T::Hash[Symbol, T.untyped], held_stack: T::Array[T::Hash[Symbol, T.untyped]], escape: T.nilable(T::Hash[Symbol, T.untyped])).returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } def record_with_acquire!(fn_name, cap, held_stack, escape) T.bind(self, SemanticAnnotator) rescue {} t = lock_identity_of(cap) @@ -192,7 +192,7 @@ def record_with_acquire!(fn_name, cap, held_stack, escape) end end - sig { params(fn_name: String, callee_name: String, held_stack: T::Array[T::Hash[T.untyped, T.untyped]], site_token: Lexer::Token).returns(T::Array[T::Hash[T.untyped, T.untyped]]) } + sig { params(fn_name: String, callee_name: String, held_stack: T::Array[T::Hash[Symbol, T.untyped]], site_token: Lexer::Token).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def record_held_call!(fn_name, callee_name, held_stack, site_token) T.bind(self, SemanticAnnotator) rescue nil held_stack.each do |held| @@ -207,7 +207,7 @@ def record_held_call!(fn_name, callee_name, held_stack, site_token) # fn's "transitive acquires" set contains every lock type it or any # transitive callee takes. Mirrors compute_needs_rt! / compute_can_fail! # structure. - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, T::Set[Symbol]]) } def propagate_lock_acquires! T.bind(self, SemanticAnnotator) rescue nil @call_graph = T.let(@call_graph, T.untyped) @@ -234,7 +234,7 @@ def propagate_lock_acquires! transitive end - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, T::Array[T::Hash[Symbol, T.untyped]]]) } def resolve_held_calls! T.bind(self, SemanticAnnotator) rescue nil T.must(@lock_held_calls).each do |fn, sites| @@ -274,7 +274,7 @@ def build_lock_graph(include_opted_out: false) end # Iterative Tarjan SCC. Returns array of SCCs (each an array of nodes). - sig { params(nodes: T::Set[T.untyped], adj: T::Hash[T.untyped, T::Set[T.untyped]]).returns(T::Array[T::Array[T.untyped]]) } + sig { params(nodes: T::Set[Symbol], adj: T::Hash[T.untyped, T::Set[T.untyped]]).returns(T::Array[T::Array[Symbol]]) } def tarjan_scc(nodes, adj) T.bind(self, SemanticAnnotator) rescue nil index = {} @@ -377,7 +377,7 @@ def check_lock_handler_reachability! # - :Deadlock iff any of the WITH's cap types has a graph self-loop # Each selector in the clause must expand to at least one type in this # set. A selector that expands to the empty set here is dead code. - sig { params(site: T::Hash[Symbol, T.untyped], types_in_cycle: T::Set[Symbol], types_with_self: T::Set[T.untyped]).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } + sig { params(site: T::Hash[Symbol, T.untyped], types_in_cycle: T::Set[Symbol], types_with_self: T::Set[T.untyped]).returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } def verify_handler_reachability!(site, types_in_cycle, types_with_self) T.bind(self, SemanticAnnotator) rescue nil node = site[:node] @@ -432,7 +432,7 @@ def scc_is_cyclic?(scc, adj) T.must(adj[T.must(node)]).include?(node) end - sig { params(scc: T::Array[T.untyped], edges: T::Array[T.untyped]).returns(T.untyped) } + sig { params(scc: T::Array[T.untyped], edges: T::Array[T.untyped]).returns(T.noreturn) } def report_lock_cycle!(scc, edges) T.bind(self, SemanticAnnotator) rescue nil @current_fn_node = T.let(@current_fn_node, T.untyped) diff --git a/src/annotator-helpers/method_analysis.rb b/src/annotator-helpers/method_analysis.rb index 9ad2e9a8d..1fccedafa 100644 --- a/src/annotator-helpers/method_analysis.rb +++ b/src/annotator-helpers/method_analysis.rb @@ -54,7 +54,7 @@ def narrow_collection_type!(matched_def, args) private - sig { params(node: AST::MethodCall, obj_type: Type, registry: T::Hash[String, T.untyped], tag_field: Symbol, type_label: String).returns(T.nilable(T::Boolean)) } + sig { params(node: AST::MethodCall, obj_type: Type, registry: T::Hash[String, T::Hash[Symbol, T.untyped]], tag_field: Symbol, type_label: String).returns(T.nilable(T::Boolean)) } def resolve_typed_method(node, obj_type, registry, tag_field, type_label) T.bind(self, SemanticAnnotator) rescue nil defn = registry[node.name] diff --git a/src/annotator-helpers/pipe_analysis.rb b/src/annotator-helpers/pipe_analysis.rb index 9afa5e5aa..40aabf695 100644 --- a/src/annotator-helpers/pipe_analysis.rb +++ b/src/annotator-helpers/pipe_analysis.rb @@ -726,7 +726,7 @@ def analyze_pipe_to_func_call(node) node.full_type = result_type end - sig { params(node: AST::BinaryOp).returns(T.untyped) } + sig { params(node: AST::BinaryOp).void } def analyze_pipe_to_identifier(node) T.bind(self, SemanticAnnotator) rescue nil # Case 2: x |> f => f(x) @@ -751,7 +751,7 @@ def analyze_pipe_to_identifier(node) end end - sig { params(node: AST::BinaryOp, sig: FunctionSignature, func_name: String).returns(T.untyped) } + sig { params(node: AST::BinaryOp, sig: FunctionSignature, func_name: String).void } def analyze_pipe_to_named_function(node, sig, func_name) T.bind(self, SemanticAnnotator) rescue nil # 1. Validate Arity: Must accept exactly 1 argument (the pipe input) @@ -1116,7 +1116,7 @@ def analyze_shard_each_op(node, shard_node) # Pre-scan: check if the EACH body references any @sharded map variable # by scanning for identifiers that are in scope as @sharded (without :locked). # This runs BEFORE visiting the body, so we only check unvisited AST. - sig { params(conc: T.untyped, sharded_names: T.untyped).returns(T.untyped) } + sig { params(conc: T.untyped, sharded_names: T.untyped).void } def emit_multi_map_warning(conc, sharded_names) T.bind(self, SemanticAnnotator) rescue nil shard_counts = sharded_names.map do |name| @@ -1185,7 +1185,7 @@ def pre_scan_node_for_sharded(node) # Analyze CONCURRENT EACH with auto-detected @sharded map access. # Accepts range inputs (unlike analyze_each_op which requires collections). # Visits the body, then extracts the key expression and sets shard_context. - sig { params(smooth_node: T.untyped, conc: T.untyped, proxy: T.untyped).returns(T.untyped) } + sig { params(smooth_node: T.untyped, conc: T.untyped, proxy: AST::BinaryOp).void } def analyze_auto_shard_each_op(smooth_node, conc, proxy) T.bind(self, SemanticAnnotator) rescue nil lhs_type = smooth_node.left.type_info @@ -1218,7 +1218,7 @@ def analyze_auto_shard_each_op(smooth_node, conc, proxy) # Walks the body AST looking for map[key_expr] patterns where map is @sharded. # If found, sets shard_context on the ConcurrentOp so the transpiler emits # routed sharding instead of the normal worker pool. - sig { params(smooth_node: T.untyped, conc: T.untyped).returns(T.untyped) } + sig { params(smooth_node: T.untyped, conc: T.untyped).void } def auto_detect_sharded_access(smooth_node, conc) T.bind(self, SemanticAnnotator) rescue nil each_op = conc.op @@ -1387,7 +1387,7 @@ def analyze_shard_op(node) VALID_CONCURRENT_OPTIONS = %w[workers capacity batch parallel size].freeze VALID_CONCURRENT_SIZES = %w[MICRO STANDARD LARGE XL].freeze - sig { params(name: String, expr: T.untyped).returns(T.untyped) } + sig { params(name: String, expr: T.untyped).void } def validate_positive_numeric_concurrent_option!(name, expr) T.bind(self, SemanticAnnotator) rescue nil visit(expr) @@ -1797,7 +1797,7 @@ def check_soa_opportunity!(node, item_type) end # Wraps a pipeline body visit with SOA field tracking. - sig { params(node: AST::BinaryOp, item_type: T.untyped, blk: T.untyped).returns(T.untyped) } + sig { params(node: AST::BinaryOp, item_type: T.untyped, blk: T.untyped).returns(T.nilable(T::Array[String])) } def with_soa_tracking(node, item_type, &blk) T.bind(self, SemanticAnnotator) rescue nil @pipeline_accessed_fields = T.let(Set.new, T.nilable(T::Set[String])) diff --git a/src/annotator-helpers/reentrance.rb b/src/annotator-helpers/reentrance.rb index 1daeab8c8..a4266991f 100644 --- a/src/annotator-helpers/reentrance.rb +++ b/src/annotator-helpers/reentrance.rb @@ -622,7 +622,7 @@ def canonical_reentrance_kind(fn_node) # # When FixCollector is disabled (i.e. `clear build` / normal compile) # `fixable!` is a no-op; this method does nothing user-visible. - sig { params(fn_node: AST::FunctionDef).returns(T.untyped) } + sig { params(fn_node: AST::FunctionDef).void } def offer_legacy_reentrant_migration!(fn_node) T.bind(self, SemanticAnnotator) rescue nil return unless fn_node.reentrant_token @@ -680,7 +680,7 @@ def offer_legacy_reentrant_migration!(fn_node) # - the function declares ANY reentrance kind (REENTRANT plain, # :THUNK, or :TAIL_CALL) -- the user has already taken a stance # on reentrance and constraining the param is not their choice - sig { params(fn_node: AST::FunctionDef).returns(T.untyped) } + sig { params(fn_node: AST::FunctionDef).void } def offer_unconstrained_fn_param_fix!(fn_node) T.bind(self, SemanticAnnotator) rescue nil return if [:reentrant, :reentrant_thunk, :reentrant_tail_call, :reentrant_not_logical, :reentrant_max_depth].include?(fn_node.reentrance_kind) diff --git a/src/annotator-helpers/test_annotation.rb b/src/annotator-helpers/test_annotation.rb index c531e2289..b9efe96f0 100644 --- a/src/annotator-helpers/test_annotation.rb +++ b/src/annotator-helpers/test_annotation.rb @@ -106,7 +106,7 @@ def visit_TestThat(node) node.full_type = :Void end - sig { params(node: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped).void } def visit_AssertRaises(node) T.bind(self, SemanticAnnotator) rescue nil visit(node.expression) @@ -156,7 +156,7 @@ def visit_StubDecl(node) # has been stubbed in the enclosing WHEN block. Both runtime-level # IO builtins (file/network) and user-defined functions whose # effect set includes :BLOCKING / :EXTERN qualify as IO. - sig { params(test_that: AST::TestThat, stubbed_fns: T::Set[T.untyped]).returns(T.untyped) } + sig { params(test_that: AST::TestThat, stubbed_fns: T::Set[T.untyped]).returns(NilClass) } def validate_strict_io!(test_that, stubbed_fns) T.bind(self, SemanticAnnotator) rescue nil calls = scan_for_calls(test_that.body).first diff --git a/src/annotator-helpers/with_match_check.rb b/src/annotator-helpers/with_match_check.rb index e335704fd..78e744ab5 100644 --- a/src/annotator-helpers/with_match_check.rb +++ b/src/annotator-helpers/with_match_check.rb @@ -48,7 +48,7 @@ def self.poly_requires?(family_set) admissible_axes(family_set).size > 1 end - sig { params(fn: AST::FunctionDef, error_handler: Proc, warn_handler: T.nilable(Proc), policy_handlers: T.nilable(T::Array[T.untyped])).returns(T.nilable(T::Array[T.untyped])) } + sig { params(fn: AST::FunctionDef, error_handler: Proc, warn_handler: T.nilable(Proc), policy_handlers: T.nilable(T::Array[T::Hash[Symbol, T.untyped]])).returns(T.nilable(T::Array[T.untyped])) } def self.check_function!(fn, error_handler, warn_handler: nil, policy_handlers: nil) return unless fn.respond_to?(:body) && fn.body requires_map = (fn.respond_to?(:requires) ? fn.requires : nil) || {} @@ -184,7 +184,7 @@ def self.collect_bound_param_names(with_node, param_names) # # The check only runs for plain WITH. WITH MATCH, VIEW, MATERIALIZED VIEW, # and SNAPSHOT have their own dispatch shapes. - sig { params(node: AST::WithBlock, bound_params: T::Set[String], requires_map: T::Hash[String, T.untyped], fn: AST::FunctionDef, error_handler: Proc).returns(T.untyped) } + sig { params(node: AST::WithBlock, bound_params: T::Set[String], requires_map: T::Hash[String, T.untyped], fn: AST::FunctionDef, error_handler: Proc).returns(T.nilable(FsmOps::CallExpr)) } def self.enforce_polymorphic_iff_rule!(node, bound_params, requires_map, fn, error_handler) return if node.view_kind || node.snapshot_mode @@ -329,7 +329,7 @@ def self.disjunction_admits?(disjunction, arg_family) # downstream readers (effect resolution, mir lowering) see concrete # families uniformly. # Returns an empty Set when the arg has no sync attribute (no contention). - sig { params(arg: T.untyped).returns(T::Set[T.untyped]) } + sig { params(arg: T.untyped).returns(T::Set[Symbol]) } def self.family_of_arg_set(arg) sym = arg.symbol return Set.new unless sym @@ -383,7 +383,7 @@ def self.errors_for_requires(family_set) # remainder is empty and no warning fires. The check exists so a # user-written partial-coverage scenario (or a future strict-mode # build) surfaces unhandled polymorphic errors at the WITH site. - sig { params(node: AST::WithBlock, bound_params: T::Set[String], requires_map: T::Hash[String, T.untyped], policy_handlers: T::Array[T.untyped], warn_handler: Proc).returns(T.nilable(T::Set[T.untyped])) } + sig { params(node: AST::WithBlock, bound_params: T::Set[String], requires_map: T::Hash[String, T::Set[Symbol]], policy_handlers: T::Array[T::Hash[Symbol, T.untyped]], warn_handler: Proc).returns(T.nilable(T::Set[T.untyped])) } def self.warn_polymorphic_unhandled_errors!(node, bound_params, requires_map, policy_handlers, warn_handler) return unless warn_handler && node.polymorphic diff --git a/src/annotator.rb b/src/annotator.rb index 4c256cdd7..a83cf985b 100644 --- a/src/annotator.rb +++ b/src/annotator.rb @@ -138,7 +138,7 @@ def initialize(importer: nil, compiler: nil, source_dir: nil, strict_test: false setup_builtins end - sig { params(node: AST::Program).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(node: AST::Program).returns(T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])) } def annotate!(node) # Reset user-registered error types so state from prior runs (rspec # parallel, multi-program test harness) doesn't leak in. Stdlib @@ -196,7 +196,7 @@ def annotate!(node) # Auto inference runs after the body walk has populated type_info on # every constraint source. It mutates successful Auto declarations to # concrete types and uses operator evidence to rank ambiguous fixes. - sig { params(program_node: AST::Program).returns(T.untyped) } + sig { params(program_node: AST::Program).void } def run_auto_inference!(program_node) collector = AutoConstraintCollector.new(@fn_nodes) slots = collector.collect!(program_node) @@ -290,7 +290,7 @@ def flush_deferred_with_validations! @deferred_with_validations.clear end - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) } def setup_builtins STD_LIB.each do |name, config| current_scope.declare(name, nil, :Intrinsic, false, false, nil, :stack) @@ -482,7 +482,7 @@ def visit_Program(node) end end - sig { params(node: AST::RequireNode).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(node: AST::RequireNode).returns(T.nilable(T::Hash[Symbol, T::Hash[Symbol, T.untyped]])) } def visit_RequireNode(node) unless @importer error!(node, :REQUIRE_NEEDS_IMPORTER, hint: "Pass importer: and source_dir: to SemanticAnnotator.new.") @@ -860,7 +860,7 @@ def seed_error_types_from_raises!(program_node) # The baked-in default applied when the user writes no SYNC POLICY. # Synthesized as a hash matching the parser's lock_error_clause # shape so the resolver can use it interchangeably. - sig { returns(T::Array[T.untyped]) } + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } def baked_in_default_sync_policy [ { selectors: [{ form: :type, name: :LockTimeout, token: nil }], @@ -905,7 +905,7 @@ def validate_and_resolve_sync_policy!(program_node) # SYNC POLICY is allowed to handle (LockTimeout, MvccConflict, # AtomicConflict); Deadlock / LockCycle are explicitly forbidden; # the union of named errors must cover the required set exactly. - sig { params(decl: AST::SyncPolicyDecl).returns(T.untyped) } + sig { params(decl: AST::SyncPolicyDecl).void } def validate_sync_policy_body!(decl) seen = [] (decl.handlers || []).each do |clause| @@ -1194,7 +1194,7 @@ def visit_UnionVariantLit(node) # # @param branches [Array] Procs that execute branch logic # @return [Array>] Array of drops for each branch - sig { params(branches: T::Array[Proc], merge_to_parent: T::Boolean).returns(T.nilable(T::Array[T.nilable(T::Array[T.untyped])])) } + sig { params(branches: T::Array[Proc], merge_to_parent: T::Boolean).returns(T.nilable(T::Array[T::Array[T::Hash[Symbol, T.untyped]]])) } def analyze_control_flow_branches(branches, merge_to_parent: true) og_snapshot = @og&.fork_lightweight og_branch_snapshots = [] @@ -2059,7 +2059,7 @@ def visit_Raise(node) # - kind nil + type nil : no-op (legacy message-only form). # On collision, emits a diagnostic anchored at the second site, # naming the first registration line for context. - sig { params(node: T.untyped, kind_sym: T.nilable(Symbol), type_name_str: T.nilable(String), site_tok: Lexer::Token).returns(T.untyped) } + sig { params(node: T.untyped, kind_sym: T.nilable(Symbol), type_name_str: T.nilable(String), site_tok: Lexer::Token).returns(NilClass) } def resolve_error_registration!(node, kind_sym, type_name_str, site_tok) return if type_name_str.nil? type_sym = type_name_str.to_sym @@ -2315,7 +2315,7 @@ def collect_implicit_type_params(type, out, explicit) collect_implicit_type_params(type.element_type, out, explicit) if type.respond_to?(:array?) && type.array? end - sig { params(node: AST::StaticCall).returns(T.untyped) } + sig { params(node: AST::StaticCall).void } def visit_StaticCall(node) node.args.each { |arg| visit(arg) } @@ -2382,7 +2382,7 @@ def visit_StaticCall(node) end end - sig { params(node: AST::FuncCall).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } + sig { params(node: AST::FuncCall).returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } def visit_FuncCall(node) # Mark struct literal args as call arguments so ensure_owned_value! # skips CopyNode wrapping for rodata strings. The struct is a temporary @@ -2435,7 +2435,7 @@ def visit_FuncCall(node) end end - sig { params(node: AST::MethodCall).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(node: AST::MethodCall).returns(T.nilable(T::Hash[Symbol, T::Boolean])) } def visit_MethodCall(node) visit(node.object) node.args.each { |arg| visit(arg) } @@ -2608,7 +2608,7 @@ def visit_IntrinsicFunc(node, args) # Safety: CLEAR uses value semantics for structs (pass/return by copy). A large # struct on the stack cannot have its address escape the loop body through normal # CLEAR operations, so :stack is always safe here. - sig { params(node: AST::VarDecl).returns(T.untyped) } + sig { params(node: AST::VarDecl).void } def visit_VarDecl(node) if node.value.is_a?(AST::ListLit) && node.type.is_a?(Type) && node.type.fixed? node.value.storage = :stack @@ -2869,7 +2869,7 @@ def finalize_decl_node!(node, mutable_flag) # If x is not yet in scope → immutable declaration (like old VAR x = val). # If x is in scope and mutable → assignment (like old SET x = val). # If x is in scope and immutable → error. - sig { params(node: AST::BindExpr).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(node: AST::BindExpr).returns(T.nilable(T::Hash[Symbol, T::Array[SymbolEntry]])) } def visit_BindExpr(node) # Same pre-set as visit_VarDecl: mark fixed-array list literals as :stack before visiting. if node.value.is_a?(AST::ListLit) && node.type.is_a?(Type) && node.type.fixed? @@ -3083,7 +3083,7 @@ def mark_var_mutated(name) # flag is what post-annotation passes (like # validate_with_guard_no_body_mutation!) read to detect any mutation, # direct or indirect. - sig { params(name: String).returns(T.untyped) } + sig { params(name: String).returns(T.nilable(T::Boolean)) } def mark_var_mutated_via_call(name) scope = lookup_scope_for(name) return unless scope @@ -3190,7 +3190,7 @@ def visit_assignment_variable(identifier_or_name, node) T.must(mark_var_mutated(var_name)) end - sig { params(index_node: AST::GetIndex, assignment_node: AST::Assignment).returns(T.untyped) } + sig { params(index_node: AST::GetIndex, assignment_node: AST::Assignment).returns(NilClass) } def visit_assignment_index(index_node, assignment_node) visit(index_node) @@ -3472,7 +3472,7 @@ def visit_Slice(node) # Call-site recursion overrides are parsed but not lowered yet; emit a # precise diagnostic instead of silently generating wrong code. - sig { params(node: AST::CallSiteOverride).returns(T.untyped) } + sig { params(node: AST::CallSiteOverride).void } def visit_CallSiteOverride(node) sigil = node.kind == :thunk ? "@thunk" : "@maxDepth" variant_hint = node.kind == :thunk ? "'EFFECTS REENTRANT:THUNK'" : "'EFFECTS REENTRANT:MAX_DEPTH(#{node.n})'" @@ -3497,7 +3497,7 @@ def visit_UnaryOp(node) # ========================================== # LITERALS & BINARY OPS # ========================================== - sig { params(node: AST::HashLit).returns(T.untyped) } + sig { params(node: AST::HashLit).void } def visit_HashLit(node) # 1. Analyze values to find the Value Type (V) # Assumption: Maps are homogeneous for now (e.g. all Int64) @@ -4364,7 +4364,7 @@ def visit_FreezeNode(node) node.storage = :frozen end - sig { params(node: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped).void } def visit_Give(node) visit(node.value) @@ -4385,7 +4385,7 @@ def visit_Give(node) node.full_type = node.value.resolved_type end - sig { params(node: AST::Copy).returns(T.untyped) } + sig { params(node: AST::Copy).void } def visit_Copy(node) visit(node.value) @@ -4416,7 +4416,7 @@ def visit_CloneNode(node) current_fn_ctx.needs_rt = true if current_fn_ctx && type&.any_rc? end - sig { params(node: AST::ShareNode).returns(T.untyped) } + sig { params(node: AST::ShareNode).void } def visit_ShareNode(node) visit(node.value) source_type = node.value.type_info @@ -4796,14 +4796,14 @@ def retryable_with_universal_poly_candidate?(node) is_param && !has_req end - sig { params(node: AST::WithBlock, with_name: String, sources: T.nilable(T.any(T::Array[T.untyped], T::Array[T.untyped]))).returns(T.untyped) } + sig { params(node: AST::WithBlock, with_name: String, sources: T.nilable(T.any(T::Array[T.untyped], T::Array[T.untyped]))).void } def retryable_with_fallible_body_error!(node, with_name, sources) detail = T.must(sources).first(3).join(", ") detail += ", ..." if T.must(sources).length > 3 error!(node, :WITH_RETRYABLE_FALLIBLE_BODY, with_name: with_name, detail: detail) end - sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[T.untyped, T.untyped]]).returns(T.untyped) } + sig { params(node: AST::WithBlock, expanded_capabilities: T::Array[T::Hash[T.untyped, T.untyped]]).void } def validate_lock_error_clause!(node, expanded_capabilities) clause = node.lock_error_clause is_snapshot_txn = node.snapshot_mode == :transaction @@ -4888,7 +4888,7 @@ def validate_lock_error_clause!(node, expanded_capabilities) # GetField / GetIndex chains rooted at an @indirect:atomic # binding, fires the rejection. Other chain shapes (param # passing, etc.) are handled elsewhere. - sig { params(field_node: AST::GetField, assignment_node: AST::Assignment).returns(T.untyped) } + sig { params(field_node: AST::GetField, assignment_node: AST::Assignment).void } def reject_bare_atomic_ptr_mutation!(field_node, assignment_node) root = T.let(field_node, AST::GetField) root = root.target while root.respond_to?(:target) && !root.is_a?(AST::Identifier) @@ -4925,7 +4925,7 @@ def field_name_for_msg(node) # literally, or `:SNAPSHOTTED` (which expands to {VERSIONED, ATOMIC}). # The fix: narrow REQUIRES to a non-ATOMIC family # (e.g. `LOCKED | VERSIONED`), or refactor to single-cell WITHs. - sig { params(node: AST::WithBlock).returns(T.untyped) } + sig { params(node: AST::WithBlock).void } def validate_no_multi_object_atomic!(node) caps = (node.capabilities || []).select { |c| sync_constrained_cap?(c) } return if caps.size < 2 @@ -5113,7 +5113,7 @@ def visit_DoBlock(node) node.full_type = :Void end - sig { params(node: AST::BgStreamBlock).returns(T.untyped) } + sig { params(node: AST::BgStreamBlock).void } def visit_BgStreamBlock(node) # Effect tracking: generators are inherently unbounded (run until exhausted or cancelled). record_effect(EffectTracker::LOOP_UNBOUND) @@ -5180,7 +5180,7 @@ def stream_body_yields_frame_string?(stmts) end end - sig { params(node: AST::YieldExpr).returns(T.untyped) } + sig { params(node: AST::YieldExpr).void } def visit_YieldExpr(node) unless @current_stream_context error!(node, :YIELD_OUTSIDE_BG_STREAM) @@ -5293,7 +5293,7 @@ def visit_BgBlock(node) @current_bg_pinned = prev_bg_pinned end - sig { params(node: AST::ThenChain).returns(T.untyped) } + sig { params(node: AST::ThenChain).void } def visit_ThenChain(node) # Sequential chaining: each step runs in order inside the same fiber. # Steps with AS bindings declare a local variable accessible to later steps. @@ -5526,7 +5526,7 @@ def handle_assign_move(node) end end - sig { params(node: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped).void } def handle_assign_borrow(node) return unless node.value.is_a?(AST::FuncCall) || node.value.is_a?(AST::MethodCall) call_node = node.value @@ -5599,7 +5599,7 @@ def resolve_borrow_source(call_node) args[param_index] end - sig { params(node: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped).void } def verify_unrestricted!(node) path = get_path_to_root(node.name) return if path.nil? @@ -5720,7 +5720,7 @@ def promote_to_expr_match!(parent_node, match_node) match_node.full_type = (result_type.string? && !result_type.symbol?) ? Type.new(:String, location: :rodata) : result_type end - sig { params(node: T.untyped, branch: T.nilable(Symbol)).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(node: T.untyped, branch: T.nilable(Symbol)).returns(T.nilable(T::Hash[String, SymbolEntry])) } def finalize_scope(node, branch: nil) drops = [] current_scope.locals.each do |name, info| @@ -5791,7 +5791,7 @@ def finalize_scope(node, branch: nil) end end - sig { params(node: T.nilable(AST::MatchStatement)).returns(T::Array[T::Hash[T.untyped, T.untyped]]) } + sig { params(node: T.nilable(AST::MatchStatement)).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def collect_scope_drops(node: nil) drops = [] current_scope.locals.each do |name, info| @@ -5817,7 +5817,7 @@ def collect_scope_drops(node: nil) # mutable pointer access. Called when the chain leads to mutation # (field assignment, mutating method call, etc.) so the transpiler can # emit `.items[idx]` instead of by-value `getAt(list, idx)`. - sig { params(node: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped).void } def mark_chain_needs_mut_ref!(node) curr = node while curr @@ -5841,7 +5841,7 @@ def get_path_to_root(node) # A tied-lifetime value cannot be stored where it would outlive any # source. Nil-lifetime bindings flow through unchanged. - sig { params(assign_node: AST::Assignment).returns(T.untyped) } + sig { params(assign_node: AST::Assignment).void } def verify_tied_assignment!(assign_node) val = assign_node.value sym = val.respond_to?(:symbol) ? val.symbol : nil @@ -5878,7 +5878,7 @@ def verify_tied_assignment!(assign_node) # Returning a tied-lifetime value is legal only when the function declares # a matching `RETURNS :T`; wildcard accepts any source. - sig { params(return_node: AST::ReturnNode).returns(T.untyped) } + sig { params(return_node: AST::ReturnNode).void } def verify_tied_return!(return_node) val = return_node.value return unless val.is_a?(AST::Identifier) @@ -5945,7 +5945,7 @@ def verify_tied_return!(return_node) # Look up the binding name of a SymbolEntry by scanning scope.locals. # Returns the String name or nil if not found. - sig { params(sym: SymbolEntry).returns(T.untyped) } + sig { params(sym: SymbolEntry).returns(T.nilable(String)) } def lookup_source_name(sym) sc = sym.scope return nil unless sc @@ -6028,7 +6028,7 @@ def dest_scope_depth_for_target(target_node) # - Captures whose binding has no SymbolEntry on capture_symbols # (e.g. observable view aliases); those are already errored at # visit_BgBlock via has_non_escaping_capture. - sig { params(decl_node: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(decl_node: T.untyped).returns(T.nilable(T::Hash[Symbol, T::Array[SymbolEntry]])) } def stamp_bg_handle_lifetime!(decl_node) sources = collect_bg_sources_in_expr(decl_node.value).uniq return if sources.empty? @@ -6336,7 +6336,7 @@ def assign_fiber_stack_tiers!(program_node) # Plain `EFFECTS REENTRANT` callees require explicit `@service` on the # spawn site so OS-thread cost is an explicit user choice. - sig { params(node: T.untyped, call_names: T::Set[String], user_size: T.nilable(Symbol), can_smash: T::Boolean).returns(T.untyped) } + sig { params(node: T.untyped, call_names: T::Set[String], user_size: T.nilable(Symbol), can_smash: T::Boolean).void } def validate_fiber_stack!(node, call_names, user_size, can_smash) if can_smash emit_can_smash_unsupported_error!(node) @@ -6424,7 +6424,7 @@ def find_mutual_max_depth_callee(call_names) # Emit @service-required as a fixable when the spawn-site span is known; # DO branches fall back to a plain error because their span is ambiguous. - sig { params(node: T.untyped, reentrant_fn: String, user_size: T.nilable(Symbol)).returns(T.untyped) } + sig { params(node: T.untyped, reentrant_fn: String, user_size: T.nilable(Symbol)).void } def emit_service_required_error!(node, reentrant_fn, user_size) msg = "Stack safety: this fiber transitively calls '#{reentrant_fn}' which is " \ "`EFFECTS REENTRANT` (plain) -- the call chain is unbounded and MUST run on " \ @@ -6579,7 +6579,7 @@ def move_if_not_copyable!(node, action: :move, consumer_param_type: nil) # Reject storing a borrowed value into an owned container (struct, union, TAKES param). # Borrows can't outlive the scope they reference. Use COPY for owned data. - sig { params(val_node: T.untyped, container_desc: String).returns(T.untyped) } + sig { params(val_node: T.untyped, container_desc: String).returns(NilClass) } def reject_borrowed_value!(val_node, container_desc) borrowed_name = nil if val_node.is_a?(AST::GetIndex) diff --git a/src/ast/ast.rb b/src/ast/ast.rb index 21f6da6cf..9f7ee0271 100644 --- a/src/ast/ast.rb +++ b/src/ast/ast.rb @@ -204,7 +204,7 @@ module Locatable def line; token.line; end sig { returns(Integer) } def column; token.column; end - sig { returns(T.untyped) } + sig { void } def token_value; token.value; end sig { returns(T.nilable(Type)) } @@ -222,7 +222,7 @@ def matched_stdlib_def; @matched_stdlib_def = T.let(@matched_stdlib_def, T.untyp sig { params(val: T.untyped).returns(T.untyped) } def matched_stdlib_def=(val); @matched_stdlib_def = T.let(val, T.untyped); end - sig { returns(T.untyped) } + sig { void } def stdlib_allocates; @stdlib_allocates = T.let(@stdlib_allocates, T.untyped); end sig { params(val: T.untyped).returns(T.untyped) } def stdlib_allocates=(val); @stdlib_allocates = T.let(val, T.untyped); end @@ -257,9 +257,9 @@ def needs_heap_create; @needs_heap_create = T.let(@needs_heap_create, T.untyped) sig { params(val: T.untyped).returns(T.untyped) } def needs_heap_create=(val); @needs_heap_create = T.let(val, T.untyped); end - sig { returns(T.untyped) } + sig { void } def collection_return; @collection_return = T.let(@collection_return, T.untyped); end - sig { params(val: T.untyped).returns(T.untyped) } + sig { params(val: T.untyped).void } def collection_return=(val); @collection_return = T.let(val, T.untyped); end sig { returns(T.nilable(Integer)) } @@ -279,12 +279,12 @@ def can_fail=(val); @can_fail = T.let(val, T.untyped); end sig { returns(T.untyped) } def error_kind; @error_kind = T.let(@error_kind, T.untyped); end - sig { params(val: T.untyped).returns(T.untyped) } + sig { params(val: T.untyped).void } def error_kind=(val); @error_kind = T.let(val, T.untyped); end sig { returns(T.untyped) } def error_type; @error_type = T.let(@error_type, T.untyped); end - sig { params(val: T.untyped).returns(T.untyped) } + sig { params(val: T.untyped).void } def error_type=(val); @error_type = T.let(val, T.untyped); end sig { returns(T.untyped) } @@ -844,13 +844,13 @@ def name; self[:name].to_s end attr_accessor :is_assignment_lhs sig { returns(T::Boolean) } def wildcard?; field == '*' end - sig { returns(T.untyped) } + sig { returns(String) } def name; target.name end end GetIndex = Struct.new(:token, :target, :index) do extend T::Sig include Locatable - sig { returns(T.untyped) } + sig { returns(String) } def name; target.name end end Cast = Struct.new(:token, :value, :target) { include Locatable } @@ -972,7 +972,7 @@ def child_bodies = [body].compact OptionalUnwrap = Struct.new(:token, :target) do extend T::Sig include Locatable - sig { returns(T.untyped) } + sig { returns(T.nilable(String)) } def name; target.respond_to?(:name) ? target.name : nil end end OrRaise = Struct.new(:token) { include Locatable } # OR RAISE - bubble up error (Zig's try) diff --git a/src/ast/diagnostic_examples.rb b/src/ast/diagnostic_examples.rb index f6ad95211..843c15837 100644 --- a/src/ast/diagnostic_examples.rb +++ b/src/ast/diagnostic_examples.rb @@ -60,12 +60,12 @@ def all @all ||= load! end - sig { params(code: T.untyped).returns(T.untyped) } + sig { params(code: T.untyped).returns(T.nilable(OwnershipGraph::Node)) } def lookup(code) all[code.to_sym] end - sig { params(spec_files: T.untyped).returns(T.untyped) } + sig { params(spec_files: T.untyped).returns(T::Hash[T.untyped, T.untyped]) } def load!(spec_files = DEFAULT_SPEC_FILES) out = {} spec_files.each do |path| @@ -77,7 +77,7 @@ def load!(spec_files = DEFAULT_SPEC_FILES) # ---- internals ---- - sig { params(path: T.untyped, out: T.untyped).returns(T.untyped) } + sig { params(path: T.untyped, out: T.untyped).returns(NilClass) } def scan_file(path, out) lines = File.readlines(path) i = T.let(0, Integer) @@ -150,7 +150,7 @@ def find_block_end(lines, start_idx, indent) # body satisfies `expecting_raise` (true == contains `raise_error`, # false == does not). Extract the first `<<~CLEAR ... CLEAR` heredoc # body within that `it`. - sig { params(block_lines: T.untyped, expecting_raise: T.untyped).returns(T.untyped) } + sig { params(block_lines: T.untyped, expecting_raise: T.untyped).returns(T.nilable(String)) } def extract_first_heredoc_in_it(block_lines, expecting_raise:) block_lines.each_with_index do |line, i| next unless line =~ /^(\s*)it\b/ @@ -170,7 +170,7 @@ def extract_first_heredoc_in_it(block_lines, expecting_raise:) # The heredoc body starts on the line AFTER the `<<~CLEAR` (or # legacy `<<~FLUX`) marker and ends at the next line whose only # non-whitespace is the matching marker name. - sig { params(body: T.untyped).returns(T.untyped) } + sig { params(body: T.untyped).returns(T.nilable(String)) } def extract_heredoc(body) return nil unless body =~ /<<~(CLEAR|FLUX)\b/ marker = $~[1] diff --git a/src/ast/parser.rb b/src/ast/parser.rb index dc9d9902c..cf494eca5 100644 --- a/src/ast/parser.rb +++ b/src/ast/parser.rb @@ -833,7 +833,7 @@ def parse_die() AST::DieNode.new(die_token, status) end - sig { returns(T.nilable(T::Array[T.untyped])) } + sig { returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } def parse_argument_list() parse_comma_seq(:CHAR, '(', ')') do takes = match!(:KEYWORD, 'TAKES') @@ -1516,7 +1516,7 @@ def parse_function_def(visibility = :package, is_method: false) # into `requires_clauses` so they don't pollute the capability-family hash. REQUIRES_REENTRANCE_KINDS = T.let(%w[NON_REENTRANT].to_set.freeze, T::Set[String]) - sig { returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { returns(T.nilable(T::Hash[String, T::Set[Symbol]])) } def parse_requires_clause requires_hash = {} @last_requires_clauses = {} @@ -2001,7 +2001,7 @@ def parse_if_chain(if_token) AST::IfStatement.new(if_token, condition, then_branch, else_branch) end - sig { params(if_token: Lexer::Token, bindings: T::Array[T::Hash[T.untyped, T.untyped]]).returns(AST::IfBind) } + sig { params(if_token: Lexer::Token, bindings: T::Array[T::Hash[Symbol, T.untyped]]).returns(AST::IfBind) } def parse_if_bind_body(if_token, bindings) consume(:KEYWORD, 'THEN') then_branch = parse_block_body(['ELSE', 'ELSE_IF', 'END']) @@ -2411,7 +2411,7 @@ def parse_stmts_until_end parse_block_body(['END']) end - sig { returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { returns(T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])) } def parse_struct_body _, pairs = parse_comma_seq(:CHAR, '{', '}') do name_tok = consume(:VAR_ID) @@ -2439,7 +2439,7 @@ def parse_struct_body # Cross-callsite type inference into named aggregates is intentionally not # supported; aggregate field types must be concrete. - sig { params(type: Type, field_name: String, field_tok: T.nilable(Lexer::Token), context_label: String).returns(T.untyped) } + sig { params(type: Type, field_name: String, field_tok: T.nilable(Lexer::Token), context_label: String).void } def reject_auto_in_aggregate_field!(type, field_name, field_tok, context_label) return unless type.is_a?(Type) && type.auto? auto_tok = type.respond_to?(:auto_token) ? type.auto_token : nil @@ -3036,7 +3036,7 @@ def parse_tap_op # Parses an optional `:sharded(N)` or `:soa` suffix after @pool or @list. # Returns { shard_count:, soa: } hash with parsed values. # Raises a ParserError if the syntax is malformed or N < 2. - sig { params(cap_tok: T.untyped).returns(T::Hash[Symbol, T.untyped]) } + sig { params(cap_tok: Lexer::Token).returns(T::Hash[Symbol, T.untyped]) } def parse_collection_modifiers!(cap_tok) result = T.let({ shard_count: nil, soa: false }, T::Hash[Symbol, T.untyped]) return result unless match?(:CHAR, ':') @@ -3484,7 +3484,7 @@ def parse_sync_policy_block # RETRY(N) THEN -- sugar for ON Transient # Returns a Hash { selectors:, kinds:, types:, action:, retries:, ... } or nil. # Selector validation (existence, retry-is-Transient) runs in the annotator. - sig { returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { returns(T.nilable(T::Hash[Symbol, T::Array[T.untyped]])) } def parse_lock_error_clause if match?(:KEYWORD, 'ON') consume(:KEYWORD, 'ON') @@ -3521,7 +3521,7 @@ def match_optional_retry! # selector; anything else is a type selector. Types are enum values # (no `:` prefix) per the unified error-system design; the 6 kind # names are effectively reserved. - sig { returns(T::Array[T.untyped]) } + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } def parse_error_selectors selectors = [parse_error_selector] while match!(:CHAR, ',') @@ -3678,7 +3678,7 @@ def parse_lock_rank_arg!(sigil_tok, attrs, dims) # Returns { pinned: Bool, stack_size: Symbol|nil }. # Only enters the prefix parser when the first token is a known DO branch sigil. # After `:`, the next identifier is normalised (@ prepended if absent). - sig { returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { returns(T.nilable(T::Hash[Symbol, T.nilable(FalseClass)])) } def parse_branch_prefix pinned = T.let(false, T::Boolean) parallel = T.let(false, T::Boolean) diff --git a/src/ast/scope.rb b/src/ast/scope.rb index 7b3978b86..ccd6b9681 100644 --- a/src/ast/scope.rb +++ b/src/ast/scope.rb @@ -20,7 +20,7 @@ def initialize @depth = T.let(0, Integer) end - sig { params(name: String, reg: T.untyped, type: T.untyped, is_mutable: T.untyped, is_rebindable: T::Boolean, size: T.nilable(Integer), storage: Symbol, capabilities: T::Set[T.untyped], _borrowed_paths: T::Array[T.untyped], sync: T.nilable(Symbol), layout: T.nilable(Symbol), resource: T.nilable(T::Boolean), close_zig: T.nilable(String)).returns(SymbolEntry) } + sig { params(name: String, reg: T.untyped, type: T.untyped, is_mutable: T.untyped, is_rebindable: T::Boolean, size: T.nilable(Integer), storage: Symbol, capabilities: T::Set[Symbol], _borrowed_paths: T::Array[T.untyped], sync: T.nilable(Symbol), layout: T.nilable(Symbol), resource: T.nilable(T::Boolean), close_zig: T.nilable(String)).returns(SymbolEntry) } def declare(name, reg, type, is_mutable = true, is_rebindable = false, size = nil, storage = :stack, capabilities = Set.new, _borrowed_paths = [], sync: nil, layout: nil, resource: nil, close_zig: nil) @owned_names.add(name) entry = SymbolEntry.new( @@ -241,7 +241,7 @@ def get_path_to_root(node) path end - sig { params(name: String).returns(T.untyped) } + sig { params(name: String).void } def check_validity!(name) entry = @locals[name] return unless entry diff --git a/src/ast/source_error.rb b/src/ast/source_error.rb index 62553abaf..91392142e 100644 --- a/src/ast/source_error.rb +++ b/src/ast/source_error.rb @@ -27,7 +27,7 @@ module ErrorHelper # `%{name}` interpolation against the hash. Legacy positional args # against `%s`/`%d` still work for the (shrinking) set of templates # that haven't been migrated to named form yet. - sig { params(node_or_token: T.untyped, code_or_message: T.untyped, args: T.untyped, kwargs: T.untyped).returns(T.untyped) } + sig { params(node_or_token: T.untyped, code_or_message: T.untyped, args: String, kwargs: T.untyped).returns(T.untyped) } def error!(node_or_token, code_or_message, *args, **kwargs) T.bind(self, T.untyped) rescue nil # 1. Extract the Token (works for AST Node or raw Token) diff --git a/src/ast/symbol_entry.rb b/src/ast/symbol_entry.rb index f7363afbb..2c641a7b3 100644 --- a/src/ast/symbol_entry.rb +++ b/src/ast/symbol_entry.rb @@ -141,7 +141,7 @@ def lifetime_sources # Build a tied lifetime from source SymbolEntries. Empty / nil input returns # nil so callers can pass collected captures through without a guard. - sig { params(sources: T.nilable(T::Array[SymbolEntry])).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(sources: T.nilable(T::Array[SymbolEntry])).returns(T.nilable(T::Hash[Symbol, T::Array[SymbolEntry]])) } def self.tied_lifetime(sources) return nil if sources.nil? || sources.empty? { sources: sources.uniq } diff --git a/src/ast/syntax_typo_scanner.rb b/src/ast/syntax_typo_scanner.rb index d63dc9d42..b08d283e4 100644 --- a/src/ast/syntax_typo_scanner.rb +++ b/src/ast/syntax_typo_scanner.rb @@ -35,7 +35,7 @@ module SyntaxTypoScanner { match: '=>', replace: '->', label: 'arrow (use `->`, not `=>`)' }, ].freeze, T::Array[T.untyped]) - sig { params(source: String).returns(T.untyped) } + sig { params(source: String).returns(NilClass) } def self.scan!(source) return unless FixCollector.enabled? return unless source && !source.empty? diff --git a/src/ast/type.rb b/src/ast/type.rb index e5ade5647..e90500d1f 100644 --- a/src/ast/type.rb +++ b/src/ast/type.rb @@ -436,7 +436,7 @@ def accepts?(other_type) end # Used specifically to check if assigning an array too large to a fixed array - sig { params(other_type: Type).returns(T.untyped) } + sig { params(other_type: Type).returns(T.nilable(T::Boolean)) } def array_overflow?(other_type) return false if !other_type.array? || !self.array? return false if self.base_type != other_type.base_type @@ -1096,7 +1096,7 @@ def tense_observable? # class references resolve at first-call time, after src/ast/ast.rb # has finished loading. type.rb is required from inside ast.rb, so # AST::SumOp is not yet defined while type.rb's class body evaluates. - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) } def self.observable_terminals @observable_terminals ||= T.let({ sum: { @@ -1170,7 +1170,7 @@ def self.observable_terminals # (and the existing observable_wrapper_zig method) can continue to # work without rewriting. Lazy via class method for the same load-order # reason as observable_terminals. - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[Symbol, Proc]) } def self.observable_wrappers T.must(@observable_wrappers = T.let(observable_terminals.transform_values { |e| e[:wrapper] }.freeze, T.nilable(T::Hash[T.untyped, T.untyped]))) end @@ -2301,7 +2301,7 @@ def is_safe_autocast?(source_type, target_type) # Called after coercion context is known for integer literals and constant-foldable # unary negations (e.g. -200). Errors if the value does not fit in the effective # target type. No-op for non-integer or non-literal nodes. - sig { params(node: T.untyped, effective_type: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped, effective_type: T.untyped).returns(NilClass) } def check_prefixed_int_range!(node, effective_type) T.bind(self, SemanticAnnotator) rescue nil val = if node.is_a?(AST::Literal) && (node.type == :PREFIXED_INT || node.type == :INT64) diff --git a/src/backends/pipeline_generator.rb b/src/backends/pipeline_generator.rb index 1eebc3900..7342b40ff 100644 --- a/src/backends/pipeline_generator.rb +++ b/src/backends/pipeline_generator.rb @@ -138,7 +138,7 @@ def build_pipe_items_block(lhs_type, _alloc) # Returns [min_sentinel, max_sentinel] for a given Zig numeric type. # min_sentinel is the initial value for a MIN accumulator (highest possible). # max_sentinel is the initial value for a MAX accumulator (lowest possible). - sig { params(zig_t: String, resolved_sym: Symbol).returns(T::Array[T.untyped]) } + sig { params(zig_t: String, resolved_sym: Symbol).returns(T::Array[String]) } def agg_minmax_sentinels(zig_t, resolved_sym) T.bind(self, T.untyped) rescue nil if [:Float32, :Float64].include?(resolved_sym) diff --git a/src/backends/pipeline_host.rb b/src/backends/pipeline_host.rb index 3b9ad58d3..d3557a394 100644 --- a/src/backends/pipeline_host.rb +++ b/src/backends/pipeline_host.rb @@ -754,7 +754,7 @@ def lower_soa_scalar_fold(site, fold_node) build_soa_scalar_fold_block(site, fold_node, label, source_mir, expr_mir, fields, needs_whole_item) end - sig { params(site: PipelineHost::PipelineSite, fold_node: T.untyped, label: String, source_mir: T.untyped, expr_mir: T.untyped, fields: T::Array[T.untyped], needs_whole_item: T::Boolean).returns(MIR::BlockExpr) } + sig { params(site: PipelineHost::PipelineSite, fold_node: T.untyped, label: String, source_mir: T.untyped, expr_mir: T.untyped, fields: T::Array[String], needs_whole_item: T::Boolean).returns(MIR::BlockExpr) } def build_soa_scalar_fold_block(site, fold_node, label, source_mir, expr_mir, fields, needs_whole_item) list_node = site.list lhs_type = list_node.type_info @@ -2618,7 +2618,7 @@ def lower_each_range(range_lit, stages, each_op) # opcode path drives iteration. The fusion stage_stmts (BreakStmt for # TAKE_WHILE/LIMIT, ContinueStmt for WHERE/SKIP) compose cleanly inside # a ForStmt, so semantics are preserved. - sig { params(range_lit: T.untyped, capture_name: T.untyped).returns(T::Array[T.untyped]) } + sig { params(range_lit: T.untyped, capture_name: T.nilable(String)).returns(T::Array[T.untyped]) } def bc_for_iter_range(range_lit, capture_name) start_mir = visit_mir(range_lit.start) end_mir = visit_mir(range_lit.finish) @@ -3427,7 +3427,7 @@ def concurrent_range_runtime_source?(lhs) # data source. This makes `users AS $u |> CONCURRENT SELECT $u.field` # work: substitute_placeholders rewrites $u to it inside the inner # expression at MIR-build time. - sig { params(lhs: T.untyped, conc_op: AST::ConcurrentOp, smooth_node: T.untyped).returns(T.untyped) } + sig { params(lhs: T.untyped, conc_op: AST::ConcurrentOp, smooth_node: T.untyped).returns(FsmOps::CallExpr) } def lower_concurrent_bc(lhs, conc_op, smooth_node) inner = conc_op.op @@ -3783,7 +3783,7 @@ def lower_shard_concurrent_each_zig(id, range_node, conc_op, each_op, ctx, # the failable expression, checks for an error sentinel, and only appends # on success. Result list element type is the SUCCESS type of the # failable expression (smooth_node.full_type). - sig { params(lhs: T.untyped, inner_expr: T.untyped, smooth_node: AST::BinaryOp).returns(T.untyped) } + sig { params(lhs: T.untyped, inner_expr: T.untyped, smooth_node: AST::BinaryOp).returns(MIR::BlockExpr) } def lower_bc_concurrent_select_prune(lhs, inner_expr, smooth_node) res_zig = transpile_type(T.must(T.must(smooth_node.full_type).element_type).resolved.to_s) alloc = pipeline_alloc(smooth_node) @@ -3810,7 +3810,7 @@ def lower_bc_concurrent_select_prune(lhs, inner_expr, smooth_node) # CONCURRENT WHERE ... OR PRUNE: predicate evaluation that raises is # treated as "false" (item skipped). Same loop shape as lower_where but # the truthiness check is gated by !isError. - sig { params(lhs: T.untyped, inner_expr: T.untyped, smooth_node: AST::BinaryOp).returns(T.untyped) } + sig { params(lhs: T.untyped, inner_expr: T.untyped, smooth_node: AST::BinaryOp).returns(MIR::BlockExpr) } def lower_bc_concurrent_where_prune(lhs, inner_expr, smooth_node) elem_type = lhs.full_type.element_type.resolved.to_s elem_zig = transpile_type(elem_type) diff --git a/src/lsp/analyzer.rb b/src/lsp/analyzer.rb index 50926ba84..f856eb5f7 100644 --- a/src/lsp/analyzer.rb +++ b/src/lsp/analyzer.rb @@ -34,7 +34,7 @@ def fatal?; !fatal_error.nil?; end # Result with the FixCollector findings and an optional # `fatal_error` (a synthetic FixableFinding) if the parser or # annotator raised. - sig { params(source: String).returns(T.untyped) } + sig { params(source: String).returns(Result) } def run(source) FixCollector.enable! findings = [] @@ -78,7 +78,7 @@ def fatal? end end - sig { params(err: T.untyped).returns(T.untyped) } + sig { params(err: T.untyped).returns(SyntheticFinding) } def synthetic_finding_from(err) tok = err.token ? err.token : SyntheticToken.new(line: 1, column: 1, value: "") SyntheticFinding.new( diff --git a/src/lsp/code_actions.rb b/src/lsp/code_actions.rb index 45c98974d..679c1db94 100644 --- a/src/lsp/code_actions.rb +++ b/src/lsp/code_actions.rb @@ -56,7 +56,7 @@ def for_range(document, request_range) # ---- internals ---- - sig { params(fix: T.untyped, _finding: T.untyped, diag: T.untyped, document: T.untyped, source: T.untyped).returns(T.untyped) } + sig { params(fix: T.untyped, _finding: T.untyped, diag: T.untyped, document: T.untyped, source: T.untyped).returns(T::Hash[T.untyped, T.untyped]) } def build_action(fix, _finding, diag, document, source) kind = fix.confidence == :auto ? KIND_QUICKFIX : KIND_REFACTOR edits = fix.edits.map { |e| build_text_edit(e, source) } @@ -80,7 +80,7 @@ def build_action(fix, _finding, diag, document, source) # Convert a Fix's Edit (line/col/length-based) into an LSP # TextEdit (range/newText). - sig { params(edit: T.untyped, source: T.untyped).returns(T.untyped) } + sig { params(edit: T.untyped, source: T.untyped).returns(T::Hash[T.untyped, T.untyped]) } def build_text_edit(edit, source) { range: Position.range_for_span(edit.span, source), diff --git a/src/lsp/diagnostics.rb b/src/lsp/diagnostics.rb index 609ed3450..86d07b7f6 100644 --- a/src/lsp/diagnostics.rb +++ b/src/lsp/diagnostics.rb @@ -69,7 +69,7 @@ def from_result(result, source_text = nil) # prefix, or a type suffix). When `source_text` is provided, scan # the source line at tok.column to recover the true byte span; # otherwise fall back to a quote-aware heuristic. - sig { params(tok: T.untyped, source_text: T.untyped).returns(T.untyped) } + sig { params(tok: T.untyped, source_text: T.untyped).returns(Integer) } def token_length(tok, source_text = nil) val = tok.respond_to?(:value) ? tok.value : nil if source_text && tok.respond_to?(:line) && tok.line && tok.respond_to?(:column) && tok.column @@ -88,7 +88,7 @@ def token_length(tok, source_text = nil) # Scan a source slice (starting at the token's column) for the # token's textual span. Returns nil when the slice doesn't begin # with a recognizable literal — caller falls back. - sig { params(rest: T.untyped).returns(T.untyped) } + sig { params(rest: T.untyped).returns(T.nilable(Integer)) } def literal_span_in(rest) return nil if rest.nil? || rest.empty? if rest.start_with?('"""') @@ -109,7 +109,7 @@ def literal_span_in(rest) m ? m[0].length : nil end - sig { params(val: T.untyped).returns(T.untyped) } + sig { params(val: T.untyped).returns(Integer) } def fallback_token_length(val) case val when String @@ -131,7 +131,7 @@ def fallback_token_length(val) # start with "Cannot read field '"), so a prefix-only match would # mis-stamp; full-template matching disambiguates on the trailing # literal segments. - sig { params(finding: T.untyped).returns(T.untyped) } + sig { params(finding: T.untyped).returns(T.nilable(String)) } def code_for(finding) msg = finding.message.to_s return nil if msg.empty? diff --git a/src/lsp/document_store.rb b/src/lsp/document_store.rb index b1973164d..330bb3bf6 100644 --- a/src/lsp/document_store.rb +++ b/src/lsp/document_store.rb @@ -25,7 +25,7 @@ class DocumentStore # after each `analyze_and_publish` pass. sig { returns(T.untyped) } def cached_findings; @cached_findings = T.let(@cached_findings, T.untyped); end - sig { params(value: T.untyped).returns(T.untyped) } + sig { params(value: T.untyped).void } def cached_findings=(value); @cached_findings = value; end sig { returns(T.nilable(Integer)) } def cached_version; @cached_version = T.let(@cached_version, T.nilable(Integer)); end diff --git a/src/lsp/hover.rb b/src/lsp/hover.rb index bb40dfdbf..e55b9c1ec 100644 --- a/src/lsp/hover.rb +++ b/src/lsp/hover.rb @@ -27,7 +27,7 @@ module Hover # Build a hover response for the document at `position`. Returns # nil when no diagnostic overlaps the cursor. - sig { params(document: T.untyped, position: T.untyped).returns(T.untyped) } + sig { params(document: T.untyped, position: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def render(document, position) return nil unless document result = document.cached_findings diff --git a/src/lsp/logger.rb b/src/lsp/logger.rb index 4f5c7ce4d..b5b343596 100644 --- a/src/lsp/logger.rb +++ b/src/lsp/logger.rb @@ -15,11 +15,11 @@ def initialize(level: :info, io: $stderr) @io = T.let(io, IO) end - sig { params(msg: String).returns(T.untyped) } + sig { params(msg: String).returns(T.nilable(IO)) } def debug(msg); log(:debug, msg); end - sig { params(msg: String).returns(T.untyped) } + sig { params(msg: String).returns(T.nilable(IO)) } def info(msg); log(:info, msg); end - sig { params(msg: String).returns(T.untyped) } + sig { params(msg: String).returns(T.nilable(IO)) } def warn(msg); log(:warn, msg); end sig { params(msg: String).returns(IO) } def error(msg); T.must(log(:error, msg)); end diff --git a/src/lsp/server.rb b/src/lsp/server.rb index 4c63fd8f7..695652405 100644 --- a/src/lsp/server.rb +++ b/src/lsp/server.rb @@ -174,7 +174,7 @@ def handle_shutdown(_params) # `exit` notification — terminate. Per LSP, exit code 0 if a # `shutdown` was received first, 1 otherwise. - sig { returns(T.untyped) } + sig { returns(T.noreturn) } def handle_exit @logger.info("exit (clean=#{@shutdown_requested})") Kernel.exit(@shutdown_requested ? 0 : 1) @@ -214,7 +214,7 @@ def handle_did_change(params) # `textDocument/didSave` — re-analyze immediately (save is an # explicit user action; no need to debounce). - sig { params(params: T::Hash[T.untyped, T.untyped]).returns(T.untyped) } + sig { params(params: T::Hash[T.untyped, T.untyped]).returns(T.nilable(IO)) } def handle_did_save(params) uri = params["textDocument"]["uri"] @logger.debug("didSave #{uri}") @@ -224,7 +224,7 @@ def handle_did_save(params) # `textDocument/didClose` — drop the document and clear any # pending diagnostics on the client. - sig { params(params: T::Hash[T.untyped, T.untyped]).returns(T.untyped) } + sig { params(params: T::Hash[T.untyped, T.untyped]).returns(T.nilable(IO)) } def handle_did_close(params) uri = params["textDocument"]["uri"] cancel_timer(uri) @@ -278,7 +278,7 @@ def analyze_and_publish(uri) end # Send a `textDocument/publishDiagnostics` notification. - sig { params(uri: String, diagnostics: T::Array[T.untyped]).returns(T.untyped) } + sig { params(uri: String, diagnostics: T::Array[T.untyped]).returns(T.nilable(IO)) } def publish_diagnostics(uri, diagnostics) send_message( jsonrpc: "2.0", diff --git a/src/mir/concurrency_checks.rb b/src/mir/concurrency_checks.rb index 20282a6cd..6457ded04 100644 --- a/src/mir/concurrency_checks.rb +++ b/src/mir/concurrency_checks.rb @@ -28,7 +28,7 @@ module ConcurrencyChecks # `lock_ranks` is a Hash {type_sym => rank}; bindings whose declared # type appears here participate in the rank-DAG protocol; the rank-cycle # analysis owns ordering for those bindings. - sig { params(fn_nodes: T.untyped, sig_lookup: T.untyped, error_handler: T.untyped, lock_ranks: T.untyped).returns(T.untyped) } + sig { params(fn_nodes: T.untyped, sig_lookup: T.untyped, error_handler: T.untyped, lock_ranks: T.untyped).void } def check_all!(fn_nodes, sig_lookup, error_handler, lock_ranks: {}) fn_nodes.each_value do |fn| next unless fn&.body @@ -43,7 +43,7 @@ def check_all!(fn_nodes, sig_lookup, error_handler, lock_ranks: {}) # this WITH body. The yield property itself is read from the existing # annotator-stamped effect set (fn.effects, populated by record_effect # at visit_BgBlock / visit_NextExpr and propagated by compute_effects!). - sig { params(fn: T.untyped, fn_nodes: T.untyped, error_handler: T.untyped).returns(T.untyped) } + sig { params(fn: T.untyped, fn_nodes: T.untyped, error_handler: T.untyped).void } def check_hold_across_yield!(fn, fn_nodes, error_handler) walk_with_blocks(fn.body) do |with_block, scope| walk_scope_no_nested_with(scope) do |node| @@ -84,7 +84,7 @@ def check_hold_across_yield!(fn, fn_nodes, error_handler) # checks; reentrant detection covers the dangerous case). LOCK_HOLDING_CAPABILITIES = T.let(%i[EXCLUSIVE write_locked_read infer].to_set.freeze, T::Set[Symbol]) - sig { params(fn: T.untyped, error_handler: T.untyped, lock_ranks: T.untyped).returns(T.untyped) } + sig { params(fn: T.untyped, error_handler: T.untyped, lock_ranks: T.untyped).void } def check_naked_nested_with!(fn, error_handler, lock_ranks = {}) walk_with_blocks(fn.body) do |outer, outer_scope| outer_lock_names = lock_holding_names(outer) @@ -132,7 +132,7 @@ def any_lock_rank?(with_block, lock_ranks) # Names of bindings that the WithBlock acquires a LOCK on (vs. # borrow-only captures). - sig { params(with_block: T.untyped).returns(T.untyped) } + sig { params(with_block: T.untyped).returns(T::Set[T.untyped]) } def lock_holding_names(with_block) out = Set.new (with_block.capabilities || []).each do |cap| @@ -195,7 +195,7 @@ def walk_with_blocks(body, &blk) # Walk a WithBlock's scope. Descend into IF/WHILE/FOR/ etc., but stop # at nested WithBlocks (they own their own checks) and lambdas. - sig { params(stmts: T.untyped, blk: T.untyped).returns(T.untyped) } + sig { params(stmts: T.untyped, blk: T.untyped).returns(NilClass) } def walk_scope_no_nested_with(stmts, &blk) stack = stmts.is_a?(Array) ? stmts.dup : [stmts] until stack.empty? @@ -218,7 +218,7 @@ def walk_scope_no_nested_with(stmts, &blk) # Find nested WithBlocks within a scope (no recursion into them; we # only care about the topmost nested one per branch). - sig { params(stmts: T.untyped, blk: T.untyped).returns(T.untyped) } + sig { params(stmts: T.untyped, blk: T.untyped).returns(NilClass) } def walk_scope_for_nested_with(stmts, &blk) walk_scope_no_nested_with(stmts) do |node| yield(node) if node.is_a?(AST::WithBlock) @@ -226,7 +226,7 @@ def walk_scope_for_nested_with(stmts, &blk) end # Names of function parameters held by a WithBlock's bindings. - sig { params(with_block: T.untyped, fn: T.untyped).returns(T.untyped) } + sig { params(with_block: T.untyped, fn: T.untyped).returns(T::Set[T.untyped]) } def collect_held_params(with_block, fn) return Set.new unless fn.respond_to?(:params) param_names = fn.params.map { |p| p[:name].to_s }.to_set diff --git a/src/mir/control_flow.rb b/src/mir/control_flow.rb index 553432423..2b611c96f 100644 --- a/src/mir/control_flow.rb +++ b/src/mir/control_flow.rb @@ -45,7 +45,7 @@ def add_successor(block) block.predecessors << self unless block.predecessors.include?(self) end - sig { returns(T.untyped) } + sig { void } def terminator @stmts.last end @@ -327,7 +327,7 @@ def consumed # needs_cleanup are immutable properties set at declaration, never change). OwnerEntry = Struct.new(:state, :allocator, :needs_cleanup, keyword_init: true) do extend T::Sig - sig { params(other: T.anything).returns(T.untyped) } + sig { params(other: T.anything).returns(T::Boolean) } def ==(other) case other when OwnerEntry then state == other.state @@ -405,14 +405,14 @@ def analyze! end # Ownership state at function exit (join of all paths reaching exit_block). - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, OwnershipDataflow::OwnerEntry]) } def exit_states @block_in[@cfg.exit_block.id] || {} end # Per-variable summary: { name => { needs_cleanup: bool, has_moved_guard: bool } } # Backward-compatible: reads .state from OwnerEntry. - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, T::Hash[Symbol, T::Boolean]]) } def cleanup_summary summary = {} exit_states.each do |name, entry| @@ -443,7 +443,7 @@ def cleanup_summary # error unwind) -> no defer needed. # 3. Exception: MATCH TAKES unions that are moved on all paths still need # a guard because non-AS branches don't extract ownership. - sig { params(fn_node: AST::FunctionDef, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T::Hash[String, T::Hash[T.untyped, T.untyped]]) } + sig { params(fn_node: AST::FunctionDef, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) } def cleanup_decisions!(fn_node, bindings) summary = cleanup_summary @@ -503,7 +503,7 @@ def self.analyze(fn_node, can_fail_fns: nil, schema_lookup: nil) # TAKES params start as :owned (callee must clean them up). # TAKES params are always heap-allocated (caller passes heap ownership). - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, OwnershipDataflow::OwnerEntry]) } def init_entry_state state = {} (@fn_node.params || []).each do |p| @@ -833,7 +833,7 @@ def collect_bg_captures_in_args(stmt, state) consumed end - sig { params(expr: T.untyped, state: T::Hash[String, OwnershipDataflow::OwnerEntry], consumed: T::Array[T.untyped]).returns(T.nilable(T::Array[T.untyped])) } + sig { params(expr: T.untyped, state: T::Hash[String, OwnershipDataflow::OwnerEntry], consumed: T::Array[String]).returns(T.nilable(T::Array[T.untyped])) } def _walk_bg_captures_in_expr(expr, state, consumed) return unless expr case expr @@ -1283,7 +1283,7 @@ def self.walk_stmts!(stmts) stmts.each { |s| walk_stmt!(s) } end - sig { params(stmt: T.untyped).returns(T.nilable(T::Array[T.untyped])) } + sig { params(stmt: T.untyped).returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } def self.walk_stmt!(stmt) case stmt when AST::WhileLoop, AST::WhileBindLoop @@ -1489,7 +1489,7 @@ def self.promote_outer_mutations!(body) # Used to detect outer-string-reassignment patterns like # `resp = resp + i.toString()` or `last = makePrefix(i)` where the RHS creates # a frame string that would be freed by the loop's per-iteration rewind. - sig { params(expr: T.untyped, names: T.untyped).returns(T.untyped) } + sig { params(expr: T.untyped, names: T.untyped).returns(T::Boolean) } def self.rhs_references_any?(expr, names) return false unless expr # COPY/CLONE produce a detached value/handle -- carry var doesn't need promotion @@ -1594,7 +1594,7 @@ def self.promote_value_to_heap!(node) end # Promote a container Identifier's declaration to heap. - sig { params(ident_node: AST::Identifier).returns(T.untyped) } + sig { params(ident_node: AST::Identifier).returns(T.nilable(Symbol)) } def self.promote_to_heap!(ident_node) decl_node = ident_node.symbol&.reg return unless decl_node @@ -1609,7 +1609,7 @@ def self.promote_to_heap!(ident_node) end # Promote a frame-allocated declaration whose value escapes this loop. - sig { params(decl_node: T.untyped).returns(T.untyped) } + sig { params(decl_node: T.untyped).returns(T.nilable(Symbol)) } def self.promote_decl_to_heap!(decl_node) decl_ti = Type.from_node(decl_node) return unless decl_ti.is_a?(Type) @@ -1883,7 +1883,7 @@ def handle_with_block(stmt) # Check if any identifier being moved in a binding RHS is currently borrowed. # Mirrors OwnershipDataflow#collect_binding_moves. - sig { params(expr: T.untyped, token: Lexer::Token).returns(T.nilable(T::Set[T.untyped])) } + sig { params(expr: T.untyped, token: Lexer::Token).returns(T.nilable(T::Set[String])) } def check_binding_moves(expr, token) return unless expr moved = collect_moved_names(expr) diff --git a/src/mir/escape_analysis.rb b/src/mir/escape_analysis.rb index fee9d37c0..ab1a80c1e 100644 --- a/src/mir/escape_analysis.rb +++ b/src/mir/escape_analysis.rb @@ -599,7 +599,7 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) end # Find the first Identifier matching var_name across all return node values. - sig { params(return_nodes: T::Array[T.untyped], var_name: String).returns(T.untyped) } + sig { params(return_nodes: T::Array[T.untyped], var_name: String).returns(T.nilable(AST::Identifier)) } private_class_method def self.e2_find_return_ident(return_nodes, var_name) return_nodes.each do |ret| next unless ret.value @@ -702,7 +702,7 @@ def self.tag_transitive_provenance!(fn_nodes, heap_fns) # value. Params with declared sync (legacy) are not overwritten. # # @param fn_nodes [Hash] name -> AST::FunctionDef - sig { params(fn_nodes: T::Hash[String, T.untyped]).returns(T.untyped) } + sig { params(fn_nodes: T::Hash[String, T.untyped]).void } def self.propagate_caller_sync!(fn_nodes) return if fn_nodes.empty? @@ -765,7 +765,7 @@ def self.propagate_caller_sync!(fn_nodes) # Walk every Locatable descendant (incl. expression sub-trees), record # FuncCalls. - sig { params(body: T::Array[T.untyped], callsites: T::Hash[String, T::Array[T.untyped]]).returns(T.untyped) } + sig { params(body: T::Array[T.untyped], callsites: T::Hash[String, T::Array[T.untyped]]).returns(NilClass) } private_class_method def self.collect_callsites_deep(body, callsites) stack = body.is_a?(Array) ? body.dup : [body] until stack.empty? diff --git a/src/mir/fsm_lowering.rb b/src/mir/fsm_lowering.rb index 2508c729f..73724f699 100644 --- a/src/mir/fsm_lowering.rb +++ b/src/mir/fsm_lowering.rb @@ -247,7 +247,7 @@ def render_mir_list(mir_list) # isn't a lock-suspending capability or its target isn't a BG # capture. Consumed by FsmTransform::Emit.expand_lock_segment # (per-cap fan-out) for both single-cap and multi-cap WITH. - sig { params(cap: T::Hash[Symbol, T.untyped], with_node: AST::WithBlock, ctx_id: Integer, captured: T::Hash[String, Type]).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(cap: T::Hash[Symbol, T.untyped], with_node: AST::WithBlock, ctx_id: Integer, captured: T::Hash[String, Type]).returns(T.nilable(T::Hash[Symbol, T::Hash[Symbol, T.untyped]])) } def fsm_cap_metadata(cap, with_node, ctx_id, captured) T.bind(self, MIRLowering) rescue nil return nil unless cap[:capability] == :EXCLUSIVE || diff --git a/src/mir/fsm_transform/emit.rb b/src/mir/fsm_transform/emit.rb index 369f465f1..e0bf52a68 100644 --- a/src/mir/fsm_transform/emit.rb +++ b/src/mir/fsm_transform/emit.rb @@ -691,7 +691,7 @@ def check_fsm_cleanup_invariant!(seg_codes, segments, liveness, # # Returns nil on metadata / error-arm failure (caller falls # back to stackful). - sig { params(spec: T.untyped, ctx: T.untyped, capture_map: T.untyped, lowering: T.untyped, base_idx: T.untyped).returns(T.untyped) } + sig { params(spec: T.untyped, ctx: T.untyped, capture_map: T.untyped, lowering: T.untyped, base_idx: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def expand_lock_segment(spec, ctx, capture_map, lowering, base_idx) T.bind(self, T.untyped) rescue nil tail = spec[:tail] @@ -856,7 +856,7 @@ def build_segment_descriptor(seg, ctx, lowering, capture_map, sp_idx: nil) # encountered. Returns { seg.index => sp_N }. Suspends that are # unreachable from index 0 fall back to a follow-up scan # (rare). - sig { params(segments: T.untyped).returns(T.untyped) } + sig { params(segments: T.untyped).returns(T::Hash[T.untyped, T.untyped]) } def compute_sp_indices(segments) T.bind(self, T.untyped) rescue nil out = {} @@ -896,7 +896,7 @@ def compute_sp_indices(segments) # Shared spawn/init/break setup. Identical across all FSM # body shapes. - sig { params(ctx: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(ctx: T.untyped, lowering: T.untyped).returns(MIR::FsmSpawnSetup) } def build_spawn_setup(ctx, lowering) T.bind(self, T.untyped) rescue nil is_local_pin = (ctx[:pin_mode] == true || ctx[:pin_mode] == :local) @@ -939,7 +939,7 @@ def build_spawn_setup(ctx, lowering) ) end - sig { params(dispatch: T.untyped).returns(T.untyped) } + sig { params(dispatch: T.untyped).returns(Integer) } def profile_dispatch_id(dispatch) T.bind(self, T.untyped) rescue nil case dispatch @@ -950,7 +950,7 @@ def profile_dispatch_id(dispatch) end end - sig { params(ctx: T.untyped, dispatch: T.untyped, form: T.untyped).returns(T.untyped) } + sig { params(ctx: T.untyped, dispatch: T.untyped, form: T.untyped).returns(String) } def bg_profile_site_comment(ctx, dispatch, form) T.bind(self, T.untyped) rescue nil "// CLEAR_PROFILE_TASK_SITE id=#{ctx[:profile_site_id]} kind=BG line=#{ctx[:profile_line]} column=#{ctx[:profile_column]} dispatch=#{dispatch} form=#{form}" diff --git a/src/mir/fsm_transform/liveness.rb b/src/mir/fsm_transform/liveness.rb index d5bf0b339..de3886a7e 100644 --- a/src/mir/fsm_transform/liveness.rb +++ b/src/mir/fsm_transform/liveness.rb @@ -35,7 +35,7 @@ module Liveness # already-known state field names that must be excluded # (captures aren't local-defined in the body; suspend-stash # fields are added by the emitter, not the body). - sig { params(segments: T.untyped, ctx: T.untyped).returns(T.untyped) } + sig { params(segments: T.untyped, ctx: T.untyped).returns(Result) } def analyze(segments, ctx) capture_names = (ctx[:captured] || {}).keys.to_set @@ -116,7 +116,7 @@ def analyze(segments, ctx) # tail edges (i.e. members of a non-trivial strongly-connected # component, or have a self-loop). Used to widen the live-set # for back-edge cases like B2-LOOP's cond+loop_pre+loop_post. - sig { params(segments: T.untyped).returns(T.untyped) } + sig { params(segments: T.untyped).returns(T::Set[T.untyped]) } def compute_cyclic_segments(segments) adj = {} segments.each { |seg| adj[seg.index] = tail_targets(seg) } @@ -171,7 +171,7 @@ def tail_targets(seg) # inside the body and stash into ctx.sp; subsequent steps # reference ctx.sp, not the original identifiers, so no extra # tail reads are recorded here. - sig { params(seg: T.untyped, uses_by_seg: T.untyped).returns(T.untyped) } + sig { params(seg: T.untyped, uses_by_seg: T.untyped).void } def collect_tail_uses(seg, uses_by_seg) tail = seg.tail case tail @@ -192,7 +192,7 @@ def collect_tail_uses(seg, uses_by_seg) # Type resolution mirrors the legacy collect_fsm_promoted_locals # fallback chain so consumers (FSM ctx-field decl emission) # always have a usable type. - sig { params(stmt: T.untyped, into: T.untyped).returns(T.untyped) } + sig { params(stmt: T.untyped, into: T.untyped).void } def collect_defs(stmt, into) case stmt when AST::VarDecl, AST::BindExpr @@ -213,7 +213,7 @@ def collect_defs(stmt, into) end end - sig { params(stmt: T.untyped).returns(T.untyped) } + sig { params(stmt: T.untyped).returns(T::Hash[T.untyped, T.untyped]) } def stmt_decl_type(stmt) candidates = [] candidates << stmt.full_type diff --git a/src/mir/fsm_transform/recursive_splitter.rb b/src/mir/fsm_transform/recursive_splitter.rb index 7b9a8c652..073956d76 100644 --- a/src/mir/fsm_transform/recursive_splitter.rb +++ b/src/mir/fsm_transform/recursive_splitter.rb @@ -84,7 +84,7 @@ def with_alias_overrides(overrides, &blk) @current_alias_overrides = prev end - sig { params(idx: Integer).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(idx: Integer).returns(T.nilable(T::Hash[String, String])) } def stamp_overrides(idx) T.bind(self, T.untyped) rescue nil return if @current_alias_overrides.nil? || @current_alias_overrides.empty? @@ -94,7 +94,7 @@ def stamp_overrides(idx) # Synthetic ctx field decls produced by control-flow-form # synthesis (e.g. ForRange's iter / user var). The unified # emit reads these and adds them to extra_ctx_fields. - sig { params(decl: String).returns(T.nilable(T::Array[T.untyped])) } + sig { params(decl: String).returns(T.nilable(T::Array[String])) } def add_synthetic_field(decl) T.bind(self, T.untyped) rescue nil @synthetic_fields << decl unless @synthetic_fields.include?(decl) @@ -129,7 +129,7 @@ def push(stmts, tail) idx end - sig { returns(T::Array[T.untyped]) } + sig { returns(T::Array[FsmTransform::Segments::Segment]) } def finalize T.bind(self, T.untyped) rescue nil unfilled = @segments.each_with_index.select { |s, _| s == :placeholder } @@ -149,7 +149,7 @@ def finalize # `lowering` is used inside emit_*_fragment for cond-rendering # (loop / if conditions are lowered to Zig text at split time # since they appear in CondBranch tails as raw Zig). - sig { params(body: T.untyped, lowering: T.untyped, ctx: BasicObject).returns(T.untyped) } + sig { params(body: T.untyped, lowering: T.untyped, ctx: BasicObject).returns(T.nilable(T::Array[T.untyped])) } def split(body, lowering, ctx: nil) T.bind(self, T.untyped) rescue nil return nil if contains_unsupported?(body) @@ -243,7 +243,7 @@ def emit_stmts(stmts, after_idx, builder, lowering) # Suspend with pre-stmts in the same segment. The pre's locals # live in the same Zig fn as the descriptor's setup_stmts. - sig { params(susp_tail: T.untyped, pre: T.untyped, after_idx: BasicObject, builder: T.untyped).returns(T.untyped) } + sig { params(susp_tail: T.untyped, pre: T.untyped, after_idx: BasicObject, builder: T.untyped).returns(Integer) } def emit_suspend_with_pre(susp_tail, pre, after_idx, builder) T.bind(self, T.untyped) rescue nil idx = builder.reserve_index @@ -268,7 +268,7 @@ def emit_suspend_with_pre(susp_tail, pre, after_idx, builder) # Does this stmt introduce a segment split? True for top-level # suspends and control-flow constructs whose subtree contains a # suspend (including a WithBlock with a lock-suspending cap). - sig { params(stmt: T.anything).returns(T.untyped) } + sig { params(stmt: T.anything).returns(T::Boolean) } def stmt_introduces_split?(stmt) T.bind(self, T.untyped) rescue nil return true if Segments.classify_suspend(stmt) @@ -396,7 +396,7 @@ def stmt_unsupported_suspend?(stmt) end # Dispatch to the appropriate fragment emitter. - sig { params(stmt: T.untyped, after_idx: T.untyped, builder: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(stmt: T.untyped, after_idx: T.untyped, builder: T.untyped, lowering: T.untyped).returns(Integer) } def emit_pivot(stmt, after_idx, builder, lowering) T.bind(self, T.untyped) rescue nil sus = Segments.classify_suspend(stmt) @@ -421,7 +421,7 @@ def emit_pivot(stmt, after_idx, builder, lowering) # Suspend fragment: a single segment whose tail is the suspend. # The tail's next_index is set to after_idx so the resume # transitions to wherever this fragment exits. - sig { params(susp_tail: T.untyped, after_idx: BasicObject, builder: T.untyped).returns(T.untyped) } + sig { params(susp_tail: T.untyped, after_idx: BasicObject, builder: T.untyped).returns(Integer) } def emit_suspend(susp_tail, after_idx, builder) T.bind(self, T.untyped) rescue nil idx = builder.reserve_index @@ -447,7 +447,7 @@ def emit_suspend(susp_tail, after_idx, builder) # # cond_seg: [], CondBranch(cond_zig, body_entry, after_idx) # body_segs (recursive): exit to cond_seg's index - sig { params(while_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(while_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(Integer) } def emit_while_fragment(while_stmt, after_idx, builder, lowering) T.bind(self, T.untyped) rescue nil cond_idx = builder.reserve_index @@ -475,7 +475,7 @@ def emit_while_fragment(while_stmt, after_idx, builder, lowering) # cond_seg: [], CondBranch(ctx.__for_X < end, body_entry, after_idx) # body_segs: exit to incr_seg # incr_seg: [ctx.__for_X += 1; ctx.var = ctx.__for_X], Goto(cond) - sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(Integer) } def emit_for_range_fragment(for_stmt, after_idx, builder, lowering) T.bind(self, T.untyped) rescue nil var_name = for_stmt.var_name @@ -521,7 +521,7 @@ def emit_for_range_fragment(for_stmt, after_idx, builder, lowering) # fsm_foreach_descriptor (defined on Type) so adding a new # collection = one new branch on Type#fsm_foreach_descriptor. # The splitter never inspects the type directly. - sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(Integer) } def emit_for_each_fragment(for_stmt, after_idx, builder, lowering) T.bind(self, T.untyped) rescue nil coll_ast = for_stmt.collection @@ -564,7 +564,7 @@ def emit_for_each_fragment(for_stmt, after_idx, builder, lowering) end end - sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped, coll_zig: T.untyped, var_name: T.untyped, ctx_var: T.untyped, elem_zig: T.untyped, counter: T.untyped, desc: T.untyped, ct: T.untyped).returns(T.untyped) } + sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped, coll_zig: T.nilable(String), var_name: T.untyped, ctx_var: String, elem_zig: T.untyped, counter: Integer, desc: T.untyped, ct: T.untyped).returns(Integer) } def emit_for_each_iterator(for_stmt, after_idx, builder, lowering, coll_zig, var_name, ctx_var, elem_zig, counter, desc, ct) @@ -612,7 +612,7 @@ def emit_for_each_iterator(for_stmt, after_idx, builder, lowering, # Indexed slice: list / array. ctx.__feidx walks 0..len; body_init # assigns ctx.var from the slice; incr bumps the idx. - sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped, coll_zig: T.untyped, var_name: T.untyped, ctx_var: T.untyped, elem_zig: T.untyped, counter: T.untyped, slice_suffix: T.untyped).returns(T.untyped) } + sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped, coll_zig: T.nilable(String), var_name: T.untyped, ctx_var: String, elem_zig: T.untyped, counter: Integer, slice_suffix: T.untyped).returns(Integer) } def emit_for_each_indexed(for_stmt, after_idx, builder, lowering, coll_zig, var_name, ctx_var, elem_zig, counter, slice_suffix) @@ -645,7 +645,7 @@ def emit_for_each_indexed(for_stmt, after_idx, builder, lowering, # Pool indexed: like :indexed_slice but body_init has a skip-dead # branch that Gotos straight to incr when the slot's `alive` flag # is false. - sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped, coll_zig: T.untyped, var_name: T.untyped, ctx_var: T.untyped, elem_zig: T.untyped, counter: T.untyped).returns(T.untyped) } + sig { params(for_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped, coll_zig: T.nilable(String), var_name: T.untyped, ctx_var: String, elem_zig: T.untyped, counter: Integer).returns(Integer) } def emit_for_each_pool(for_stmt, after_idx, builder, lowering, coll_zig, var_name, ctx_var, elem_zig, counter) T.bind(self, T.untyped) rescue nil @@ -683,7 +683,7 @@ def emit_for_each_pool(for_stmt, after_idx, builder, lowering, # IF fragment: pre + CondBranch to then-entry / else-entry. Both # branches Goto to after_idx (the convergence point). - sig { params(if_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(if_stmt: T.untyped, after_idx: BasicObject, builder: T.untyped, lowering: T.untyped).returns(Integer) } def emit_if_fragment(if_stmt, after_idx, builder, lowering) T.bind(self, T.untyped) rescue nil then_branch = if_stmt.then_branch.is_a?(Array) ? diff --git a/src/mir/fsm_transform/segments.rb b/src/mir/fsm_transform/segments.rb index 048808867..a0e1b1472 100644 --- a/src/mir/fsm_transform/segments.rb +++ b/src/mir/fsm_transform/segments.rb @@ -113,7 +113,7 @@ def kind; :cond_branch; end # # Adding new shapes (IF with suspend, WhileLoop+IO, etc.) extends # this method's case dispatch + adds a new tail variant if needed. - sig { params(body: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(body: T.untyped, lowering: T.untyped).returns(T.nilable(T::Array[Segment])) } def split(body, lowering) # Rewrite pipeline+IO shapes (`readFile(p) |> stage`) into # linear stmts so the standard splitter sees the suspending @@ -158,7 +158,7 @@ def split(body, lowering) # 2 loop_pre -- NextSuspend / IoSuspend -> 3 # 3 loop_post -- LoopBack(1) # 4 post -- Done - sig { params(body: T.untyped).returns(T.untyped) } + sig { params(body: T.untyped).returns(T.nilable(T::Array[T.untyped])) } def split_while_loop_next(body) T.bind(self, T.untyped) rescue nil return nil unless body.is_a?(Array) @@ -351,7 +351,7 @@ def suspending_call?(expr) # (suspending pipeline as the value of a bind) # - Multi-stage chains where the suspend isn't at the LHS-most # position - sig { params(body: T.untyped).returns(T.untyped) } + sig { params(body: T.untyped).returns(T::Array[T.untyped]) } def rewrite_pipeline_io(body) T.bind(self, T.untyped) rescue nil return body unless body.is_a?(Array) diff --git a/src/mir/fsm_transform/suspend_resolvers.rb b/src/mir/fsm_transform/suspend_resolvers.rb index fdec2f195..5e987680c 100644 --- a/src/mir/fsm_transform/suspend_resolvers.rb +++ b/src/mir/fsm_transform/suspend_resolvers.rb @@ -25,7 +25,7 @@ module SuspendResolvers # (e.g. "__rt_bg0"). # `lowering` provides .lower(ast_node) and is used inside the # caller's capture-map context (set up via with_fiber_capture_map). - sig { params(seg: T.untyped, ctx: T.untyped, lowering: T.untyped, susp_idx: T.untyped).returns(T.untyped) } + sig { params(seg: T.untyped, ctx: T.untyped, lowering: T.untyped, susp_idx: T.untyped).returns(MIR::SuspendDescriptor) } def resolve(seg, ctx, lowering, susp_idx: nil) T.bind(self, T.untyped) rescue nil case seg.tail @@ -52,7 +52,7 @@ def resolve(seg, ctx, lowering, susp_idx: nil) # ctx_field_decls: rendered fsm_state_decls # result_var / result_zig_type: from the call's return type + # the bound name in the body stmt - sig { params(io_tail: T.untyped, ctx: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(io_tail: T.untyped, ctx: T.untyped, lowering: T.untyped).returns(MIR::SuspendDescriptor) } def resolve_io(io_tail, ctx, lowering) T.bind(self, T.untyped) rescue nil stdlib_def = io_tail.stdlib_def @@ -131,7 +131,7 @@ def resolve_io(io_tail, ctx, lowering) # The dispatch arm already registered/yielded or observed count==0, # so finishFsmNext consumes the settled result and destroys Inner # without blocking the scheduler thread. - sig { params(next_tail: T.untyped, ctx: T.untyped, lowering: T.untyped, susp_idx: T.untyped).returns(T.untyped) } + sig { params(next_tail: T.untyped, ctx: T.untyped, lowering: T.untyped, susp_idx: T.untyped).returns(MIR::SuspendDescriptor) } def resolve_next(next_tail, ctx, lowering, susp_idx:) T.bind(self, T.untyped) rescue nil id = ctx[:id] diff --git a/src/mir/fsm_wrapper_emitter.rb b/src/mir/fsm_wrapper_emitter.rb index c0a8faf5e..8c7ca9f3d 100644 --- a/src/mir/fsm_wrapper_emitter.rb +++ b/src/mir/fsm_wrapper_emitter.rb @@ -84,7 +84,7 @@ def render_b1_body(body) parts.join("\n") end - sig { params(s: T.untyped, mir_emitter: T.untyped).returns(String) } + sig { params(s: T.untyped, mir_emitter: MIREmitter).returns(String) } def render_b1_ctx_struct(s, mir_emitter) T.bind(self, T.untyped) rescue nil parts = [] @@ -104,7 +104,7 @@ def render_b1_ctx_struct(s, mir_emitter) parts.join("\n") end - sig { params(step: T.untyped, mir_emitter: T.untyped).returns(String) } + sig { params(step: T.untyped, mir_emitter: MIREmitter).returns(String) } def render_run_body(step, mir_emitter) T.bind(self, T.untyped) rescue nil rendered = (step.body_stmts || []).filter_map do |stmt| @@ -177,7 +177,7 @@ def render_ctx_size_gate(type_name) # ----- struct decl with member fns ---------------------------------------- - sig { params(s: T.untyped, mir_emitter: T.untyped).returns(String) } + sig { params(s: T.untyped, mir_emitter: MIREmitter).returns(String) } def render_ctx_struct(s, mir_emitter) T.bind(self, T.untyped) rescue nil parts = [] @@ -210,7 +210,7 @@ def render_ctx_struct(s, mir_emitter) # come back empty / nil so verification-only nodes (AllocMark, # ReturnMark, ...) don't leave blank lines in the output. - sig { params(step: T.untyped, mir_emitter: T.untyped).returns(String) } + sig { params(step: T.untyped, mir_emitter: MIREmitter).returns(String) } def render_step(step, mir_emitter) T.bind(self, T.untyped) rescue nil rendered = (step.body_stmts || []).filter_map do |stmt| @@ -264,7 +264,7 @@ def render_generic_body(body) parts.join("\n") end - sig { params(s: T.untyped, mir_emitter: T.untyped).returns(String) } + sig { params(s: MIR::FsmGenericCtxStruct, mir_emitter: MIREmitter).returns(String) } def render_generic_ctx_struct(s, mir_emitter) T.bind(self, T.untyped) rescue nil parts = [] @@ -502,7 +502,7 @@ def render_tail(t, ctx_id) end end - sig { params(fn: T.untyped, mir_emitter: T.untyped).returns(String) } + sig { params(fn: T.untyped, mir_emitter: MIREmitter).returns(String) } def render_member_fn(fn, mir_emitter) T.bind(self, T.untyped) rescue nil rendered = (fn.body_stmts || []).filter_map do |stmt| @@ -524,7 +524,7 @@ def render_member_fn(fn, mir_emitter) # ----- post-struct alloc + spawn + break ---------------------------------- - sig { params(s: T.untyped, blk_label: T.untyped).returns(String) } + sig { params(s: MIR::FsmSpawnSetup, blk_label: T.untyped).returns(String) } def render_spawn_setup(s, blk_label) T.bind(self, T.untyped) rescue nil parts = [] diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb index d6b25ee40..378c1393f 100644 --- a/src/mir/mir_checker.rb +++ b/src/mir/mir_checker.rb @@ -927,7 +927,7 @@ def allocating_expr?(expr) # Yield each immediate sub-expression of expr. # Stops at opaque boundaries (RawZig, InlineZig, BgBlock). # BlockExpr bodies are walked separately by check_stmts_for_unhoisted. - sig { params(expr: T.anything, blk: T.untyped).returns(T.untyped) } + sig { params(expr: T.anything, blk: T.untyped).returns(T.nilable(T::Array[T.untyped])) } def each_sub_expr(expr, &blk) return unless expr case expr diff --git a/src/mir/mir_lowering.rb b/src/mir/mir_lowering.rb index 30ed5224f..16ef48bbf 100644 --- a/src/mir/mir_lowering.rb +++ b/src/mir/mir_lowering.rb @@ -37,7 +37,7 @@ class MIRLowering attr_reader :fn_sigs attr_accessor :shard_context - sig { params(struct_schemas: T::Hash[Symbol, T.untyped], enum_schemas: T::Hash[Symbol, T::Array[T.untyped]], union_schemas: T::Hash[Symbol, T.untyped], fn_sigs: T::Hash[T.untyped, T.untyped], moved_guard_info: T::Hash[String, T::Hash[T.untyped, T.untyped]], importer: T.nilable(ModuleImporter), source_dir: T.nilable(String), debug_mode: T::Boolean, target: Symbol).void } + sig { params(struct_schemas: T::Hash[Symbol, Schemas::StructSchema], enum_schemas: T::Hash[Symbol, T::Array[String]], union_schemas: T::Hash[Symbol, Schemas::UnionSchema], fn_sigs: T::Hash[String, FunctionSignature], moved_guard_info: T::Hash[String, T::Hash[String, TrueClass]], importer: T.nilable(ModuleImporter), source_dir: T.nilable(String), debug_mode: T::Boolean, target: Symbol).void } def initialize(struct_schemas: {}, enum_schemas: {}, union_schemas: {}, fn_sigs: {}, moved_guard_info: {}, importer: nil, source_dir: nil, @@ -2072,7 +2072,7 @@ def lower_extern_direct_call(node) MIR::Call.new("#{mod_prefix}#{node.name}", args, false) end - sig { params(node: T.untyped).returns(MIR::MethodCall) } + sig { params(node: AST::MethodCall).returns(MIR::MethodCall) } def lower_extern_direct_method(node) obj = lower(node.object) args = node.args.map { |a| lower(a) } @@ -2558,14 +2558,14 @@ def with_cap_is_param?(var_node) # field), else the bare value. The same fn body then works for both # `Locked(T)` and `Arc(Locked(T))` callers without runtime overhead. # Mutable parameters arrive as `*T`, so probe and deref through `*`. - sig { params(zig_var: T.untyped).returns(String) } + sig { params(zig_var: String).returns(String) } def comptime_arc_unwrap_expr(zig_var) "(if (@hasField(@TypeOf(#{zig_var}.*), \"ctrl\")) #{zig_var}.ctrl.data.* else #{zig_var}.*)" end # Recursively build the Zig string for a (possibly nested) field path. # Stops at the root Identifier; intermediate GetFields chain via `.`. - sig { params(node: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped).returns(String) } def build_field_path_zig(node) case node when AST::Identifier @@ -3019,7 +3019,7 @@ def with_match_unwrap_value(zig_var) # Both VERSIONED and LOCKED arms produce `const : *T = ...` # so the body's `.field` access lowers identically across # families. The Guard's `defer release()` handles teardown. - sig { params(family: Symbol, zig_var: String, alias_name: String, node: AST::WithBlock).returns(T.untyped) } + sig { params(family: Symbol, zig_var: String, alias_name: String, node: AST::WithBlock).returns(T.nilable(String)) } def with_match_arm_prelude(family, zig_var, alias_name, node) safe_alias = zig_safe_name(alias_name) unwrap = with_match_unwrap_value(zig_var) @@ -3406,7 +3406,7 @@ def emit_snapshot_mutable_call(node, with_label) # Emit the user's snapshot-conflict action using the conflict error chosen # for the cell family. Retry loops are handled around the transaction call, # not inside this action. - sig { params(clause: T::Hash[Symbol, T.untyped], with_label: T.nilable(String), with_node: AST::WithBlock, conflict_error: Symbol).returns(String) } + sig { params(clause: T::Hash[Symbol, T::Array[T.untyped]], with_label: T.nilable(String), with_node: AST::WithBlock, conflict_error: Symbol).returns(String) } def emit_conflict_action_zig(clause, with_label, with_node, conflict_error = :MvccConflict) line = with_node.token&.line.to_s err_name = conflict_error.to_s @@ -3422,7 +3422,7 @@ def emit_lock_action_zig(clause, with_label, with_node) emit_error_action_zig(clause, with_label, with_node, :LockTimeout, "lock acquire timed out") end - sig { params(clause: T::Hash[Symbol, T.untyped], with_label: T.nilable(String), with_node: AST::WithBlock, error_type: Symbol, default_msg: String).returns(String) } + sig { params(clause: T::Hash[Symbol, T::Array[T.untyped]], with_label: T.nilable(String), with_node: AST::WithBlock, error_type: Symbol, default_msg: String).returns(String) } def emit_error_action_zig(clause, with_label, with_node, error_type, default_msg) line = with_node.token&.line.to_s err_name = error_type.to_s @@ -3455,7 +3455,7 @@ def emit_error_action_zig(clause, with_label, with_node, error_type, default_msg # in its own labeled block today, so collisions are impossible in # practice, but the suffix makes the property defensible against # future lowering changes. - sig { params(fallible_caps: T::Array[T::Hash[T.untyped, T.untyped]], fallible: T::Boolean, with_node: T.nilable(AST::WithBlock)).returns(T::Array[T::Hash[T.untyped, T.untyped]]) } + sig { params(fallible_caps: T::Array[T::Hash[Symbol, T.untyped]], fallible: T::Boolean, with_node: T.nilable(AST::WithBlock)).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def build_sorted_acquire_entries(fallible_caps, fallible:, with_node: nil) suffix = with_node ? "_#{with_node.object_id.abs}" : "" fallible_caps.each_with_index.map do |cap, i| @@ -3494,7 +3494,7 @@ def build_sorted_acquire_entries(fallible_caps, fallible:, with_node: nil) # - const alias = __guardN.get() aliases # Uses panic-variant acquire methods (acquire / read / write); no # ON-clause handling. The fallible-variant emitter sits beside this. - sig { params(fallible_caps: T::Array[T.untyped], with_node: T.nilable(AST::WithBlock)).returns(String) } + sig { params(fallible_caps: T::Array[T::Hash[Symbol, T.untyped]], with_node: T.nilable(AST::WithBlock)).returns(String) } def emit_sorted_lock_acquires(fallible_caps, with_node = nil) n = fallible_caps.length entries = build_sorted_acquire_entries(fallible_caps, fallible: false, with_node: with_node) @@ -4081,7 +4081,7 @@ def lower_bg_block(node) # producing silent UAFs. Users must write GIVE / COPY / CLONE inside # the BG body to transfer ownership, or wrap the container in # @shared:locked / @multiowned for shared access. - sig { params(node: T.any(AST::BgBlock, AST::BgStreamBlock), _captured: T::Hash[String, T.untyped]).returns(T.untyped) } + sig { params(node: T.any(AST::BgBlock, AST::BgStreamBlock), _captured: T::Hash[String, Type]).void } def enforce_bg_capture_strategies!(node, _captured) refused = (node.capture_analysis&.strategies || {}).select do |_name, strat| strat.is_a?(CaptureStrategy::Refuse) @@ -4552,7 +4552,7 @@ def strip_all_type_defs(body) result.join end - sig { params(source: String, names: T::Set[T.untyped]).returns(String) } + sig { params(source: String, names: T::Set[String]).returns(String) } def filter_zig_blocks(source, names) lines = source.lines result = [] diff --git a/src/mir/mir_pass.rb b/src/mir/mir_pass.rb index e71e611ee..4a46f12e8 100644 --- a/src/mir/mir_pass.rb +++ b/src/mir/mir_pass.rb @@ -131,7 +131,7 @@ def transform!(ast) private - sig { params(fn: AST::FunctionDef, promo: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(fn: AST::FunctionDef, promo: T::Hash[Symbol, T.any(T::Array[T::Hash[Symbol, String]], T::Set[Integer])]).returns(T.nilable(T::Hash[String, TrueClass])) } def transform_function!(fn, promo) bindings = @cleanup_bindings[fn.name] has_bindings = bindings && !bindings.empty? @@ -217,12 +217,12 @@ def transform_function!(fn, promo) # already correct. Without this, insert_bg_resource_suppress! would mutate # bindings AFTER Drops were created, causing a split between the Drop's # snapshot and the binding's current state. - sig { params(fn: AST::FunctionDef, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T::Array[T.untyped]) } + sig { params(fn: AST::FunctionDef, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T::Array[T.untyped]) } def pre_mark_bg_resource_captures!(fn, bindings) T.must(walk_for_bg_captures(fn.body, bindings)) end - sig { params(stmts: T.nilable(T::Array[T.untyped]), bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } + sig { params(stmts: T.nilable(T::Array[T.untyped]), bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } def walk_for_bg_captures(stmts, bindings) return unless stmts.is_a?(Array) stmts.each do |stmt| @@ -384,7 +384,7 @@ def recurse_branches!(stmt, ctx) # visible here, so we must not emit SuppressCleanup for them inside the # fiber. The outer-scope pass (insert_bg_give_suppress!) handles moves # of captures; inside the body only BG-local bindings are consumable. - sig { params(bg_node: T.any(AST::BgBlock, AST::BgStreamBlock), bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T::Hash[String, T::Hash[T.untyped, T.untyped]]) } + sig { params(bg_node: T.any(AST::BgBlock, AST::BgStreamBlock), bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) } def bg_inner_bindings(bg_node, bindings) return bindings unless bindings captures = bg_node.capture_analysis&.captures @@ -395,7 +395,7 @@ def bg_inner_bindings(bg_node, bindings) # Insert MIR::SuppressCleanup after statements that consume ownership of # tracked bindings. Replaces the transpiler's emit_move_suppression and # emit_consumed_moves methods. - sig { params(result: T::Array[T.untyped], stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]])).returns(T.nilable(T::Set[T.untyped])) } + sig { params(result: T::Array[T.untyped], stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])).returns(T.nilable(T::Set[String])) } def insert_suppress_cleanup!(result, stmt, bindings) return unless bindings return if stmt.is_a?(AST::ReturnNode) # handled by insert_return! @@ -418,7 +418,7 @@ def insert_suppress_cleanup!(result, stmt, bindings) # every BG body via `collect_bg_body_give_names` / `_walk_expr_for_give` # — a parallel implementation that drifted from # OwnershipDataflow.collect_bg_body_gives (the 378036a0 class of bug). - sig { params(result: T::Array[T.untyped], stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]])).returns(T.untyped) } + sig { params(result: T::Array[T.untyped], stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])).returns(T.untyped) } def insert_bg_give_suppress!(result, stmt, bindings) # Shallow walk: SuppressCleanup is emitted in this stmt's scope and # only affects names in `bindings` (this scope's locals). Nested BG @@ -442,7 +442,7 @@ def insert_bg_give_suppress!(result, stmt, bindings) # regardless of scope depth. We must insert SuppressCleanup whenever a # BG block captures a resource — even for inner-scope variables that # don't appear in the function-level bindings hash. - sig { params(result: T::Array[T.untyped], stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]])).returns(T.untyped) } + sig { params(result: T::Array[T.untyped], stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])).returns(T.untyped) } def insert_bg_resource_suppress!(result, stmt, bindings) # Shallow walk: same reason as insert_bg_give_suppress! above. AST.each_bg_block_in_stmt(stmt) do |bg| @@ -626,7 +626,7 @@ def insert_catch_string_dupe!(result, ret_node) # Annotate an OrRescue node where the success path is heap-promoted and the # fallback is a struct literal. Sets or_fallback_dupe on the BinaryOp so the # transpiler heap-dupes string fields in the fallback to match cleanup semantics. - sig { params(result: T::Array[T.untyped], stmt: T.untyped).returns(T.untyped) } + sig { params(result: T::Array[T.untyped], stmt: T.untyped).void } def insert_or_fallback_dupe!(result, stmt) or_node = find_or_rescue_in_value(stmt) return unless or_node @@ -692,7 +692,7 @@ def or_rescue_needs_fallback_dupe_left?(expr) # 1. Direct RHS: identifier used as value in assignment/declaration # 2. Standalone GIVE: `GIVE x;` as a statement # 3. Nested: identifier passed as TAKES/GIVE arg or used as struct field - sig { params(stmt: T.untyped, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T::Set[String]) } + sig { params(stmt: T.untyped, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T::Set[String]) } def collect_consumed_names(stmt, bindings) names = Set.new @@ -740,7 +740,7 @@ def collect_consumed_names(stmt, bindings) # Recursively walk an expression to find consumed identifiers in # StructLit fields and FuncCall/MethodCall TAKES/GIVE args. - sig { params(node: T.untyped, names: T::Set[String], bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.untyped) } + sig { params(node: T.untyped, names: T::Set[String], bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.untyped) } def walk_consumed(node, names, bindings) return unless node case node @@ -770,7 +770,7 @@ def walk_consumed(node, names, bindings) # Add identifier to consumed set if it has a moved guard and passes # Copy-type filters. RC types only consume on explicit GIVE (MoveNode). - sig { params(ident: AST::Identifier, names: T::Set[T.untyped], bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]], is_move: T::Boolean).returns(T.nilable(T::Set[T.untyped])) } + sig { params(ident: AST::Identifier, names: T::Set[String], bindings: T::Hash[String, T::Hash[Symbol, T.untyped]], is_move: T::Boolean).returns(T.nilable(T::Set[String])) } def add_if_consumed(ident, names, bindings, is_move) name = ident.name.to_s entry = bindings[name] @@ -792,7 +792,7 @@ def add_if_consumed(ident, names, bindings, is_move) end # Stamp reassign_cleanup on BindExpr :assign nodes that overwrite non-Copy variables. - sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]])).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def stamp_reassign_cleanup!(stmt, bindings) return unless bindings return unless stmt.is_a?(AST::BindExpr) && stmt.mode == :assign @@ -809,7 +809,7 @@ def stamp_reassign_cleanup!(stmt, bindings) # Insert MIR nodes for MATCH-AS cleanup into case bodies. # Previously stamp-only; now inserts MIR::Alloc + MIR::Drop + MIR::SuppressCleanup # so the checker verifies match_as cleanup like any other binding. - sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]])).returns(T.nilable(T::Boolean)) } + sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])).returns(T.nilable(T::Boolean)) } def stamp_match_as_cleanup!(stmt, bindings) return unless bindings return unless stmt.is_a?(AST::MatchStatement) @@ -846,7 +846,7 @@ def stamp_match_as_cleanup!(stmt, bindings) src_entry[:has_moved_guard] = true if has_as_cleanup && src_entry && src_entry[:needs_cleanup] end - sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]])).returns(T.nilable(T::Array[T.untyped])) } + sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])).returns(T.nilable(T::Array[T.untyped])) } def stamp_while_bind_cleanup!(stmt, bindings) return unless stmt.is_a?(AST::WhileBindLoop) entry = T.must(bindings)[stmt.binding_name.to_s] @@ -858,7 +858,7 @@ def stamp_while_bind_cleanup!(stmt, bindings) stmt.do_branch = [alloc_node, drop] + (stmt.do_branch || []) end - sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]])).returns(T.nilable(T::Array[T.untyped])) } + sig { params(stmt: T.untyped, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])).returns(T.nilable(T::Array[T.untyped])) } def stamp_if_bind_cleanup!(stmt, bindings) return unless stmt.is_a?(AST::IfBind) mir_prefix = [] @@ -905,7 +905,7 @@ def insert_container_promote!(result, stmt) end # Resolve the INDEX_OPS :set entry for a container type. - sig { params(type_info: T.nilable(T.any(FalseClass, Type))).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(type_info: T.nilable(T.any(FalseClass, Type))).returns(T.nilable(T::Hash[Symbol, T::Array[Symbol]])) } def resolve_container_set_op(type_info) return nil unless type_info kind = container_kind(type_info) @@ -921,7 +921,7 @@ def container_kind(type_info) # Build moved_guard_info: { var_name => bool } for all bindings. - sig { params(fn: AST::FunctionDef, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(fn: AST::FunctionDef, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Hash[String, TrueClass])) } def stamp_moved_guard_info!(fn, bindings) info = {} bindings.each do |name, entry| @@ -936,7 +936,7 @@ def stamp_moved_guard_info!(fn, bindings) # Defer suppression for escaped variables is handled by MIR::Return # (inserted by insert_return!) and consumed by the transpiler's # collect_escaping_identifiers in the ReturnNode handler. - sig { params(result: T::Array[T.untyped], ret_node: AST::ReturnNode, promo: T.nilable(T::Hash[Symbol, T.untyped])).returns(T.nilable(T.any(T::Hash[T.untyped, T.untyped], Symbol, T::Hash[T.untyped, T.untyped]))) } + sig { params(result: T::Array[T.untyped], ret_node: AST::ReturnNode, promo: T.nilable(T::Hash[Symbol, T.any(T::Array[T::Hash[Symbol, String]], T::Set[Integer])])).returns(T.nilable(T.any(T::Hash[T.untyped, T.untyped], Symbol, T::Hash[T.untyped, T.untyped]))) } def insert_promotion!(result, ret_node, promo) return unless promo && !promo.empty? @@ -964,7 +964,7 @@ def insert_promotion!(result, ret_node, promo) # Insert MIR::Return before a ReturnNode to mark which local variables' # ownership escapes to the caller. The checker uses this to know that # escaped vars don't need local cleanup. - sig { params(result: T::Array[T.untyped], ret_node: AST::ReturnNode, bindings: T.nilable(T::Hash[String, T::Hash[T.untyped, T.untyped]]), fn_node: T.nilable(AST::FunctionDef)).returns(T.nilable(T::Array[String])) } + sig { params(result: T::Array[T.untyped], ret_node: AST::ReturnNode, bindings: T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]]), fn_node: T.nilable(AST::FunctionDef)).returns(T.nilable(T::Array[String])) } def insert_return!(result, ret_node, bindings, fn_node: nil) return unless bindings escaped = collect_return_escapes(ret_node, bindings, fn_node: fn_node) @@ -980,7 +980,7 @@ def insert_return!(result, ret_node, bindings, fn_node: nil) # Walk a return expression and collect variable names whose ownership # transfers to the caller. Mirrors transpiler's collect_escaping_identifiers # but filters to bindings with has_moved_guard (those needing suppression). - sig { params(ret_node: AST::ReturnNode, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]], fn_node: T.nilable(AST::FunctionDef)).returns(T::Array[String]) } + sig { params(ret_node: AST::ReturnNode, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]], fn_node: T.nilable(AST::FunctionDef)).returns(T::Array[String]) } def collect_return_escapes(ret_node, bindings, fn_node: nil) return [] unless ret_node.value ids = collect_escaping_ids(ret_node.value) @@ -1015,7 +1015,7 @@ def classify_promote_strategy(zig_type) end end - sig { params(type_obj: T.untyped).returns(T.nilable(String)) } + sig { params(type_obj: T.nilable(Type)).returns(T.nilable(String)) } def list_elem_zig_type(type_obj) elem = type_obj&.element_type return nil unless elem diff --git a/src/mir/ownership_graph.rb b/src/mir/ownership_graph.rb index c817cc1e6..4013fa244 100644 --- a/src/mir/ownership_graph.rb +++ b/src/mir/ownership_graph.rb @@ -48,7 +48,7 @@ def initialize @completed_nodes = T.let({}, T::Hash[T.untyped, T.untyped]) end - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[String, OwnershipGraph::Node]) } def nodes @nodes.empty? ? @completed_nodes : @nodes end @@ -77,7 +77,7 @@ def remove_edge(edge) # ── Core Operations ─────────────────────────────────────────────── # Declare a new variable or field path. - sig { params(path: String, kind: Symbol, type_info: T.nilable(Type), scope_depth: Integer, line: Integer).returns(T.nilable(T::Set[T.untyped])) } + sig { params(path: String, kind: Symbol, type_info: T.nilable(Type), scope_depth: Integer, line: Integer).returns(T.nilable(T::Set[String])) } def declare(path, kind: :affine, type_info: nil, scope_depth: 0, line: 0) @nodes[path] = Node.new( path: path, kind: kind, state: :live, @@ -208,7 +208,7 @@ def moved?(path) # Lightweight snapshot: only saves node states, not full graph. # Use for branches that won't declare new nodes (IF/ELSE in flat code). - sig { returns(T::Hash[T.untyped, T.untyped]) } + sig { returns(T::Hash[Symbol, T::Hash[String, T::Hash[Symbol, T.untyped]]]) } def fork_lightweight states = {} @nodes.each do |k, v| @@ -223,7 +223,7 @@ def fork_lightweight end # Restore from lightweight snapshot: reset states and truncate edges. - sig { params(snapshot: T::Hash[Symbol, T.untyped]).returns(T.untyped) } + sig { params(snapshot: T::Hash[Symbol, T.untyped]).void } def restore_lightweight(snapshot) snapshot[:node_states].each do |path, saved| node = @nodes[path] diff --git a/src/mir/promotion_plan.rb b/src/mir/promotion_plan.rb index 8533bad97..967a4f19d 100644 --- a/src/mir/promotion_plan.rb +++ b/src/mir/promotion_plan.rb @@ -34,7 +34,7 @@ module PromotionClassifier # @param schema_lookup [Proc] lambda(type_name_sym) -> schema hash or nil # @return [Hash] { var_promotes:, struct_promote:, promote_return_ids:, unhandled_promote_fields: } # or empty hash - sig { params(fn_node: AST::FunctionDef, schema_lookup: Proc).returns(T::Hash[Symbol, T.untyped]) } + sig { params(fn_node: AST::FunctionDef, schema_lookup: Proc).returns(T::Hash[Symbol, T.any(T::Array[T::Hash[Symbol, String]], T::Set[Integer])]) } def self.classify(fn_node, schema_lookup:) return {} unless fn_allocates?(fn_node) || fn_node.return_provenance == :heap || fn_has_escapable_return?(fn_node, schema_lookup) @@ -243,7 +243,7 @@ def self.needs_promote?(plan, ret_node) elem.to_s end - sig { params(node: T.untyped).returns(T::Set[T.untyped]) } + sig { params(node: T.untyped).returns(T::Set[String]) } private_class_method def self.referenced_vars(node) vars = Set.new return vars unless node @@ -287,7 +287,7 @@ module CleanupClassifier # @param fn_nodes [Hash] name => FunctionDef for all functions # @param schema_lookup [Proc] lambda(type_sym) => schema hash # @return [Hash] { var_name => entry_hash } or empty hash - sig { params(fn_node: AST::FunctionDef, fn_nodes: T::Hash[String, T.untyped], schema_lookup: Proc).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } + sig { params(fn_node: AST::FunctionDef, fn_nodes: T::Hash[String, T.untyped], schema_lookup: Proc).returns(T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])) } def self.classify(fn_node, fn_nodes:, schema_lookup:) return {} unless fn_node.body @@ -312,7 +312,7 @@ def self.classify(fn_node, fn_nodes:, schema_lookup:) # Walk field assignments that need pre-cleanup (free old value before overwrite). # Stamps Assignment nodes directly with { zig_type:, alloc: }. - sig { params(body: T::Array[T.untyped], bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]], schema_lookup: T.nilable(Proc)).returns(T.nilable(T::Array[T.untyped])) } + sig { params(body: T::Array[T.untyped], bindings: T::Hash[String, T::Hash[Symbol, T.untyped]], schema_lookup: T.nilable(Proc)).returns(T.nilable(T::Array[T.untyped])) } def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) AST.walk_body(body) do |stmt| next unless stmt.is_a?(AST::Assignment) @@ -387,7 +387,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) # ── Walk VarDecl / BindExpr ────────────────────────────────────── - sig { params(body: T::Array[T.untyped], promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T::Array[T.untyped]) } + sig { params(body: T::Array[T.untyped], promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T::Array[T.untyped]) } private_class_method def self.walk_bindings(body, promoted_fns, schema_lookup, bindings) AST.walk_body(body) do |node| next unless node.is_a?(AST::VarDecl) || node.is_a?(AST::BindExpr) @@ -412,7 +412,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) # Walk BgBlock bodies found in expression positions within a statement list. # Only handles BgBlock (outer consumer fiber), not BgStreamBlock (generator # fiber bodies have special YIELD handling and no heap-cleanup variables). - sig { params(body: T.nilable(T::Array[T.untyped]), promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } + sig { params(body: T.nilable(T::Array[T.untyped]), promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } private_class_method def self.walk_expression_bg_bodies(body, promoted_fns, schema_lookup, bindings) return unless body.is_a?(Array) body.each do |stmt| @@ -472,7 +472,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) # 3. via_pointer: true — needs_pointer_passing? types (HashMap, # Pool) arrive at the callee already as # *T; cleanup must NOT re-apply &. - sig { params(fn_node: AST::FunctionDef, schema_lookup: Proc, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T::Hash[T.untyped, T.untyped]])) } + sig { params(fn_node: AST::FunctionDef, schema_lookup: Proc, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) } private_class_method def self.walk_takes_params(fn_node, schema_lookup, bindings) (fn_node.params || []).select { |p| p[:takes] }.each do |p| ti = p[:type].is_a?(Type) ? p[:type] : Type.new(p[:type] || :Any) @@ -521,7 +521,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) # ── Walk MATCH AS bindings ────────────────────────────────────── - sig { params(body: T::Array[T.untyped], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } + sig { params(body: T::Array[T.untyped], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } private_class_method def self.walk_match_as_bindings(body, schema_lookup, bindings) AST.walk_body(body) do |node| next unless node.is_a?(AST::MatchStatement) @@ -586,7 +586,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) # Only MethodCall/FuncCall results are considered: variable/field access is a # borrow and the original binding retains cleanup responsibility. # RESOLVE is handled separately (rcRelease in lower_while_bind). - sig { params(body: T::Array[T.untyped], promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } + sig { params(body: T::Array[T.untyped], promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } private_class_method def self.walk_while_bind_bindings(body, promoted_fns, schema_lookup, bindings) AST.walk_body(body) do |node| next unless node.is_a?(AST::WhileBindLoop) @@ -610,7 +610,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) end # Classify IF bind captures that come from ownership-transferring calls. - sig { params(body: T::Array[T.untyped], promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[T.untyped, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } + sig { params(body: T::Array[T.untyped], promoted_fns: T::Set[String], schema_lookup: Proc, bindings: T::Hash[String, T::Hash[Symbol, T.untyped]]).returns(T.nilable(T::Array[T.untyped])) } private_class_method def self.walk_if_bind_bindings(body, promoted_fns, schema_lookup, bindings) AST.walk_body(body) do |node| next unless node.is_a?(AST::IfBind) @@ -902,7 +902,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) entry(:struct_with_cleanup_fields, alloc: ti.provenance_alloc || :heap) end - sig { params(ti: Type, schema_lookup: Proc).returns(T.untyped) } + sig { params(ti: Type, schema_lookup: Proc).returns(T.nilable(T::Hash[Symbol, T.untyped])) } private_class_method def self.classify_non_copy_union(ti, schema_lookup) schema = schema_lookup.call(ti.resolved) rescue nil return nil unless (schema = Schemas.as_union_schema(schema)) diff --git a/src/mir/thunk_transform/emit.rb b/src/mir/thunk_transform/emit.rb index 9ee800d1f..00df92a35 100644 --- a/src/mir/thunk_transform/emit.rb +++ b/src/mir/thunk_transform/emit.rb @@ -75,7 +75,7 @@ module Emit # Synthesize a structural MIR trampoline body for a function whose # AST::FunctionDef has a thunk_plan (set by Phase 4c detection). - sig { params(fn_node: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(fn_node: T.untyped, lowering: T.untyped).returns(MIR::ThunkTrampoline) } def build_trampoline(fn_node, lowering) T.bind(self, T.untyped) rescue nil plan = fn_node.thunk_plan @@ -136,14 +136,14 @@ def render_expr(ast_expr, lowering) lowering.send(:emit_expr, mir) end - sig { params(param: T.untyped, _lowering: T.untyped).returns(T.untyped) } + sig { params(param: T.untyped, _lowering: T.untyped).returns(String) } def param_zig_type(param, _lowering) T.bind(self, T.untyped) rescue nil type = param[:type] type.respond_to?(:zig_type) ? type.zig_type : type.to_s end - sig { params(fn_node: T.untyped, _lowering: T.untyped).returns(T.untyped) } + sig { params(fn_node: T.untyped, _lowering: T.untyped).returns(String) } def ret_zig_type(fn_node, _lowering) T.bind(self, T.untyped) rescue nil rt = fn_node.return_type @@ -164,7 +164,7 @@ def ret_zig_type(fn_node, _lowering) # an `errdefer` chain-walk in the emitted body is the surgical # fix; this guard fails loudly so the extension can't ship the # leak silently. - sig { params(fn_node: T.untyped, ret_zig: T.untyped).returns(T.untyped) } + sig { params(fn_node: T.untyped, ret_zig: T.untyped).returns(NilClass) } def assert_non_fallible_ret!(fn_node, ret_zig) T.bind(self, T.untyped) rescue nil return unless ret_zig.start_with?("!") @@ -219,7 +219,7 @@ def qualify_params(zig_text, fn_node) # Each cycle member emits its own trampoline (same union layout, # different starting variant). Callers reach the cycle through # the public fn name they actually call. - sig { params(fn_node: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(fn_node: T.untyped, lowering: T.untyped).returns(MIR::MutualThunkTrampoline) } def build_mutual_trampoline(fn_node, lowering) T.bind(self, T.untyped) rescue nil mtp = fn_node.mutual_thunk_plan @@ -262,7 +262,7 @@ def build_mutual_trampoline(fn_node, lowering) # One switch arm: handle the variant whose payload is `cf`'s # params; emit base cases (early returns) and the tail # transition that overwrites `current` with the partner variant. - sig { params(cf: T.untyped, _mtp: T.untyped, ret_zig: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(cf: T.untyped, _mtp: T.untyped, ret_zig: String, lowering: T.untyped).returns(T::Hash[T.untyped, T.untyped]) } def build_mutual_arm(cf, _mtp, ret_zig, lowering) T.bind(self, T.untyped) rescue nil own_plan = cf.mutual_thunk_plan.own_plan @@ -295,7 +295,7 @@ def build_mutual_arm(cf, _mtp, ret_zig, lowering) } end - sig { params(cf: T.untyped, name: T.untyped).returns(T.untyped) } + sig { params(cf: T.untyped, name: T.untyped).returns(T.noreturn) } def find_cycle_member(cf, name) T.bind(self, T.untyped) rescue nil cf.mutual_thunk_plan.cycle_fns.find { |x| x.name == name } or diff --git a/src/mir/thunk_transform/recursive_splitter.rb b/src/mir/thunk_transform/recursive_splitter.rb index 6a759207a..235d9fc7f 100644 --- a/src/mir/thunk_transform/recursive_splitter.rb +++ b/src/mir/thunk_transform/recursive_splitter.rb @@ -84,7 +84,7 @@ module RecursiveSplitter # When the shape matches but codegen isn't yet wired, the # caller still errors -- pattern detection alone doesn't make # the function compilable. - sig { params(body: T.untyped, fn_name: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(body: T.untyped, fn_name: T.untyped, lowering: T.untyped).returns(T.nilable(Plan)) } def split(body, fn_name, lowering) T.bind(self, T.untyped) rescue nil _ = lowering # Phase 4c does pure AST inspection; no lowering needed yet. @@ -131,7 +131,7 @@ def split(body, fn_name, lowering) # variant in place). Returns nil if the body has any non-tail # call to ANY cycle member, or if the final return isn't a # direct call to a partner. - sig { params(body: T.untyped, fn_name: T.untyped, partner_names: T.untyped, lowering: T.untyped).returns(T.untyped) } + sig { params(body: T.untyped, fn_name: T.untyped, partner_names: T.untyped, lowering: T.untyped).returns(T.nilable(MutualPlan)) } def split_mutual(body, fn_name, partner_names, lowering) T.bind(self, T.untyped) rescue nil _ = lowering @@ -164,7 +164,7 @@ def split_mutual(body, fn_name, partner_names, lowering) # An IF base case for the mutual shape: `IF -> RETURN ;` # where neither cond nor expr contains ANY call to a cycle member # (self or partner). The cycle set includes the current fn name. - sig { params(stmt: T.untyped, cycle_names: T.untyped).returns(T.untyped) } + sig { params(stmt: T.untyped, cycle_names: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def match_mutual_base_case(stmt, cycle_names) T.bind(self, T.untyped) rescue nil return nil unless stmt.is_a?(AST::IfStatement) @@ -180,7 +180,7 @@ def match_mutual_base_case(stmt, cycle_names) # `partner_fn(args...)` directly (not nested), where partner_fn is # one of the named partners. Returns { name:, args: } or nil. - sig { params(node: T.untyped, partner_names: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped, partner_names: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def match_tail_mutual_call(node, partner_names) T.bind(self, T.untyped) rescue nil return nil unless node.is_a?(AST::FuncCall) @@ -210,7 +210,7 @@ def contains_any_call?(node, names_set) # cond nor expr contains a self-call. Both the shorthand and # block IF forms parse to AST::IfStatement; the body is a # single-element list with the RETURN. - sig { params(stmt: T.untyped, fn_name: T.untyped).returns(T.untyped) } + sig { params(stmt: T.untyped, fn_name: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def match_base_case(stmt, fn_name) T.bind(self, T.untyped) rescue nil return nil unless stmt.is_a?(AST::IfStatement) @@ -233,7 +233,7 @@ def match_base_case(stmt, fn_name) # remember the surface character. SUPPORTED_OPS = [:ADD, :SUB, :MUL, :DIV].freeze - sig { params(expr: T.untyped, fn_name: T.untyped).returns(T.untyped) } + sig { params(expr: T.untyped, fn_name: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def match_recursive_combine(expr, fn_name) T.bind(self, T.untyped) rescue nil return nil unless expr.is_a?(AST::BinaryOp) @@ -253,7 +253,7 @@ def match_recursive_combine(expr, fn_name) # If `node` is exactly `fn_name(args...)`, return its args. # Returns nil otherwise (including for nested self-calls). - sig { params(node: T.untyped, fn_name: T.untyped).returns(T.untyped) } + sig { params(node: T.untyped, fn_name: T.untyped).returns(T.nilable(T::Array[T.untyped])) } def direct_self_call(node, fn_name) T.bind(self, T.untyped) rescue nil return nil unless node.is_a?(AST::FuncCall) && node.name == fn_name diff --git a/src/tools/formatter.rb b/src/tools/formatter.rb index a33fde82e..d155972ed 100644 --- a/src/tools/formatter.rb +++ b/src/tools/formatter.rb @@ -683,7 +683,7 @@ def copy_arm_tokens(out, toks, s, e) # Preserves source NLs inside nested blocks so expand_then_do_blocks # still sees the user's multi-line shape; collapses redundant NLs at # arm-body top-level since `;` already inserts one. - sig { params(out: Array, toks: Array, s: Integer, e: Integer).returns(T.untyped) } + sig { params(out: Array, toks: Array, s: Integer, e: Integer).void } def emit_match_body(out, toks, s, e) bdepth = 0 kdepth = 0 @@ -893,7 +893,7 @@ def should_wrap_fn_sig?(sig) # p2: T # ) # RETURNS T -> - sig { params(out: Array, sig: Formatter::Emitter::FnSig).returns(T.untyped) } + sig { params(out: Array, sig: Formatter::Emitter::FnSig).void } def emit_fn_signature_wrapped(out, sig) toks = sig.toks # Tokens from FN through and including `(`. diff --git a/src/tools/pprof.rb b/src/tools/pprof.rb index e7afa6453..71421df45 100644 --- a/src/tools/pprof.rb +++ b/src/tools/pprof.rb @@ -84,7 +84,7 @@ def intern(s) # Declare a sample value column. `type` is the metric (e.g. # `"alloc_space"`); `unit` is the unit (`"bytes"`, `"count"`, # `"nanoseconds"`). The column index matches the order of calls. - sig { params(type: String, unit: String).returns(T.untyped) } + sig { params(type: String, unit: String).void } def add_sample_type(type, unit) @sample_types << [intern(type), intern(unit)] end diff --git a/tools/autogen_sigs.rb b/tools/autogen_sigs.rb deleted file mode 100644 index 96a30cbe0..000000000 --- a/tools/autogen_sigs.rb +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env ruby -# typed: false -# -# Auto-generate Sorbet `sig` blocks for visitor/dispatcher patterns. -# -# Many methods in src/ follow a uniform shape: -# def emit(node) -# case node -# when MIR::Lit then emit_lit(node) -# when MIR::Call then emit_call(node) -# ... -# end -# end -# -# def emit_lit(node) -# ... -# end -# -# Each helper's param type is determined by the dispatch arm. This -# tool walks dispatcher methods (named `emit`, `visit`, `lower` by -# default), builds a `helper_name => arg_class` map from the case/when -# table, and emits a `sig { params(node: ).returns() }` -# block above each helper that doesn't already have one. -# -# Return-type policy (per dispatcher): -# emit_* -> String (MIREmitter is a Zig-text template engine) -# visit_* -> .void (SemanticAnnotator's visitors are side-effecting) -# lower_* -> T.untyped (MIRLowering returns various MIR shapes; no -# single union type yet) -# -# Usage: -# bundle exec ruby tools/autogen_sigs.rb src/mir/mir_emitter.rb -# bundle exec ruby tools/autogen_sigs.rb src/annotator.rb -# bundle exec ruby tools/autogen_sigs.rb src/mir/mir_lowering.rb - -require "prism" - -# Map from dispatcher method name to (helper-name-prefix, return type). -# Return types are conservative T.untyped where the prefix is reused -# across different host classes with different return contracts (e.g. -# `visit_X` returns void in SemanticAnnotator but String in PipelineHost). -# Tighten to specific types in a manual pass when we sig the public API. -DISPATCHERS = { - "emit" => { prefix: "emit_", returns: "String" }, - "visit" => { prefix: "visit_", returns: "T.untyped" }, - "lower" => { prefix: "lower_", returns: "T.untyped" }, - "transpile" => { prefix: "transpile_", returns: "String" }, -}.freeze - -# When true, every helper's param is `T.untyped` instead of the -# class extracted from the dispatch arm. Safer for bulk autogen -# because per-arm class typing surfaces sites where the helper -# was written assuming a wider node shape (e.g. respond_to? for -# attrs that exist on a sibling class). Tightening param types -# is a manual follow-up. Set false for high-precision runs. -LOOSE_PARAM_TYPES = ENV.fetch("AUTOGEN_LOOSE", "1") == "1" - -# Extract the case/when dispatch arms from a Prism CaseNode. Returns -# an array of [helper_method_name, class_path_string] pairs. Skips -# arms that don't have a clean `helper(node)` body — those need -# manual sigging. -def extract_dispatch_arms(case_node) - arms = [] - case_node.conditions.each do |when_node| - next unless when_node.is_a?(Prism::WhenNode) - statements = when_node.statements&.body || [] - # Body must be a single CallNode of the form `helper_name(arg)` - next unless statements.length == 1 - call = statements.first - next unless call.is_a?(Prism::CallNode) - next if call.receiver # `node.foo` — not a helper dispatch - helper_name = call.name.to_s - # Each `when` may match multiple class names (e.g., `when MIR::A, MIR::B`). - # If multiple, the helper accepts a union — record that. - classes = when_node.conditions.filter_map do |c| - stringify_const(c) - end - next if classes.empty? - # Single-class arm: helper expects that class. - # Multi-class arm: helper expects T.any(...) — but for autogen we - # union into the helper's existing class set. - arms << [helper_name, classes] - end - arms -end - -def stringify_const(node) - case node - when Prism::ConstantReadNode - node.name.to_s - when Prism::ConstantPathNode - parts = [] - n = node - while n.is_a?(Prism::ConstantPathNode) - parts.unshift(n.name.to_s) - n = n.parent - end - parts.unshift(n.name.to_s) if n.is_a?(Prism::ConstantReadNode) - parts.join("::") - end -end - -# Walk a method body to find a top-level case/when. Returns the -# dispatch arms or nil. -# -# The case may be the body's only statement, or wrapped — e.g. -# `case node ... end.tap { |mir| ... }` (mir_lowering.rb#lower) parses -# as CallNode whose receiver is the CaseNode. We do a shallow recursive -# search through receivers / blocks to find the first CaseNode. -def find_dispatch_arms(def_node) - body = def_node.body - return nil unless body.is_a?(Prism::StatementsNode) - body.body.each do |stmt| - case_node = find_case_node(stmt) - return extract_dispatch_arms(case_node) if case_node - end - nil -end - -def find_case_node(node) - return node if node.is_a?(Prism::CaseNode) - case node - when Prism::CallNode - found = node.receiver && find_case_node(node.receiver) - return found if found - return find_case_node(node.block) if node.block - when Prism::BlockNode - body = node.body - if body.is_a?(Prism::StatementsNode) - body.body.each do |s| - f = find_case_node(s) - return f if f - end - end - end - nil -end - -# Build helper_name => Set map from a single dispatcher def. -def build_helper_map(def_node) - arms = find_dispatch_arms(def_node) - return {} unless arms - map = Hash.new { |h, k| h[k] = [] } - arms.each do |helper, classes| - map[helper].concat(classes) - end - map.transform_values(&:uniq) -end - -def has_sig?(def_node, src_lines) - # Look at the line immediately above the def. If it contains - # `sig {` (possibly followed by attr modifiers), skip. - line_idx = def_node.location.start_line - 2 - return false if line_idx < 0 - src_lines[line_idx]&.match?(/\s*sig\s*\{/) -end - -ARGV.each do |file| - parsed = Prism.parse_file(file) - abort "parse failed: #{file}" unless parsed.success? - src_lines = File.readlines(file) - - # Pass 1: find dispatcher methods, build maps for each helper-name-prefix. - helper_maps = {} # prefix => { helper_name => [class_paths...] } - return_types = {} # prefix => String - walk1 = nil - walk1 = lambda do |node| - if node.is_a?(Prism::DefNode) && DISPATCHERS.key?(node.name.to_s) - cfg = DISPATCHERS[node.name.to_s] - m = build_helper_map(node) - helper_maps[cfg[:prefix]] = m - return_types[cfg[:prefix]] = cfg[:returns] - end - node.respond_to?(:child_nodes) && node.child_nodes.compact.each { |c| walk1.(c) } - end - walk1.(parsed.value) - - # If a dispatcher exists but had no case/when (e.g. `def visit(node); - # send("visit_#{class}", node); end`), still autogen helpers based on - # the naming convention. Helper map stays empty (no per-arm class - # info), but with LOOSE_PARAM_TYPES we use T.untyped anyway, so we - # just need the prefix + return-type to be recognised. - walk1b = nil - walk1b = lambda do |node| - if node.is_a?(Prism::DefNode) && DISPATCHERS.key?(node.name.to_s) - cfg = DISPATCHERS[node.name.to_s] - helper_maps[cfg[:prefix]] ||= {} # empty map, but prefix is recognised - return_types[cfg[:prefix]] = cfg[:returns] - end - node.respond_to?(:child_nodes) && node.child_nodes.compact.each { |c| walk1b.(c) } - end - walk1b.(parsed.value) - - if helper_maps.empty? - warn "#{file}: no dispatcher methods found" - next - end - - # Pass 2: walk methods, find ones whose name matches a prefix + - # helper map, insert sig if missing. - inserts = [] # [insert_line_1based, sig_text] - - walk2 = nil - walk2 = lambda do |node| - if node.is_a?(Prism::DefNode) - name = node.name.to_s - helper_maps.each do |prefix, map| - next unless name.start_with?(prefix) - # Allow sigging even if the helper isn't in the dispatch map — - # convention-based naming (`visit_X` → AST::X) covers send-style - # dispatchers. With LOOSE_PARAM_TYPES the param type is T.untyped - # regardless, so the missing class info is harmless. - next unless map.key?(name) || LOOSE_PARAM_TYPES - next if has_sig?(node, src_lines) - # Build sig params for ALL method params (required, optional, - # keyword, rest, kwrest, block). Param types default to T.untyped - # for all but the first required param when LOOSE_PARAM_TYPES is - # off, where the dispatch arm tells us the class. - params = node.parameters - next unless params - - first_required = params.requireds.first - next unless first_required # need at least one positional - - sig_params = [] - classes = map[name] || [] - first_done = false - params.requireds.each do |p| - pname = p.name.to_s - ptype = if !first_done && !LOOSE_PARAM_TYPES - classes.length == 1 ? classes.first : "T.any(#{classes.join(', ')})" - else - "T.untyped" - end - first_done = true - sig_params << "#{pname}: #{ptype}" - end - params.optionals.each { |p| sig_params << "#{p.name}: T.untyped" } - params.keywords.each do |kw| - sig_params << "#{kw.name}: T.untyped" - end - if params.rest - rest_name = params.rest.name&.to_s || "args" - sig_params << "#{rest_name}: T.untyped" - end - if params.keyword_rest - krest_name = params.keyword_rest.name&.to_s || "kwargs" - sig_params << "#{krest_name}: T.untyped" - end - if params.block - bname = params.block.name&.to_s || "blk" - sig_params << "#{bname}: T.untyped" - end - - ret_type = return_types[prefix] - ret_clause = ret_type == ".void" ? "void" : "returns(#{ret_type})" - sig_line = sig_params.empty? ? - "sig { #{ret_clause} }" : - "sig { params(#{sig_params.join(', ')}).#{ret_clause} }" - # Indent matches the def line. - def_line_idx = node.location.start_line - 1 - indent = src_lines[def_line_idx][/^\s*/] - inserts << [node.location.start_line, "#{indent}#{sig_line}\n"] - end - end - node.respond_to?(:child_nodes) && node.child_nodes.compact.each { |c| walk2.(c) } - end - walk2.(parsed.value) - - if inserts.empty? - puts "#{file}: 0 sigs to add" - next - end - - inserts.sort_by { |line, _| -line }.each do |line, sig| - src_lines.insert(line - 1, sig) - end - File.write(file, src_lines.join) - puts "#{file}: +#{inserts.size} sigs" -end diff --git a/tools/clear-nil-kill-runtime.sh b/tools/clear-nil-kill-runtime.sh index 3ef2c1849..d68af4aa7 100755 --- a/tools/clear-nil-kill-runtime.sh +++ b/tools/clear-nil-kill-runtime.sh @@ -1,11 +1,92 @@ #!/usr/bin/env bash -set -euo pipefail +# nil-kill runtime evidence workload. Run under `nil-kill collect`. +# +# NOT a CI gate -- the evidence-collection workload. Every stage is +# fault-tolerant (no `set -e`, each unit `|| true`): a failure must +# not abort collection; we keep the runtime observed up to it and run +# every later stage. +# +# Speed: under nil-kill's source-instrumentation + tracer each Ruby +# process is ~100x slower, so PARALLELISM is the dominant lever. +# NK_JOBS controls fan-out (default = all cores). Lower it if the box +# is memory-bound (each traced worker loads full src + sorbet-runtime +# + tracer). Per-stage wall-clock is printed so the slow stage is +# obvious. +set -uo pipefail -bundle exec prspec spec/ -./clear test transpile-tests/ +JOBS="${NK_JOBS:-$(nproc)}" +# parallel_rspec (this `prspec`) reads ENV['WORKERS'] (default 4 -- +# that was the ~4-of-32-cores bottleneck). It forwards all CLI args +# to rspec, so a `-n` flag is mis-parsed as a spec file; WORKERS is +# the only correct knob. +export WORKERS="$JOBS" +TOTAL_START=$SECONDS -find examples benchmarks -type f -name '*.cht' -print0 | - sort -z | - while IFS= read -r -d '' file; do - ruby src/backends/transpiler.rb "$file" >/dev/null - done +run() { + local label="$1"; shift + local t0=$SECONDS + echo "=== nil-kill workload [$label] start (jobs=$JOBS) ===" + "$@" || echo "=== [$label] stage failed, continuing ===" + echo "=== nil-kill workload [$label] done in $((SECONDS - t0))s ===" +} + +# Run `ruby ` over a NUL-delimited file list, JOBS-way +# parallel. Each child inherits RUBYOPT -> traced; each writes its own +# PID-keyed dump, merged by `infer` (parallel-safe by construction). +par_ruby() { # par_ruby