diff --git a/.github/workflows/transpile-pure.yml b/.github/workflows/transpile-pure.yml new file mode 100644 index 000000000..974df7c30 --- /dev/null +++ b/.github/workflows/transpile-pure.yml @@ -0,0 +1,131 @@ +name: Transpile Pure + +# Verifies that this branch produces byte-identical .zig output for every +# .cht file in `transpile-tests/` compared to the merge base. Triggered +# by including `#TRANSPILE_PURE` in the PR description. +# +# Use this when you've made a refactor that should NOT change the +# transpiler's output — e.g., the schema-extraction refactors in #2 or +# the FnSig/PipelineSite extractions in #4/#3. Editing the PR body to +# add or remove the marker re-triggers the check. +# +# Scope: `transpile-tests/` only. The 408 standalone .cht files there +# are designed to round-trip through the transpiler without external +# package paths or runtime args. `examples/` is intentionally excluded +# — many examples in that tree are driven by bc_emitter or need +# specific --pkg flags that emit-zig can't infer. + +on: + pull_request: + types: [opened, synchronize, edited, reopened] + +concurrency: + group: transpile-pure-${{ github.ref }} + cancel-in-progress: true + +env: + RUBY_VERSION: "3.2" + +jobs: + diff-zig: + name: Byte-diff generated .zig vs merge base + runs-on: ubuntu-latest + if: contains(github.event.pull_request.body, '#TRANSPILE_PURE') + steps: + - uses: actions/checkout@v4 + with: + # Need the full history so we can find and check out the merge + # base. Default `fetch-depth: 1` would fail on `git checkout`. + fetch-depth: 0 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.RUBY_VERSION }} + bundler-cache: true + + - name: Compute merge base + id: base + run: | + BASE_SHA=$(git merge-base HEAD origin/${{ github.event.pull_request.base.ref }}) + echo "sha=$BASE_SHA" >> $GITHUB_OUTPUT + echo "Comparing branch HEAD ($(git rev-parse HEAD)) against merge base $BASE_SHA" + + - name: Emit .zig from branch HEAD + run: | + mkdir -p /tmp/zig-branch + ./clear emit-zig transpile-tests -o /tmp/zig-branch || true + + - name: Stage branch tooling for merge-base run + # The `clear emit-zig` subcommand may have been introduced or + # patched on this branch. Stash the branch's `clear` script and + # `tools/` so we use the SAME emitter against both checkouts — + # any diff is then purely a src/ change, not an emitter change. + run: | + cp clear /tmp/clear-branch + cp -r tools /tmp/tools-branch + + - name: Check out merge base + run: | + git checkout ${{ steps.base.outputs.sha }} + + - name: Restore branch tooling + run: | + cp /tmp/clear-branch ./clear + chmod +x ./clear + rm -rf tools && cp -r /tmp/tools-branch tools + + - name: Reinstall gems for merge base + run: bundle install + + - name: Emit .zig from merge base + run: | + mkdir -p /tmp/zig-base + ./clear emit-zig transpile-tests -o /tmp/zig-base || true + + - name: Normalize auto-generated counter identifiers + # The transpiler uses node.object_id.abs to disambiguate nested-WITH + # guard variables, MATCH binding aliases, etc. — the IDs shift + # whenever Ruby allocation order changes (refactors that add/remove + # objects), even though the generated Zig is functionally identical. + # tools/normalize_zig.rb maps each unique counter to a stable + # first-occurrence index per file (`__c_guard_3220` -> `__c_guard_N1`) + # so the diff surfaces real semantic changes, not allocation drift. + # See the script's docs for collision-safety reasoning. + run: | + ruby tools/normalize_zig.rb /tmp/zig-base /tmp/zig-branch + + - name: Diff trees + run: | + # `diff -r` exits 0 (no diff) / 1 (diffs) / 2 (error). We want + # to fail the job only on 1, so allow-fail and inspect. + set +e + diff -r --brief /tmp/zig-base /tmp/zig-branch > /tmp/diff.txt + status=$? + set -e + + if [ "$status" = "0" ]; then + echo "::notice::Output is byte-identical between merge base and branch HEAD." + exit 0 + fi + + echo "::error::Generated .zig differs between merge base and branch HEAD." + echo "" + echo "=== Summary of changed files ===" + cat /tmp/diff.txt + echo "" + echo "=== Full unified diffs (first 500 lines per file) ===" + # Iterate over differing files and print a bounded unified diff. + while IFS= read -r line; do + # Lines look like: "Files /tmp/zig-base/X and /tmp/zig-branch/X differ" + # or: "Only in /tmp/zig-...: " + if [[ "$line" == Files* ]]; then + base_file=$(echo "$line" | awk '{print $2}') + branch_file=$(echo "$line" | awk '{print $4}') + echo "----- $base_file vs $branch_file -----" + diff -u "$base_file" "$branch_file" | head -500 + echo "" + else + echo "$line" + fi + done < /tmp/diff.txt + exit 1 diff --git a/.gitignore b/.gitignore index f8c83b930..2312cac15 100644 --- a/.gitignore +++ b/.gitignore @@ -21,11 +21,13 @@ !/docs/ !/examples/ !/manifesto/ +!/sorbet/ !/spec/ !/src/ !/stdlib/ !/syntaxes/ !/testdata/ +!/tools/ !/transpile-tests/ !/zig/ diff --git a/Gemfile b/Gemfile index 8dd7e6a29..361abd221 100644 --- a/Gemfile +++ b/Gemfile @@ -16,5 +16,15 @@ group :development do gem 'simplecov', require: false # Cobertura XML output for Codecov / coveralls / GitLab integration gem 'simplecov-cobertura', require: false + + # Gradual typing — staged adoption per the self-host prep tracker + # (TODO.md "Self-host preparation" P1 / tasks #10 + #20). `sorbet` + # provides the static type checker (`srb tc`); `sorbet-runtime` is + # the inline `T::Sig` API (no runtime cost when not invoked because + # files start at `# typed: false`); `tapioca` generates RBI files + # for our gems so Sorbet sees their public APIs. + gem 'sorbet', require: false + gem 'sorbet-runtime' + gem 'tapioca', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index 48ce3e2ac..9cf2aab74 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,6 +8,7 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + benchmark (0.5.0) bigdecimal (4.1.2) byebug (12.0.0) childprocess (5.1.0) @@ -69,7 +70,9 @@ GEM logger (~> 1.6) logger (1.7.0) msgpack (1.8.0) + netrc (0.11.0) ostruct (0.6.3) + parallel (1.28.0) parallel_rspec (3.0.0) rake (> 10.0) rspec @@ -82,12 +85,20 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.3.1) + rbi (0.3.10) + prism (~> 1.0) + rbs (>= 4.0.1) + rbs (4.0.2) + logger + prism (>= 1.6.0) + tsort reek (6.5.0) dry-schema (~> 1.13) logger (~> 1.6) parser (~> 3.3.0) rainbow (>= 2.0, < 4.0) rexml (~> 3.1) + require-hooks (0.4.0) rexml (3.4.4) rspec (3.13.2) rspec-core (~> 3.13.0) @@ -119,6 +130,7 @@ GEM simplecov (>= 0.22.0) tty-which (~> 0.5.0) virtus (~> 2.0) + rubydex (0.2.1-x86_64-linux) sexp_processor (4.17.5) simplecov (0.22.0) docile (~> 1.1) @@ -129,7 +141,36 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sorbet (0.6.13196) + sorbet-static (= 0.6.13196) + sorbet-runtime (0.6.13196) + sorbet-static (0.6.13196-x86_64-linux) + sorbet-static-and-runtime (0.6.13196) + sorbet (= 0.6.13196) + sorbet-runtime (= 0.6.13196) + spoom (1.7.13) + erubi (>= 1.10.0) + prism (>= 0.28.0) + rbi (>= 0.3.3) + rbs (>= 4.0.0.dev.5) + rexml (>= 3.2.6) + sorbet-static-and-runtime (>= 0.5.10187) + thor (>= 0.19.2) + tapioca (0.19.1) + benchmark + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (>= 0.3.7) + require-hooks (>= 0.2.2) + rubydex (>= 0.1.0.beta10) + sorbet-static-and-runtime (>= 0.6.12698) + spoom (>= 1.7.9) + thor (>= 1.2.0) + tsort + thor (1.5.0) thread_safe (0.3.6) + tsort (0.2.0) tty-which (0.5.0) virtus (2.0.0) axiom-types (~> 0.1) @@ -138,7 +179,6 @@ GEM zeitwerk (2.7.5) PLATFORMS - ruby x86_64-linux-gnu DEPENDENCIES @@ -153,6 +193,9 @@ DEPENDENCIES rubycritic simplecov simplecov-cobertura + sorbet + sorbet-runtime + tapioca BUNDLED WITH 2.7.2 diff --git a/TODO.md b/TODO.md index 3316f08bf..3cc4150d7 100644 --- a/TODO.md +++ b/TODO.md @@ -74,6 +74,58 @@ Vision is clear: Gradual Typing (Ruby/Python) -> Typed (Rust) -> STRICT Typed (H `./clear doctor ` + profiling on replay can take you ~95% of the way from an untyped correct app to ADA-level safety and HFT-C speed automatically. +## Self-host preparation (`src/` Ruby cleanup) + +Source signal: 70 .rb files / ~57k LOC in `src/`. Anti-pattern census from grep + `reek` + `debride`: + + - 1409 `is_a?` checks (141 `is_a?(Hash)` = hash-as-struct; 99 `is_a?(Array)` = array-as-tuple; 67 mixed `is_a?(String)`/`is_a?(Symbol)`) + - 635 `respond_to?` (duck-typing escape hatches; 289 surface as reek `ManualDispatch`) + - 228 `.nil?` checks (164 surface as reek `NilCheck`) + - 64 reek `DataClump` clusters (top: PipelineHost/PipelineGenerator with `(list_node, smooth_node)` through 18 methods, Formatter::Emitter `(start, toks)` through 17, etc.) + - 91 `ControlParameter` + 54 `BooleanParameter` (flag args switching behavior) + - 313 `Struct.new` already in use → good baseline + +Each P0 item is mechanical and self-verifying via existing tests; doing them BEFORE typing lands keeps the typing pass cheap. Each P1 item depends on at least one P0 item. + +### P0 — must complete before declaring "ready to type" + + - [ ] Extract top hash-as-struct schemas (target: 141 `is_a?(Hash)` → ~40): `Schema` for the `schema.is_a?(Hash) && schema[:kind] == :union` pattern in promotion_plan.rb / type.rb / annotator.rb / mir_lowering.rb (clustered: 36+25+24+14 sites). **Subsumes String/Symbol normalization:** ~15 of the 65 `is_a?(String|Symbol)` sites are schema mixed-key filters (`k.is_a?(Symbol)` rejecting `:kind`/`:field_defaults` metadata vs String field names) — typed Schema (`kind: Symbol`, `field_names: Array[String]`) kills them at the root. The other ~50 sites are either correct primitive-rejection in AST walkers or syntactic dispatch on `node.name`, NOT normalization. + - [ ] `PipelineSite`/`PipelineFrame` struct: collapse the `(id, options, rt_name, workers_code, list_node, smooth_node, stream_node, conc_op, lhs)` clump that DataClump-flags 25+ methods across PipelineHost / PipelineGenerator / PipelineRewriter + - [ ] `Formatter::Emitter::EmitterState` struct: collapse the `(toks, start, out, pc, po, arrow_idx)` clump (17-method DataClump cluster, single-class, smallest blast radius — good warm-up extraction) + - [ ] Tighten nilable fields where field is always set in practice: top targets `sync` (13), `node` (7), `out` (6), `schema` (5), `entry`/`code`/`expr_type`/`layout`/`mir`/`name`/`ownership`/`path`/`rl`/`rt`/`syn` (3 each); replace `.nil?` guards with construction-time invariants + +### P1 — high-leverage, depends on P0 + + - [ ] AST dispatch via Ruby 3 pattern matching: convert `if node.is_a?(AST::Identifier) ... elsif node.is_a?(AST::BinaryOp) ...` chains (~600 sites) to `case node in AST::Identifier(...)`. Maps directly to CLEAR union-tag dispatch later. + - [ ] Array-as-tuple → `Data.define(:kind, :value, :loc)` etc. (99 `is_a?(Array)` sites). Token tuples in lexer/parser, MIR ops with positional payload, multi-return method results. + - [ ] `MIRPass::WalkState` + `OwnershipDataflow::DataflowStep` struct extractions (`(bindings, result, stmt)` 9× / `(node, state, consumed)` 6× DataClump clusters) + - [ ] `respond_to?` purge (635 → ~50): each site is one of three things — missing trait (introduce module/interface), wrong type (fix caller), genuine duck-typing on heterogeneous external input (keep, document) + - [ ] Add Sorbet (or rbs+steep) to Gemfile; bootstrap with `tapioca init`; flip files from `# typed: false` to `# typed: true` per file. Acceptance gate per file: `# typed: strict` before it's a self-host candidate. + +### P2 — structural cleanup + + - [ ] Eliminate `ControlParameter` (91) + `BooleanParameter` (54): every flag arg should be a separate method or a sealed enum input + - [ ] `LongParameterList` audit (135): residual after P0 PipelineSite / EmitterState extractions; remaining are either missing structs or genuinely 5+ orthogonal inputs + - [ ] Method-return-type uniformity audit: methods that return `value | nil` use Optional; methods that return different value types in different paths split or use sealed union + - [ ] Custom Rubocop cops as CI signal: `Project/HashAsStruct` (flags `is_a?(Hash)` near `[:key]` access), `Project/UnnecessaryNilCheck` (flags `.nil?` on locals never assigned nil), `Project/SymbolOrString` (flags the 2-arm dispatch). ~50 lines each. + - [ ] FsmTransform::RecursiveSplitter `(after_idx, builder, lowering)` 12-method DataClump cluster + +### P3 — nice-to-have / cosmetic + + - [ ] `UncommunicativeVariableName` cleanup pass (890 reek hits) — names like `n`, `t`, `p` in non-trivial scopes + - [ ] `TooManyStatements` method splits (993 hits) — only where NOT intrinsic AST/MIR dispatch (those should stay as one method) + - [ ] `flog` triage: top-30 by score, refactor only the ones that aren't dispatch-shaped (rest are accidentally-flagged intrinsic complexity) + - [ ] `DuplicateMethodCall` cleanup (3637 reek hits): mostly local-let extractions, low-leverage but reduces noise + +### Acceptance criteria for "this file is self-host ready" + +A file passes when ALL of: + - [ ] `# typed: strict` (Sorbet or equivalent rbs) + - [ ] Zero `is_a?(Hash)` calls + - [ ] Zero `respond_to?` calls (or each remaining one is documented as genuine external-input duck-typing) + - [ ] Zero unguarded `.nil?` checks (every survivor is on a typed-nilable field, locally justified) + - [ ] Reek output for that file shows no `DataClump`, `LongParameterList`, `ControlParameter`, or `BooleanParameter` warnings + ## v0.4 (December 25) - [ ] EXTREME STRICT compilation pt 1: en route to ADA-levels of safety (just blocking compilation where required, ensuring the syntax is forward compatible) diff --git a/docs/agents/benchmark-fix-findings.md b/docs/agents/benchmark-fix-findings.md new file mode 100644 index 000000000..ab5607f00 --- /dev/null +++ b/docs/agents/benchmark-fix-findings.md @@ -0,0 +1,71 @@ +# Benchmark-Fix Branch: Tech Debt and Bug Findings + +This document summarizes the technical debt, architectural violations, potential bugs, and partial implementations identified in the `benchmark-fix` branch (relative to `master`). + +## 1. Architectural Violations + +### MIREmitter Type Inspection (Tech Debt) +- **File:** `src/mir/mir_emitter.rb` +- **Issue:** The emitter uses regex to parse Zig type strings (e.g., `node.zig_type[/ArrayListUnmanaged\((.+)\)/, 1]`) to determine element types. +- **Violation:** Violates the "Dumb Transpiler" rule (Role 3 in `GEMINI.md`). The emitter should not perform semantic analysis or string parsing to make code-gen decisions. +- **Recommendation:** Explicitly pass the element type in the MIR node (`MIR::EscapePromote`, `MIR::ContainerInit`, etc.). + +### Transpiler Semantic Decisions +- **File:** `src/backends/pipeline_generator.rb` +- **Issue:** Contains logic to determine cleanup requirements (e.g., `src_needs_cleanup = list_node.is_a?(AST::MethodCall) && ...`). +- **Violation:** All allocator and cleanup decisions must reside in Pass 2 (`MIRLowering`) per `GEMINI.md`. Pass 4 should be a passive consumer of these decisions. +- **Recommendation:** Move this logic into `MIRLowering` and communicate it via MIR nodes. + +### Legacy Pipeline Path +- **File:** `src/mir/mir_lowering.rb` (`lower_smooth`) +- **Issue:** Complex pipeline operators still fall back to `pipeline_legacy_host`, which generates `RawZig`. +- **Violation:** `RawZig` is an unsafe escape hatch that bypasses `MIRChecker` (INV-12). Chained pipelines using this path are unverified for memory safety. +- **Recommendation:** Complete the migration of all pipeline operators to structural MIR. + +## 2. Potential Bugs and Safety Risks + +### SmartEventFd.notify Optimization +- **File:** `zig/runtime/scheduler.zig` +- **Issue:** Re-introduces an optimization to skip the `write()` syscall if the target is not `WakeParked`. +- **Risk:** A previous version of this optimization was removed because it caused deadlocks. While `prepareSleep` now uses atomics, this is a high-risk change for a core synchronization primitive. +- **Recommendation:** Verify with aggressive Hammer and VOPR tests that no wake-up signals are lost. + +### Incomplete FsmTask Sequence Bumping +- **File:** `zig/runtime/scheduler.zig` +- **Issue:** `FsmTask.seq` (the park/wake transition counter) is only bumped on lock timeouts. It is NOT bumped during regular `WaitForIO` or `WaitForLock` transitions. +- **Risk:** This breaks the `detectCycleFsm` protocol, which relies on `seq` to validate that a task hasn't been recycled or moved during a cross-scheduler chain walk. +- **Recommendation:** Bump `task.seq` in `submitFsmResume` and `submitFsmSpawn` (or in the scheduler's FSM dequeue/dispatch loop). + +### FSM Liveness for IoSuspend Async Access +- **File:** `src/mir/fsm_transform/liveness.rb` +- **Issue:** For `IoSuspend` (e.g., async `read`), the kernel may access arguments after the FSM body has yielded. +- **Risk:** If those arguments (like a buffer) are stack-allocated, they must be promoted to the FSM `ctx` struct. The current analysis charges these reads to the "next" segment, which should trigger promotion, but this needs rigorous validation to ensure no Use-After-Free (UAF) windows exist. +- **Recommendation:** Add explicit tests for async IO with stack-allocated buffers in FSM tasks. + +## 3. Partial Implementations and Stubs + +### Missing detectCycleFsm +- **File:** `zig/runtime/scheduler.zig`, `zig/runtime/fsm.zig` +- **Issue:** References to `detectCycleFsm` and the UAF-safe "slab-pin" protocol exist, but the actual cycle detection logic for FSM tasks is missing. +- **Status:** Partial implementation. + +### FSM Next-Bind RawZig +- **File:** `src/mir/fsm_transform/suspend_resolvers.rb` +- **Issue:** `resolve_next` uses a block of `RawZig` to handle `fsm_next_bind` logic. +- **Status:** A "small follow-up" is noted to migrate this to structured MIR (`IfStmt` + `ReturnStmt`). + +### Disabled Liveness Safety Check +- **File:** `src/mir/fsm_transform/liveness.rb` +- **Issue:** `return unless uses_by_seg.key?(next_idx) || true` (line 143). +- **Status:** The `|| true` effectively disables the check. Likely a debug leftover. + +## 4. Testing Gaps + +### FSM Stealing Stress +- **Issue:** `fsm-steal-test.zig` covers basic functionality, but interactions between stealing and status transitions (Blocked/Ready) under heavy load need more coverage. +- **Recommendation:** Implement a Hammer test specifically for FSM work-stealing with oversubscribed workers and high IO/Lock contention. + +### ArrayListUnmanaged String Matching +- **Issue:** The `MIREmitter` logic for `ArrayListUnmanaged` relies on string-pattern matching on `zig_type`. +- **Risk:** Fragile; could fail on complex nested types or if Zig's internal type naming changes. +- **Recommendation:** Add unit tests in `spec/mir_emitter_spec.rb` covering complex nested collections to verify the regex robustness. diff --git a/docs/agents/respond_to_inventory.md b/docs/agents/respond_to_inventory.md new file mode 100644 index 000000000..3dbebc3e1 --- /dev/null +++ b/docs/agents/respond_to_inventory.md @@ -0,0 +1,80 @@ +# respond_to? Inventory (TODO #9 Phase 0) + +`tools/respond_to_inventory.rb` walks `src/ast/ast.rb` to map AST classes +to their attrs (Struct members + `include Locatable` + `attr_accessor` + +custom `def name` getters), then walks `src/**/*.rb` for every +`respond_to?(:X)` site and writes: + +- `tmp/respond_to_inventory/attrs_by_class.csv` — `class, attr` +- `tmp/respond_to_inventory/sites.csv` — `file, line, attr, receiver, method, caller_kind, classes_with_attr` +- `tmp/respond_to_inventory/summary.md` — top-30 attrs + caller-kind histogram + +`caller_kind` is a heuristic on the enclosing method's params: + +- **generic_walker** — first param has a generic name (`node`, `stmt`, + `expr`, `body`, `value`, `n`, `v`, `item`, etc.) AND that's the + receiver of the `respond_to?`. Treat as legitimate duck-typing on + heterogeneous tree input. +- **typed_or_unclear** — receiver doesn't match a generic-named param. + The caller likely has a typed param. `respond_to?` here is usually + dead defensive code. +- **unknown** — no enclosing method (top-level scripts, etc.). + +## Headline numbers (current) + +- 655 total `respond_to?` sites +- 384 generic_walker (58.6%) — legitimate, candidates for declarative + metadata fix (Phase 1b) +- 249 typed_or_unclear (38.0%) — candidates for pure-deletion sweep +- 22 unknown — small tail + +## Locatable is universal + +117 of 117 inventoried AST classes include `AST::Locatable`. Locatable +provides 33 attrs (`token`, `symbol`, `full_type`, `type_info`, +`storage`, `matched_stdlib_def`, `was_moved`, `zig_pattern`, `line`, +`column`, `coerced_type`, etc.). For every AST node receiver, +`respond_to?(:LOCATABLE_ATTR)` is unconditionally `true`. + +This collapses the dominant attr categories in the top-30: + +- `:symbol` (50), `:full_type` (44), `:token` (26), `:type_info` (20), + `:storage` (20), `:line` (9), `:matched_stdlib_def` (9), + `:coerced_type` (6), `:was_moved` (5), `:zig_pattern` (5), + `:mutates_receiver` (4) + +Total: ~198 sites where the `respond_to?` is testing a Locatable attr. +Of these, ~150 are in `typed_or_unclear` callers — candidates for +straight deletion (use safe-nav `&.` instead). + +## Phased plan + +| Phase | Scope | Sites | Action | +|---|---|---:|---| +| 1a | Locatable attrs in typed callers | ~150 | Delete `respond_to?(:X)` guard; keep `&.` for nilable values. | +| 1b | Generic-walker sites probing AST shape | ~190 | Introduce `AST::HasBodies` / `HasChildExprs` declarative traits. Walkers iterate via `node.child_bodies` instead of hand-coded case chains. | +| 2 | Multi-class shared-trait attrs (`:name` 22cls, `:value` 17cls, `:target` 5cls) | ~80 | Per-trait module + `include` in the relevant classes. `respond_to?(:value)` becomes `is_a?(AST::HasValue)`. | +| 3 | Single-class attrs (`:layout`, `:sync`, `:requires`, `:ownership`) | ~40 | Tighten the caller's signature so the param is the one class that has the attr. | +| 4 | External duck-typing on String / Hash / Type / FFI return values (`:strip`, `:each_pair`, `:error_union?`, `:empty?`) | ~75 | Document with one-line comment per site. Keep the check. | + +## Risk hotspots + +- Phase 1a relies on `caller_kind` being correct. The classifier marks + `typed_or_unclear` whenever the param name is non-generic, but some + methods take typed args with generic names (`def visit(node)`) and + some take generic args with typed names. Per-site eyeball still + required. +- Phase 1b introduces base modules; risk is over-narrowing the walkers + and missing a node type that participates structurally without the + trait include. Tests catch this. +- Phase 3 signature-tightening will surface real type mismatches — + expect to find a few callers that have been quietly passing the + wrong shape. That's a feature: those bugs are revealed. + +## Re-run + +``` +bundle exec ruby tools/respond_to_inventory.rb +``` + +Idempotent. Reads only; writes only to `tmp/respond_to_inventory/`. diff --git a/docs/agents/sorbet-string-symbol-workflow.md b/docs/agents/sorbet-string-symbol-workflow.md new file mode 100644 index 000000000..b1a069e2a --- /dev/null +++ b/docs/agents/sorbet-string-symbol-workflow.md @@ -0,0 +1,99 @@ +# Driving the String/Symbol normalization with Sorbet + +## What's installed + +- `gem 'sorbet'` — static checker (`bundle exec srb tc`) +- `gem 'sorbet-runtime'` — `T::Sig` API for inline signatures +- `gem 'tapioca'` — RBI generation (currently broken on Ruby 3.2 + tapioca 0.19; init crashes in `coerce_and_check_module_types`. Workaround: hand-write minimal RBI stubs in `sorbet/rbi/clear-stubs.rbi` for now.) +- `sorbet/config` — points Sorbet at `.` and ignores `vendor/`, `zig*/`, `spec/`, `transpile-tests/`, `examples/`, `benchmarks/`, `tmp/`, `docs/`. +- `sorbet/rbi/clear-stubs.rbi` — hand-written stubs for project classes that Sorbet's name resolver cannot find at `# typed: false`. + +Status: **`bundle exec srb tc` reports no errors** at default `# typed: false` across all of `src/`. + +## The String/Symbol problem today + +67 sites in `src/` mix `String` and `Symbol` for what should be one type. Examples: + +```ruby +# src/backends/importer.rb:93 +struct_schemas[stmt.name.to_sym] = stmt.fields +``` + +`stmt.name` is a `String` here but the schema map uses `Symbol` keys. Every read/write of the schema map needs `.to_sym` or `.to_s` glue. Two existing 2-arm checks make the polymorphism explicit: + +```ruby +# src/mir/fsm_transform/liveness.rb:232 +return if node.is_a?(Symbol) || node.is_a?(String) || ... + +# src/backends/pipeline_host.rb:159 +return false if node.is_a?(String) || node.is_a?(Symbol) || ... +``` + +## How Sorbet drives the fix + +The cycle for one method: + +1. **Pick a method** that currently accepts either type. Add `# typed: true` to the file. + +2. **Declare the truth**: + ```ruby + sig { params(name: T.any(String, Symbol)).returns(T.untyped) } + def lookup_struct(name) + @struct_schemas[name.to_sym] + end + ``` + `srb tc` should still pass — this just makes the union explicit. + +3. **Pick the target type** (project rule: `Symbol` for identifiers, `String` for user-facing text and paths). Tighten the sig: + ```ruby + sig { params(name: Symbol).returns(T.untyped) } + def lookup_struct(name) + @struct_schemas[name] # ← drop the .to_sym, it's already a Symbol now + end + ``` + +4. **Run `bundle exec srb tc`**. Sorbet flags every call site that still passes a `String`: + ``` + src/some/caller.rb:42: Expected `Symbol` but found `String("foo")` for argument `name` + lookup_struct("foo") + ^^^^^^ + ``` + +5. **Fix each call site** — either upstream (so the caller produces a `Symbol`) or with an explicit `.to_sym` at the boundary. The end state is one normalised type all the way through. + +6. **Repeat** for the next method until the type signature unions across the codebase converge. + +The win vs. grep: Sorbet finds **every** caller, including dynamically-dispatched ones the compiler can resolve, in one pass. The dataflow becomes a worklist instead of a hunt. + +## Per-file rollout protocol + +A file's typed level moves through three states as the cleanup work lands: + +| State | Meaning | Sorbet enforcement | +|---|---|---| +| (no comment, default `# typed: false`) | File has not been touched yet | Name resolution only; Sorbet ignores sigs and bodies | +| `# typed: true` | Sigs are checked; bodies use Sorbet inference | Catches type mismatches in sigged methods | +| `# typed: strict` | Every method has a sig; every constant has a type | Self-host-ready gate | + +The TODO.md "Self-host preparation" P1 task #10 tracks the rollout. The per-file gate (task #20) requires `# typed: strict` plus zero `is_a?(Hash)`, `respond_to?`, and unguarded `.nil?` checks. + +## Useful one-liners + +```bash +# Run the static checker +bundle exec srb tc + +# Run on one file (still type-checks the whole project, but useful for focus) +bundle exec srb tc src/backends/importer.rb + +# Auto-generate sigs from runtime observation (after tapioca is fixed) +# bundle exec tapioca dsl --only=ActiveSupport::TaggedLogging::Formatter + +# Suggest types based on usage +bundle exec srb suggest-typed +``` + +## Known issues + +- **tapioca 0.19 + Ruby 3.2**: `tapioca init` crashes with `Invalid value for type constraint. Got a NilClass.` This blocks auto-RBI generation for our gems. Until upstream fix lands, hand-write minimal stubs in `sorbet/rbi/clear-stubs.rbi`. The bigger Sorbet workflow is not affected. +- **Default-typed name resolution**: at `# typed: false`, Sorbet still tries to resolve constant references and may suggest unrelated stdlib names (e.g., `Token` → `Socket`). Stubs in `sorbet/rbi/clear-stubs.rbi` silence these. Generated RBI files would too. diff --git a/sorbet/config b/sorbet/config new file mode 100644 index 000000000..de217a808 --- /dev/null +++ b/sorbet/config @@ -0,0 +1,13 @@ +--dir +. +--ignore=vendor/ +--ignore=zig/ +--ignore=zig-old/ +--ignore=zig-out/ +--ignore=spec/ +--ignore=transpile-tests/ +--ignore=examples/ +--ignore=benchmarks/ +--ignore=tmp/ +--ignore=docs/ +--ignore=tools/ diff --git a/sorbet/rbi/ast-struct-fields.rbi b/sorbet/rbi/ast-struct-fields.rbi new file mode 100644 index 000000000..c3af20f0c --- /dev/null +++ b/sorbet/rbi/ast-struct-fields.rbi @@ -0,0 +1,1103 @@ +# 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. + +class AST::AllOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::AnyOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::Assert + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def condition; end + sig { returns(T.untyped) } + def message; end +end + +class AST::AssertRaises + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def error_name; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::Assignment + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def value; end +end + +class AST::AverageOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::BatchWindowOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def options; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::BenchmarkStmt + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end + sig { returns(T.untyped) } + def iterations; end +end + +class AST::BgBlock + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def deferred_drops; end + sig { returns(T.untyped) } + def stack_size; end + sig { returns(T.untyped) } + def pinned; end + sig { returns(T.untyped) } + def parallel; end + sig { returns(T.untyped) } + def arena_mode; end + sig { returns(T.untyped) } + def can_smash; end +end + +class AST::BgStreamBlock + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def deferred_drops; end + sig { returns(T.untyped) } + def stack_size; end +end + +class AST::BinaryOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def left; end + sig { returns(T.untyped) } + def op; end + sig { returns(T.untyped) } + def right; end +end + +class AST::BindExpr + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def type; end + sig { returns(T.untyped) } + def value; end +end + +class AST::BlockExpr + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def result; end +end + +class AST::BreakNode + sig { returns(Token) } + def token; end +end + +class AST::CallSiteOverride + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def n; end + sig { returns(T.untyped) } + def inner; end +end + +class AST::CapabilityWrap + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def value; end + sig { returns(T.untyped) } + def ownership; end + sig { returns(T.untyped) } + def sync; end + sig { returns(T.untyped) } + def layout; end +end + +class AST::Cast + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end + sig { returns(T.untyped) } + def target; end +end + +class AST::CatchBlock + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def catch_clauses; end + sig { returns(T.untyped) } + def default_body; end +end + +class AST::CloneNode + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::CollectOp + sig { returns(T.nilable(Token)) } + def token; end +end + +class AST::ConcurrentOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def op; end + sig { returns(T.untyped) } + def options; end +end + +class AST::ContinueNode + sig { returns(Token) } + def token; end +end + +class AST::Copy + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::CopyNode + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::CountOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::DefaultLit + sig { returns(Token) } + def token; end +end + +class AST::DieNode + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def status; end +end + +class AST::DistinctOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::DoBlock + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def branches; end +end + +class AST::EachOp + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def body; end +end + +class AST::EnumDef + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def variants; end + sig { returns(T.untyped) } + def visibility; end +end + +class AST::ExternFnDecl + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T::Array[T.untyped]) } + def params; end + sig { returns(T.untyped) } + def return_type; end + sig { returns(T.untyped) } + def from_module; end + sig { returns(T.untyped) } + def effects; end +end + +class AST::ExternStructDecl + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def from_module; end +end + +class AST::FindOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::ForEach + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def var_name; end + sig { returns(T.untyped) } + def collection; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def deferred_drops; end + sig { returns(T.untyped) } + def is_mutable; end +end + +class AST::ForRange + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def var_name; end + sig { returns(T.untyped) } + def start_expr; end + sig { returns(T.untyped) } + def end_expr; end + sig { returns(T.untyped) } + def inclusive; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def deferred_drops; end + sig { returns(T.untyped) } + def mark_per_iter; end +end + +class AST::FreezeNode + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::FuncCall + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T::Array[T.untyped]) } + def args; end +end + +class AST::FunctionDef + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T::Array[T.untyped]) } + def params; end + sig { returns(T.nilable(T::Array[T.untyped])) } + def captures; end + sig { returns(T.untyped) } + def return_type; end + sig { returns(T.untyped) } + def return_lifetime; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def catch_clauses; end + sig { returns(T.untyped) } + def default_catch; end + sig { returns(T.untyped) } + def visibility; end + sig { returns(T.untyped) } + def deferred_drops; end + sig { returns(T.untyped) } + def uses_frame; end +end + +class AST::GetField + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def target; end + sig { returns(T.untyped) } + def field; end +end + +class AST::GetIndex + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def target; end + sig { returns(T.untyped) } + def index; end +end + +class AST::HashLit + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def pairs; end + sig { returns(T.untyped) } + def storage; end +end + +class AST::Identifier + sig { returns(Token) } + def token; end + sig { returns(String) } + def name; end +end + +class AST::IfBind + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def bindings; end + sig { returns(T.untyped) } + def then_branch; end + sig { returns(T.untyped) } + def else_branch; end +end + +class AST::IfStatement + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def condition; end + sig { returns(T.untyped) } + def then_branch; end + sig { returns(T.untyped) } + def else_branch; end + sig { returns(T.untyped) } + def then_drops; end + sig { returns(T.untyped) } + def else_drops; end +end + +class AST::IndexOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::JoinOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def right_source; end + sig { returns(T.untyped) } + def key_expr; end +end + +class AST::LambdaLit + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def params; end + sig { returns(T::Array[T.untyped]) } + def captures; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def storage; end + sig { returns(T.untyped) } + def deferred_drops; end +end + +class AST::LetBinding + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def expr; end +end + +class AST::LimitOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def count; end +end + +class AST::LinkNode + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::ListLit + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def items; end + sig { returns(T.untyped) } + def storage; end +end + +class AST::Literal + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def type; end + sig { returns(T.untyped) } + def value; end + sig { returns(T.untyped) } + def storage; end +end + +class AST::MatchStatement + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expr; end + sig { returns(T::Array[T.untyped]) } + def cases; end + sig { returns(T.untyped) } + def default_case; end + sig { returns(T.untyped) } + def case_drops; end + sig { returns(T.untyped) } + def default_drops; end + sig { returns(T.untyped) } + def exhaustive; end + sig { returns(T.untyped) } + def takes; end +end + +class AST::MaxOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::MethodCall + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def object; end + sig { returns(T.untyped) } + def name; end + sig { returns(T::Array[T.untyped]) } + def args; end +end + +class AST::MinOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::MoveNode + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::NextExpr + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expr; end +end + +class AST::OptionalUnwrap + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def target; end +end + +class AST::OrBreak + sig { returns(Token) } + def token; end +end + +class AST::OrExit + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def error_name; end + sig { returns(T.untyped) } + def message; end +end + +class AST::OrPass + sig { returns(Token) } + def token; end +end + +class AST::OrPrune + sig { returns(Token) } + def token; end +end + +class AST::OrRaise + sig { returns(Token) } + def token; end +end + +class AST::OrderByOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::PassStmt + sig { returns(Token) } + def token; end +end + +class AST::Placeholder + sig { returns(T.nilable(Token)) } + def token; end +end + +class AST::ProfileStmt + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::Program + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def statements; end +end + +class AST::Raise + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def error_name; end + sig { returns(T.untyped) } + def message_expr; end +end + +class AST::RangeLit + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def start; end + sig { returns(T.untyped) } + def finish; end + sig { returns(T.untyped) } + def inclusive; end +end + +class AST::RecoverOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def default_expr; end +end + +class AST::ReduceOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def initial_value; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::Require + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def path; end +end + +class AST::RequireNode + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def path; end + sig { returns(T.untyped) } + def namespace; end + sig { returns(T.untyped) } + def kind; end +end + +class AST::ResolveNode + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::ReturnNode + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::SelectOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::ShardOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def key_expr; end + sig { returns(T.untyped) } + def target_map; end +end + +class AST::ShareNode + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::SkipOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def count; end +end + +class AST::Slice + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def target; end + sig { returns(T.untyped) } + def start; end + sig { returns(T.untyped) } + def end; end +end + +class AST::SmashStmt + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::StaticCall + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def type_name; end + sig { returns(T.untyped) } + def method_name; end + sig { returns(T::Array[T.untyped]) } + def args; end +end + +class AST::StringConcat + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def parts; end +end + +class AST::StructDef + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def visibility; end + sig { returns(T::Array[T.untyped]) } + def type_params; end +end + +class AST::StructLit + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def storage; end + sig { returns(T.untyped) } + def type_args; end +end + +class AST::StructPattern + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def partial; end +end + +class AST::StubDecl + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def function_name; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def value; end +end + +class AST::SumOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::SyncPolicyDecl + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def handlers; end +end + +class AST::TakeWhileOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::TapOp + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def body; end +end + +class AST::TestBlock + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def setup; end + sig { returns(T.untyped) } + def whens; end +end + +class AST::TestThat + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def description; end + sig { returns(T::Array[T.untyped]) } + def body; end +end + +class AST::ThenChain + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def steps; end +end + +class AST::ThrowNode + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def value; end +end + +class AST::UnaryOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def op; end + sig { returns(T.untyped) } + def right; end +end + +class AST::UnionDef + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def variants; end + sig { returns(T.untyped) } + def visibility; end +end + +class AST::UnionVariantLit + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def union_name; end + sig { returns(T.untyped) } + def variant_name; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def storage; end +end + +class AST::UnnestOp + sig { returns(T.nilable(Token)) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::VarDecl + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def type; end + sig { returns(T.untyped) } + def value; end + sig { returns(T.untyped) } + def mutable; end +end + +class AST::WhenBlock + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def description; end + sig { returns(T.untyped) } + def setup; end + sig { returns(T.untyped) } + def tests; end + sig { returns(T.untyped) } + def benchmarks; end +end + +class AST::WhereOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::WhileBindLoop + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def condition; end + sig { returns(T.untyped) } + def binding_name; end + sig { returns(T.untyped) } + def binding_token; end + sig { returns(T.untyped) } + def do_branch; end + sig { returns(T.untyped) } + def deferred_drops; end +end + +class AST::WhileLoop + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def condition; end + sig { returns(T.untyped) } + def do_branch; end + sig { returns(T.untyped) } + def deferred_drops; end +end + +class AST::WindowOp + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def size; end + sig { returns(T.untyped) } + def expression; end +end + +class AST::WithBlock + sig { returns(Token) } + def token; end + sig { returns(T::Array[T.untyped]) } + def capabilities; end + sig { returns(T::Array[T.untyped]) } + def body; end + sig { returns(T.untyped) } + def deferred_drops; end +end + +class AST::YieldExpr + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def expr; end +end + +class MIR::Alloc + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::Drop + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def kind; end + sig { returns(T.untyped) } + def alloc; end + sig { returns(T.untyped) } + 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::FieldCleanup + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def target_name; end + sig { returns(T.untyped) } + def field; end + sig { returns(T.untyped) } + def alloc; 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(T.untyped) } + def strategy; end + sig { returns(T.untyped) } + def fields; end + sig { returns(T.untyped) } + def elem_type; end +end + +class MIR::ReassignCleanup + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def alloc; end +end + +class MIR::Return + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def escaped_vars; end +end + +class MIR::SuppressCleanup + sig { returns(Token) } + def token; end + sig { returns(T.untyped) } + def name; end +end + diff --git a/sorbet/rbi/clear-attr-accessors.rbi b/sorbet/rbi/clear-attr-accessors.rbi new file mode 100644 index 000000000..09af15196 --- /dev/null +++ b/sorbet/rbi/clear-attr-accessors.rbi @@ -0,0 +1,1794 @@ +# typed: true +# frozen_string_literal: true +# +# AUTO-GENERATED. Do not edit by hand. Regenerate with: +# bundle exec ruby tools/gen_attr_rbi.rb > sorbet/rbi/clear-attr-accessors.rbi +# +# Sorbet's automatic Struct.new typing only surfaces the positional +# Struct fields, not `attr_accessor`/`attr_reader`/`attr_writer` +# declarations inside the do-block. Without this shim, every file +# that flips to `# typed: true` and reads such an attribute trips a +# `Method does not exist` error. This file declares T.untyped sigs +# so the per-file typing rollout can proceed. +# +# As the Self-host preparation tracker (TODO.md) advances, individual +# T.untyped sigs are tightened to real types (Symbol for identifiers +# per task #1, etc.). When all attrs are typed, this shim can be +# deleted and replaced by inline T::Sig declarations on the classes +# themselves. + +class AST::Assignment + sig { returns(T.untyped) } + def auto_atomic_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def auto_atomic_op=(value); end + sig { returns(T.untyped) } + def auto_lock; end + sig { params(value: T.untyped).returns(T.untyped) } + def auto_lock=(value); end + sig { returns(T.untyped) } + def compound_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def compound_op=(value); end + sig { returns(T.untyped) } + def container_promote_zig_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def container_promote_zig_type=(value); end + sig { returns(T.untyped) } + def field_pre_cleanup; end + sig { params(value: T.untyped).returns(T.untyped) } + def field_pre_cleanup=(value); end +end + +class AST::BgBlock + sig { returns(T.untyped) } + def capture_analysis; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_analysis=(value); end + sig { returns(T.untyped) } + def capture_string_dupes; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_string_dupes=(value); end + sig { returns(T.untyped) } + def captures_resource; end + sig { params(value: T.untyped).returns(T.untyped) } + def captures_resource=(value); end + sig { returns(T.untyped) } + def computed_stack_tier; end + sig { params(value: T.untyped).returns(T.untyped) } + def computed_stack_tier=(value); end + sig { returns(T.untyped) } + def exit_promote; end + sig { params(value: T.untyped).returns(T.untyped) } + def exit_promote=(value); end + sig { returns(T.untyped) } + def fsm_ineligible_reason; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_ineligible_reason=(value); end + sig { returns(T.untyped) } + def fsm_suspend_points; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_suspend_points=(value); end + sig { returns(T.untyped) } + def open_brace_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def open_brace_token=(value); end + sig { returns(T.untyped) } + def prefix_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def prefix_token=(value); end + sig { returns(T.untyped) } + def return_provenance; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_provenance=(value); end + sig { returns(T.untyped) } + def spawn_form; end + sig { params(value: T.untyped).returns(T.untyped) } + def spawn_form=(value); end +end + +class AST::BgStreamBlock + sig { returns(T.untyped) } + def capture_analysis; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_analysis=(value); end + sig { returns(T.untyped) } + def capture_string_dupes; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_string_dupes=(value); end + sig { returns(T.untyped) } + def computed_stack_tier; end + sig { params(value: T.untyped).returns(T.untyped) } + def computed_stack_tier=(value); end + sig { returns(T.untyped) } + def fsm_ineligible_reason; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_ineligible_reason=(value); end + sig { returns(T.untyped) } + def fsm_suspend_points; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_suspend_points=(value); end + sig { returns(T.untyped) } + def spawn_form; end + sig { params(value: T.untyped).returns(T.untyped) } + def spawn_form=(value); end + sig { returns(T.untyped) } + def yields_frame_strings; end + sig { params(value: T.untyped).returns(T.untyped) } + def yields_frame_strings=(value); end +end + +class AST::BinaryOp + sig { returns(T.untyped) } + def observable_dest; end + sig { params(value: T.untyped).returns(T.untyped) } + def observable_dest=(value); end + sig { returns(T.untyped) } + def observable_terminal; end + sig { params(value: T.untyped).returns(T.untyped) } + def observable_terminal=(value); end + sig { returns(T.untyped) } + def or_fallback_dupe; end + sig { params(value: T.untyped).returns(T.untyped) } + def or_fallback_dupe=(value); end + sig { returns(T.untyped) } + def paren_bind; end + sig { params(value: T.untyped).returns(T.untyped) } + def paren_bind=(value); end + sig { returns(T.untyped) } + def storage; end + sig { params(value: T.untyped).returns(T.untyped) } + def storage=(value); end + sig { returns(T.untyped) } + def string_concat; end + sig { params(value: T.untyped).returns(T.untyped) } + def string_concat=(value); end +end + +class AST::BindExpr + sig { returns(T.untyped) } + def auto_atomic_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def auto_atomic_op=(value); end + sig { returns(T.untyped) } + def compound_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def compound_op=(value); end + sig { returns(T.untyped) } + def mir_binding_entry; end + sig { params(value: T.untyped).returns(T.untyped) } + def mir_binding_entry=(value); end + sig { returns(T.untyped) } + def mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def mode=(value); end + sig { returns(T.untyped) } + def reassign_cleanup; end + sig { params(value: T.untyped).returns(T.untyped) } + def reassign_cleanup=(value); end +end + +class AST::CapabilityWrap + sig { returns(T.untyped) } + def lock_rank; end + sig { params(value: T.untyped).returns(T.untyped) } + def lock_rank=(value); end +end + +class AST::ConcurrentOp + sig { returns(T.untyped) } + def capture_analysis; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_analysis=(value); end + sig { returns(T.untyped) } + def shard_context; end + sig { params(value: T.untyped).returns(T.untyped) } + def shard_context=(value); end +end + +class AST::CopyNode + sig { returns(T.untyped) } + def deep_copy; end + sig { params(value: T.untyped).returns(T.untyped) } + def deep_copy=(value); end +end + +class AST::ExternFnDecl + sig { returns(T.untyped) } + def fn_type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_type_params=(value); end + sig { returns(T.untyped) } + def owner_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def owner_type=(value); end + sig { returns(T.untyped) } + def owner_type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def owner_type_params=(value); end +end + +class AST::ExternStructDecl + sig { returns(T.untyped) } + def as_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def as_type=(value); end + sig { returns(T.untyped) } + def close_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def close_method=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def type_params=(value); end +end + +class AST::ForEach + sig { returns(T.untyped) } + def mark_per_iter; end + sig { params(value: T.untyped).returns(T.untyped) } + def mark_per_iter=(value); end + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class AST::ForRange + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class AST::FuncCall + sig { returns(T.untyped) } + def arg_families; end + sig { params(value: T.untyped).returns(T.untyped) } + def arg_families=(value); end + sig { returns(T.untyped) } + def collapsed_errors; end + sig { params(value: T.untyped).returns(T.untyped) } + def collapsed_errors=(value); end + sig { returns(T.untyped) } + def error_union_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def error_union_type=(value); end + sig { returns(T.untyped) } + def extern_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_call=(value); end + sig { returns(T.untyped) } + def extern_effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_effects=(value); end + sig { returns(T.untyped) } + def fn_var_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_var_call=(value); end + sig { returns(T.untyped) } + def generic_type_args; end + sig { params(value: T.untyped).returns(T.untyped) } + def generic_type_args=(value); end + sig { returns(T.untyped) } + def heap_dupe_result; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_dupe_result=(value); end + sig { returns(T.untyped) } + def module_alias; end + sig { params(value: T.untyped).returns(T.untyped) } + def module_alias=(value); end + sig { returns(T.untyped) } + def pipe_lhs; end + sig { params(value: T.untyped).returns(T.untyped) } + def pipe_lhs=(value); end +end + +class AST::FunctionDef + sig { returns(T.untyped) } + def arrow_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def arrow_token=(value); end + sig { returns(T.untyped) } + def can_fail; end + sig { params(value: T.untyped).returns(T.untyped) } + def can_fail=(value); end + sig { returns(T.untyped) } + def cleanup_bindings; end + sig { params(value: T.untyped).returns(T.untyped) } + def cleanup_bindings=(value); end + sig { returns(T.untyped) } + def effect_set; end + sig { params(value: T.untyped).returns(T.untyped) } + def effect_set=(value); end + sig { returns(T.untyped) } + def effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def effects=(value); end + sig { returns(T.untyped) } + def effects_decl; end + sig { params(value: T.untyped).returns(T.untyped) } + def effects_decl=(value); end + sig { returns(T.untyped) } + def effects_span; end + sig { params(value: T.untyped).returns(T.untyped) } + def effects_span=(value); end + sig { returns(T.untyped) } + def explicit_return_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def explicit_return_type=(value); end + sig { returns(T.untyped) } + def fsm_eligible; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_eligible=(value); end + sig { returns(T.untyped) } + def fsm_ineligible_reason; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_ineligible_reason=(value); end + sig { returns(T.untyped) } + def fsm_suspend_points; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_suspend_points=(value); end + sig { returns(T.untyped) } + def has_promotion; end + sig { params(value: T.untyped).returns(T.untyped) } + def has_promotion=(value); end + sig { returns(T.untyped) } + def heap_carry_return; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_carry_return=(value); end + sig { returns(T.untyped) } + def heap_carry_return_vars; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_carry_return_vars=(value); end + sig { returns(T.untyped) } + def inferred_effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def inferred_effects=(value); end + sig { returns(T.untyped) } + def max_depth_n; end + sig { params(value: T.untyped).returns(T.untyped) } + def max_depth_n=(value); end + sig { returns(T.untyped) } + def moved_guard_info; end + sig { params(value: T.untyped).returns(T.untyped) } + def moved_guard_info=(value); end + sig { returns(T.untyped) } + def mutual_thunk_plan; end + sig { params(value: T.untyped).returns(T.untyped) } + def mutual_thunk_plan=(value); end + sig { returns(T.untyped) } + def needs_rt; end + sig { params(value: T.untyped).returns(T.untyped) } + def needs_rt=(value); end + sig { returns(T.untyped) } + def reentrance_kind; end + sig { params(value: T.untyped).returns(T.untyped) } + def reentrance_kind=(value); end + sig { returns(T.untyped) } + def reentrant; end + sig { params(value: T.untyped).returns(T.untyped) } + def reentrant=(value); end + sig { returns(T.untyped) } + def reentrant_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def reentrant_token=(value); end + sig { returns(T.untyped) } + def requires; end + sig { params(value: T.untyped).returns(T.untyped) } + def requires=(value); end + sig { returns(T.untyped) } + def requires_clauses; end + sig { params(value: T.untyped).returns(T.untyped) } + def requires_clauses=(value); end + sig { returns(T.untyped) } + def return_provenance; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_provenance=(value); end + sig { returns(T.untyped) } + def return_type_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_type_token=(value); end + sig { returns(T.untyped) } + def snapshot_types; end + sig { params(value: T.untyped).returns(T.untyped) } + def snapshot_types=(value); end + sig { returns(T.untyped) } + def stack_tier; end + sig { params(value: T.untyped).returns(T.untyped) } + def stack_tier=(value); end + sig { returns(T.untyped) } + def stack_vars_bytes; end + sig { params(value: T.untyped).returns(T.untyped) } + def stack_vars_bytes=(value); end + sig { returns(T.untyped) } + def tail_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def tail_call=(value); end + sig { returns(T.untyped) } + def thunk_plan; end + sig { params(value: T.untyped).returns(T.untyped) } + def thunk_plan=(value); end + sig { returns(T.untyped) } + def tight_reentrance; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight_reentrance=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def type_params=(value); end + sig { returns(T.untyped) } + def uses_alloc; end + sig { params(value: T.untyped).returns(T.untyped) } + def uses_alloc=(value); end + sig { returns(T.untyped) } + def uses_heap; end + sig { params(value: T.untyped).returns(T.untyped) } + def uses_heap=(value); end + sig { returns(T.untyped) } + def uses_rt; end + sig { params(value: T.untyped).returns(T.untyped) } + def uses_rt=(value); end +end + +class AST::Identifier + sig { returns(T.untyped) } + def atomic_borrow; end + sig { params(value: T.untyped).returns(T.untyped) } + def atomic_borrow=(value); end + sig { returns(T.untyped) } + def fn_ref; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_ref=(value); end + sig { returns(T.untyped) } + def heap_dupe_result; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_dupe_result=(value); end +end + +class AST::IfStatement + sig { returns(T.untyped) } + def else_result_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def else_result_type=(value); end + sig { returns(T.untyped) } + def expr_mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def expr_mode=(value); end + sig { returns(T.untyped) } + def then_result_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def then_result_type=(value); end +end + +class AST::MatchStatement + sig { returns(T.untyped) } + def case_result_types; end + sig { params(value: T.untyped).returns(T.untyped) } + def case_result_types=(value); end + sig { returns(T.untyped) } + def default_result_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def default_result_type=(value); end + sig { returns(T.untyped) } + def expr_mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def expr_mode=(value); end + sig { returns(T.untyped) } + def string_match; end + sig { params(value: T.untyped).returns(T.untyped) } + def string_match=(value); end +end + +class AST::MethodCall + sig { returns(T.untyped) } + def extern_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_call=(value); end + sig { returns(T.untyped) } + def extern_effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_effects=(value); end + sig { returns(T.untyped) } + def generic_type_args; end + sig { params(value: T.untyped).returns(T.untyped) } + def generic_type_args=(value); end + sig { returns(T.untyped) } + def heap_dupe_result; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_dupe_result=(value); end + sig { returns(T.untyped) } + def map_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def map_method=(value); end + sig { returns(T.untyped) } + def pool_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def pool_method=(value); end + sig { returns(T.untyped) } + def set_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def set_method=(value); end +end + +class AST::Program + sig { returns(T.untyped) } + def sync_policy; end + sig { params(value: T.untyped).returns(T.untyped) } + def sync_policy=(value); end +end + +class AST::ReturnNode + sig { returns(T.untyped) } + def catch_string_dupe_ret; end + sig { params(value: T.untyped).returns(T.untyped) } + def catch_string_dupe_ret=(value); end + sig { returns(T.untyped) } + def promote_ret_wrap; end + sig { params(value: T.untyped).returns(T.untyped) } + def promote_ret_wrap=(value); end + sig { returns(T.untyped) } + def ret_field_promote_data; end + sig { params(value: T.untyped).returns(T.untyped) } + def ret_field_promote_data=(value); end +end + +class AST::StringConcat + sig { returns(T.untyped) } + def storage; end + sig { params(value: T.untyped).returns(T.untyped) } + def storage=(value); end +end + +class AST::StructLit + sig { returns(T.untyped) } + def field_tokens; end + sig { params(value: T.untyped).returns(T.untyped) } + def field_tokens=(value); end +end + +class AST::TestThat + sig { returns(T.untyped) } + def synthetic_fn; end + sig { params(value: T.untyped).returns(T.untyped) } + def synthetic_fn=(value); end +end + +class AST::UnionDef + sig { returns(T.untyped) } + def methods; end + sig { params(value: T.untyped).returns(T.untyped) } + def methods=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def type_params=(value); end +end + +class AST::VarDecl + sig { returns(T.untyped) } + def mir_binding_entry; end + sig { params(value: T.untyped).returns(T.untyped) } + def mir_binding_entry=(value); end +end + +class AST::WhileBindLoop + sig { returns(T.untyped) } + def mark_per_iter; end + sig { params(value: T.untyped).returns(T.untyped) } + def mark_per_iter=(value); end + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class AST::WhileLoop + sig { returns(T.untyped) } + def mark_per_iter; end + sig { params(value: T.untyped).returns(T.untyped) } + def mark_per_iter=(value); end + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class AST::WithBlock + sig { returns(T.untyped) } + def arms; end + sig { params(value: T.untyped).returns(T.untyped) } + def arms=(value); end + sig { returns(T.untyped) } + def deadlock_escape; end + sig { params(value: T.untyped).returns(T.untyped) } + def deadlock_escape=(value); end + sig { returns(T.untyped) } + def lock_error_clause; end + sig { params(value: T.untyped).returns(T.untyped) } + def lock_error_clause=(value); end + sig { returns(T.untyped) } + def polymorphic; end + sig { params(value: T.untyped).returns(T.untyped) } + def polymorphic=(value); end + sig { returns(T.untyped) } + def snapshot_mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def snapshot_mode=(value); end + sig { returns(T.untyped) } + def universal_poly; end + sig { params(value: T.untyped).returns(T.untyped) } + def universal_poly=(value); end + sig { returns(T.untyped) } + def view_kind; end + sig { params(value: T.untyped).returns(T.untyped) } + def view_kind=(value); end +end + +class AST::YieldExpr + sig { returns(T.untyped) } + def yield_dupe; end + sig { params(value: T.untyped).returns(T.untyped) } + def yield_dupe=(value); end +end + +class Assignment + sig { returns(T.untyped) } + def auto_atomic_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def auto_atomic_op=(value); end + sig { returns(T.untyped) } + def auto_lock; end + sig { params(value: T.untyped).returns(T.untyped) } + def auto_lock=(value); end + sig { returns(T.untyped) } + def compound_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def compound_op=(value); end + sig { returns(T.untyped) } + def container_promote_zig_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def container_promote_zig_type=(value); end + sig { returns(T.untyped) } + def field_pre_cleanup; end + sig { params(value: T.untyped).returns(T.untyped) } + def field_pre_cleanup=(value); end +end + +class BasicBlock + sig { returns(T.untyped) } + def id; end + sig { params(value: T.untyped).returns(T.untyped) } + def id=(value); end + sig { returns(T.untyped) } + def predecessors; end + sig { params(value: T.untyped).returns(T.untyped) } + def predecessors=(value); end + sig { returns(T.untyped) } + def stmts; end + sig { params(value: T.untyped).returns(T.untyped) } + def stmts=(value); end + sig { returns(T.untyped) } + def successors; end + sig { params(value: T.untyped).returns(T.untyped) } + def successors=(value); end +end + +class BgBlock + sig { returns(T.untyped) } + def capture_analysis; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_analysis=(value); end + sig { returns(T.untyped) } + def capture_string_dupes; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_string_dupes=(value); end + sig { returns(T.untyped) } + def captures_resource; end + sig { params(value: T.untyped).returns(T.untyped) } + def captures_resource=(value); end + sig { returns(T.untyped) } + def computed_stack_tier; end + sig { params(value: T.untyped).returns(T.untyped) } + def computed_stack_tier=(value); end + sig { returns(T.untyped) } + def exit_promote; end + sig { params(value: T.untyped).returns(T.untyped) } + def exit_promote=(value); end + sig { returns(T.untyped) } + def fsm_ineligible_reason; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_ineligible_reason=(value); end + sig { returns(T.untyped) } + def fsm_suspend_points; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_suspend_points=(value); end + sig { returns(T.untyped) } + def open_brace_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def open_brace_token=(value); end + sig { returns(T.untyped) } + def prefix_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def prefix_token=(value); end + sig { returns(T.untyped) } + def return_provenance; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_provenance=(value); end + sig { returns(T.untyped) } + def spawn_form; end + sig { params(value: T.untyped).returns(T.untyped) } + def spawn_form=(value); end +end + +class BgStreamBlock + sig { returns(T.untyped) } + def capture_analysis; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_analysis=(value); end + sig { returns(T.untyped) } + def capture_string_dupes; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_string_dupes=(value); end + sig { returns(T.untyped) } + def computed_stack_tier; end + sig { params(value: T.untyped).returns(T.untyped) } + def computed_stack_tier=(value); end + sig { returns(T.untyped) } + def fsm_ineligible_reason; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_ineligible_reason=(value); end + sig { returns(T.untyped) } + def fsm_suspend_points; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_suspend_points=(value); end + sig { returns(T.untyped) } + def spawn_form; end + sig { params(value: T.untyped).returns(T.untyped) } + def spawn_form=(value); end + sig { returns(T.untyped) } + def yields_frame_strings; end + sig { params(value: T.untyped).returns(T.untyped) } + def yields_frame_strings=(value); end +end + +class BinaryOp + sig { returns(T.untyped) } + def observable_dest; end + sig { params(value: T.untyped).returns(T.untyped) } + def observable_dest=(value); end + sig { returns(T.untyped) } + def observable_terminal; end + sig { params(value: T.untyped).returns(T.untyped) } + def observable_terminal=(value); end + sig { returns(T.untyped) } + def or_fallback_dupe; end + sig { params(value: T.untyped).returns(T.untyped) } + def or_fallback_dupe=(value); end + sig { returns(T.untyped) } + def paren_bind; end + sig { params(value: T.untyped).returns(T.untyped) } + def paren_bind=(value); end + sig { returns(T.untyped) } + def storage; end + sig { params(value: T.untyped).returns(T.untyped) } + def storage=(value); end + sig { returns(T.untyped) } + def string_concat; end + sig { params(value: T.untyped).returns(T.untyped) } + def string_concat=(value); end +end + +class BindExpr + sig { returns(T.untyped) } + def auto_atomic_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def auto_atomic_op=(value); end + sig { returns(T.untyped) } + def compound_op; end + sig { params(value: T.untyped).returns(T.untyped) } + def compound_op=(value); end + sig { returns(T.untyped) } + def mir_binding_entry; end + sig { params(value: T.untyped).returns(T.untyped) } + def mir_binding_entry=(value); end + sig { returns(T.untyped) } + def mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def mode=(value); end + sig { returns(T.untyped) } + def reassign_cleanup; end + sig { params(value: T.untyped).returns(T.untyped) } + def reassign_cleanup=(value); end +end + +class BorrowChecker + sig { returns(T.untyped) } + def errors; end +end + +class Builder + sig { returns(T.untyped) } + def segments; end + sig { returns(T.untyped) } + def synthetic_fields; end +end + +class CapabilityWrap + sig { returns(T.untyped) } + def lock_rank; end + sig { params(value: T.untyped).returns(T.untyped) } + def lock_rank=(value); end +end + +class ConcurrentOp + sig { returns(T.untyped) } + def capture_analysis; end + sig { params(value: T.untyped).returns(T.untyped) } + def capture_analysis=(value); end + sig { returns(T.untyped) } + def shard_context; end + sig { params(value: T.untyped).returns(T.untyped) } + def shard_context=(value); end +end + +class CopyNode + sig { returns(T.untyped) } + def deep_copy; end + sig { params(value: T.untyped).returns(T.untyped) } + def deep_copy=(value); end +end + +class Drop + sig { returns(T.untyped) } + def cleanup_entry; end + sig { params(value: T.untyped).returns(T.untyped) } + def cleanup_entry=(value); end +end + +class Edit + sig { returns(T.untyped) } + def replacement; end + sig { returns(T.untyped) } + def span; end +end + +class EffectSet + sig { returns(T.untyped) } + def effects; end +end + +class ExternFnDecl + sig { returns(T.untyped) } + def fn_type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_type_params=(value); end + sig { returns(T.untyped) } + def owner_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def owner_type=(value); end + sig { returns(T.untyped) } + def owner_type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def owner_type_params=(value); end +end + +class ExternStructDecl + sig { returns(T.untyped) } + def as_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def as_type=(value); end + sig { returns(T.untyped) } + def close_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def close_method=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def type_params=(value); end +end + +class FieldDef + sig { returns(T.untyped) } + def boxed_capture; end + sig { params(value: T.untyped).returns(T.untyped) } + def boxed_capture=(value); end +end + +class Fix + sig { returns(T.untyped) } + def confidence; end + sig { returns(T.untyped) } + def description; end + sig { returns(T.untyped) } + def edits; end +end + +class FixableFinding + sig { returns(T.untyped) } + def category; end + sig { returns(T.untyped) } + def fixes; end + sig { returns(T.untyped) } + def level; end + sig { returns(T.untyped) } + def message; end + sig { returns(T.untyped) } + def token; end +end + +class ForEach + sig { returns(T.untyped) } + def mark_per_iter; end + sig { params(value: T.untyped).returns(T.untyped) } + def mark_per_iter=(value); end + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class ForRange + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class FsmTransform::Builder + sig { returns(T.untyped) } + def segments; end + sig { returns(T.untyped) } + def synthetic_fields; end +end + +class FsmTransform::RecursiveSplitter::Builder + sig { returns(T.untyped) } + def segments; end + sig { returns(T.untyped) } + def synthetic_fields; end +end + +class FuncCall + sig { returns(T.untyped) } + def arg_families; end + sig { params(value: T.untyped).returns(T.untyped) } + def arg_families=(value); end + sig { returns(T.untyped) } + def collapsed_errors; end + sig { params(value: T.untyped).returns(T.untyped) } + def collapsed_errors=(value); end + sig { returns(T.untyped) } + def error_union_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def error_union_type=(value); end + sig { returns(T.untyped) } + def extern_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_call=(value); end + sig { returns(T.untyped) } + def extern_effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_effects=(value); end + sig { returns(T.untyped) } + def fn_var_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_var_call=(value); end + sig { returns(T.untyped) } + def generic_type_args; end + sig { params(value: T.untyped).returns(T.untyped) } + def generic_type_args=(value); end + sig { returns(T.untyped) } + def heap_dupe_result; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_dupe_result=(value); end + sig { returns(T.untyped) } + def module_alias; end + sig { params(value: T.untyped).returns(T.untyped) } + def module_alias=(value); end + sig { returns(T.untyped) } + def pipe_lhs; end + sig { params(value: T.untyped).returns(T.untyped) } + def pipe_lhs=(value); end +end + +class FunctionCFG + sig { returns(T.untyped) } + def blocks; end + sig { returns(T.untyped) } + def entry; end + sig { returns(T.untyped) } + def exit_block; end + sig { returns(T.untyped) } + def fn_name; end +end + +class FunctionContext + sig { returns(T.untyped) } + def alloc_count; end + sig { params(value: T.untyped).returns(T.untyped) } + def alloc_count=(value); end + sig { returns(T.untyped) } + def conditional_depth; end + sig { params(value: T.untyped).returns(T.untyped) } + def conditional_depth=(value); end + sig { returns(T.untyped) } + def frame_count; end + sig { params(value: T.untyped).returns(T.untyped) } + def frame_count=(value); end + sig { returns(T.untyped) } + def heap_count; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_count=(value); end + sig { returns(T.untyped) } + def lifetime; end + sig { params(value: T.untyped).returns(T.untyped) } + def lifetime=(value); end + sig { returns(T.untyped) } + def loop_depth; end + sig { params(value: T.untyped).returns(T.untyped) } + def loop_depth=(value); end + sig { returns(T.untyped) } + def name; end + sig { params(value: T.untyped).returns(T.untyped) } + def name=(value); end + sig { returns(T.untyped) } + def needs_rt; end + sig { params(value: T.untyped).returns(T.untyped) } + def needs_rt=(value); end + sig { returns(T.untyped) } + def return_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_type=(value); end + sig { returns(T.untyped) } + def returns; end + sig { params(value: T.untyped).returns(T.untyped) } + def returns=(value); end + sig { returns(T.untyped) } + def stack_vars_bytes; end + sig { params(value: T.untyped).returns(T.untyped) } + def stack_vars_bytes=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def type_params=(value); end +end + +class FunctionDef + sig { returns(T.untyped) } + def arrow_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def arrow_token=(value); end + sig { returns(T.untyped) } + def can_fail; end + sig { params(value: T.untyped).returns(T.untyped) } + def can_fail=(value); end + sig { returns(T.untyped) } + def cleanup_bindings; end + sig { params(value: T.untyped).returns(T.untyped) } + def cleanup_bindings=(value); end + sig { returns(T.untyped) } + def effect_set; end + sig { params(value: T.untyped).returns(T.untyped) } + def effect_set=(value); end + sig { returns(T.untyped) } + def effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def effects=(value); end + sig { returns(T.untyped) } + def effects_decl; end + sig { params(value: T.untyped).returns(T.untyped) } + def effects_decl=(value); end + sig { returns(T.untyped) } + def effects_span; end + sig { params(value: T.untyped).returns(T.untyped) } + def effects_span=(value); end + sig { returns(T.untyped) } + def explicit_return_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def explicit_return_type=(value); end + sig { returns(T.untyped) } + def fsm_eligible; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_eligible=(value); end + sig { returns(T.untyped) } + def fsm_ineligible_reason; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_ineligible_reason=(value); end + sig { returns(T.untyped) } + def fsm_suspend_points; end + sig { params(value: T.untyped).returns(T.untyped) } + def fsm_suspend_points=(value); end + sig { returns(T.untyped) } + def has_promotion; end + sig { params(value: T.untyped).returns(T.untyped) } + def has_promotion=(value); end + sig { returns(T.untyped) } + def heap_carry_return; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_carry_return=(value); end + sig { returns(T.untyped) } + def heap_carry_return_vars; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_carry_return_vars=(value); end + sig { returns(T.untyped) } + def inferred_effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def inferred_effects=(value); end + sig { returns(T.untyped) } + def max_depth_n; end + sig { params(value: T.untyped).returns(T.untyped) } + def max_depth_n=(value); end + sig { returns(T.untyped) } + def moved_guard_info; end + sig { params(value: T.untyped).returns(T.untyped) } + def moved_guard_info=(value); end + sig { returns(T.untyped) } + def mutual_thunk_plan; end + sig { params(value: T.untyped).returns(T.untyped) } + def mutual_thunk_plan=(value); end + sig { returns(T.untyped) } + def needs_rt; end + sig { params(value: T.untyped).returns(T.untyped) } + def needs_rt=(value); end + sig { returns(T.untyped) } + def reentrance_kind; end + sig { params(value: T.untyped).returns(T.untyped) } + def reentrance_kind=(value); end + sig { returns(T.untyped) } + def reentrant; end + sig { params(value: T.untyped).returns(T.untyped) } + def reentrant=(value); end + sig { returns(T.untyped) } + def reentrant_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def reentrant_token=(value); end + sig { returns(T.untyped) } + def requires; end + sig { params(value: T.untyped).returns(T.untyped) } + def requires=(value); end + sig { returns(T.untyped) } + def requires_clauses; end + sig { params(value: T.untyped).returns(T.untyped) } + def requires_clauses=(value); end + sig { returns(T.untyped) } + def return_provenance; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_provenance=(value); end + sig { returns(T.untyped) } + def return_type_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_type_token=(value); end + sig { returns(T.untyped) } + def snapshot_types; end + sig { params(value: T.untyped).returns(T.untyped) } + def snapshot_types=(value); end + sig { returns(T.untyped) } + def stack_tier; end + sig { params(value: T.untyped).returns(T.untyped) } + def stack_tier=(value); end + sig { returns(T.untyped) } + def stack_vars_bytes; end + sig { params(value: T.untyped).returns(T.untyped) } + def stack_vars_bytes=(value); end + sig { returns(T.untyped) } + def tail_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def tail_call=(value); end + sig { returns(T.untyped) } + def thunk_plan; end + sig { params(value: T.untyped).returns(T.untyped) } + def thunk_plan=(value); end + sig { returns(T.untyped) } + def tight_reentrance; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight_reentrance=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def type_params=(value); end + sig { returns(T.untyped) } + def uses_alloc; end + sig { params(value: T.untyped).returns(T.untyped) } + def uses_alloc=(value); end + sig { returns(T.untyped) } + def uses_heap; end + sig { params(value: T.untyped).returns(T.untyped) } + def uses_heap=(value); end + sig { returns(T.untyped) } + def uses_rt; end + sig { params(value: T.untyped).returns(T.untyped) } + def uses_rt=(value); end +end + +class FunctionSignature + sig { returns(T.untyped) } + def can_fail; end + sig { params(value: T.untyped).returns(T.untyped) } + def can_fail=(value); end + sig { returns(T.untyped) } + def effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def effects=(value); end + sig { returns(T.untyped) } + def extern; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern=(value); end + sig { returns(T.untyped) } + def extern_effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_effects=(value); end + sig { returns(T.untyped) } + def fn_type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_type_params=(value); end + sig { returns(T.untyped) } + def intrinsic; end + sig { params(value: T.untyped).returns(T.untyped) } + def intrinsic=(value); end + sig { returns(T.untyped) } + def module_alias; end + sig { params(value: T.untyped).returns(T.untyped) } + def module_alias=(value); end + sig { returns(T.untyped) } + def needs_rt; end + sig { params(value: T.untyped).returns(T.untyped) } + def needs_rt=(value); end + sig { returns(T.untyped) } + def owner_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def owner_type=(value); end + sig { returns(T.untyped) } + def owner_type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def owner_type_params=(value); end + sig { returns(T.untyped) } + def params; end + sig { returns(T.untyped) } + def reentrant; end + sig { returns(T.untyped) } + def requires; end + sig { params(value: T.untyped).returns(T.untyped) } + def requires=(value); end + sig { returns(T.untyped) } + def return_lifetime; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_lifetime=(value); end + sig { returns(T.untyped) } + def return_provenance; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_provenance=(value); end + sig { returns(T.untyped) } + def return_strategy; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_strategy=(value); end + sig { returns(T.untyped) } + def return_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def return_type=(value); end + sig { returns(T.untyped) } + def stack_tier; end + sig { params(value: T.untyped).returns(T.untyped) } + def stack_tier=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { returns(T.untyped) } + def visibility; end + sig { returns(T.untyped) } + def zig_pattern; end + sig { params(value: T.untyped).returns(T.untyped) } + def zig_pattern=(value); end +end + +class Identifier + sig { returns(T.untyped) } + def atomic_borrow; end + sig { params(value: T.untyped).returns(T.untyped) } + def atomic_borrow=(value); end + sig { returns(T.untyped) } + def fn_ref; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_ref=(value); end + sig { returns(T.untyped) } + def heap_dupe_result; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_dupe_result=(value); end +end + +class IfStatement + sig { returns(T.untyped) } + def else_result_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def else_result_type=(value); end + sig { returns(T.untyped) } + def expr_mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def expr_mode=(value); end + sig { returns(T.untyped) } + def then_result_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def then_result_type=(value); end +end + +class MIR::Drop + sig { returns(T.untyped) } + def cleanup_entry; end + sig { params(value: T.untyped).returns(T.untyped) } + def cleanup_entry=(value); end +end + +class MIR::FieldDef + sig { returns(T.untyped) } + def boxed_capture; end + sig { params(value: T.untyped).returns(T.untyped) } + def boxed_capture=(value); end +end + +class MIRChecker + sig { returns(T.untyped) } + def errors; end +end + +class MIREmitter + sig { returns(T.untyped) } + def rt_name; end + sig { params(value: T.untyped).returns(T.untyped) } + def rt_name=(value); end +end + +class MIRLowering + sig { returns(T.untyped) } + def fn_sigs; end + sig { returns(T.untyped) } + def shard_context; end + sig { params(value: T.untyped).returns(T.untyped) } + def shard_context=(value); end +end + +class MIRPass + sig { returns(T.untyped) } + def cleanup_bindings; end +end + +class MatchStatement + sig { returns(T.untyped) } + def case_result_types; end + sig { params(value: T.untyped).returns(T.untyped) } + def case_result_types=(value); end + sig { returns(T.untyped) } + def default_result_type; end + sig { params(value: T.untyped).returns(T.untyped) } + def default_result_type=(value); end + sig { returns(T.untyped) } + def expr_mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def expr_mode=(value); end + sig { returns(T.untyped) } + def string_match; end + sig { params(value: T.untyped).returns(T.untyped) } + def string_match=(value); end +end + +class MethodCall + sig { returns(T.untyped) } + def extern_call; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_call=(value); end + sig { returns(T.untyped) } + def extern_effects; end + sig { params(value: T.untyped).returns(T.untyped) } + def extern_effects=(value); end + sig { returns(T.untyped) } + def generic_type_args; end + sig { params(value: T.untyped).returns(T.untyped) } + def generic_type_args=(value); end + sig { returns(T.untyped) } + def heap_dupe_result; end + sig { params(value: T.untyped).returns(T.untyped) } + def heap_dupe_result=(value); end + sig { returns(T.untyped) } + def map_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def map_method=(value); end + sig { returns(T.untyped) } + def pool_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def pool_method=(value); end + sig { returns(T.untyped) } + def set_method; end + sig { params(value: T.untyped).returns(T.untyped) } + def set_method=(value); end +end + +class OwnershipDataflow + sig { returns(T.untyped) } + def block_in; end + sig { returns(T.untyped) } + def block_out; end + sig { returns(T.untyped) } + def point_states; end +end + +class OwnershipGraph + sig { returns(T.untyped) } + def edges; end + sig { returns(T.untyped) } + def nodes; end +end + +class PipelineHost + sig { returns(T.untyped) } + def fn_sigs; end + sig { params(value: T.untyped).returns(T.untyped) } + def fn_sigs=(value); end +end + +class Program + sig { returns(T.untyped) } + def sync_policy; end + sig { params(value: T.untyped).returns(T.untyped) } + def sync_policy=(value); end +end + +class RecursiveSplitter::Builder + sig { returns(T.untyped) } + def segments; end + sig { returns(T.untyped) } + def synthetic_fields; end +end + +class ReturnNode + sig { returns(T.untyped) } + def catch_string_dupe_ret; end + sig { params(value: T.untyped).returns(T.untyped) } + def catch_string_dupe_ret=(value); end + sig { returns(T.untyped) } + def promote_ret_wrap; end + sig { params(value: T.untyped).returns(T.untyped) } + def promote_ret_wrap=(value); end + sig { returns(T.untyped) } + def ret_field_promote_data; end + sig { params(value: T.untyped).returns(T.untyped) } + def ret_field_promote_data=(value); end +end + +class Scope + sig { returns(T.untyped) } + def dependencies; end + sig { params(value: T.untyped).returns(T.untyped) } + def dependencies=(value); end + sig { returns(T.untyped) } + def depth; end + sig { params(value: T.untyped).returns(T.untyped) } + def depth=(value); end + sig { returns(T.untyped) } + def locals; end + sig { params(value: T.untyped).returns(T.untyped) } + def locals=(value); end + sig { returns(T.untyped) } + def owned_names; end + sig { params(value: T.untyped).returns(T.untyped) } + def owned_names=(value); end + sig { returns(T.untyped) } + def types; end +end + +class SemanticAnnotator + sig { returns(T.untyped) } + def scope_stack; end + sig { returns(T.untyped) } + def source_code; end + sig { params(value: T.untyped).returns(T.untyped) } + def source_code=(value); end +end + +class SourceError + sig { returns(T.untyped) } + def original_message; end + sig { returns(T.untyped) } + def source_code; end + sig { returns(T.untyped) } + def token; end +end + +class Span + sig { returns(T.untyped) } + def col; end + sig { returns(T.untyped) } + def file; end + sig { returns(T.untyped) } + def length; end + sig { returns(T.untyped) } + def line; end +end + +class StackVerifier + sig { returns(T.untyped) } + def binary_path; end + sig { returns(T.untyped) } + def module_prefix; end +end + +class StringConcat + sig { returns(T.untyped) } + def storage; end + sig { params(value: T.untyped).returns(T.untyped) } + def storage=(value); end +end + +class StructLit + sig { returns(T.untyped) } + def field_tokens; end + sig { params(value: T.untyped).returns(T.untyped) } + def field_tokens=(value); end +end + +class SymbolEntry + sig { returns(T.untyped) } + def borrowed_alias; end + sig { params(value: T.untyped).returns(T.untyped) } + def borrowed_alias=(value); end + sig { returns(T.untyped) } + def capabilities; end + sig { params(value: T.untyped).returns(T.untyped) } + def capabilities=(value); end + sig { returns(T.untyped) } + def close_zig; end + sig { params(value: T.untyped).returns(T.untyped) } + def close_zig=(value); end + sig { returns(T.untyped) } + def invalid_reason; end + sig { params(value: T.untyped).returns(T.untyped) } + def invalid_reason=(value); end + sig { returns(T.untyped) } + def is_param; end + sig { params(value: T.untyped).returns(T.untyped) } + def is_param=(value); end + sig { returns(T.untyped) } + def layout; end + sig { params(value: T.untyped).returns(T.untyped) } + def layout=(value); end + sig { returns(T.untyped) } + def lifetime; end + sig { params(value: T.untyped).returns(T.untyped) } + def lifetime=(value); end + sig { returns(T.untyped) } + def link_source; end + sig { params(value: T.untyped).returns(T.untyped) } + def link_source=(value); end + sig { returns(T.untyped) } + def mutable; end + sig { params(value: T.untyped).returns(T.untyped) } + def mutable=(value); end + sig { returns(T.untyped) } + def ownership_kind; end + sig { params(value: T.untyped).returns(T.untyped) } + def ownership_kind=(value); end + sig { returns(T.untyped) } + def poly_borrow_target; end + sig { params(value: T.untyped).returns(T.untyped) } + def poly_borrow_target=(value); end + sig { returns(T.untyped) } + def read; end + sig { params(value: T.untyped).returns(T.untyped) } + def read=(value); end + sig { returns(T.untyped) } + def rebindable; end + sig { params(value: T.untyped).returns(T.untyped) } + def rebindable=(value); end + sig { returns(T.untyped) } + def reg; end + sig { params(value: T.untyped).returns(T.untyped) } + def reg=(value); end + sig { returns(T.untyped) } + def resource; end + sig { params(value: T.untyped).returns(T.untyped) } + def resource=(value); end + sig { returns(T.untyped) } + def scope; end + sig { params(value: T.untyped).returns(T.untyped) } + def scope=(value); end + sig { returns(T.untyped) } + def scope_depth; end + sig { params(value: T.untyped).returns(T.untyped) } + def scope_depth=(value); end + sig { returns(T.untyped) } + def size; end + sig { params(value: T.untyped).returns(T.untyped) } + def size=(value); end + sig { returns(T.untyped) } + def storage; end + sig { params(value: T.untyped).returns(T.untyped) } + def storage=(value); end + sig { returns(T.untyped) } + def sync; end + sig { params(value: T.untyped).returns(T.untyped) } + def sync=(value); end + sig { returns(T.untyped) } + def sync_families; end + sig { params(value: T.untyped).returns(T.untyped) } + def sync_families=(value); end + sig { returns(T.untyped) } + def takes; end + sig { params(value: T.untyped).returns(T.untyped) } + def takes=(value); end + sig { returns(T.untyped) } + def type; end + sig { params(value: T.untyped).returns(T.untyped) } + def type=(value); end + sig { returns(T.untyped) } + def valid; end + sig { params(value: T.untyped).returns(T.untyped) } + def valid=(value); end +end + +class TestThat + sig { returns(T.untyped) } + def synthetic_fn; end + sig { params(value: T.untyped).returns(T.untyped) } + def synthetic_fn=(value); end +end + +class Type + sig { returns(T.untyped) } + def capacity; end + sig { returns(T.untyped) } + def collection; end + sig { params(value: T.untyped).returns(T.untyped) } + def collection=(value); end + sig { returns(T.untyped) } + def elem_ownership; end + sig { params(value: T.untyped).returns(T.untyped) } + def elem_ownership=(value); end + sig { returns(T.untyped) } + def elem_sync; end + sig { params(value: T.untyped).returns(T.untyped) } + def elem_sync=(value); end + sig { returns(T.untyped) } + def generic_args; end + sig { returns(T.untyped) } + def is_observable; end + sig { params(value: T.untyped).returns(T.untyped) } + def is_observable=(value); end + sig { params(value: T.untyped).returns(T.untyped) } + def is_resource=(value); end + sig { returns(T.untyped) } + def layout; end + sig { params(value: T.untyped).returns(T.untyped) } + def layout=(value); end + sig { returns(T.untyped) } + def link_source; end + sig { params(value: T.untyped).returns(T.untyped) } + def link_source=(value); end + sig { returns(T.untyped) } + def lock_rank; end + sig { params(value: T.untyped).returns(T.untyped) } + def lock_rank=(value); end + sig { returns(T.untyped) } + def name; end + sig { returns(T.untyped) } + def observable_terminal; end + sig { params(value: T.untyped).returns(T.untyped) } + def observable_terminal=(value); end + sig { returns(T.untyped) } + def observable_token; end + sig { params(value: T.untyped).returns(T.untyped) } + def observable_token=(value); end + sig { returns(T.untyped) } + def ownership; end + sig { params(value: T.untyped).returns(T.untyped) } + def ownership=(value); end + sig { returns(T.untyped) } + def provenance; end + sig { params(value: T.untyped).returns(T.untyped) } + def provenance=(value); end + sig { returns(T.untyped) } + def raw; end + sig { returns(T.untyped) } + def shard_count; end + sig { params(value: T.untyped).returns(T.untyped) } + def shard_count=(value); end + sig { returns(T.untyped) } + def soa; end + sig { params(value: T.untyped).returns(T.untyped) } + def soa=(value); end + sig { returns(T.untyped) } + def sync; end + sig { params(value: T.untyped).returns(T.untyped) } + def sync=(value); end +end + +class UnionDef + sig { returns(T.untyped) } + def methods; end + sig { params(value: T.untyped).returns(T.untyped) } + def methods=(value); end + sig { returns(T.untyped) } + def type_params; end + sig { params(value: T.untyped).returns(T.untyped) } + def type_params=(value); end +end + +class UseAfterMoveChecker + sig { returns(T.untyped) } + def errors; end +end + +class VarDecl + sig { returns(T.untyped) } + def mir_binding_entry; end + sig { params(value: T.untyped).returns(T.untyped) } + def mir_binding_entry=(value); end +end + +class WhileBindLoop + sig { returns(T.untyped) } + def mark_per_iter; end + sig { params(value: T.untyped).returns(T.untyped) } + def mark_per_iter=(value); end + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class WhileLoop + sig { returns(T.untyped) } + def mark_per_iter; end + sig { params(value: T.untyped).returns(T.untyped) } + def mark_per_iter=(value); end + sig { returns(T.untyped) } + def tight; end + sig { params(value: T.untyped).returns(T.untyped) } + def tight=(value); end +end + +class WithBlock + sig { returns(T.untyped) } + def arms; end + sig { params(value: T.untyped).returns(T.untyped) } + def arms=(value); end + sig { returns(T.untyped) } + def deadlock_escape; end + sig { params(value: T.untyped).returns(T.untyped) } + def deadlock_escape=(value); end + sig { returns(T.untyped) } + def lock_error_clause; end + sig { params(value: T.untyped).returns(T.untyped) } + def lock_error_clause=(value); end + sig { returns(T.untyped) } + def polymorphic; end + sig { params(value: T.untyped).returns(T.untyped) } + def polymorphic=(value); end + sig { returns(T.untyped) } + def snapshot_mode; end + sig { params(value: T.untyped).returns(T.untyped) } + def snapshot_mode=(value); end + sig { returns(T.untyped) } + def universal_poly; end + sig { params(value: T.untyped).returns(T.untyped) } + def universal_poly=(value); end + sig { returns(T.untyped) } + def view_kind; end + sig { params(value: T.untyped).returns(T.untyped) } + def view_kind=(value); end +end + +class YieldExpr + sig { returns(T.untyped) } + def yield_dupe; end + sig { params(value: T.untyped).returns(T.untyped) } + def yield_dupe=(value); end +end + +class ZigTranspiler + sig { returns(T.untyped) } + def enum_schemas; end + sig { returns(T.untyped) } + def module_type_defs; end + sig { returns(T.untyped) } + def struct_schemas; end + sig { returns(T.untyped) } + def union_schemas; end +end + diff --git a/sorbet/rbi/clear-stubs.rbi b/sorbet/rbi/clear-stubs.rbi new file mode 100644 index 000000000..7cd199ab8 --- /dev/null +++ b/sorbet/rbi/clear-stubs.rbi @@ -0,0 +1,40 @@ +# typed: true + +# Minimal stub declarations so Sorbet's static name resolver does not +# emit `Did you mean Socket?`-style false positives on user-defined +# classes when files default to `# typed: false`. As files are flipped +# to `# typed: true` / `# typed: strict` per the self-host prep tracker +# (TODO.md), these stubs become redundant and can be tightened or +# replaced with real signatures generated by `tapioca` once the +# Ruby 3.2 + tapioca 0.19 incompat (`coerce_and_check_module_types` +# crash on init) is resolved. + +class 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 + +# AST::Locatable is mixed into AST nodes; declare Kernel methods so +# Sorbet doesn't trip over `node.is_a?(...)`, `node.class`, etc. when +# helper modules type-narrow against AST::Locatable receivers. The +# real Object methods are available at runtime via the host class. +module AST::Locatable + include Kernel + + # Every AST node is a Struct + includes Locatable; expose the Struct + # surface (`node[:member]`) so generic AST walkers type-check. + sig { params(member: T.untyped).returns(T.untyped) } + def [](member); end + + # First Struct member on every AST node. Always non-nil in + # production (parser passes a Token at every construction site). + # Specs that pass `nil` are excluded from Sorbet via sorbet/config. + sig { returns(Token) } + def token; end +end diff --git a/spec/annotator_spec.rb b/spec/annotator_spec.rb index 1fd8af4d2..89c4032e0 100644 --- a/spec/annotator_spec.rb +++ b/spec/annotator_spec.rb @@ -2338,7 +2338,8 @@ def get_strategy(source) raise "No FunctionDef found in AST" unless func_node signature = func_node.full_type - signature[:return_strategy] + signature = signature.raw if signature.is_a?(Type) + signature.return_strategy end let(:preamble) { "STRUCT Config { id: Float64 }" } @@ -2522,7 +2523,7 @@ def run_with_annotator(source) it "stores :pub visibility in the scope signature" do _, annotator = run_with_annotator(code) sig = annotator.scope_stack.first.locals["foo"].type - expect(sig[:visibility]).to eq(:pub) + expect(sig.visibility).to eq(:pub) end end @@ -2536,7 +2537,7 @@ def run_with_annotator(source) it "stores :private visibility in the scope signature" do _, annotator = run_with_annotator(code) sig = annotator.scope_stack.first.locals["foo"].type - expect(sig[:visibility]).to eq(:private) + expect(sig.visibility).to eq(:private) end end @@ -2550,7 +2551,7 @@ def run_with_annotator(source) it "stores :package visibility in the scope signature" do _, annotator = run_with_annotator(code) sig = annotator.scope_stack.first.locals["foo"].type - expect(sig[:visibility]).to eq(:package) + expect(sig.visibility).to eq(:package) end end diff --git a/spec/clear_fmt_spec.rb b/spec/clear_fmt_spec.rb index 48811f032..2c680d4b7 100644 --- a/spec/clear_fmt_spec.rb +++ b/spec/clear_fmt_spec.rb @@ -826,7 +826,8 @@ def tokenize(src) toks = tokenize("FN foo -> END\n") arrow_idx = toks.index { |t| t.type == :OP && t.raw == '->' } out = [] - em.send(:emit_fn_signature_metadata_wrapped, out, toks, 0, arrow_idx, nil, nil) + sig = Formatter::Emitter::FnSig.new(toks: toks, start: 0, arrow_idx: arrow_idx, po: nil, pc: nil) + em.send(:emit_fn_signature_metadata_wrapped, out, sig) expect(out.first.raw).to eq("FN") expect(out.map(&:raw).join("")).to include("FN") expect(out.map(&:raw).join("")).to include("foo") diff --git a/spec/first_class_function_spec.rb b/spec/first_class_function_spec.rb index 3a0363822..e610706f4 100644 --- a/spec/first_class_function_spec.rb +++ b/spec/first_class_function_spec.rb @@ -118,7 +118,7 @@ def fn_type_for(source) # Parse only — don't run the annotator (Void lambdas need separate handling) tokens = Lexer.new("FN() -> Void").tokenize # Parse just the type annotation directly via the parser - t = Type.new({ params: [], return: { type: Type.new(:Void) }, fn_type: true }) + t = Type.new(FunctionSignature.new(params: [], return_type: Type.new(:Void))) expect(t.zig_type).to eq("*const fn(*Runtime) anyerror!void") end end @@ -167,11 +167,10 @@ def fn_type_for(source) # ------------------------------------------------------------------------- describe "Type#accepts? full signature matching (Phase 2)" do def fn_type(params, ret) - Type.new({ + Type.new(FunctionSignature.new( params: params.map.with_index { |t, i| { name: "arg#{i}", type: Type.new(t), required: true, mutable: false, takes: false } }, - return: { type: Type.new(ret) }, - fn_type: true - }) + return_type: Type.new(ret) + )) end it "accepts identical signatures" do @@ -540,7 +539,7 @@ def transpile(source) tree = run(code) fn = tree.statements.find { |s| s.is_a?(AST::FunctionDef) && s.name == "apply" } cb_param = fn.params.find { |p| p[:name] == "cb" } - expect(cb_param[:type].raw[:reentrant]).to be true + expect(cb_param[:type].raw.reentrant).to be true end it "leaves reentrant false on a plain fn-type param annotation" do @@ -552,7 +551,7 @@ def transpile(source) tree = run(code) fn = tree.statements.find { |s| s.is_a?(AST::FunctionDef) && s.name == "apply" } cb_param = fn.params.find { |p| p[:name] == "cb" } - expect(cb_param[:type].raw[:reentrant]).to be_falsy + expect(cb_param[:type].raw.reentrant).to be_falsy end end @@ -574,7 +573,7 @@ def transpile(source) tree = run(code) call = tree.statements.last.value fib_arg = call.args.find { |a| a.is_a?(AST::Identifier) && a.name == "fib" } - expect(fib_arg.full_type.raw[:reentrant]).to be true + expect(fib_arg.full_type.raw.reentrant).to be true end it "does not mark the fn_ref type as reentrant for a non-@reentrant function" do @@ -590,7 +589,7 @@ def transpile(source) tree = run(code) call = tree.statements.last.value double_arg = call.args.find { |a| a.is_a?(AST::Identifier) && a.name == "double" } - expect(double_arg.full_type.raw[:reentrant]).to be_falsy + expect(double_arg.full_type.raw.reentrant).to be_falsy end end diff --git a/spec/mir_lowering_spec.rb b/spec/mir_lowering_spec.rb index 5d3d092d8..530379a08 100644 --- a/spec/mir_lowering_spec.rb +++ b/spec/mir_lowering_spec.rb @@ -1384,8 +1384,7 @@ def make_fn(name, params: [], return_type: :Void, body: [], visibility: nil, body = make_lit(:NUMBER, 42, full_type: :Int64) body.coerced_type = :Int64 node = AST::LambdaLit.new(tok, [], nil, body, nil, nil) - sig_hash = { params: [], return: { type: :Int64 }, lambda: true } - node.full_type = sig_hash + node.full_type = FunctionSignature.new(params: [], return_type: :Int64) result = lowering.lower(node) expect(result).to be_a(MIR::LambdaExpr) zig = emit(result) diff --git a/spec/stack_verifier_spec.rb b/spec/stack_verifier_spec.rb index d65852212..a8ad91f9a 100644 --- a/spec/stack_verifier_spec.rb +++ b/spec/stack_verifier_spec.rb @@ -291,6 +291,7 @@ def stub_verifier(objdump_string, prefix: PREFIX) main = report[:functions].find { |f| f[:name] == "main" } expect(main[:line]).to be_nil end + end describe "#has_errors?" do diff --git a/spec/type_promotion_spec.rb b/spec/type_promotion_spec.rb index 1d25b7894..e5e8d2dc2 100644 --- a/spec/type_promotion_spec.rb +++ b/spec/type_promotion_spec.rb @@ -14,24 +14,24 @@ RSpec.describe "Type promotion/cleanup analysis" do let(:schemas) do { - Point: { "x" => :Float64, "y" => :Float64 }, - User: { "name" => :String, "age" => :Int64 }, - ListHolder: { "items" => Type.new(:"Int64[]"), "label" => :String }, - MapHolder: { "data" => Type.new(:"HashMap"), "label" => :String }, - PureSliceHolder: { "items" => Type.new(:"Int64[]"), "count" => :Int64 }, - JsonValue: { kind: :union, variants: { + Point: Schemas::StructSchema.new(fields: { "x" => :Float64, "y" => :Float64 }), + User: Schemas::StructSchema.new(fields: { "name" => :String, "age" => :Int64 }), + ListHolder: Schemas::StructSchema.new(fields: { "items" => Type.new(:"Int64[]"), "label" => :String }), + MapHolder: Schemas::StructSchema.new(fields: { "data" => Type.new(:"HashMap"), "label" => :String }), + PureSliceHolder: Schemas::StructSchema.new(fields: { "items" => Type.new(:"Int64[]"), "count" => :Int64 }), + JsonValue: Schemas::UnionSchema.new(variants: { "Null" => nil, "JBool" => Type.new(:Bool), "JNum" => Type.new(:Float64), "JStr" => Type.new(:String), "JArray" => Type.new(:"JsonValue[]"), "JObj" => Type.new(:"HashMap"), - }}, - Direction: { kind: :enum, variants: Set["North", "South"] }, - SimpleUnion: { kind: :union, variants: { + }), + Direction: Schemas::EnumSchema.new(variants: Set["North", "South"]), + SimpleUnion: Schemas::UnionSchema.new(variants: { "A" => Type.new(:Float64), "B" => Type.new(:Int64), - }}, + }), } end diff --git a/src/annotator-helpers/auto_inference.rb b/src/annotator-helpers/auto_inference.rb index ea95d6842..ec321f0ad 100644 --- a/src/annotator-helpers/auto_inference.rb +++ b/src/annotator-helpers/auto_inference.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" # Auto inference — Pass B (constraint collection). # # Given a parsed program and the @fn_nodes registry produced by @@ -346,7 +348,7 @@ def resolve! resolved = {} ambiguous = {} - progress = true + progress = T.let(true, T::Boolean) while progress progress = false @slots.each do |id, slot| @@ -542,9 +544,8 @@ def walk_for_shape_decls(node, &block) return if node.nil? case node when AST::BindExpr, AST::VarDecl - yield node if node.respond_to?(:name) && node.respond_to?(:type) && - node.type.is_a?(Type) && node.type.auto? - walk_for_shape_decls(node.value, &block) if node.respond_to?(:value) + yield node if node.type.is_a?(Type) && node.type.auto? + walk_for_shape_decls(node.value, &block) when AST::FunctionDef # Don't recurse into nested function definitions. when Array @@ -565,7 +566,7 @@ def walk(node, name_map) when AST::MethodCall record_method_call(node, name_map) walk(node.object, name_map) - (node.args || []).each { |a| walk(a, name_map) } + node.args.each { |a| walk(a, name_map) } when AST::Assignment record_index_assign(node, name_map) walk(node.value, name_map) @@ -694,7 +695,7 @@ def walk_for_local_decls(node, &block) case node when AST::BindExpr, AST::VarDecl yield node if auto?(node.type) - walk_for_local_decls(node.value, &block) if node.respond_to?(:value) + walk_for_local_decls(node.value, &block) when AST::FunctionDef # Don't recurse into nested function definitions. when Array diff --git a/src/annotator-helpers/capabilities.rb b/src/annotator-helpers/capabilities.rb index 80697372b..a4362e6a6 100644 --- a/src/annotator-helpers/capabilities.rb +++ b/src/annotator-helpers/capabilities.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" # capabilities.rb — Capability validation, audit, and helpers for CLEAR's type system. # # Three concerns, three modules: @@ -19,10 +21,11 @@ module Capabilities }.freeze # Capabilities that are mutually exclusive with each other. + Conflict = Struct.new(:set_a, :set_b, :message) CONFLICTS = [ - [[:soa], [:shared, :multiowned], "SOA layout is incompatible with reference-counted ownership"], - [[:arena], [:parallel], "@arena cannot be combined with @parallel — arena memory is thread-local"], - [[:local], [:parallel], "@local requires single-scheduler affinity, incompatible with @parallel"], + Conflict.new([:soa], [:shared, :multiowned], "SOA layout is incompatible with reference-counted ownership"), + Conflict.new([:arena], [:parallel], "@arena cannot be combined with @parallel — arena memory is thread-local"), + Conflict.new([:local], [:parallel], "@local requires single-scheduler affinity, incompatible with @parallel"), ].freeze def self.errors_for(type) @@ -38,10 +41,10 @@ def self.errors_for(type) end end - CONFLICTS.each do |set_a, set_b, message| - has_a = set_a.any? { |c| caps.include?(c) } - has_b = set_b.any? { |c| caps.include?(c) } - errors << message if has_a && has_b + CONFLICTS.each do |conflict| + has_a = conflict.set_a.any? { |c| caps.include?(c) } + has_b = conflict.set_b.any? { |c| caps.include?(c) } + errors << conflict.message if has_a && has_b end errors @@ -78,18 +81,18 @@ module CapabilityHelper # during annotation). These helpers paper over the difference so the # rest of the WITH logic doesn't have to. def cap_var_sync(var_node) - sym_sync = var_node.respond_to?(:symbol) && var_node.symbol ? var_node.symbol.sync : nil + T.bind(self, SemanticAnnotator) rescue nil + sym_sync = var_node.symbol&.sync return sym_sync if sym_sync - if var_node.respond_to?(:full_type) && var_node.full_type.is_a?(Type) - return var_node.full_type.sync - end + return var_node.full_type.sync if var_node.full_type.is_a?(Type) nil end def cap_var_storage(var_node) - sym = var_node.respond_to?(:symbol) ? var_node.symbol : nil + T.bind(self, SemanticAnnotator) rescue nil + sym = var_node.symbol return sym.storage if sym - if var_node.respond_to?(:full_type) && var_node.full_type.is_a?(Type) + if var_node.full_type.is_a?(Type) case var_node.full_type.ownership when :shared then return :shared when :multiowned then return :multiowned @@ -103,19 +106,16 @@ def cap_var_storage(var_node) # node's full_type (fallback when symbol isn't bound yet, e.g. # GetField paths). Mirrors cap_var_sync / cap_var_storage. def cap_var_layout(var_node) - if var_node.respond_to?(:symbol) && var_node.symbol && - var_node.symbol.respond_to?(:layout) - sym_layout = var_node.symbol.layout - return sym_layout if sym_layout - end - if var_node.respond_to?(:full_type) && var_node.full_type.is_a?(Type) - return var_node.full_type.layout - end + T.bind(self, SemanticAnnotator) rescue nil + sym_layout = var_node.symbol&.layout + return sym_layout if sym_layout + return var_node.full_type.layout if var_node.full_type.is_a?(Type) nil end # Validate that a capability type is legal for the given variable. def validate_capability(node, capability_type, var_node) + T.bind(self, SemanticAnnotator) rescue nil var_type = var_node.full_type allowed = [AST::Identifier, AST::GetField] allowed << AST::GetIndex if capability_type == :BORROWED @@ -248,11 +248,12 @@ def validate_capability(node, capability_type, var_node) # declaration -- skipped here because the declaration may be in # another module / file; `clear fix` will surface only the :auto fix. def emit_view_not_observable_finding!(node, var_node, var_type) + T.bind(self, SemanticAnnotator) rescue nil name = var_node.respond_to?(:name) ? var_node.name : var_node.field msg = "WITH VIEW requires an `@observable` source, but '#{name}' has type #{var_type.resolved}. " \ "Use `WITH MATERIALIZED VIEW` for non-observable aggregates, or annotate the binding as `~T@observable`." - cap_entry = (node.capabilities || []).find { |c| c[:capability] == :VIEW && c[:var_node].equal?(var_node) } + cap_entry = node.capabilities.find { |c| c[:capability] == :VIEW && c[:var_node].equal?(var_node) } view_tok = cap_entry && cap_entry[:view_token] fixes = [] @@ -277,6 +278,7 @@ def emit_view_not_observable_finding!(node, var_node, var_type) # predicate may reference only function parameters). Branches on the # active context's :kind so each surface gets its own diagnostic. def predicate_identifier_allowed!(node) + T.bind(self, SemanticAnnotator) rescue nil ctx = @current_predicate_context return unless ctx return if %w[TRUE FALSE].include?(node.name) @@ -327,6 +329,7 @@ def predicate_identifier_allowed!(node) end def record_predicate_call_site!(node) + T.bind(self, SemanticAnnotator) rescue nil ctx = @current_predicate_context return unless ctx @predicate_call_sites << { @@ -340,6 +343,7 @@ def record_predicate_call_site!(node) end def validate_predicate_purity! + T.bind(self, SemanticAnnotator) rescue nil (@predicate_call_sites || []).each do |site| call = site[:call] callee = site[:callee] @@ -362,6 +366,7 @@ def validate_predicate_purity! end def predicate_impurity_reason(call, callee) + T.bind(self, SemanticAnnotator) rescue nil return "is an extern call" if call.respond_to?(:extern_call) && call.extern_call return "has extern effects" if call.respond_to?(:extern_effects) && call.extern_effects && !call.extern_effects.empty? return "can fail" if call.respond_to?(:can_fail) && call.can_fail @@ -383,6 +388,7 @@ def predicate_impurity_reason(call, callee) end def validate_and_visit_with_guards!(node) + T.bind(self, SemanticAnnotator) rescue nil caps = node.capabilities || [] guarded = caps.select { |cap| cap[:guard_expr] } return if guarded.empty? @@ -434,6 +440,7 @@ def validate_and_visit_with_guards!(node) # catches the explicit-T case (since pre_clauses now flag the fn as # raising); the implicit-RETURNS case is caught here. def visit_pre_clauses!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil pre_clauses = fn_node.respond_to?(:pre_clauses) ? (fn_node.pre_clauses || []) : [] return if pre_clauses.empty? @@ -492,6 +499,7 @@ def visit_pre_clauses!(fn_node) # the lock is racy. POST predicates are debug-only assertions and do # NOT require the function to return an error union. def visit_post_clauses!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil post_clauses = fn_node.respond_to?(:post_clauses) ? (fn_node.post_clauses || []) : [] return if post_clauses.empty? @@ -555,6 +563,7 @@ def visit_post_clauses!(fn_node) # index store, mutating method dispatch, RESTRICT borrow) have stamped # the alias's SymbolEntry. Lookup-only — no AST re-walking. def validate_with_guard_no_body_mutation!(node) + T.bind(self, SemanticAnnotator) rescue nil caps = node.capabilities || [] return if caps.none? { |cap| cap[:guard_expr] } @@ -570,6 +579,7 @@ def validate_with_guard_no_body_mutation!(node) end def alias_mutated?(alias_name) + T.bind(self, SemanticAnnotator) rescue nil scope = lookup_scope_for(alias_name) return false unless scope !!scope.locals[alias_name]&.mutated @@ -583,6 +593,7 @@ def alias_mutated?(alias_name) # @param cap [Hash] the capability entry { :capability, :var_node, :alias } # @param expanded [Array] accumulator for resolved capabilities def acquire_capability!(node, cap, expanded) + T.bind(self, SemanticAnnotator) rescue nil var_node = cap[:var_node] visit(var_node) cap[:resolved_type] = var_node.full_type @@ -677,7 +688,8 @@ def acquire_capability!(node, cap, expanded) error!(node, :BORROW_WILDCARD_NEEDS_STRUCT, name: var_node.target.name, type: target_type) end - schema.each do |field_name, _| + fields = schema.is_a?(Schemas::StructSchema) ? schema.fields : schema + fields.each do |field_name, _| field_node = AST::GetField.new(var_node.token, var_node.target, field_name) expanded << { capability: cap[:capability], @@ -695,6 +707,7 @@ def acquire_capability!(node, cap, expanded) # (mutable, stack-allocated) and re-declares the locked var for accessibility. # For all others, delegates to scope.declare_with_new_capability. def cap_var_name(var_node) + T.bind(self, SemanticAnnotator) rescue nil case var_node when AST::Identifier then var_node.name when AST::GetField then var_node.name @@ -704,6 +717,7 @@ def cap_var_name(var_node) end def declare_capability_scope!(cap) + T.bind(self, SemanticAnnotator) rescue nil var_name = cap_var_name(cap[:var_node]) source_entry = cap[:old_scope]&.locals&.[](var_name) # Sync may live on the binding (Identifier path) or on the field's @@ -850,6 +864,7 @@ def declare_capability_scope!(cap) end def capability_alias_type(type) + T.bind(self, SemanticAnnotator) rescue nil t = type.is_a?(Type) ? Type.new(type) : Type.new(type) if t.any_sync? || t.ownership != :affine t.bare_data_type @@ -904,6 +919,7 @@ def pin_reason; has_sharded ? :sharded : :shared; end # Replaces 6 separate walks (_captures_with_storage?, _captures_with_sync?, # _captures_shared?, _auto_pin_reason, _has_outer_ref?, _audit_walk_captures). def analyze_fiber_captures(body_exprs, is_parallel: false) + T.bind(self, SemanticAnnotator) rescue nil result = CaptureAnalysis.new( has_local: false, has_rc: false, has_shared: false, has_sharded: false, has_affine_locked: false, has_outer_ref: false, @@ -919,6 +935,7 @@ def analyze_fiber_captures(body_exprs, is_parallel: false) # Validate capture safety using pre-computed analysis. def validate_fiber_captures!(node, body, is_parallel, is_pinned) + T.bind(self, SemanticAnnotator) rescue nil analysis = analyze_fiber_captures(body, is_parallel: is_parallel) if is_parallel @@ -939,11 +956,13 @@ def validate_fiber_captures!(node, body, is_parallel, is_pinned) # Walk a BG block's body AST and mark any outer-scope resource, affine, or # frame-allocated variables as :moved. Stops at nested BgBlock boundaries. def walk_bg_capture_moves(stmts, scope, locally_bound) + T.bind(self, SemanticAnnotator) rescue nil stmts.each { |expr| _bg_walk(expr, scope, locally_bound) } end # Returns true if body references any outer-scope variable not in locally_bound. def captures_outer_variables?(body, locally_bound) + T.bind(self, SemanticAnnotator) rescue nil result = CaptureAnalysis.new( has_local: false, has_rc: false, has_shared: false, has_sharded: false, has_affine_locked: false, has_outer_ref: false, @@ -961,6 +980,7 @@ def captures_outer_variables?(body, locally_bound) # One recursive walk that checks each outer-scope identifier for ALL properties. def _unified_capture_walk(nodes, locally_bound, result, is_parallel) + T.bind(self, SemanticAnnotator) rescue nil nodes.each do |node| next unless node.is_a?(AST::Locatable) @@ -991,7 +1011,7 @@ def _unified_capture_walk(nodes, locally_bound, result, is_parallel) # Phase 3 (was_moved CopyNode wrapper produced by ensure_owned_value!): # function_analysis.rb stamps was_moved=true on a CopyNode wrapper that # encodes the user's GIVE intent for type adaptation. Treat as moved. - if node.is_a?(AST::CopyNode) && node.respond_to?(:was_moved) && node.was_moved && + if node.is_a?(AST::CopyNode) && node.was_moved && node.value.is_a?(AST::Identifier) nm = node.value.name.to_s result.site_moved << nm unless locally_bound.include?(nm) @@ -1026,10 +1046,10 @@ def _unified_capture_walk(nodes, locally_bound, result, is_parallel) result.capture_symbols[name] = info # Pre-compute per-capture metadata for transpiler. - t = cap_type.is_a?(Type) ? cap_type : (cap_type ? Type.new(cap_type) : nil) - result.pointer_captures << name if t&.needs_pointer_passing? - result.string_captures << name if t&.string? - result.resource_captures << name if t&.resource? || info.close_zig + t = cap_type.is_a?(Type) ? cap_type : Type.new(cap_type || :Any) + result.pointer_captures << name if t.needs_pointer_passing? + result.string_captures << name if t.string? + result.resource_captures << name if t.resource? || info.close_zig # Plain string-keyed HashMap captures (no @sharded / @shared / # @locked / @multiowned wrappers): same MoveInto pattern as # @set / @pool. CheatLib.StringMap stores its own allocator @@ -1037,9 +1057,9 @@ def _unified_capture_walk(nodes, locally_bound, result, is_parallel) # bucket_alloc)` — both args are ignored, self.alloc drives # the actual frees. Wrapped variants take the RcClone / # safe-shared path in the classifier and don't need this. - if t&.map? && !t&.numeric_map? && !info.close_zig && - !t&.sharded? && !(t.respond_to?(:striped?) && t.striped?) && - !t&.shared? && !t&.multiowned? && + if t.map? && !t.numeric_map? && !info.close_zig && + !t.sharded? && !(t.respond_to?(:striped?) && t.striped?) && + !t.shared? && !t.multiowned? && !(t.respond_to?(:any_sync?) && t.any_sync?) result.resource_captures << name result.close_patterns[name] ||= "{0}.deinit(rt.heapAlloc(), rt.heapAlloc())" @@ -1145,7 +1165,7 @@ def _unified_capture_walk(nodes, locally_bound, result, is_parallel) # binding. Add alias names + per-arm aliases to locally_bound for # the recursive walk into body / arms below. with_locally_bound = locally_bound - (node.capabilities || []).each do |cap| + node.capabilities.each do |cap| a = cap[:alias] with_locally_bound = with_locally_bound | Set[a] if a.is_a?(String) end @@ -1191,6 +1211,7 @@ def _unified_capture_walk(nodes, locally_bound, result, is_parallel) end def _bg_walk(node, scope, locally_bound) + T.bind(self, SemanticAnnotator) rescue nil return unless node.is_a?(AST::Locatable) return if node.is_a?(AST::BgBlock) @@ -1244,11 +1265,13 @@ def _bg_walk(node, scope, locally_bound) # warns about over-engineered capabilities (ghost locks, isolated shares, etc.) module CapabilityAudit def capability_audit_init! + T.bind(self, SemanticAnnotator) rescue nil @capability_audit = {} end # Record a capability binding for later audit. def record_capability_binding(var_name, node, final_type, storage) + T.bind(self, SemanticAnnotator) rescue nil return unless var_name.is_a?(String) && current_fn_ctx&.name info = current_scope.locals[var_name] @@ -1261,7 +1284,7 @@ def record_capability_binding(var_name, node, final_type, storage) return if fn_node.respond_to?(:visibility) && fn_node.visibility == :pub key = "#{current_fn_ctx&.name}:#{var_name}" - line = node.respond_to?(:token) && node.token ? node.token.line : nil + line = node.token&.line ft = final_type.is_a?(Type) ? final_type : nil is_sharded = ft&.respond_to?(:sharded?) && ft.sharded? @capability_audit[key] = { @@ -1272,6 +1295,7 @@ def record_capability_binding(var_name, node, final_type, storage) end def audit_mark_mutated(var_name) + T.bind(self, SemanticAnnotator) rescue nil return unless current_fn_ctx&.name key = "#{current_fn_ctx&.name}:#{var_name}" @capability_audit[key][:mutated] = true if @capability_audit[key] @@ -1280,9 +1304,11 @@ 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. def audit_mark_bg_captures(body_exprs, is_parallel) + T.bind(self, SemanticAnnotator) rescue nil end def finalize_capability_audit! + T.bind(self, SemanticAnnotator) rescue nil @capability_audit.each do |_key, info| loc = info[:line] ? " (line #{info[:line]})" : "" sync = info[:sync] diff --git a/src/annotator-helpers/effects.rb b/src/annotator-helpers/effects.rb index 21036947e..b0d45c7c9 100644 --- a/src/annotator-helpers/effects.rb +++ b/src/annotator-helpers/effects.rb @@ -323,7 +323,8 @@ def inherit_effects_from_callee(caller_set, callee_set, site_ctx) def compute_needs_rt! needs_rt = {} @fn_nodes.each do |name, fn_node| - ret_type = fn_node.full_type.is_a?(Type) ? fn_node.full_type[:return]&.dig(:type) : nil + raw = fn_node.full_type.is_a?(Type) ? fn_node.full_type.raw : fn_node.full_type + ret_type = raw.is_a?(FunctionSignature) ? raw.return_type : nil heap_return = ret_type.is_a?(Type) && (ret_type.heap? || ret_type.dynamic?) has_takes_heap = fn_node.params&.any? { |p| next unless p[:takes] diff --git a/src/annotator-helpers/function_analysis.rb b/src/annotator-helpers/function_analysis.rb index 0c45714dd..072a0a00e 100644 --- a/src/annotator-helpers/function_analysis.rb +++ b/src/annotator-helpers/function_analysis.rb @@ -108,15 +108,7 @@ def build_lambda_signature(params, return_type) } end - # Return a Hash (not FunctionSignature) because this feeds into - # Type.new({ params:, return:, fn_type: true }) which expects a Hash raw. - # Converting this requires Type to support FunctionSignature as raw. - { - params: normalized_params, - return: { type: return_type }, - lambda: true, - fn_type: true - } + FunctionSignature.new(params: normalized_params, return_type: return_type) end # Resolve a function call: look up the function, dispatch based on type @@ -141,7 +133,7 @@ def resolve_call(node, args) if func_type == :Intrinsic visit_IntrinsicFunc(node, args) - elsif func_type.is_a?(FunctionSignature) || func_type.is_a?(Hash) + elsif func_type.is_a?(FunctionSignature) node.module_alias = func_type.module_alias if node.respond_to?(:module_alias=) && func_type.module_alias if node.respond_to?(:extern_call=) && func_type.extern node.extern_call = true @@ -195,7 +187,7 @@ def resolve_call(node, args) substituted = substitute_type_params(func_type, subst) call_node = Struct.new(:token, :name, :args).new(node.token, func_name, args) verify_function_signature!(call_node, substituted) - node.full_type = substituted[:return][:type] + node.full_type = substituted.return_type else call_node = Struct.new(:token, :name, :args).new(node.token, func_name, args) verify_function_signature!(call_node, func_type) @@ -254,13 +246,14 @@ def resolve_call(node, args) elsif func_type.is_a?(Type) && func_type.fn_type? node.fn_var_call = true if node.respond_to?(:fn_var_call=) lookup_scope_for(func_name)&.mark_read(func_name) - synthetic_sig = { - params: func_type.raw[:params], - return: { type: func_type.raw[:return][:type] } - } + sig = func_type.raw + synthetic_sig = FunctionSignature.new( + params: sig.params, + return_type: sig.return_type + ) call_node = Struct.new(:token, :name, :args).new(node.token, func_name, args) verify_function_signature!(call_node, synthetic_sig) - node.full_type = func_type.raw[:return][:type] + node.full_type = sig.return_type elsif func_type.is_a?(Symbol) node.full_type = func_type @@ -333,7 +326,7 @@ def normalize_intrinsic_signature(config) end def verify_function_signature!(node, signature) - params = signature[:params] + params = signature.params min_args = params.count { |param| param[:required] } max_args = params.size given_args = node.args.size @@ -468,7 +461,7 @@ def verify_function_signature!(node, signature) if actual_type_obj.is_a?(Type) && expected_type_obj.accepts?(actual_type_obj) match = true elsif actual_type_obj.is_a?(Type) && actual_type_obj.fn_type? && - actual_type_obj.raw[:reentrant] && !expected_type_obj.raw[:reentrant] + actual_type_obj.raw.reentrant && !expected_type_obj.raw.reentrant arg_name = arg_node.respond_to?(:name) ? arg_node.name : "Expression" error!(arg_node, :REENTRANT_FN_TO_NON_REENTRANT_PARAM, name: arg_name, param: param[:name]) end @@ -585,7 +578,7 @@ def atomic_cell_to_atomic_param?(arg_node, param, signature) return true if param[:sync] == :atomic return true if param[:symbol]&.respond_to?(:sync) && param[:symbol].sync == :atomic - requires = signature.respond_to?(:requires) ? signature.requires : signature[:requires] + requires = signature.requires families = requires && requires[param[:name].to_s] families.respond_to?(:include?) && families.include?(:ATOMIC) end @@ -621,10 +614,10 @@ def verify_param_lifetime!(arg_node, param, signature) error!(arg_node, :MUTABLE_ARG_RESTRICTED, name: arg_node.name) end - # Atomics M2.4 / M2.5: signature[:return][:lifetime] is now an + # Atomics M2.4 / M2.5: signature.return_lifetime is now an # Array of dotted-path strings (or [:wildcard]) instead of a # single string. Empty array = no lifetime annotation. - lifetime_paths = signature.dig(:return, :lifetime) || [] + lifetime_paths = signature.return_lifetime || [] lifetime_paths = [lifetime_paths] unless lifetime_paths.is_a?(Array) return true if lifetime_paths.empty? diff --git a/src/annotator-helpers/function_context.rb b/src/annotator-helpers/function_context.rb index a9420ba25..f62e4351c 100644 --- a/src/annotator-helpers/function_context.rb +++ b/src/annotator-helpers/function_context.rb @@ -1,3 +1,4 @@ +require "sorbet-runtime" # Per-function state scoped to the function context stack. # Replaces loose instance variables (@frame_usage_count, @heap_usage_count, etc.) # that were manually reset in visit_FunctionDef and could leak across functions. diff --git a/src/annotator-helpers/function_signature.rb b/src/annotator-helpers/function_signature.rb index ee5f29976..0f399fdfb 100644 --- a/src/annotator-helpers/function_signature.rb +++ b/src/annotator-helpers/function_signature.rb @@ -45,78 +45,6 @@ def initialize(params:, return_type:, return_lifetime: nil, visibility: nil, @zig_pattern = zig_pattern end - # ── Hash compatibility layer (migration aid) ────────────────────── - # Allows existing code to use sig[:params], sig.key?(:params), etc. - # Remove after Phase D when all readers use method access. - - FIELD_MAP = { - params: :params, - return: nil, # special: returns { type:, lifetime: } hash - visibility: :visibility, - type_params: :type_params, - reentrant: :reentrant, - extern: :extern, - module_alias: :module_alias, - extern_effects: :extern_effects, - fn_type_params: :fn_type_params, - owner_type: :owner_type, - owner_type_params: :owner_type_params, - intrinsic: :intrinsic, - zig: :zig_pattern, - return_strategy: :return_strategy, - needs_rt: :needs_rt, - can_fail: :can_fail, - return_provenance: :return_provenance, - effects: :effects, - stack_tier: :stack_tier, - }.freeze - - def [](key) - if key == :return - { type: @return_type, lifetime: @return_lifetime } - elsif FIELD_MAP.key?(key) - send(FIELD_MAP[key]) - end - end - - def []=(key, value) - case key - when :return - @return_type = value[:type] if value.is_a?(Hash) - @return_lifetime = value[:lifetime] if value.is_a?(Hash) - when :return_strategy then @return_strategy = value - when :needs_rt then @needs_rt = value - when :can_fail then @can_fail = value - when :return_provenance then @return_provenance = value - when :module_alias then @module_alias = value - end - end - - def key?(key) - FIELD_MAP.key?(key) - end - - def is_a?(klass) - return true if klass == Hash # many sites check is_a?(Hash) to detect function sigs - super - end - - def dig(*keys) - val = self[keys.first] - return val if keys.length == 1 - val.is_a?(Hash) ? val.dig(*keys[1..]) : nil - end - - # Used for module imports: sig.merge(module_alias: "math") - def merge(other_hash) - dup_sig = self.dup - other_hash.each do |k, v| - dup_sig[k] = v if dup_sig.respond_to?(:[]=) - end - dup_sig - end - - # Deep dup for merge def dup FunctionSignature.new( params: @params, return_type: @return_type, return_lifetime: @return_lifetime, diff --git a/src/annotator-helpers/lock_helper.rb b/src/annotator-helpers/lock_helper.rb index 6a7fdf01f..51514c843 100644 --- a/src/annotator-helpers/lock_helper.rb +++ b/src/annotator-helpers/lock_helper.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" require "set" # All lock-safety analysis lives here. Two layers in one module so they @@ -35,6 +37,7 @@ module LockHelper # Called once from SemanticAnnotator#initialize. def init_lock_analysis! + T.bind(self, SemanticAnnotator) rescue nil @lock_direct_edges ||= Hash.new { |h, k| h[k] = [] } @lock_direct_acquires ||= Hash.new { |h, k| h[k] = Set.new } @lock_held_calls ||= Hash.new { |h, k| h[k] = [] } @@ -49,6 +52,7 @@ def init_lock_analysis! # First declaration of T with a rank wins; subsequent mismatches error. def record_lock_type_rank!(type_sym, rank, node) + T.bind(self, SemanticAnnotator) rescue nil return unless type_sym && rank existing = @lock_type_ranks[type_sym] if existing.nil? @@ -61,12 +65,14 @@ def record_lock_type_rank!(type_sym, rank, node) # For the Phase 3 ordering check: return the rank of a WITH capability's # lock type, or nil if the type has no rank declared anywhere. def rank_of_cap(cap) + T.bind(self, SemanticAnnotator) rescue nil t = lock_identity_of(cap) return nil unless t @lock_type_ranks[t] end def record_lock_clause_site!(node, expanded_capabilities) + T.bind(self, SemanticAnnotator) rescue nil return unless node.lock_error_clause fallible = expanded_capabilities.select { |c| c[:capability] == :EXCLUSIVE || c[:capability] == :write_locked_read @@ -83,6 +89,7 @@ def record_lock_clause_site!(node, expanded_capabilities) # 2 handles cross-function type-level cycles). Opt-outs downgrade to a # [Note]. def check_nested_lock_reacquire!(node, expanded_capabilities) + T.bind(self, SemanticAnnotator) rescue nil return unless @held_locks expanded_capabilities.each do |cap| next unless cap[:capability] == :EXCLUSIVE || cap[:capability] == :write_locked_read @@ -109,6 +116,7 @@ def check_nested_lock_reacquire!(node, expanded_capabilities) # on the inner WITH downgrades the error to a [Note] so the risk is # visible but not blocking. def check_lock_rank_ordering!(node, expanded_capabilities) + T.bind(self, SemanticAnnotator) rescue nil return unless @held_lock_types && !@held_lock_types.empty? return unless @lock_type_ranks && !@lock_type_ranks.empty? escape = node.deadlock_escape @@ -140,6 +148,7 @@ def check_lock_rank_ordering!(node, expanded_capabilities) # inner type's base symbol (:Counter for Locked(Counter) or # @shared:locked Counter), or nil if we can't determine it. def lock_identity_of(cap) + T.bind(self, SemanticAnnotator) rescue nil ti = cap[:resolved_type] return nil unless ti return nil unless ti.respond_to?(:base_type) @@ -153,10 +162,11 @@ def lock_identity_of(cap) # — the outer holder, the inner acquire, or both — and each form has # the same suppression effect on the cycle graph. def record_with_acquire!(fn_name, cap, held_stack, escape) + T.bind(self, SemanticAnnotator) rescue nil t = lock_identity_of(cap) return unless t @lock_direct_acquires[fn_name] << t - site_tok = cap[:var_node].respond_to?(:token) ? cap[:var_node].token : nil + site_tok = cap[:var_node].token acquirer_opt = !escape.nil? held_stack.each do |held| @lock_direct_edges[fn_name] << LockEdge.new( @@ -168,6 +178,7 @@ def record_with_acquire!(fn_name, cap, held_stack, escape) end def record_held_call!(fn_name, callee_name, held_stack, site_token) + T.bind(self, SemanticAnnotator) rescue nil held_stack.each do |held| @lock_held_calls[fn_name] << { held: held[:type], callee: callee_name, site_token: site_token, @@ -181,12 +192,13 @@ def record_held_call!(fn_name, callee_name, held_stack, site_token) # transitive callee takes. Mirrors compute_needs_rt! / compute_can_fail! # structure. def propagate_lock_acquires! + T.bind(self, SemanticAnnotator) rescue nil transitive = {} @lock_direct_acquires.each { |fn, set| transitive[fn] = set.dup } @call_graph.each_key { |fn| transitive[fn] ||= Set.new } loop do - changed = false + changed = T.let(false, T::Boolean) @call_graph.each do |fn, callees| callees.each do |callee| next unless transitive[callee] @@ -204,6 +216,7 @@ def propagate_lock_acquires! end def resolve_held_calls! + T.bind(self, SemanticAnnotator) rescue nil @lock_held_calls.each do |fn, sites| sites.each do |site| (@lock_transitive_acquires[site[:callee]] || Set.new).each do |t| @@ -223,6 +236,7 @@ def resolve_held_calls! # reachability analysis (opt-out edges are paths that CAN fire at # runtime, so ON :LockCycle handlers reaching them are live). def build_lock_graph(include_opted_out: false) + T.bind(self, SemanticAnnotator) rescue nil adj = Hash.new { |h, k| h[k] = Set.new } nodes = Set.new live = [] @@ -240,6 +254,7 @@ def build_lock_graph(include_opted_out: false) # Iterative Tarjan SCC. Returns array of SCCs (each an array of nodes). def tarjan_scc(nodes, adj) + T.bind(self, SemanticAnnotator) rescue nil index = {} lowlink = {} on_stack = {} @@ -290,6 +305,7 @@ def tarjan_scc(nodes, adj) # Called as a post-pass once @call_graph is complete. def check_lock_cycles! + T.bind(self, SemanticAnnotator) rescue nil propagate_lock_acquires! resolve_held_calls! @@ -307,6 +323,7 @@ def check_lock_cycles! end def check_lock_handler_reachability! + T.bind(self, SemanticAnnotator) rescue nil return if @lock_clause_sites.nil? || @lock_clause_sites.empty? full = build_lock_graph(include_opted_out: true) @@ -336,6 +353,7 @@ def check_lock_handler_reachability! # 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. def verify_handler_reachability!(site, types_in_cycle, types_with_self) + T.bind(self, SemanticAnnotator) rescue nil node = site[:node] clause = node.lock_error_clause return unless clause @@ -381,12 +399,14 @@ def verify_handler_reachability!(site, types_in_cycle, types_with_self) end def scc_is_cyclic?(scc, adj) + T.bind(self, SemanticAnnotator) rescue nil return true if scc.length > 1 node = scc.first adj[node].include?(node) end def report_lock_cycle!(scc, edges) + T.bind(self, SemanticAnnotator) rescue nil scc_set = scc.to_set participating = edges.select { |e| scc_set.include?(e.held) && scc_set.include?(e.acquired) } sample = participating.first diff --git a/src/annotator-helpers/method_analysis.rb b/src/annotator-helpers/method_analysis.rb index 1c79a7122..7ab42ef5a 100644 --- a/src/annotator-helpers/method_analysis.rb +++ b/src/annotator-helpers/method_analysis.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" # method_analysis.rb — Type-specific method resolution for Pool and HashMap. # # Resolves method calls on collection types using the declarative registries @@ -8,6 +10,7 @@ module MethodAnalysis # Returns true if handled, false if the caller should fall through to UFCS. # Dispatch is driven by COLLECTION_METHOD_CONFIGS keyed on Type#dispatch_key. def resolve_collection_method(node) + T.bind(self, SemanticAnnotator) rescue nil obj_type = node.object.type_info config = COLLECTION_METHOD_CONFIGS[obj_type&.dispatch_key] return false unless config @@ -23,6 +26,7 @@ def resolve_collection_method(node) # @param matched_def [Hash] the STD_LIB definition that matched # @param args [Array] the resolved argument nodes def narrow_collection_type!(matched_def, args) + T.bind(self, SemanticAnnotator) rescue nil return unless matched_def[:narrows_collection] && args.size >= 2 list_arg = args[0] @@ -47,6 +51,7 @@ def narrow_collection_type!(matched_def, args) private def resolve_typed_method(node, obj_type, registry, tag_field, type_label) + T.bind(self, SemanticAnnotator) rescue nil defn = registry[node.name] unless defn available = registry.keys.join(", ") @@ -148,6 +153,7 @@ def resolve_typed_method(node, obj_type, registry, tag_field, type_label) # Returns the :get or :set sub-entry, or nil. # Dispatch is driven by Type#dispatch_key — add new indexable types there. def resolve_index_op(type_info, op) + T.bind(self, SemanticAnnotator) rescue nil return nil if type_info&.promise_list? INDEX_OPS.dig(type_info&.dispatch_key, op) end diff --git a/src/annotator-helpers/pipe_analysis.rb b/src/annotator-helpers/pipe_analysis.rb index 3a91e57d4..6710ae1e4 100644 --- a/src/annotator-helpers/pipe_analysis.rb +++ b/src/annotator-helpers/pipe_analysis.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" require_relative "../ast/ast" require_relative "../ast/type" require 'set' @@ -7,6 +9,7 @@ module PipeAnalysis # SMOOTH OPERATOR (|>) # ========================================================= def visit_Smooth(node) + T.bind(self, SemanticAnnotator) rescue nil @smooth_depth += 1 # Logic: x |> f -> f(x) @@ -31,6 +34,7 @@ def visit_Smooth(node) private def finite_stream_source?(node) + T.bind(self, SemanticAnnotator) rescue nil node.is_a?(AST::RangeLit) || node.type_info&.dynamic_stream? || node.type_info&.bounded_stream? || node.type_info&.open_stream? end @@ -47,6 +51,7 @@ def finite_stream_source?(node) # observable type without requiring an explicit annotation. The # user joins via `|> COLLECT` (or NEXT) to get back a scalar T. def stamp_observable_terminal!(node) + T.bind(self, SemanticAnnotator) rescue nil # RangeLits annotate as `~Int64[]` (a tense dynamic_stream) but # fold eagerly to a scalar -- there's no fiber producing values # while a reader could WITH VIEW the accumulator. Exclude them. @@ -71,6 +76,7 @@ def stamp_observable_terminal!(node) # lift_to_observable_if_terminal!(node, terminal: :distinct, # raw: :"~Int64[]", collection: :set) def lift_to_observable_if_terminal!(node, terminal:, raw:, **type_kwargs) + T.bind(self, SemanticAnnotator) rescue nil return unless node.observable_terminal node.full_type = Type.new(raw, observable: true, @@ -88,15 +94,18 @@ def lift_to_observable_if_terminal!(node, terminal:, raw:, **type_kwargs) # carries is terminal kind + raw type + extra type kwargs, so a # single helper is enough. def mark_observable_terminal!(node, terminal:, raw:, **type_kwargs) + T.bind(self, SemanticAnnotator) rescue nil stamp_observable_terminal!(node) - lift_to_observable_if_terminal!(node, terminal: terminal, raw: raw, **type_kwargs) + lift_to_observable_if_terminal!(node, **T.unsafe({terminal: terminal, raw: raw, **type_kwargs})) end def bounded_stream_source?(node) + T.bind(self, SemanticAnnotator) rescue nil node.type_info&.bounded_stream? end def finite_stream_element_type(node) + T.bind(self, SemanticAnnotator) rescue nil return range_element_type(node) if node.is_a?(AST::RangeLit) return node.type_info.open_stream_element_type.resolved if node.type_info&.open_stream? node.type_info.tense_type.element_type.resolved @@ -104,15 +113,18 @@ def finite_stream_element_type(node) # Element type for an InfStream source (~T[INF]). def inf_stream_element_type(node) + T.bind(self, SemanticAnnotator) rescue nil node.type_info.inf_stream_element_type.resolved end def has_catch_blocks? + T.bind(self, SemanticAnnotator) rescue nil fn = @fn_nodes&.dig(current_fn_ctx&.name) fn && fn.catch_clauses.is_a?(Array) && fn.catch_clauses.any? end def higher_order_list_op?(node) + T.bind(self, SemanticAnnotator) rescue nil node.is_a?(AST::SelectOp) || node.is_a?(AST::WhereOp) || node.is_a?(AST::IndexOp) || @@ -143,6 +155,7 @@ def higher_order_list_op?(node) end def analyze_higher_order_op(node) + T.bind(self, SemanticAnnotator) rescue nil case node.right when AST::SelectOp, AST::WhereOp, AST::IndexOp, AST::OrderByOp analyze_select_family_op(node) @@ -201,6 +214,7 @@ def analyze_higher_order_op(node) # moved so the consume-or-transfer rule for ~T futures sees the # binding as consumed. def analyze_collect_op(node) + T.bind(self, SemanticAnnotator) rescue nil lhs_t = node.left.type_info unless lhs_t&.future? && lhs_t&.observable? ty = lhs_t&.resolved || "" @@ -228,6 +242,7 @@ def analyze_collect_op(node) # SELECT and WHERE also accept a RangeLit or InfStream source (fused lazy path). # INDEX accepts finite stream sources (~T[], ~T[N]). def analyze_select_family_op(node) + T.bind(self, SemanticAnnotator) rescue nil is_inf = node.left.type_info&.inf_stream? && (node.right.is_a?(AST::SelectOp) || node.right.is_a?(AST::WhereOp)) is_stream = (finite_stream_source?(node.left) || is_inf) && @@ -286,6 +301,7 @@ def analyze_select_family_op(node) end def analyze_take_while_op(node) + T.bind(self, SemanticAnnotator) rescue nil is_inf = node.left.type_info&.inf_stream? is_stream = finite_stream_source?(node.left) || is_inf require_array_input!(node, "TAKE_WHILE", allow_range: is_stream, allow_stream: is_stream) @@ -312,6 +328,7 @@ def analyze_take_while_op(node) end def analyze_window_op(node) + T.bind(self, SemanticAnnotator) rescue nil require_array_input!(node, "WINDOW") item_type = node.left.type_info.element_type.resolved @@ -340,12 +357,14 @@ def analyze_window_op(node) BATCH_WINDOW_TIME_NS = { 'ms' => 1_000_000, 's' => 1_000_000_000, 'min' => 60_000_000_000, 'h' => 3_600_000_000_000 }.freeze def parse_batch_window_time_ns(str) + T.bind(self, SemanticAnnotator) rescue nil m = BATCH_WINDOW_TIME_RE.match(str) return nil unless m (m[1].to_f * BATCH_WINDOW_TIME_NS[m[2]]).to_i end def analyze_batch_window_op(node) + T.bind(self, SemanticAnnotator) rescue nil opts = node.right.options bw = node.right @@ -409,6 +428,7 @@ def analyze_batch_window_op(node) end def analyze_join_op(node) + T.bind(self, SemanticAnnotator) rescue nil require_array_input!(node, "JOIN") left_type = node.left.type_info.element_type.resolved @@ -453,10 +473,10 @@ def analyze_join_op(node) # Register a synthetic struct type for the join result so field access works. join_type_name = :"JoinResult_#{left_type}_#{right_type}" unless current_scope.resolve_type_definition(join_type_name) - current_scope.declare_type(join_type_name, { + current_scope.declare_type(join_type_name, Schemas::StructSchema.new(fields: { "left" => left_type, "right" => :"?#{right_type}", - }) + })) end node.full_type = :"#{join_type_name}[]" @@ -465,9 +485,10 @@ def analyze_join_op(node) end def analyze_recover_op(node) + T.bind(self, SemanticAnnotator) rescue nil # RECOVER(default): replace error with default value in pipeline visit(node.right.default_expr) - lhs_type = node.left.respond_to?(:full_type) ? node.left.full_type : nil + lhs_type = node.left.full_type lhs_t = lhs_type ? Type.new(lhs_type) : nil if lhs_t&.error_union? node.full_type = lhs_t.payload_type.resolved @@ -478,6 +499,7 @@ def analyze_recover_op(node) end def analyze_reduce_op(node) + T.bind(self, SemanticAnnotator) rescue nil # REDUCE: list |> REDUCE(initial) acc + _.value # Also accepts range/stream sources for the fused lazy path. is_stream = finite_stream_source?(node.left) @@ -530,10 +552,12 @@ def analyze_reduce_op(node) ].freeze def observable_reducible_scalar?(acc_type) + T.bind(self, SemanticAnnotator) rescue nil OBSERVABLE_REDUCIBLE_NUMERIC.include?(acc_type) end def analyze_limit_op(node) + T.bind(self, SemanticAnnotator) rescue nil # LIMIT: list |> LIMIT n # Also accepts range and stream sources (fused lazy path). is_range = node.left.is_a?(AST::RangeLit) @@ -564,6 +588,7 @@ def analyze_limit_op(node) end def analyze_unnest_op(node) + T.bind(self, SemanticAnnotator) rescue nil # UNNEST: list |> UNNEST _.arr (flatmap) # Optional inner binding: UNNEST _.arr AS $o parses as UNNEST BIND_VAR(_.arr, $o) # because :pipe_expression uses parse_expression(1) which consumes AS at prec 2. @@ -604,6 +629,7 @@ def analyze_unnest_op(node) end def analyze_distinct_op(node) + T.bind(self, SemanticAnnotator) rescue nil # DISTINCT: list |> DISTINCT _.field (or just DISTINCT _) # Returns a Set of unique key values (T[]@set or T[N]@set). lhs_type = node.left.type_info @@ -652,6 +678,7 @@ def analyze_distinct_op(node) end def analyze_pipe_to_func_call(node) + T.bind(self, SemanticAnnotator) rescue nil # Case 1: x |> f(y) => f(x, y) # We intentionally modify the AST temporarily to leverage visit_FuncCall's # existing validation logic (arity, type checks, intrinsics). @@ -672,15 +699,17 @@ def analyze_pipe_to_func_call(node) end def analyze_pipe_to_identifier(node) + T.bind(self, SemanticAnnotator) rescue nil # Case 2: x |> f => f(x) # We must MANUALLY validate this because we aren't creating a FuncCall node. visit(node.right) # Resolves 'f' to its Signature/Type sig = node.right.full_type + sig = sig.raw if sig.is_a?(Type) && sig.raw.is_a?(FunctionSignature) func_name = node.right.name - if sig.respond_to?(:params) ? sig.params : sig[:params] + if sig.is_a?(FunctionSignature) # Named Function or Lambda (both use standard signature format) analyze_pipe_to_named_function(node, sig, func_name) elsif sig == :Intrinsic || sig == :Nil @@ -694,8 +723,9 @@ def analyze_pipe_to_identifier(node) end 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) - params = sig.respond_to?(:params) ? sig.params : sig[:params] + params = sig.params min_args = params.count { |p| p[:required] } max_args = params.size @@ -720,7 +750,7 @@ def analyze_pipe_to_named_function(node, sig, func_name) end # 3. Set Result Type. Unwrap error unions in CATCH functions. - result_type = sig.respond_to?(:return_type) ? sig.return_type : sig[:return][:type] + result_type = sig.return_type if has_catch_blocks? && result_type t = result_type.is_a?(Type) ? result_type : Type.new(result_type) result_type = t.payload_type.resolved if t.error_union? @@ -729,6 +759,7 @@ def analyze_pipe_to_named_function(node, sig, func_name) end def analyze_each_op(node) + T.bind(self, SemanticAnnotator) rescue nil # EACH accepts arrays, collections, and finite streams. lhs_type = node.left.type_info is_pool = lhs_type&.pool? @@ -765,6 +796,7 @@ def analyze_each_op(node) end def analyze_skip_op(node) + T.bind(self, SemanticAnnotator) rescue nil # SKIP: list |> SKIP n -> same list type with first n elements removed (also accepts range/InfStream) is_inf = node.left.type_info&.inf_stream? is_range = finite_stream_source?(node.left) || is_inf @@ -788,6 +820,7 @@ def analyze_skip_op(node) end def analyze_tap_op(node) + T.bind(self, SemanticAnnotator) rescue nil # TAP: list |> TAP { body } -> same list type (pass-through); also accepts range/stream source. lhs_type = node.left.type_info is_inf = lhs_type&.inf_stream? @@ -826,6 +859,7 @@ def analyze_tap_op(node) # ========================================================= def analyze_find_op(node) + T.bind(self, SemanticAnnotator) rescue nil # FIND: list |> FIND predicate → ?ElemType (first match or null; also accepts range) is_range = finite_stream_source?(node.left) require_array_input!(node, "FIND", allow_range: is_range, allow_stream: is_range) @@ -846,6 +880,7 @@ def analyze_find_op(node) end def analyze_any_op(node) + T.bind(self, SemanticAnnotator) rescue nil # ANY: list |> ANY predicate → Bool (short-circuits; also accepts range) is_range = finite_stream_source?(node.left) require_array_input!(node, "ANY", allow_range: is_range, allow_stream: is_range) @@ -866,6 +901,7 @@ def analyze_any_op(node) end def analyze_all_op(node) + T.bind(self, SemanticAnnotator) rescue nil # ALL: list |> ALL predicate → Bool (vacuous truth on empty; also accepts range) is_range = finite_stream_source?(node.left) require_array_input!(node, "ALL", allow_range: is_range, allow_stream: is_range) @@ -886,6 +922,7 @@ def analyze_all_op(node) end def analyze_count_op(node) + T.bind(self, SemanticAnnotator) rescue nil # COUNT: list |> COUNT predicate → Int64 (also accepts range) is_range = finite_stream_source?(node.left) require_array_input!(node, "COUNT", allow_range: is_range, allow_stream: is_range) @@ -913,6 +950,7 @@ def analyze_count_op(node) # Covers :Float64, :Int64, :Byte, :Float64. def analyze_sum_op(node) + T.bind(self, SemanticAnnotator) rescue nil # SUM: list |> SUM expr → upsized numeric type (int→Int64/UInt64, float→same float) is_range = finite_stream_source?(node.left) require_array_input!(node, "SUM", allow_range: is_range, allow_stream: is_range) @@ -934,6 +972,7 @@ def analyze_sum_op(node) end def analyze_average_op(node) + T.bind(self, SemanticAnnotator) rescue nil # AVERAGE: list |> AVERAGE expr → Float64 (0 for empty; also accepts range) is_range = finite_stream_source?(node.left) require_array_input!(node, "AVERAGE", allow_range: is_range, allow_stream: is_range) @@ -955,6 +994,7 @@ def analyze_average_op(node) end def analyze_min_op(node) + T.bind(self, SemanticAnnotator) rescue nil # MIN: list |> MIN expr → exact expression type (panics on empty; also accepts range) is_range = finite_stream_source?(node.left) require_array_input!(node, "MIN", allow_range: is_range, allow_stream: is_range) @@ -976,6 +1016,7 @@ def analyze_min_op(node) end def analyze_max_op(node) + T.bind(self, SemanticAnnotator) rescue nil # MAX: list |> MAX expr → exact expression type (panics on empty; also accepts range) is_range = finite_stream_source?(node.left) require_array_input!(node, "MAX", allow_range: is_range, allow_stream: is_range) @@ -1002,6 +1043,7 @@ def analyze_max_op(node) # SHARD + CONCURRENT EACH: the EACH body sees `_` typed as the map's key type. def analyze_shard_each_op(node, shard_node) + T.bind(self, SemanticAnnotator) rescue nil conc = node.right each_op = conc.op @@ -1033,6 +1075,7 @@ def analyze_shard_each_op(node, shard_node) # by scanning for identifiers that are in scope as @sharded (without :locked). # This runs BEFORE visiting the body, so we only check unvisited AST. def emit_multi_map_warning(conc, sharded_names) + T.bind(self, SemanticAnnotator) rescue nil shard_counts = sharded_names.map do |name| sc = lookup_scope_for(name)&.locals&.[](name)&.type t = sc.is_a?(Type) ? sc : Type.new(sc) @@ -1050,6 +1093,7 @@ def emit_multi_map_warning(conc, sharded_names) end def collect_sharded_names(node, names) + T.bind(self, SemanticAnnotator) rescue nil return unless node.is_a?(AST::Locatable) if node.is_a?(AST::Identifier) entry = node.symbol @@ -1071,6 +1115,7 @@ def collect_sharded_names(node, names) end def pre_scan_node_for_sharded(node) + T.bind(self, SemanticAnnotator) rescue nil return false unless node.is_a?(AST::Locatable) if node.is_a?(AST::Identifier) entry = node.symbol @@ -1096,6 +1141,7 @@ def pre_scan_node_for_sharded(node) # Accepts range inputs (unlike analyze_each_op which requires collections). # Visits the body, then extracts the key expression and sets shard_context. def analyze_auto_shard_each_op(smooth_node, conc, proxy) + T.bind(self, SemanticAnnotator) rescue nil lhs_type = smooth_node.left.type_info is_range = finite_stream_source?(smooth_node.left) is_array = smooth_node.left.metatype == :array @@ -1127,6 +1173,7 @@ def analyze_auto_shard_each_op(smooth_node, conc, proxy) # If found, sets shard_context on the ConcurrentOp so the transpiler emits # routed sharding instead of the normal worker pool. def auto_detect_sharded_access(smooth_node, conc) + T.bind(self, SemanticAnnotator) rescue nil each_op = conc.op return unless each_op.is_a?(AST::EachOp) @@ -1173,6 +1220,7 @@ def auto_detect_sharded_access(smooth_node, conc) # Recursively walk AST nodes to find GetIndex on @sharded maps. def walk_for_sharded_access(nodes, results) + T.bind(self, SemanticAnnotator) rescue nil nodes.each do |node| next unless node.is_a?(AST::Locatable) @@ -1205,6 +1253,7 @@ def walk_for_sharded_access(nodes, results) # Find GetIndex on @sharded maps in expression context (reads) def walk_for_sharded_getindex(nodes, results) + T.bind(self, SemanticAnnotator) rescue nil nodes.each do |node| next unless node.is_a?(AST::Locatable) if node.is_a?(AST::GetIndex) && node.target.is_a?(AST::Identifier) @@ -1226,6 +1275,7 @@ def walk_for_sharded_getindex(nodes, results) end def analyze_shard_op(node) + T.bind(self, SemanticAnnotator) rescue nil shard_op = node.right # ShardOp node # LHS must be iterable (range or array) @@ -1286,6 +1336,7 @@ def analyze_shard_op(node) VALID_CONCURRENT_SIZES = %w[MICRO STANDARD LARGE XL].freeze def validate_positive_numeric_concurrent_option!(name, expr) + T.bind(self, SemanticAnnotator) rescue nil visit(expr) unless [:Float64, :Int64].include?(expr.resolved_type) error!(expr, :CONCURRENT_OPT_NEEDS_NUMBER, name: name, got: expr.resolved_type) @@ -1298,6 +1349,7 @@ def validate_positive_numeric_concurrent_option!(name, expr) end def numeric_literal_value(expr) + T.bind(self, SemanticAnnotator) rescue nil if expr.is_a?(AST::Literal) expr.value.to_f elsif expr.is_a?(AST::UnaryOp) && expr.op == :SUB @@ -1307,6 +1359,7 @@ def numeric_literal_value(expr) end def queue_backed_concurrent_source?(node) + T.bind(self, SemanticAnnotator) rescue nil lhs = node.left lhs_type = lhs.type_info shard_concurrent_source?(lhs) || bounded_stream_source?(lhs) || @@ -1315,10 +1368,12 @@ def queue_backed_concurrent_source?(node) end def shard_concurrent_source?(lhs) + T.bind(self, SemanticAnnotator) rescue nil lhs.is_a?(AST::BinaryOp) && lhs.op == :SMOOTH && lhs.right.is_a?(AST::ShardOp) end def analyze_concurrent_op(node) + T.bind(self, SemanticAnnotator) rescue nil conc = node.right # the ConcurrentOp node options = conc.options lhs_type = node.left.type_info @@ -1486,6 +1541,7 @@ def analyze_concurrent_op(node) end def analyze_concurrent_bounded_select_family_op(node) + T.bind(self, SemanticAnnotator) rescue nil lhs_type = node.left.type_info item_type = lhs_type.stream_element_type.resolved is_parallel = node.right.options["parallel"].is_a?(AST::Identifier) && @@ -1517,6 +1573,7 @@ def analyze_concurrent_bounded_select_family_op(node) end def analyze_concurrent_bounded_each_op(node) + T.bind(self, SemanticAnnotator) rescue nil lhs_type = node.left.type_info item_type = lhs_type.stream_element_type.resolved is_parallel = node.right.options["parallel"].is_a?(AST::Identifier) && @@ -1541,6 +1598,7 @@ def analyze_concurrent_bounded_each_op(node) # Uses BoundedChannel for SPMC back pressure: feeder reads source, workers compete. # Produces a materialized list (not another stream) regardless of source kind. def analyze_concurrent_stream_select_family_op(node) + T.bind(self, SemanticAnnotator) rescue nil lhs_type = node.left.type_info item_type = if lhs_type&.inf_stream? inf_stream_element_type(node.left) @@ -1575,6 +1633,7 @@ def analyze_concurrent_stream_select_family_op(node) # CONCURRENT EACH on ~T[] (dynamic stream) or ~T[INF] (InfStream). def analyze_concurrent_stream_each_op(node) + T.bind(self, SemanticAnnotator) rescue nil lhs_type = node.left.type_info item_type = if lhs_type&.inf_stream? inf_stream_element_type(node.left) @@ -1607,6 +1666,7 @@ def analyze_concurrent_stream_each_op(node) # Returns the CLEAR result type for SUM based on the expression's input type. # Signed integers upsize to Int64; unsigned to UInt64; floats stay their own type. def sum_result_clear_type(expr_sym) + T.bind(self, SemanticAnnotator) rescue nil case expr_sym when :Int8, :Int16, :Int32, :Int64 then :Int64 when :UInt8, :Byte, :UInt16, :UInt32, :UInt64 then :UInt64 @@ -1616,6 +1676,7 @@ def sum_result_clear_type(expr_sym) end def require_array_input!(node, op_name, allow_range: false, allow_stream: false) + T.bind(self, SemanticAnnotator) rescue nil lhs_type = node.left.type_info return if node.left.metatype == :array return if lhs_type&.collection? @@ -1632,8 +1693,8 @@ def require_array_input!(node, op_name, allow_range: false, allow_stream: false) # Element type for a range source (used by fusible stage ops applied to ranges). def range_element_type(range_node) - start_ft = range_node.start.respond_to?(:full_type) ? range_node.start.full_type : :Number - start_ft || :Number + T.bind(self, SemanticAnnotator) rescue nil + range_node.start.full_type || :Number end # ========================================================= @@ -1650,15 +1711,15 @@ def range_element_type(range_node) SOA_THRESHOLD = 0.5 # warn when < 50% of fields accessed def check_soa_opportunity!(node, item_type) + T.bind(self, SemanticAnnotator) rescue nil return unless @pipeline_accessed_fields accessed = @pipeline_accessed_fields return if accessed.empty? - schema = lookup_type_schema(item_type) - return unless schema.is_a?(Hash) - return if schema[:kind] # skip enums, unions, resources + schema = Schemas.as_struct_schema(lookup_type_schema(item_type)) + return unless schema - total = schema.keys.reject { |k| k.is_a?(Symbol) }.size # only real field names (skip :type_params etc.) + total = schema.fields.keys.size return if total < SOA_MIN_FIELDS ratio = accessed.size.to_f / total @@ -1671,6 +1732,7 @@ def check_soa_opportunity!(node, item_type) # Wraps a pipeline body visit with SOA field tracking. def with_soa_tracking(node, item_type) + T.bind(self, SemanticAnnotator) rescue nil @pipeline_accessed_fields = Set.new yield check_soa_opportunity!(node, item_type) diff --git a/src/annotator-helpers/reentrance.rb b/src/annotator-helpers/reentrance.rb index 440f83e94..4e86cfa21 100644 --- a/src/annotator-helpers/reentrance.rb +++ b/src/annotator-helpers/reentrance.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" # Reentrance bridge — Thunk Phase 1.3 # # Unifies the two declaration sources for function reentrance into a single @@ -38,6 +40,7 @@ module ReentranceBridge # in @fn_nodes. Idempotent. Validates REQUIRES clauses against the # parameter list of each function. def bridge_reentrance!(program_node) + T.bind(self, SemanticAnnotator) rescue nil program_node.statements.each do |stmt| next unless stmt.is_a?(AST::FunctionDef) fn_node = stmt @@ -107,6 +110,7 @@ def bridge_reentrance!(program_node) # type and need user judgment about gas budgets, so we don't # force them via auto-fix). def offer_plain_reentrant_variant_fix!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil return unless fn_node.reentrance_kind == :reentrant return unless fn_node.effects_span # no span => can't auto-edit return unless fn_node.effects_decl == :reentrant # only act on the new clause @@ -147,6 +151,7 @@ def offer_plain_reentrant_variant_fix!(fn_node) # :NOT_LOGICAL -> System UnexpectedRecursion (StackGuard) # :MAX_DEPTH(N) -> System MaxDepthExceeded (depth counter) def validate_not_logical_return!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil return unless [:reentrant_not_logical, :reentrant_max_depth].include?(fn_node.reentrance_kind) rt = fn_node.return_type is_err_union = rt.is_a?(Type) && rt.error_union? @@ -174,6 +179,7 @@ def validate_not_logical_return!(fn_node) # :reentrant_thunk for downstream effect-propagation rules (Phase # 5 will use this to keep :THUNK out of @service). def route_thunk_to_tail_call_compat!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil return unless fn_node.reentrance_kind == :reentrant_thunk return unless thunk_all_self_calls_in_tail_position?(fn_node) fn_node.tail_call = true @@ -204,6 +210,7 @@ def route_thunk_to_tail_call_compat!(fn_node) # bounded depth). Runs after `check_indirect_reentrancy!` so the # call-graph is settled and transitive cycles are visible. def validate_not_logical_recursion! + T.bind(self, SemanticAnnotator) rescue nil @fn_nodes.each do |name, fn_node| next unless fn_node.reentrance_kind == :reentrant_not_logical @@ -234,6 +241,7 @@ def validate_not_logical_recursion! # Direct self-recursion is fine -- the depth counter handles it # exactly. Only the cross-fn cycle case demotes. def validate_max_depth_mutual_cycle! + T.bind(self, SemanticAnnotator) rescue nil @fn_nodes.each do |name, fn_node| next unless fn_node.reentrance_kind == :reentrant_max_depth # Direct-only is fine; counter handles it. @@ -271,7 +279,7 @@ def validate_max_depth_mutual_cycle! if fixes.empty? fixable!(fn_node, message: msg, category: :reentrance, level: :warning, - raise_in_collector: false) + raise_in_collector: false, fixes:) else fixable!(fn_node, message: msg, category: :reentrance, level: :warning, fixes: fixes, raise_in_collector: false) @@ -280,6 +288,7 @@ def validate_max_depth_mutual_cycle! end def validate_thunk_recursion! + T.bind(self, SemanticAnnotator) rescue nil @fn_nodes.each do |name, fn_node| next unless fn_node.reentrance_kind == :reentrant_thunk next if fn_node.tail_call # tail-recursive :THUNK already routed (Phase 4b) @@ -319,6 +328,7 @@ def validate_thunk_recursion! # return true. Otherwise return false (the caller emits the # precise error). def try_stamp_mutual_thunk_plan!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil cycle_names = thunk_cycle_members(fn_node.name) return false if cycle_names.size < 2 # Phase 4f.1 scope: every cycle member must be defined locally @@ -374,6 +384,7 @@ def try_stamp_mutual_thunk_plan!(fn_node) # runtime that the cycle is logically impossible; raises System # UnexpectedRecursion if violated. def emit_mutual_thunk_unsupported!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil name = fn_node.name cycle_names = thunk_cycle_members(name) cycle_thunk_fns = cycle_names.filter_map { |n| @fn_nodes[n] } @@ -482,6 +493,7 @@ def emit_mutual_thunk_unsupported!(fn_node) # lines (multi-line edits aren't expressible with the current Span # shape and the user's source layout would need manual editing anyway). def effects_clause_edits(fn_node, replacement) + T.bind(self, SemanticAnnotator) rescue nil span = fn_node.effects_span return [] unless span && span[:start_tok] && span[:end_tok] s = span[:start_tok] @@ -499,6 +511,7 @@ def effects_clause_edits(fn_node, replacement) # `start` itself when start is on a cycle). Used by Phase 4f.1 # to enumerate cycle members for tagged-union frame codegen. def thunk_cycle_members(start) + T.bind(self, SemanticAnnotator) rescue nil start_s = start.to_s forward = compute_reachable(@call_graph, start_s) reverse_graph = {} @@ -512,6 +525,7 @@ def thunk_cycle_members(start) end def compute_reachable(graph, start) + T.bind(self, SemanticAnnotator) rescue nil seen = Set.new queue = (graph[start] || Set.new).to_a until queue.empty? @@ -528,6 +542,7 @@ def compute_reachable(graph, start) # transitively calls back to itself but doesn't have a direct # self-call edge.) def reachable_from_self?(start) + T.bind(self, SemanticAnnotator) rescue nil visited = Set.new queue = (@call_graph[start] || Set.new).to_a until queue.empty? @@ -548,6 +563,7 @@ def reachable_from_self?(start) # Returns true iff the function is self-recursive AND every # recursive self-call is the direct value of a RETURN node. def thunk_all_self_calls_in_tail_position?(fn_node) + T.bind(self, SemanticAnnotator) rescue nil all = collect_self_calls(fn_node.body, fn_node.name) return false if all.empty? blessed = collect_returns(fn_node.body).filter_map { |r| @@ -563,6 +579,7 @@ def thunk_all_self_calls_in_tail_position?(fn_node) # was set. Public so specs and downstream passes can use the same # mapping if they ever need to recompute. def canonical_reentrance_kind(fn_node) + T.bind(self, SemanticAnnotator) rescue nil return fn_node.effects_decl if fn_node.effects_decl if fn_node.reentrant == :reentrant return fn_node.tail_call ? :reentrant_tail_call : :reentrant @@ -583,6 +600,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. def offer_legacy_reentrant_migration!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil return unless fn_node.reentrant_token tok = fn_node.reentrant_token if fn_node.tail_call @@ -639,6 +657,7 @@ def offer_legacy_reentrant_migration!(fn_node) # :THUNK, or :TAIL_CALL) -- the user has already taken a stance # on reentrance and constraining the param is not their choice 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) return unless fn_node.arrow_token @@ -649,7 +668,7 @@ def offer_unconstrained_fn_param_fix!(fn_node) type = p[:type] next nil unless type.respond_to?(:fn_type?) && type.fn_type? raw = type.respond_to?(:raw) ? type.raw : nil - next nil if raw.is_a?(Hash) && raw[:reentrant] == true + next nil if raw.is_a?(FunctionSignature) && raw.reentrant == true name end return if candidates.empty? @@ -684,6 +703,7 @@ def offer_unconstrained_fn_param_fix!(fn_node) end def validate_requires_clauses!(fn_node) + T.bind(self, SemanticAnnotator) rescue nil return if fn_node.requires_clauses.nil? || fn_node.requires_clauses.empty? # Params come from the parser as hashes ({ name:, type:, default:, ... }). # See pre_register_function in annotator.rb. diff --git a/src/annotator-helpers/test_annotation.rb b/src/annotator-helpers/test_annotation.rb index ab4e4b94e..785ff369b 100644 --- a/src/annotator-helpers/test_annotation.rb +++ b/src/annotator-helpers/test_annotation.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" require 'set' # TestAnnotation — annotator-side handling of CLEAR's test grammar: @@ -22,6 +24,7 @@ module TestAnnotation socketRead socketWrite socketClose].to_set.freeze def visit_TestBlock(node) + T.bind(self, SemanticAnnotator) rescue nil with_new_scope do node.setup.each { |s| visit(s) } visit_test_lets(node) @@ -32,6 +35,7 @@ def visit_TestBlock(node) end def visit_WhenBlock(node) + T.bind(self, SemanticAnnotator) rescue nil node.setup.each { |s| visit(s) } visit_test_lets(node) visit_test_hook_bodies(node) @@ -60,6 +64,7 @@ def visit_WhenBlock(node) # body inside the enclosing block. Lowering injects the actual # variable declarations at the top of each test body. def visit_test_lets(node) + T.bind(self, SemanticAnnotator) rescue nil return unless node.respond_to?(:lets) (node.lets || []).each do |let_node| visit(let_node.expr) @@ -79,6 +84,7 @@ def visit_test_lets(node) # annotated against the enclosing scope so type errors surface at # compile time. def visit_test_hook_bodies(node) + T.bind(self, SemanticAnnotator) rescue nil return unless node.respond_to?(:before_each) (node.before_each || []).each { |body| body.each { |s| visit(s) } } (node.after_each || []).each { |body| body.each { |s| visit(s) } } @@ -87,33 +93,41 @@ def visit_test_hook_bodies(node) end def visit_TestThat(node) + T.bind(self, SemanticAnnotator) rescue nil visit_stmts(node.body) node.full_type = :Void end def visit_AssertRaises(node) + T.bind(self, SemanticAnnotator) rescue nil visit(node.expression) node.full_type = :Void end def visit_BenchmarkStmt(node) + T.bind(self, SemanticAnnotator) rescue nil visit(node.expression) node.full_type = :Void end def visit_SmashStmt(node) + T.bind(self, SemanticAnnotator) rescue nil visit(node.expression) node.full_type = :Void end def visit_ProfileStmt(node) + T.bind(self, SemanticAnnotator) rescue nil visit(node.expression) node.full_type = :Void end def visit_StubDecl(node) + T.bind(self, SemanticAnnotator) rescue nil # Visit the value for type checking if it's an expression. - visit(node.value) if node.value.respond_to?(:full_type) + # node.value is either an AST expression or a Symbol like :CAPTURES. + # Only descend into AST expressions; Symbols are leaf metadata. + visit(node.value) if node.value.is_a?(AST::Locatable) # CAPTURES stubs declare a variable in the current scope. if node.kind == :captures cap_name = node.value # the variable name string @@ -130,6 +144,7 @@ def visit_StubDecl(node) # IO builtins (file/network) and user-defined functions whose # effect set includes :BLOCKING / :EXTERN qualify as IO. def validate_strict_io!(test_that, stubbed_fns) + T.bind(self, SemanticAnnotator) rescue nil calls = scan_for_calls(test_that.body).first visited = Set.new queue = calls.to_a.dup diff --git a/src/annotator-helpers/union.rb b/src/annotator-helpers/union.rb index 08c7b8d25..708bb2fb5 100644 --- a/src/annotator-helpers/union.rb +++ b/src/annotator-helpers/union.rb @@ -44,7 +44,7 @@ def validate_union_methods!(node) end sig = local.type - unless sig.is_a?(Hash) && sig.key?(:params) + unless sig.is_a?(FunctionSignature) error!(req_tok, :UNION_METHOD_MISSING, union: union_name, method: fn_name, fn: fn_name) end diff --git a/src/annotator-helpers/with_match_check.rb b/src/annotator-helpers/with_match_check.rb index 0ae15147e..3e75a038a 100644 --- a/src/annotator-helpers/with_match_check.rb +++ b/src/annotator-helpers/with_match_check.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" # P2.4 / P2.5 / P2.6: validation of REQUIRES + WITH MATCH at the function # level + call-site family check. # @@ -112,7 +114,7 @@ def self.check_function!(fn, error_handler, warn_handler: nil, policy_handlers: # migration is visible. The shim will be removed in a future release. if warn_handler warn_handler.call(node, - "WITH at line #{node.token&.line} uses parameter '#{pname}' " \ + "WITH at line #{node.token.line} uses parameter '#{pname}' " \ "without a REQUIRES clause. Auto-inferring " \ "'REQUIRES #{pname}: LOCKED' for this release. " \ "Add the clause explicitly to silence this warning; the shim " \ @@ -124,7 +126,7 @@ def self.check_function!(fn, error_handler, warn_handler: nil, policy_handlers: fn.requires[pname] = Set[:LOCKED] else error_handler.call(node, - "WITH at line #{node.token&.line} uses parameter '#{pname}', " \ + "WITH at line #{node.token.line} uses parameter '#{pname}', " \ "but '#{pname}' is not constrained by REQUIRES. " \ "Add a REQUIRES clause naming the families this function supports:\n" \ " REQUIRES #{pname}: LOCKED\n" \ @@ -146,7 +148,7 @@ def self.check_function!(fn, error_handler, warn_handler: nil, policy_handlers: missing.each do |fam| error_handler.call(node, - "WITH MATCH at line #{node.token&.line} is missing a WHEN arm for " \ + "WITH MATCH at line #{node.token.line} is missing a WHEN arm for " \ "#{fam} (declared in REQUIRES). Add an arm:\n" \ " WHEN #{fam}\n" \ " -> { }") @@ -154,7 +156,7 @@ def self.check_function!(fn, error_handler, warn_handler: nil, policy_handlers: extra.each do |fam| error_handler.call(node, - "WITH MATCH at line #{node.token&.line} has a WHEN arm for #{fam}, " \ + "WITH MATCH at line #{node.token.line} has a WHEN arm for #{fam}, " \ "but #{fam} is not in REQUIRES. Either remove the arm or add #{fam} " \ "to REQUIRES.") end @@ -251,10 +253,10 @@ def self.check_call_sites!(fn, sig_lookup, error_handler) sig.params.each_with_index do |param, idx| pname = (param[:name] || param["name"]).to_s fams = sig.requires[pname] - next unless fams && fams.respond_to?(:empty?) && fams.empty? + next unless fams && fams.empty? arg = (call_node.args || [])[idx] next unless arg.is_a?(AST::Identifier) - sym = arg.respond_to?(:symbol) ? arg.symbol : nil + sym = arg.symbol next unless sym next if sym.sync || sym.storage == :shared || sym.storage == :multiowned || sym.storage == :local || sym.storage == :heap @@ -450,7 +452,7 @@ def self.deep_funcalls(body) until stack.empty? node = stack.pop next unless node.is_a?(AST::Locatable) - out << node if node.is_a?(AST::FuncCall) && node.respond_to?(:name) + out << node if node.is_a?(AST::FuncCall) next if node.is_a?(AST::FunctionDef) || node.is_a?(AST::LambdaLit) node.class.members.each do |m| v = node[m] diff --git a/src/annotator.rb b/src/annotator.rb index e2b60cc55..9ce5b0fc4 100644 --- a/src/annotator.rb +++ b/src/annotator.rb @@ -562,7 +562,7 @@ def visit_RequireNode(node) # Import function signatures that are visible from this call site. mod.global_scope.locals.each do |name, entry| sig = entry.type - next unless sig.is_a?(FunctionSignature) || (sig.is_a?(Hash) && sig.key?(:params)) + next unless sig.is_a?(FunctionSignature) # For package imports: skip functions that were themselves imported from # another module (they have a pre-existing module_alias). Those functions @@ -579,7 +579,8 @@ def visit_RequireNode(node) next unless importable # Tag the signature with the namespace so the transpiler can qualify calls. - imported_sig = sig.merge(module_alias: node.namespace) + imported_sig = sig.dup + imported_sig.module_alias = node.namespace current_scope.declare(name, nil, imported_sig, false, false, nil, :static) end @@ -835,7 +836,7 @@ def visit_FunctionDef(node) node.uses_rt = ctx.needs_rt node.stack_vars_bytes = ctx.stack_vars_bytes # Seed for compute_can_fail! post-pass: direct failure sources. - ret_type_obj = signature.respond_to?(:return_type) ? signature.return_type : (signature.is_a?(Hash) ? signature[:return]&.dig(:type) : nil) + ret_type_obj = signature.return_type heap_ret = ret_type_obj.is_a?(Type) && (ret_type_obj.heap? || ret_type_obj.dynamic?) @fn_raises_directly[node.name] = node.uses_frame || node.uses_heap || node.uses_alloc || heap_ret || (@fn_has_fnptr[node.name] == true) || @@ -2402,12 +2403,12 @@ def visit_MethodCall(node) if type_schema.is_a?(Hash) && type_schema[:methods]&.key?(node.name) method_sig = type_schema[:methods][node.name] node.extern_call = true - node.extern_effects = method_sig[:extern_effects] if method_sig[:extern_effects] + node.extern_effects = method_sig.extern_effects if method_sig.extern_effects node.instance_variable_set(:@extern_method, true) - node.full_type = method_sig.respond_to?(:return_type) ? (method_sig.return_type || :Void) : (method_sig[:return]&.dig(:type) || :Void) + node.full_type = method_sig.return_type || :Void record_effect(EffectTracker::EXTERN) # Track allocator usage for EFFECTS :alloc methods. - alloc_kind = method_sig[:extern_effects]&.dig(:alloc) + alloc_kind = method_sig.extern_effects&.dig(:alloc) if alloc_kind && current_fn_ctx if alloc_kind == :heap current_fn_ctx.heap_count += 1 @@ -2909,11 +2910,10 @@ def visit_Identifier(node) # 2. Resolve Type raw_type = scope.resolve_full_type(node.name) - if raw_type.raw.is_a?(Hash) && raw_type.raw[:params] && !raw_type.fn_type? - # Named function used as a value — build a proper fn_type Type. - # Propagate :reentrant so the type-checker can enforce param constraints. - sig = raw_type.raw - node.full_type = Type.new({ params: sig[:params], return: sig[:return], fn_type: true, reentrant: sig[:reentrant] == true }) + if raw_type.raw.is_a?(FunctionSignature) + # Named function used as a value — re-wrap the signature in a Type + # tagged as a fn_ref so the transpiler emits `&fn_name`. + node.full_type = Type.new(raw_type.raw) node.fn_ref = true elsif raw_type.is_a?(Type) && raw_type.atomic? && raw_type.layout != :indirect # Atomics M1.5: a read of an `@shared:atomic` binding produces @@ -2954,7 +2954,7 @@ def visit_Identifier(node) def classify_ownership!(entry) return unless entry t = entry.type - return if t.is_a?(Hash) # function signature, not a variable + return if t.is_a?(FunctionSignature) # function signature, not a variable type_obj = t.is_a?(Type) ? t : Type.new(t || :Any) entry.ownership_kind = if entry.resource :resource @@ -3306,13 +3306,14 @@ def visit_GetField(node) return end - schema = lookup_type_schema(type) - if schema.is_a?(Hash) && schema[:kind] == :enum + raw_schema = lookup_type_schema(type) + struct_schema = Schemas.as_struct_schema(raw_schema) + if raw_schema.is_a?(Hash) && raw_schema[:kind] == :enum error!(node, :ENUM_FIELD_ACCESS, enum: type) - elsif schema.is_a?(Hash) && schema[:kind] == :union + elsif raw_schema.is_a?(Schemas::UnionSchema) || (raw_schema.is_a?(Hash) && raw_schema[:kind] == :union) error!(node, :UNION_FIELD_ACCESS, union: type) - elsif schema && schema[node.field] - field_type = schema[node.field] + elsif struct_schema && struct_schema.fields[node.field] + field_type = struct_schema.fields[node.field] # SOA tracking: record field access on pipeline variable `_` if @pipeline_accessed_fields && node.target.is_a?(AST::Identifier) && node.target.name == "_" @pipeline_accessed_fields << node.field @@ -3321,9 +3322,9 @@ def visit_GetField(node) # Handles compound types like T[], ?T, !T via apply_type_subst. # BORROWED fields are stored as plain types in the schema (borrowed_fields tracks which). type_obj = Type.new(type) - if type_obj.generic_instance? && schema[:type_params] + if type_obj.generic_instance? && struct_schema.type_params subst = {} - schema[:type_params].zip(type_obj.generic_args).each do |param, arg| + struct_schema.type_params.zip(type_obj.generic_args).each do |param, arg| subst[param] = arg.resolved end field_type = apply_type_subst(field_type, subst) if subst.any? @@ -5502,13 +5503,13 @@ def resolve_borrow_source(call_node) return nil end - # Path 2: user-defined functions with return: { lifetime: [...] } + # Path 2: user-defined functions with return_lifetime: [...] func_name = call_node.name scope = lookup_scope_for(func_name) return nil unless scope func_type = scope.resolve_type(func_name) - return nil unless func_type.is_a?(Hash) + return nil unless func_type.is_a?(FunctionSignature) # Atomics M2.4 / M2.5: multi-binding lifetime returns the FIRST # source. The borrow tracking still records under one root; if a @@ -5519,7 +5520,7 @@ def resolve_borrow_source(call_node) # conservative side here (track only the first source) to avoid # spurious errors before the audit lands. Wildcard returns nil # (no specific source to track). - lifetime = func_type.dig(:return, :lifetime) + lifetime = func_type.return_lifetime return nil if lifetime.nil? lifetime = [lifetime] unless lifetime.is_a?(Array) return nil if lifetime.empty? || lifetime == [:wildcard] @@ -5527,7 +5528,7 @@ def resolve_borrow_source(call_node) return nil if primary == :wildcard primary_root = primary.to_s.split(".").first - param_index = func_type[:params]&.find_index { |p| p[:name] == primary_root } + param_index = func_type.params&.find_index { |p| p[:name] == primary_root } return nil unless param_index args = call_node.is_a?(AST::MethodCall) ? [call_node.object] + call_node.args : call_node.args diff --git a/src/ast/ast.rb b/src/ast/ast.rb index a92459d1f..2af433510 100644 --- a/src/ast/ast.rb +++ b/src/ast/ast.rb @@ -1,4 +1,5 @@ require_relative "type" +require_relative "schemas" # ========================================== # AST @@ -10,35 +11,10 @@ module AST # Adding a new control flow node type requires updating only this method. def self.walk_body(body, &visitor) return unless body - nodes = body.is_a?(Array) ? body : [body] - nodes.each do |node| + Array(body).each do |node| yield node - case node - when IfStatement - walk_body(node.then_branch, &visitor) - walk_body(node.else_branch, &visitor) - when MatchStatement - (node.cases || []).each { |c| walk_body(c[:body], &visitor) } - walk_body(node.default_case, &visitor) if node.default_case - when WhileLoop, WhileBindLoop - walk_body(node.do_branch, &visitor) - when ForRange, ForEach - walk_body(node.body, &visitor) - when BgBlock, BgStreamBlock - walk_body(node.body, &visitor) - when WithBlock - walk_body(node.body, &visitor) - when DoBlock - (node.branches || []).each { |b| walk_body(b[:body], &visitor) } - when FunctionDef - walk_body(node.body, &visitor) - when TestBlock - walk_body(node.setup, &visitor) - node.whens&.each do |w| - walk_body(w.setup, &visitor) - w.tests&.each { |t| walk_body(t.body, &visitor) } - end - end + next unless node.is_a?(HasBodies) + node.child_bodies.each { |b| walk_body(b, &visitor) } end end @@ -55,35 +31,19 @@ def self.each_bg_block(body, &block) end def self._bg_visit_recursive(node, &block) - case node - when BgBlock, BgStreamBlock + if node.is_a?(BgBlock) || node.is_a?(BgStreamBlock) yield node - each_bg_block(node.body, &block) - when IfStatement - each_bg_block(node.then_branch, &block) - each_bg_block(node.else_branch, &block) - when MatchStatement - (node.cases || []).each { |c| each_bg_block(c[:body], &block) } - each_bg_block(node.default_case, &block) if node.default_case - when WhileLoop, WhileBindLoop - each_bg_block(node.do_branch, &block) - when ForRange, ForEach - each_bg_block(node.body, &block) - when WithBlock - each_bg_block(node.body, &block) - when DoBlock - (node.branches || []).each { |b| each_bg_block(b[:body], &block) } - when FunctionDef - each_bg_block(node.body, &block) - when VarDecl, BindExpr, Assignment - _expr_each_bg_block_recursive(node.value, &block) if node.respond_to?(:value) + end + case node + when HasBodies + node.child_bodies.each { |b| each_bg_block(b, &block) } + when VarDecl, BindExpr, Assignment, ReturnNode + _expr_each_bg_block_recursive(node.value, &block) when FuncCall node.args&.each { |a| _expr_each_bg_block_recursive(a, &block) } when MethodCall _expr_each_bg_block_recursive(node.object, &block) node.args&.each { |a| _expr_each_bg_block_recursive(a, &block) } - when ReturnNode - _expr_each_bg_block_recursive(node.value, &block) if node.respond_to?(:value) end end @@ -182,6 +142,20 @@ def self._expr_each_concurrent_capture(node, &block) end end + # Declarative metadata for AST nodes that own one or more statement + # lists (control flow, blocks, function bodies). Generic walkers like + # `walk_body` iterate via `child_bodies` instead of hand-coded + # case chains. Adding a new such node type means including this and + # defining child_bodies — no walker edit required. + module HasBodies + # Override in including classes. Returns Array of stmt lists (each + # itself an Array of statements). Nil/empty entries are filtered by + # the walker via `Array(...)`/`.compact`. + def child_bodies + [] + end + end + module Locatable def line; token.line; end def column; token.column; end @@ -293,7 +267,7 @@ def finalize_storage!(final_type, &schema_lookup) value_sync = nil if respond_to?(:value) && value.respond_to?(:type_object) && value.type_object vt = value.type_object - value_sync = vt.sync if vt.respond_to?(:sync) + value_sync = vt.sync end # Build a Type that carries the resolved base type plus storage-derived capabilities. @@ -371,13 +345,13 @@ def finalize_storage!(final_type, &schema_lookup) # Propagate additional capability fields from the value's type_object if respond_to?(:value) && value.respond_to?(:type_object) && value.type_object vt = value.type_object - t.ownership = vt.ownership if vt.respond_to?(:ownership) && vt.ownership && vt.ownership != :affine - t.sync = vt.sync if vt.respond_to?(:sync) && vt.sync + t.ownership = vt.ownership if vt.ownership && vt.ownership != :affine + t.sync = vt.sync if vt.sync # AtomicPtr M3.5: carry the :layout axis (e.g. :indirect from # @indirect:atomic on the VALUE) into the binding's full_type # so visit_VarDecl's `node.type_info&.layout` -> SymbolEntry # propagation lands. Mirrors the sync line above. - t.layout = vt.layout if vt.respond_to?(:layout) && vt.layout + t.layout = vt.layout if vt.layout end self.full_type = t @@ -448,6 +422,8 @@ def metatype RequireNode = Struct.new(:token, :path, :namespace, :kind) { include Locatable } FunctionDef = Struct.new(:token, :name, :params, :captures, :return_type, :return_lifetime, :body, :catch_clauses, :default_catch, :visibility, :deferred_drops, :uses_frame) do include Locatable + include HasBodies + def child_bodies = [body].compact attr_accessor :type_params # Array of type param name strings, e.g. ["T", "K"], or nil # Post-#335: the user EXPLICITLY wrote a `RETURNS T` clause (vs # implicit-Void / annotator-inferred return). Stamped at parse @@ -652,6 +628,8 @@ def coerce!(declared_type) LambdaLit = Struct.new(:token, :params, :captures, :body, :storage, :deferred_drops) { include Locatable } IfStatement = Struct.new(:token, :condition, :then_branch, :else_branch, :then_drops, :else_drops) do include Locatable + include HasBodies + def child_bodies = [then_branch, else_branch].compact attr_accessor :expr_mode # true when used as an expression (x = IF ...) attr_accessor :then_result_type # Type of last value expression in then_branch attr_accessor :else_result_type # Type of last value expression in else_branch @@ -665,11 +643,15 @@ def coerce!(declared_type) end WhileLoop = Struct.new(:token, :condition, :do_branch, :deferred_drops) do include Locatable + include HasBodies + def child_bodies = [do_branch].compact attr_accessor :mark_per_iter attr_accessor :tight # true when declared with TIGHT WHILE — no yield injection, no loop marks end WhileBindLoop = Struct.new(:token, :condition, :binding_name, :binding_token, :do_branch, :deferred_drops) do include Locatable + include HasBodies + def child_bodies = [do_branch].compact attr_accessor :mark_per_iter attr_accessor :tight end @@ -746,6 +728,8 @@ def name; target.name end # retries > 0 means RETRY(N) THEN ; retries nil/0 means plain ON TIMEOUT . WithBlock = Struct.new(:token, :capabilities, :body, :deferred_drops) do include Locatable + include HasBodies + def child_bodies = [body].compact attr_accessor :lock_error_clause # Per-WITH opt-out from a static nested-lock check. Hash shape: # { kind: :deadlock | :lock_cycle, token: Token } @@ -968,7 +952,11 @@ def name; target.respond_to?(:name) ? target.name : nil end # branches: Array of { body: Array, pinned: Boolean, stack_size: :standard | :micro | :large | :xl | nil } # pinned=true → dispatch to least-loaded scheduler (spawnBest) instead of current (submitSpawn) # stack_size nil → defaults to :standard (16 KB total: 12 KB stack + 4 KB arena) - DoBlock = Struct.new(:token, :branches) { include Locatable } + DoBlock = Struct.new(:token, :branches) do + include Locatable + include HasBodies + def child_bodies = (branches || []).filter_map { |b| b[:body] } + end # BgBlock: background execution — spawns a fiber and returns a linear Promise (~T). # body: Array of expression nodes. The last expression's type determines T. @@ -976,6 +964,8 @@ def name; target.respond_to?(:name) ? target.name : nil end # stack_size: :standard (default, 16 KB) | :micro (4 KB) | :large (64 KB) | :xl (256 KB) BgBlock = Struct.new(:token, :body, :deferred_drops, :stack_size, :pinned, :parallel, :arena_mode, :can_smash) do include Locatable + include HasBodies + def child_bodies = [body].compact attr_accessor :return_provenance # :heap when BG body calls a returns_promoted function attr_accessor :computed_stack_tier # auto-computed tier from call-graph analysis (:micro, :standard, :large, :xl) attr_accessor :captures_resource # true when BG captures a TCP/resource fd — spawn on accepting scheduler @@ -1005,6 +995,8 @@ def name; target.respond_to?(:name) ? target.name : nil end # stack_size: :standard (default, 16 KB) | :micro (4 KB) | :large (64 KB) | :xl (256 KB) BgStreamBlock = Struct.new(:token, :body, :deferred_drops, :stack_size) do include Locatable + include HasBodies + def child_bodies = [body].compact attr_accessor :computed_stack_tier attr_accessor :capture_analysis # CaptureAnalysis with captures hash attr_accessor :capture_string_dupes # Set of capture names that need heap-dupe inside the stream run fn @@ -1035,6 +1027,12 @@ def name; target.respond_to?(:name) ? target.name : nil end # default_drops: drop-array for default branch (or nil), filled by annotator MatchStatement = Struct.new(:token, :expr, :cases, :default_case, :case_drops, :default_drops, :exhaustive, :takes) do include Locatable + include HasBodies + def child_bodies + bodies = (cases || []).filter_map { |c| c[:body] } + bodies << default_case if default_case + bodies + end attr_accessor :string_match # set by annotator: true when expr is string type (use strEql) attr_accessor :expr_mode # true when used as an expression (x = MATCH ...) attr_accessor :case_result_types # Array of Types (parallel to cases), for expression-MATCH @@ -1045,12 +1043,16 @@ def name; target.respond_to?(:name) ? target.name : nil end # inclusive: true = ..= (start to end), false = ..< (start to end-1) ForRange = Struct.new(:token, :var_name, :start_expr, :end_expr, :inclusive, :body, :deferred_drops, :mark_per_iter) do include Locatable + include HasBodies + def child_bodies = [body].compact attr_accessor :tight end # ForEach: FOR var IN collection DO body END ForEach = Struct.new(:token, :var_name, :collection, :body, :deferred_drops, :is_mutable) do include Locatable + include HasBodies + def child_bodies = [body].compact attr_accessor :mark_per_iter attr_accessor :tight end @@ -1060,6 +1062,20 @@ def name; target.respond_to?(:name) ? target.name : nil end # TEST name DO setup... WHEN... END TestBlock = Struct.new(:token, :name, :setup, :whens) do include Locatable + include HasBodies + def child_bodies + bodies = [] + bodies << setup if setup + (before_each || []).each { |b| bodies << b } + (after_each || []).each { |b| bodies << b } + (before_all || []).each { |b| bodies << b } + (after_all || []).each { |b| bodies << b } + (whens || []).each do |w| + bodies << w.setup if w.setup + (w.tests || []).each { |t| bodies << t.body if t.body } + end + bodies + end # Hook bodies. Each is an Array of statement Arrays (multiple # `BEFORE EACH DO ... END` blocks at the same level run in # declaration order). TestLowering composes outer (TEST-level) diff --git a/src/ast/parser.rb b/src/ast/parser.rb index 6596acdd9..3e1ee684b 100644 --- a/src/ast/parser.rb +++ b/src/ast/parser.rb @@ -2569,14 +2569,13 @@ def parse_fn_type_annotation consume(:VAR_ID) allows_reentrant = true end - Type.new({ + Type.new(FunctionSignature.new( params: param_types.each_with_index.map { |t, i| { name: "arg#{i}", type: t, required: true, mutable: false, takes: false } }, - return: { type: return_type }, - fn_type: true, + return_type: return_type, reentrant: allows_reentrant - }) + )) end def parse_type_annotation diff --git a/src/ast/schemas.rb b/src/ast/schemas.rb new file mode 100644 index 000000000..6f998ed49 --- /dev/null +++ b/src/ast/schemas.rb @@ -0,0 +1,101 @@ +# Typed schemas for declared types stored in Scope. +# +# Replaces the hash-as-struct pattern where every type's schema was a +# Hash and dispatch was done by inspecting `schema[:kind]`. The kinds +# are heterogeneous enough that one Hash made the call sites repeat +# `schema.is_a?(Hash) && schema[:kind] == :X` ~60 times across the +# annotator and MIR pipeline. +# +# Migration is incremental — see TODO.md "Self-host preparation" task #2. +# Until all kinds (struct, union, resource) move out of Hash, callers +# may see EITHER a typed schema or a Hash, and must handle both. +module Schemas + EnumSchema = Data.define(:variants, :visibility) do + def initialize(variants:, visibility: :package) + super + end + end + + # Resource type schema — types with RAII cleanup (CLOSE method). + # + # Used for the 3 hand-written runtime types (File, TCPServer, TCPClient) + # and EXTERN STRUCT ... CLOSE forms, which can carry generic type params, + # an extern module name, and an AS alias. + ResourceSchema = Data.define(:close_zig, :static_methods, :fields, :type_params, :extern_module, :as_type, :visibility) do + def initialize(close_zig:, static_methods: {}, fields: {}, type_params: nil, extern_module: nil, as_type: nil, visibility: :package) + super + end + end + + # Union (sum-type) schema. `variants` is a Hash[Symbol => Type|Hash] + # where the value is `:nil` for payload-less variants, a Type for + # typed variants, or a Hash with `:kind => :inline_struct` for inline + # struct variants. The variant-level Hash (inline_struct shape) is + # itself a hash-as-struct that hasn't been extracted yet — that's a + # smaller, per-variant scope and can wait. + UnionSchema = Data.define(:variants, :type_params, :visibility) do + def initialize(variants:, type_params: nil, visibility: :package) + super + end + end + + # Struct/record schema. `fields` maps String field names to Type/Symbol/Hash + # representations of field types — Hash form is `{type:, default:, borrowed:}` + # produced by the parser, kept here unflattened for now. Metadata (defaults, + # borrowed-set, generic type params, methods, EXTERN module, AS alias type, + # visibility) live as named attrs rather than mixed Symbol keys in fields. + StructSchema = Data.define(:fields, :field_defaults, :borrowed_fields, :type_params, :methods, :visibility, :extern_module, :as_type) do + def initialize(fields: {}, field_defaults: nil, borrowed_fields: nil, type_params: nil, methods: {}, visibility: :package, extern_module: nil, as_type: nil) + super + end + end + + # ── Coercion helpers ───────────────────────────────────────────── + # The annotator stores schemas as raw Hashes in scope.declare_type; + # the MIR side wraps in Schemas::* via lower_struct_def / lower_union_def. + # Consumers (PromotionClassifier, CleanupClassifier, Type) see EITHER + # form depending on which pipeline phase they're invoked from. + # + # These coercion helpers normalize to the typed form (returning nil + # when the input isn't a struct / union schema), so a single call + # site can use `.fields` / `.variants` regardless of source. + + def self.as_struct_schema(schema) + return schema if schema.is_a?(StructSchema) + return nil unless schema.is_a?(Hash) && !schema[:kind] + StructSchema.new( + fields: schema.reject { |k, _| k.is_a?(Symbol) }, + field_defaults: schema[:field_defaults], + borrowed_fields: schema[:borrowed_fields], + type_params: schema[:type_params], + methods: schema[:methods] || {}, + visibility: schema[:visibility] || :package, + extern_module: schema[:extern_module], + as_type: schema[:as_type], + ) + end + + def self.as_union_schema(schema) + return schema if schema.is_a?(UnionSchema) + return nil unless schema.is_a?(Hash) && schema[:kind] == :union + UnionSchema.new( + variants: schema[:variants] || {}, + type_params: schema[:type_params], + visibility: schema[:visibility] || :package, + ) + end + + def self.as_resource_schema(schema) + return schema if schema.is_a?(ResourceSchema) + return nil unless schema.is_a?(Hash) && schema[:kind] == :resource + ResourceSchema.new( + close_zig: schema[:close_zig], + static_methods: schema[:static_methods] || {}, + fields: schema[:fields] || {}, + type_params: schema[:type_params], + extern_module: schema[:extern_module], + as_type: schema[:as_type], + visibility: schema[:visibility] || :package, + ) + end +end diff --git a/src/ast/scope.rb b/src/ast/scope.rb index 141524e69..75c7b8a0f 100644 --- a/src/ast/scope.rb +++ b/src/ast/scope.rb @@ -257,7 +257,7 @@ def resolve_variable_scope(name) scope else fn_scope = lookup_scope_for(name) - fn_scope && fn_scope.resolve_type(name).is_a?(Hash) ? fn_scope : nil + fn_scope && fn_scope.resolve_type(name).is_a?(FunctionSignature) ? fn_scope : nil end end diff --git a/src/ast/source_error.rb b/src/ast/source_error.rb index 4d92d233b..f3f5d8d75 100644 --- a/src/ast/source_error.rb +++ b/src/ast/source_error.rb @@ -24,6 +24,9 @@ module ErrorHelper # that haven't been migrated to named form yet. def error!(node_or_token, code_or_message, *args, **kwargs) # 1. Extract the Token (works for AST Node or raw Token) + # node_or_token is either an AST::Locatable node (has .token method) + # or a token-shape value (Lexer::Token or FixableHelper::AnchorToken). + # respond_to?(:token) distinguishes them — both token shapes lack it. token = node_or_token.respond_to?(:token) ? node_or_token.token : node_or_token # 2. Determine Message @@ -66,12 +69,18 @@ def format_diagnostic_template(template, args, kwargs) # Non-fatal compiler note (printed to stderr, does not halt compilation). def note!(node_or_token, message) + # node_or_token is either an AST::Locatable node (has .token method) + # or a token-shape value (Lexer::Token or FixableHelper::AnchorToken). + # respond_to?(:token) distinguishes them — both token shapes lack it. token = node_or_token.respond_to?(:token) ? node_or_token.token : node_or_token loc = token ? " (line #{token.line})" : "" $stderr.puts "\e[36m[Note]\e[0m #{message}#{loc}" end def warning!(node_or_token, message) + # node_or_token is either an AST::Locatable node (has .token method) + # or a token-shape value (Lexer::Token or FixableHelper::AnchorToken). + # respond_to?(:token) distinguishes them — both token shapes lack it. token = node_or_token.respond_to?(:token) ? node_or_token.token : node_or_token loc = token ? " (line #{token.line})" : "" $stderr.puts "\e[33m[Warning]\e[0m #{message}#{loc}" @@ -96,6 +105,9 @@ def warning!(node_or_token, message) # captured; the annotator then raises so the collector gets a clean # snapshot of what was diagnosed before the cascade would start. def fixable!(node_or_token, message:, category:, level: :warning, fixes:, raise_in_collector: false) + # node_or_token is either an AST::Locatable node (has .token method) + # or a token-shape value (Lexer::Token or FixableHelper::AnchorToken). + # respond_to?(:token) distinguishes them — both token shapes lack it. token = node_or_token.respond_to?(:token) ? node_or_token.token : node_or_token finding = FixableFinding.new( level: level, message: message, token: token, diff --git a/src/ast/type.rb b/src/ast/type.rb index 66ac92bad..7eb4b6415 100644 --- a/src/ast/type.rb +++ b/src/ast/type.rb @@ -1,3 +1,5 @@ +require_relative "../annotator-helpers/function_signature" + # Result struct for binary operation type resolution BinaryOpResult = Struct.new(:type, :left_coercion, :right_coercion, :storage, :error, keyword_init: true) @@ -257,11 +259,6 @@ def initialize(raw_input, ownership: nil, sync: nil, layout: nil, location: nil, @is_auto = true if auto end - # Delegate [] to the raw value for Hash-typed raws (function signatures). - def [](key) - @raw[key] if @raw.is_a?(Hash) - end - # ----------------------------------------------- # COMPATIBILITY LAYER (The "Don't Break Tests" part) # ----------------------------------------------- @@ -287,7 +284,7 @@ def resolved # Logic moved from Locatable#resolved_type return @resolved_cache if @resolved_cache - ft = if @raw.is_a?(Hash); @raw[:return][:type] + ft = if @raw.is_a?(FunctionSignature); @raw.return_type elsif @raw.is_a?(Array); @raw[2] else; @raw; end @@ -414,7 +411,7 @@ def auto? attr_accessor :auto_token def fn_type? - @raw.is_a?(Hash) && @raw[:fn_type] == true + @raw.is_a?(FunctionSignature) end def array? @@ -1256,12 +1253,10 @@ def needs_promotion?(schema_lookup = nil) return true if string? || list_collection? || (map? && !numeric_map?) if schema_lookup schema = schema_lookup.call(resolved) rescue nil - if schema.is_a?(Hash) - if schema[:kind] == :union - return schema_union_any?(schema) { |t| t.needs_promotion?(schema_lookup) } - elsif !schema[:kind] - return schema_struct_any?(schema) { |t| t.needs_promotion?(schema_lookup) } - end + if schema.is_a?(Schemas::UnionSchema) || (schema.is_a?(Hash) && schema[:kind] == :union) + return schema_union_any?(schema) { |t| t.needs_promotion?(schema_lookup) } + elsif schema.is_a?(Schemas::StructSchema) || (schema.is_a?(Hash) && !schema[:kind]) + return schema_struct_any?(schema) { |t| t.needs_promotion?(schema_lookup) } end end false @@ -1278,12 +1273,10 @@ def needs_cleanup?(schema_lookup = nil) (array? && !string?) || any_sync? if schema_lookup schema = schema_lookup.call(resolved) rescue nil - if schema.is_a?(Hash) - if schema[:kind] == :union - return schema_union_any?(schema) { |t| t.needs_cleanup?(schema_lookup) } - elsif !schema[:kind] - return schema_struct_any?(schema) { |t| t.needs_cleanup?(schema_lookup) } - end + if schema.is_a?(Schemas::UnionSchema) || (schema.is_a?(Hash) && schema[:kind] == :union) + return schema_union_any?(schema) { |t| t.needs_cleanup?(schema_lookup) } + elsif schema.is_a?(Schemas::StructSchema) || (schema.is_a?(Hash) && !schema[:kind]) + return schema_struct_any?(schema) { |t| t.needs_cleanup?(schema_lookup) } end end false @@ -1479,7 +1472,8 @@ def finalize_storage(size, current_storage = nil) # True if any struct field in schema satisfies the block (block receives Type). # Skips metadata (Symbol) keys; unwraps {:type => T} field hashes. def schema_struct_any?(schema) - schema.any? { |k, v| + fields = schema.is_a?(Schemas::StructSchema) ? schema.fields : schema + fields.any? { |k, v| next false if k.is_a?(Symbol) ft = v.is_a?(Hash) ? v[:type] : v t = ft.is_a?(Type) ? ft : (Type.new(ft || :Any) rescue nil) @@ -1492,7 +1486,8 @@ def schema_struct_any?(schema) # Skips nil and Hash variants (inline_struct/indirect); caller handles those via # Type.variant_has_heap? when needed. def schema_union_any?(schema) - (schema[:variants] || {}).any? { |_, vt| + variants = schema.is_a?(Schemas::UnionSchema) ? schema.variants : (schema[:variants] || {}) + variants.any? { |_, vt| next false unless vt next false if vt.is_a?(Hash) t = vt.is_a?(Type) ? vt : (Type.new(vt) rescue nil) @@ -1504,17 +1499,15 @@ def schema_union_any?(schema) # Structural match for function/lambda types. Called by accepts? when self.fn_type?. def accepts_fn_type?(other_type) return true if other_type.is_a?(Type) && other_type.any? - other_raw = other_type.is_a?(Type) ? other_type.raw : nil - is_fn_or_lambda = other_type.is_a?(Type) && - (other_type.fn_type? || (other_raw.is_a?(Hash) && other_raw[:lambda])) - return false unless is_fn_or_lambda + return false unless other_type.is_a?(Type) && other_type.fn_type? + other_raw = other_type.raw - self_params = @raw[:params] || [] - other_params = (other_raw || {})[:params] || [] + self_params = @raw.params || [] + other_params = other_raw.params || [] return false unless self_params.length == other_params.length - self_ret = @raw.dig(:return, :type) - other_ret = (other_raw || {}).dig(:return, :type) + self_ret = @raw.return_type + other_ret = other_raw.return_type self_ret_t = self_ret.is_a?(Type) ? self_ret : Type.new(self_ret || :Any) other_ret_t = other_ret.is_a?(Type) ? other_ret : Type.new(other_ret || :Any) return false unless self_ret_t.accepts?(other_ret_t) @@ -1527,9 +1520,7 @@ def accepts_fn_type?(other_type) # Reentrant constraint: a @reentrant function cannot be passed to a parameter # that doesn't explicitly allow it (i.e., the param type lacks @reentrant). - self_allows_reentrant = @raw[:reentrant] == true - other_is_reentrant = other_raw.is_a?(Hash) && other_raw[:reentrant] == true - return false if other_is_reentrant && !self_allows_reentrant + return false if other_raw.reentrant && !@raw.reentrant true end @@ -1576,9 +1567,9 @@ def accepts_array?(other_type) end def parse_raw_input - # Hash and Array raws are function signatures — no string parsing applies. + # FunctionSignature and Array raws are function signatures — no string parsing applies. # @resolved_cache is left nil and computed on-demand by the resolved() fallback. - if @raw.is_a?(Hash) || @raw.is_a?(Array) + if @raw.is_a?(FunctionSignature) || @raw.is_a?(Array) @ownership = :affine @sync = nil @collection = nil @@ -1857,11 +1848,11 @@ def compute_zig_type(is_param: false, is_field: false) # 2c. Function type: FN(T, ...) -> R => *const fn(*Runtime, T, ...) anyerror!R if fn_type? - param_types_zig = @raw[:params].map do |p| + param_types_zig = @raw.params.map do |p| t = p[:type] t.is_a?(Type) ? t.zig_type(is_param: true) : Type.new(t).zig_type(is_param: true) end - ret = @raw[:return][:type] + ret = @raw.return_type ret_zig = ret.is_a?(Type) ? ret.zig_type : Type.new(ret).zig_type all_params = ["*Runtime"] + param_types_zig ret_str = ret_zig.start_with?("!") ? ret_zig : "anyerror!#{ret_zig}" diff --git a/src/backends/compiler_frontend.rb b/src/backends/compiler_frontend.rb index bcee80895..a0185ed9d 100644 --- a/src/backends/compiler_frontend.rb +++ b/src/backends/compiler_frontend.rb @@ -82,13 +82,13 @@ def self.compile(cheat_code, importer:, source_dir:, strict_test: false) return_type: stmt.return_type || :Any, return_lifetime: stmt.return_lifetime, visibility: stmt.visibility, - type_params: stmt.respond_to?(:type_params) ? stmt.type_params : nil, - reentrant: stmt.respond_to?(:reentrant) && stmt.reentrant == :reentrant + type_params: stmt.type_params, + reentrant: stmt.reentrant == :reentrant ) fs.needs_rt = stmt.needs_rt fs.can_fail = stmt.can_fail fs.effects = stmt.effects - fs.requires = stmt.requires if stmt.respond_to?(:requires) + fs.requires = stmt.requires fn_sigs[stmt.name] = fs end end diff --git a/src/backends/importer.rb b/src/backends/importer.rb index 61512c1bf..bff3c8ae4 100644 --- a/src/backends/importer.rb +++ b/src/backends/importer.rb @@ -169,9 +169,9 @@ def compile_module_mir(ast, annotator, source_dir) union_schemas = {} ast.statements.each do |stmt| case stmt - when AST::StructDef then struct_schemas[stmt.name.to_sym] = stmt.fields + when AST::StructDef then struct_schemas[stmt.name.to_sym] = Schemas::StructSchema.new(fields: stmt.fields) when AST::EnumDef then enum_schemas[stmt.name.to_sym] = stmt.variants - when AST::UnionDef then union_schemas[stmt.name.to_sym] = stmt.variants + when AST::UnionDef then union_schemas[stmt.name.to_sym] = Schemas::UnionSchema.new(variants: stmt.variants) end end diff --git a/src/backends/pipeline_host.rb b/src/backends/pipeline_host.rb index cea96a6a2..c0afb35fd 100644 --- a/src/backends/pipeline_host.rb +++ b/src/backends/pipeline_host.rb @@ -17,6 +17,12 @@ class PipelineHost include PipelineGenerator include ZigTypeMapper + # Per-stage state for sequential pipeline lowering. `list` is the source + # list AST, `options` is the smooth-node carrying alloc/error policy/etc. + # Reek flagged the (list_node, smooth_node) clump across 13+ lower_* + # methods. Bundled so the dispatcher builds once and threads through. + PipelineSite = Data.define(:list, :options) + attr_accessor :fn_sigs def initialize(lowering:, emitter:) @@ -212,8 +218,8 @@ def substitute_placeholders(node) if new_args != node.args new_call = AST::FuncCall.new(node.token, node.name, new_args) copy_type_info(node, new_call) - new_call.zig_pattern = node.zig_pattern if node.respond_to?(:zig_pattern) - new_call.matched_stdlib_def = node.matched_stdlib_def if node.respond_to?(:matched_stdlib_def) && node.matched_stdlib_def + new_call.zig_pattern = node.zig_pattern + new_call.matched_stdlib_def = node.matched_stdlib_def if node.matched_stdlib_def return new_call end when AST::MethodCall @@ -222,8 +228,8 @@ def substitute_placeholders(node) if new_target != node.object || new_args != node.args new_mc = AST::MethodCall.new(node.token, new_target, node.name, new_args) copy_type_info(node, new_mc) - new_mc.zig_pattern = node.zig_pattern if node.respond_to?(:zig_pattern) && node.zig_pattern - new_mc.matched_stdlib_def = node.matched_stdlib_def if node.respond_to?(:matched_stdlib_def) && node.matched_stdlib_def + new_mc.zig_pattern = node.zig_pattern if node.zig_pattern + new_mc.matched_stdlib_def = node.matched_stdlib_def if node.matched_stdlib_def return new_mc end when AST::BinaryOp @@ -332,7 +338,7 @@ def substitute_placeholders(node) end def copy_type_info(src, dst) - dst.full_type = src.full_type if src.respond_to?(:full_type) && src.full_type && dst.respond_to?(:full_type=) + dst.full_type = src.full_type if src.full_type && dst.respond_to?(:full_type=) dst.type_info = src.type_info if src.respond_to?(:type_info) && src.type_info && dst.respond_to?(:type_info=) dst.coerced_type = src.coerced_type if src.respond_to?(:coerced_type) && src.coerced_type && dst.respond_to?(:coerced_type=) dst.storage = src.storage if src.respond_to?(:storage) && src.storage && dst.respond_to?(:storage=) @@ -379,39 +385,40 @@ def lower_pipeline(node) return lower_binding_chain(bchain, node) end + site = PipelineSite.new(list: lhs, options: node) case rhs - when AST::CountOp then lower_count(lhs, rhs, node) - when AST::SumOp then lower_sum(lhs, rhs, node) - when AST::AverageOp then lower_average(lhs, rhs, node) - when AST::MinOp then lower_min(lhs, rhs, node) - when AST::MaxOp then lower_max(lhs, rhs, node) - when AST::AnyOp then lower_any(lhs, rhs, node) - when AST::AllOp then lower_all(lhs, rhs, node) - when AST::FindOp then lower_find(lhs, rhs, node) - when AST::WhereOp then lower_where(lhs, rhs.expression, node) - when AST::SelectOp then lower_select(lhs, rhs.expression, node) - when AST::LimitOp then lower_limit(lhs, rhs, node) - when AST::TakeWhileOp then lower_take_while(lhs, rhs.expression, node) - when AST::SkipOp then lower_skip(lhs, rhs, node) - when AST::DistinctOp then lower_distinct(lhs, rhs, node) - when AST::UnnestOp then lower_unnest(lhs, rhs, node) - when AST::ReduceOp then lower_reduce(lhs, rhs, node) - when AST::WindowOp then lower_window(lhs, rhs, node) + when AST::CountOp then lower_count(site, rhs) + when AST::SumOp then lower_sum(site, rhs) + when AST::AverageOp then lower_average(site, rhs) + when AST::MinOp then lower_min(site, rhs) + when AST::MaxOp then lower_max(site, rhs) + when AST::AnyOp then lower_any(site, rhs) + when AST::AllOp then lower_all(site, rhs) + when AST::FindOp then lower_find(site, rhs) + when AST::WhereOp then lower_where(site, rhs.expression) + when AST::SelectOp then lower_select(site, rhs.expression) + when AST::LimitOp then lower_limit(site, rhs) + when AST::TakeWhileOp then lower_take_while(site, rhs.expression) + when AST::SkipOp then lower_skip(site, rhs) + when AST::DistinctOp then lower_distinct(site, rhs) + when AST::UnnestOp then lower_unnest(site, rhs) + when AST::ReduceOp then lower_reduce(site, rhs) + when AST::WindowOp then lower_window(site, rhs) when AST::BatchWindowOp # Zig keeps the legacy template (battle-tested, has time-based # semantics via CheatLib.BatchWindow). BC has no equivalent runtime # type, so route to a structural slice-based lowering on BC only. if @lowering.instance_variable_get(:@target) == :bc - lower_batch_window(lhs, rhs, node) + lower_batch_window(site, rhs) else nil end - when AST::OrderByOp then lower_order_by(lhs, rhs, node) - when AST::IndexOp then lower_index(lhs, rhs.expression, node) - when AST::JoinOp then lower_join(lhs, rhs, node) - when AST::TapOp then lower_tap(lhs, rhs, node) - when AST::EachOp then lower_each(lhs, rhs, node) - when AST::ConcurrentOp then lower_concurrent(lhs, rhs, node) + when AST::OrderByOp then lower_order_by(site, rhs) + when AST::IndexOp then lower_index(site, rhs.expression) + when AST::JoinOp then lower_join(site, rhs) + when AST::TapOp then lower_tap(site, rhs) + when AST::EachOp then lower_each(site, rhs) + when AST::ConcurrentOp then lower_concurrent(site, rhs) else nil end end @@ -646,7 +653,8 @@ def visit_pipeline_expr_mir(list_node, expr_node, placeholder = "it") # --- Scalar accumulator lowerings (Phase 1) --- - def lower_count(list_node, count_node, _smooth_node) + def lower_count(site, count_node) + list_node = site.list pred_mir = visit_pipeline_expr_mir(list_node, count_node.expression) lower_pipeline_block(list_node) do |items, label| [ @@ -662,7 +670,8 @@ def lower_count(list_node, count_node, _smooth_node) end end - def lower_sum(list_node, sum_node, _smooth_node) + def lower_sum(site, sum_node) + list_node = site.list expr_mir = visit_pipeline_expr_mir(list_node, sum_node.expression) lower_pipeline_block(list_node) do |items, label| [ @@ -676,7 +685,8 @@ def lower_sum(list_node, sum_node, _smooth_node) end end - def lower_average(list_node, avg_node, _smooth_node) + def lower_average(site, avg_node) + list_node = site.list expr_mir = visit_pipeline_expr_mir(list_node, avg_node.expression) lower_pipeline_block(list_node) do |items, label| [ @@ -696,7 +706,8 @@ def lower_average(list_node, avg_node, _smooth_node) end end - def lower_min(list_node, min_node, _smooth_node) + def lower_min(site, min_node) + list_node = site.list expr_mir = visit_pipeline_expr_mir(list_node, min_node.expression) lower_pipeline_block(list_node) do |items, label| [ @@ -720,7 +731,8 @@ def lower_min(list_node, min_node, _smooth_node) end end - def lower_max(list_node, max_node, _smooth_node) + def lower_max(site, max_node) + list_node = site.list expr_mir = visit_pipeline_expr_mir(list_node, max_node.expression) lower_pipeline_block(list_node) do |items, label| [ @@ -744,7 +756,8 @@ def lower_max(list_node, max_node, _smooth_node) end end - def lower_any(list_node, any_node, _smooth_node) + def lower_any(site, any_node) + list_node = site.list pred_mir = visit_pipeline_expr_mir(list_node, any_node.expression) lower_pipeline_block(list_node) do |items, label| [ @@ -760,7 +773,8 @@ def lower_any(list_node, any_node, _smooth_node) end end - def lower_all(list_node, all_node, _smooth_node) + def lower_all(site, all_node) + list_node = site.list pred_mir = visit_pipeline_expr_mir(list_node, all_node.expression) lower_pipeline_block(list_node) do |items, label| [ @@ -776,7 +790,8 @@ def lower_all(list_node, all_node, _smooth_node) end end - def lower_find(list_node, find_node, _smooth_node) + def lower_find(site, find_node) + list_node = site.list elem_zig_type = transpile_type(list_node.full_type.element_type.resolved.to_s) pred_mir = visit_pipeline_expr_mir(list_node, find_node.expression) lower_pipeline_block(list_node) do |items, label| @@ -807,7 +822,9 @@ def pipeline_alloc(smooth_node) smooth_node.respond_to?(:storage) && smooth_node.storage == :heap ? :heap : :frame end - def lower_where(list_node, expr_node, smooth_node) + def lower_where(site, expr_node) + list_node = site.list + smooth_node = site.options elem_type = list_node.full_type.element_type.resolved.to_s elem_zig = transpile_type(elem_type) alloc = pipeline_alloc(smooth_node) @@ -829,7 +846,9 @@ def lower_where(list_node, expr_node, smooth_node) end end - def lower_select(list_node, expr_node, smooth_node) + def lower_select(site, expr_node) + list_node = site.list + smooth_node = site.options res_type = expr_node.full_type res_zig = transpile_type(res_type) alloc = pipeline_alloc(smooth_node) @@ -849,7 +868,9 @@ def lower_select(list_node, expr_node, smooth_node) end end - def lower_limit(list_node, limit_node, smooth_node) + def lower_limit(site, limit_node) + list_node = site.list + smooth_node = site.options elem_type = list_node.full_type.element_type.resolved.to_s elem_zig = transpile_type(elem_type) alloc = pipeline_alloc(smooth_node) @@ -901,7 +922,9 @@ def lower_limit(list_node, limit_node, smooth_node) end end - def lower_take_while(list_node, expr_node, smooth_node) + def lower_take_while(site, expr_node) + list_node = site.list + smooth_node = site.options elem_type = list_node.full_type.element_type.resolved.to_s elem_zig = transpile_type(elem_type) alloc = pipeline_alloc(smooth_node) @@ -923,7 +946,8 @@ def lower_take_while(list_node, expr_node, smooth_node) end end - def lower_skip(list_node, skip_node, _smooth_node) + def lower_skip(site, skip_node) + list_node = site.list label = next_pipe_label source_mir = visit_mir(list_node) @current_pipe_label = label @@ -945,7 +969,9 @@ def lower_skip(list_node, skip_node, _smooth_node) ]) end - def lower_distinct(list_node, distinct_node, smooth_node) + def lower_distinct(site, distinct_node) + list_node = site.list + smooth_node = site.options # Observable variant: `~T[]@set:observable` -- producer-fiber-spawn # backed by `*ObservableStreamSet(T)` (= ObservableTerminal(StreamSet(T))). # Per-item: `_ = acc.inner.submit(item) catch unreachable`. @@ -1033,7 +1059,9 @@ def lower_distinct(list_node, distinct_node, smooth_node) # --- Complex operator lowerings (Phase 3) --- - def lower_unnest(list_node, unnest_node, smooth_node) + def lower_unnest(site, unnest_node) + list_node = site.list + smooth_node = site.options inner_elem_type = unnest_node.full_type.element_type.resolved.to_s inner_zig = transpile_type(inner_elem_type) alloc = pipeline_alloc(smooth_node) @@ -1057,7 +1085,8 @@ def lower_unnest(list_node, unnest_node, smooth_node) end end - def lower_reduce(list_node, reduce_node, _smooth_node) + def lower_reduce(site, reduce_node) + list_node = site.list acc_zig = transpile_type(reduce_node.full_type) init_mir = visit_mir(reduce_node.initial_value) expr_mir = with_pipeline_context(placeholder: "it", acc: "acc") { @@ -1074,7 +1103,9 @@ def lower_reduce(list_node, reduce_node, _smooth_node) end end - def lower_window(list_node, window_node, smooth_node) + def lower_window(site, window_node) + list_node = site.list + smooth_node = site.options expr_type_str = (window_node.expression.full_type || window_node.expression.resolved_type).to_s res_zig = transpile_type(expr_type_str) alloc = pipeline_alloc(smooth_node) @@ -1132,7 +1163,9 @@ def lower_window(list_node, window_node, smooth_node) # there is no time-based mid-stream flush; the final flush captures # any accumulated tail. Tests using time-only windows (e.g. test 7 # of 243_batch_window) rely on the final flush for a single batch. - def lower_batch_window(list_node, bw_node, smooth_node) + def lower_batch_window(site, bw_node) + list_node = site.list + smooth_node = site.options expr_type_str = (bw_node.expression.full_type || bw_node.expression.resolved_type).to_s res_zig = transpile_type(expr_type_str) @@ -1281,7 +1314,9 @@ def lower_batch_window(list_node, bw_node, smooth_node) end end - def lower_order_by(list_node, order_node, smooth_node) + def lower_order_by(site, order_node) + list_node = site.list + smooth_node = site.options elem_zig = transpile_type(list_node.full_type.element_type.resolved.to_s) alloc = pipeline_alloc(smooth_node) key_a = with_pipeline_context(placeholder: "a") { visit_mir(order_node.expression) } @@ -1300,7 +1335,9 @@ def lower_order_by(list_node, order_node, smooth_node) end end - def lower_index(list_node, expr_node, smooth_node) + def lower_index(site, expr_node) + list_node = site.list + smooth_node = site.options lhs_ti = list_node.type_info alloc = pipeline_alloc(smooth_node) @@ -1419,7 +1456,9 @@ def lower_stream_index(range_chain, expr_node, elem_zig, alloc, map_type) ]) end - def lower_join(list_node, join_node, smooth_node) + def lower_join(site, join_node) + list_node = site.list + smooth_node = site.options left_zig = transpile_type(list_node.full_type.element_type.resolved.to_s) right_src_mir = visit_mir(join_node.right_source) right_type_info = join_node.right_source.type_info @@ -1478,7 +1517,9 @@ def lower_join(list_node, join_node, smooth_node) ]) end - def lower_tap(list_node, tap_op, smooth_node) + def lower_tap(site, tap_op) + list_node = site.list + smooth_node = site.options label = next_pipe_label source_mir = visit_mir(list_node) @current_pipe_label = label @@ -1496,7 +1537,9 @@ def lower_tap(list_node, tap_op, smooth_node) # --- Side-effect operator lowerings (Phase 4) --- - def lower_each(list_node, each_op, smooth_node) + def lower_each(site, each_op) + list_node = site.list + smooth_node = site.options lhs_type = list_node.type_info # Sharded pools/lists need fibers on the Zig backend (one per shard) -- @@ -2001,7 +2044,7 @@ def bc_wrap_stages(stages, placeholder, accum_stmts) # string embedding. def numeric_fold_expr_typed(expr_ast, item_var, acc_zig) expr_mir = with_pipeline_context(placeholder: item_var) { visit_mir(expr_ast) } - expr_type = expr_ast.respond_to?(:full_type) ? expr_ast.full_type : nil + expr_type = expr_ast.full_type expr_is_int = expr_type && Type.new(expr_type).integer? acc_is_float = acc_zig == "f64" || acc_zig == "f32" if expr_is_int && acc_is_float @@ -2025,7 +2068,7 @@ def build_lazy_range_prefix(source_node, stages, on_skip: nil) elsif source_ti&.inf_stream? source_ti.inf_stream_element_type else - start_ft = source_node.respond_to?(:start) && source_node.start.respond_to?(:full_type) ? source_node.start.full_type : nil + start_ft = source_node.respond_to?(:start) && source_node.start.full_type Type.new(start_ft || :Int64) end elem_zig = elem_t.zig_type @@ -2856,7 +2899,9 @@ def lower_range_reduce(range_lit, stages, reduce_op, smooth_node = nil) # CONCURRENT pipeline: worker dispatch stays as RawZig string (struct defs, # atomics, spawn -- too complex for MIR nodes), but with stdlib_def so the # checker knows the allocation effects. - def lower_concurrent(_lhs, conc_op, smooth_node) + def lower_concurrent(site, conc_op) + _lhs = site.list + smooth_node = site.options # SHARD + CONCURRENT EACH: structural lowering for both backends. # This is a fused single-fiber loop (no real concurrency); produce a # ScopeBlock + ForStmt that both backends consume directly. The @@ -2992,29 +3037,30 @@ def lower_concurrent_bc(lhs, conc_op, smooth_node) real_lhs = lhs.left end + real_site = PipelineSite.new(list: real_lhs, options: smooth_node) work = lambda do case inner when AST::SelectOp policy, inner_expr = extract_concurrent_error_policy_for_bc(inner.expression) next lower_bc_concurrent_select_prune(real_lhs, inner_expr, smooth_node) if policy == :prune - lower_select(real_lhs, inner_expr, smooth_node) + lower_select(real_site, inner_expr) when AST::WhereOp policy, inner_expr = extract_concurrent_error_policy_for_bc(inner.expression) next lower_bc_concurrent_where_prune(real_lhs, inner_expr, smooth_node) if policy == :prune - lower_where(real_lhs, inner_expr, smooth_node) + lower_where(real_site, inner_expr) when AST::EachOp - lower_each(real_lhs, inner, smooth_node) - - when AST::SumOp then lower_sum(real_lhs, inner, smooth_node) - when AST::CountOp then lower_count(real_lhs, inner, smooth_node) - when AST::MinOp then lower_min(real_lhs, inner, smooth_node) - when AST::MaxOp then lower_max(real_lhs, inner, smooth_node) - when AST::AverageOp then lower_average(real_lhs, inner, smooth_node) - when AST::AnyOp then lower_any(real_lhs, inner, smooth_node) - when AST::AllOp then lower_all(real_lhs, inner, smooth_node) - when AST::FindOp then lower_find(real_lhs, inner, smooth_node) + lower_each(real_site, inner) + + when AST::SumOp then lower_sum(real_site, inner) + when AST::CountOp then lower_count(real_site, inner) + when AST::MinOp then lower_min(real_site, inner) + when AST::MaxOp then lower_max(real_site, inner) + when AST::AverageOp then lower_average(real_site, inner) + when AST::AnyOp then lower_any(real_site, inner) + when AST::AllOp then lower_all(real_site, inner) + when AST::FindOp then lower_find(real_site, inner) else raise "lower_concurrent_bc: unsupported inner op #{inner.class}" end @@ -4038,60 +4084,36 @@ def transpile_pipeline(node) rhs = node.right # Dispatch by operator type - if rhs.is_a?(AST::SelectOp) - return transpile_select_projection(lhs, rhs.expression) - elsif rhs.is_a?(AST::WhereOp) - return transpile_where_filter(lhs, rhs.expression) - elsif rhs.is_a?(AST::IndexOp) - return transpile_index_grouping(lhs, rhs.expression, node) - elsif rhs.is_a?(AST::ReduceOp) - return transpile_reduce(lhs, rhs) - elsif rhs.is_a?(AST::OrderByOp) - return transpile_order_by(lhs, rhs, node) - elsif rhs.is_a?(AST::LimitOp) - return transpile_limit(lhs, rhs, node) - elsif rhs.is_a?(AST::UnnestOp) - return transpile_unnest(lhs, rhs, node) - elsif rhs.is_a?(AST::DistinctOp) - return transpile_distinct(lhs, rhs, node) - elsif rhs.is_a?(AST::EachOp) - return transpile_each(node) - elsif rhs.is_a?(AST::FindOp) - return transpile_find(lhs, rhs, node) - elsif rhs.is_a?(AST::AnyOp) - return transpile_any(lhs, rhs, node) - elsif rhs.is_a?(AST::AllOp) - return transpile_all(lhs, rhs, node) - elsif rhs.is_a?(AST::CountOp) - return transpile_count(lhs, rhs, node) - elsif rhs.is_a?(AST::SumOp) - return transpile_sum(lhs, rhs, node) - elsif rhs.is_a?(AST::AverageOp) - return transpile_average(lhs, rhs, node) - elsif rhs.is_a?(AST::MinOp) - return transpile_min(lhs, rhs, node) - elsif rhs.is_a?(AST::MaxOp) - return transpile_max(lhs, rhs, node) - elsif rhs.is_a?(AST::TakeWhileOp) - return transpile_take_while(lhs, rhs.expression, node) - elsif rhs.is_a?(AST::WindowOp) - return transpile_window(lhs, rhs, node) - elsif rhs.is_a?(AST::BatchWindowOp) - return transpile_batch_window(lhs, rhs, node) - elsif rhs.is_a?(AST::JoinOp) - return transpile_join(lhs, rhs, node) - elsif rhs.is_a?(AST::RecoverOp) + case rhs + when AST::SelectOp then return transpile_select_projection(lhs, rhs.expression) + when AST::WhereOp then return transpile_where_filter(lhs, rhs.expression) + when AST::IndexOp then return transpile_index_grouping(lhs, rhs.expression, node) + when AST::ReduceOp then return transpile_reduce(lhs, rhs) + when AST::OrderByOp then return transpile_order_by(lhs, rhs, node) + when AST::LimitOp then return transpile_limit(lhs, rhs, node) + when AST::UnnestOp then return transpile_unnest(lhs, rhs, node) + when AST::DistinctOp then return transpile_distinct(lhs, rhs, node) + when AST::EachOp then return transpile_each(node) + when AST::FindOp then return transpile_find(lhs, rhs, node) + when AST::AnyOp then return transpile_any(lhs, rhs, node) + when AST::AllOp then return transpile_all(lhs, rhs, node) + when AST::CountOp then return transpile_count(lhs, rhs, node) + when AST::SumOp then return transpile_sum(lhs, rhs, node) + when AST::AverageOp then return transpile_average(lhs, rhs, node) + when AST::MinOp then return transpile_min(lhs, rhs, node) + when AST::MaxOp then return transpile_max(lhs, rhs, node) + when AST::TakeWhileOp then return transpile_take_while(lhs, rhs.expression, node) + when AST::WindowOp then return transpile_window(lhs, rhs, node) + when AST::BatchWindowOp then return transpile_batch_window(lhs, rhs, node) + when AST::JoinOp then return transpile_join(lhs, rhs, node) + when AST::RecoverOp default_code = visit(rhs.default_expr) left_code = visit(lhs).sub(/^try /, '') return "(#{left_code} catch #{default_code})" - elsif rhs.is_a?(AST::TapOp) - return transpile_tap(node) - elsif rhs.is_a?(AST::SkipOp) - return transpile_skip(lhs, rhs, node) - elsif rhs.is_a?(AST::ShardOp) - raise "SHARD must be followed by |> CONCURRENT EACH" - elsif rhs.is_a?(AST::ConcurrentOp) - return transpile_concurrent(node) + when AST::TapOp then return transpile_tap(node) + when AST::SkipOp then return transpile_skip(lhs, rhs, node) + when AST::ShardOp then raise "SHARD must be followed by |> CONCURRENT EACH" + when AST::ConcurrentOp then return transpile_concurrent(node) end # Simple function pipe: x |> f -> f(x) @@ -4103,15 +4125,9 @@ def transpile_pipeline(node) raise "PipelineHost Error: Invalid Pipe Destination #{rhs.class}" end - if rhs.respond_to?(:zig_pattern) - synthetic_call.zig_pattern = rhs.zig_pattern - end - if rhs.respond_to?(:full_type) - synthetic_call.full_type = rhs.full_type - end - if rhs.respond_to?(:coerced_type) - synthetic_call.coerced_type = rhs.coerced_type - end + synthetic_call.zig_pattern = rhs.zig_pattern + synthetic_call.full_type = rhs.full_type + synthetic_call.coerced_type = rhs.coerced_type visit(synthetic_call) end diff --git a/src/backends/pipeline_rewriter.rb b/src/backends/pipeline_rewriter.rb index fbbba785a..5f26139b2 100644 --- a/src/backends/pipeline_rewriter.rb +++ b/src/backends/pipeline_rewriter.rb @@ -54,7 +54,7 @@ def rewrite_children!(node) when AST::VarDecl, AST::BindExpr node.value = rewrite!(node.value) if node.value when AST::Assignment - node.value = rewrite!(node.value) if node.respond_to?(:value) && node.value + node.value = rewrite!(node.value) if node.value when AST::ReturnNode node.value = rewrite!(node.value) if node.value when AST::IfStatement @@ -64,7 +64,7 @@ def rewrite_children!(node) when AST::MatchStatement node.expr = rewrite!(node.expr) (node.cases || []).each { |c| c[:body]&.map! { |s| rewrite!(s) } } - node.default_case&.map! { |s| rewrite!(s) } if node.default_case + node.default_case.map! { |s| rewrite!(s) } if node.default_case when AST::WhileLoop node.condition = rewrite!(node.condition) node.do_branch&.map! { |s| rewrite!(s) } if node.do_branch.is_a?(Array) @@ -203,7 +203,7 @@ def rewrite_pipeline(node) # the callee's declared return type. result_is_error = node.full_type && Type.new(node.full_type).error_union? result_is_error ||= callee_returns_error?(rhs) - needs_try = source.respond_to?(:full_type) && source.full_type && Type.new(source.full_type).error_union? + needs_try = source.full_type && Type.new(source.full_type).error_union? if rhs.is_a?(AST::FuncCall) && !result_is_error lhs_node = needs_try ? AST::UnaryOp.new(rhs.token, :TRY, source.dup) : source.dup @@ -304,7 +304,7 @@ def fuse_pipeline(smooth_node, source, stages, terminal) # 2. Build loop body current_it = AST::Identifier.new(token, it_var) - if source.respond_to?(:full_type) && source.full_type + if source.full_type src_t = Type.new(source.full_type) elem_t = if src_t.open_stream? src_t.open_stream_element_type @@ -724,8 +724,8 @@ def callee_returns_error?(rhs) ti = rhs.respond_to?(:type_info) ? rhs.type_info : nil return false unless ti raw = ti.raw - return false unless raw.is_a?(Hash) && raw[:return].is_a?(Hash) - ret_type = raw[:return][:type] + return false unless raw.is_a?(FunctionSignature) + ret_type = raw.return_type ret_type&.error_union? || false end diff --git a/src/backends/string_concat_rewriter.rb b/src/backends/string_concat_rewriter.rb index b9ca2db33..4eb7fb0fe 100644 --- a/src/backends/string_concat_rewriter.rb +++ b/src/backends/string_concat_rewriter.rb @@ -41,7 +41,7 @@ def rewrite_children!(node) when AST::VarDecl, AST::BindExpr node.value = rewrite_in_node!(node.value) if node.value when AST::Assignment - node.value = rewrite_in_node!(node.value) if node.respond_to?(:value) && node.value + node.value = rewrite_in_node!(node.value) if node.value when AST::ReturnNode node.value = rewrite_in_node!(node.value) if node.value when AST::IfStatement @@ -49,7 +49,7 @@ def rewrite_children!(node) node.else_branch&.map! { |s| rewrite_in_node!(s) } when AST::MatchStatement (node.cases || []).each { |c| c[:body]&.map! { |s| rewrite_in_node!(s) } } - node.default_case&.map! { |s| rewrite_in_node!(s) } if node.default_case + node.default_case.map! { |s| rewrite_in_node!(s) } if node.default_case when AST::WhileLoop b = node.do_branch b.map! { |s| rewrite_in_node!(s) } if b.is_a?(Array) diff --git a/src/backends/transpiler.rb b/src/backends/transpiler.rb index 3deb9a802..99b6a45ab 100644 --- a/src/backends/transpiler.rb +++ b/src/backends/transpiler.rb @@ -1,6 +1,7 @@ #! /usr/bin/env ruby require 'bundler/setup' # so `bundle exec` not needed +require "sorbet-runtime" require "optparse" require "logger" require "set" diff --git a/src/mir/concurrency_checks.rb b/src/mir/concurrency_checks.rb index 5a80fdbb6..41e98993a 100644 --- a/src/mir/concurrency_checks.rb +++ b/src/mir/concurrency_checks.rb @@ -1,3 +1,4 @@ +# typed: true # frozen_string_literal: true # Phase 3 compile-time correctness checks for concurrent CLEAR programs. @@ -55,12 +56,10 @@ def check_hold_across_yield!(fn, fn_nodes, error_handler) offender_token = node.token reason = "NEXT" when AST::FuncCall - if node.respond_to?(:name) - callee = fn_nodes[node.name.to_s] - if callee&.effects&.include?(EffectTracker::YIELD) - offender_token = node.token - reason = "call to '#{node.name}' which yields" - end + callee = fn_nodes[node.name.to_s] + if callee&.effects&.include?(EffectTracker::YIELD) + offender_token = node.token + reason = "call to '#{node.name}' which yields" end end diff --git a/src/mir/control_flow.rb b/src/mir/control_flow.rb index 1b328f70b..6e136f660 100644 --- a/src/mir/control_flow.rb +++ b/src/mir/control_flow.rb @@ -1,3 +1,4 @@ +# typed: true # control_flow.rb - CFG construction, ownership dataflow, MIR node insertion # # Three components: @@ -74,7 +75,7 @@ def self.build(fn_node, can_fail_fns: nil) cfg.instance_variable_set(:@can_fail_fns, can_fail_fns) last_block = build_body(fn_node.body || [], cfg.entry, cfg.exit_block, cfg) # Connect fall-through to exit (implicit return at end of function). - last_block&.add_successor(cfg.exit_block) if last_block + last_block.add_successor(cfg.exit_block) if last_block cfg end @@ -94,11 +95,11 @@ def self.build_body(stmts, current_block, exit_target, cfg) current_block.add_successor(else_block || join_block) then_exit = build_body(stmt.then_branch || [], then_block, exit_target, cfg) - then_exit&.add_successor(join_block) if then_exit + then_exit.add_successor(join_block) if then_exit if stmt.else_branch else_exit = build_body(stmt.else_branch, else_block, exit_target, cfg) - else_exit&.add_successor(join_block) if else_exit + else_exit.add_successor(join_block) if else_exit end current_block = join_block @@ -139,13 +140,13 @@ def self.build_body(stmts, current_block, exit_target, cfg) case_block = cfg.new_block current_block.add_successor(case_block) case_exit = build_body(c[:body] || [], case_block, exit_target, cfg) - case_exit&.add_successor(join_block) if case_exit + case_exit.add_successor(join_block) if case_exit end if stmt.default_case default_block = cfg.new_block current_block.add_successor(default_block) default_exit = build_body(stmt.default_case, default_block, exit_target, cfg) - default_exit&.add_successor(join_block) if default_exit + default_exit.add_successor(join_block) if default_exit end current_block = join_block @@ -157,7 +158,7 @@ def self.build_body(stmts, current_block, exit_target, cfg) current_block.add_successor(body_block) current_block.add_successor(after_block) # WITH can fail to acquire body_exit = build_body(stmt.body || [], body_block, exit_target, cfg) - body_exit&.add_successor(after_block) if body_exit + body_exit.add_successor(after_block) if body_exit current_block = after_block when AST::DoBlock @@ -167,7 +168,7 @@ def self.build_body(stmts, current_block, exit_target, cfg) branch_block = cfg.new_block current_block.add_successor(branch_block) branch_exit = build_body(b[:body] || [], branch_block, exit_target, cfg) - branch_exit&.add_successor(join_block) if branch_exit + branch_exit.add_successor(join_block) if branch_exit end current_block.add_successor(join_block) # fallthrough if no branches current_block = join_block @@ -282,6 +283,12 @@ class OwnershipDataflow MOVED = :moved MAYBE_MOVED = :maybe_moved + # Per-walk state for collect_ownership_transfers and friends. Reek + # flagged the (state, consumed) carry-down across 5+ methods. + # `state` (Hash) and `consumed` (Set) are both mutable; the Data + # wrapper is frozen but the inner collections are not. + DataflowStep = Data.define(:state, :consumed) + # Enriched ownership entry: carries allocator and cleanup info alongside state. # Equality is based on :state only (for fixpoint convergence -- allocator and # needs_cleanup are immutable properties set at declaration, never change). @@ -431,7 +438,7 @@ def cleanup_decisions!(fn_node, bindings) # Returns true if the given variable is the subject of a MATCH TAKES statement. def match_takes_var?(fn_node, var_name) - found = false + found = T.let(false, T::Boolean) AST.walk_body(fn_node.body) do |stmt| if stmt.is_a?(AST::MatchStatement) && stmt.takes && stmt.expr.is_a?(AST::Identifier) && stmt.expr.name.to_s == var_name @@ -562,10 +569,10 @@ def transfer_stmt(stmt, state) lhs = stmt.name lhs_is_map = lhs.is_a?(AST::GetIndex) && (Type.from_node(lhs.target)&.map? rescue false) skip_rhs_move = if lhs_is_map - val_ti_raw = stmt.value.respond_to?(:type_info) ? (stmt.value.type_info rescue nil) : nil + val_ti_raw = stmt.value.type_info rescue nil val_resolved = val_ti_raw && (val_ti_raw.is_a?(Type) ? val_ti_raw.resolved : val_ti_raw) schema = val_resolved && @schema_lookup&.call(val_resolved) - schema.is_a?(Hash) && schema[:kind] == :union + !!Schemas.as_union_schema(schema) else false end @@ -638,110 +645,110 @@ def transfer_stmt(stmt, state) # All other positions: only was_moved (set by annotator for TAKES/GIVE). def collect_binding_moves(node, state) return [] unless node - consumed = Set.new - collect_ownership_transfers(node, state, consumed) - consumed.to_a + step = DataflowStep.new(state: state, consumed: Set.new) + collect_ownership_transfers(node, step) + step.consumed.to_a end # Recursively find ownership-transferring identifiers. - def collect_ownership_transfers(node, state, consumed) + def collect_ownership_transfers(node, step) return unless node case node when AST::Identifier name = node.name.to_s - return unless state[name] + return unless step.state[name] return if copy_type?(node) # Copy types are never consumed - consumed << name + step.consumed << name when AST::StructLit - node.fields&.each_value { |v| collect_ownership_transfers(v, state, consumed) } + node.fields&.each_value { |v| collect_ownership_transfers(v, step) } when AST::MethodCall # Union constructors: U.Variant(payload) - payload transfers ownership. # Regular method calls in binding RHS: only was_moved args. # Distinguish by token type: TYPE_ID = union constructor, VAR_ID = method call. if node.object.is_a?(AST::Identifier) && node.object.token&.type == :TYPE_ID - node.args&.each { |a| collect_ownership_transfers(a, state, consumed) } + node.args.each { |a| collect_ownership_transfers(a, step) } else - collect_explicit_in(node, state, consumed) + collect_explicit_in(node, step) end when AST::FuncCall - collect_explicit_in(node, state, consumed) + collect_explicit_in(node, step) when AST::ListLit - node.items&.each { |i| collect_ownership_transfers(i, state, consumed) } + node.items.each { |i| collect_ownership_transfers(i, step) } when AST::MoveNode inner = node.value if inner.is_a?(AST::Identifier) name = inner.name.to_s - consumed << name if state[name] + step.consumed << name if step.state[name] end when AST::ShareNode - collect_share_transfer(node, state, consumed) + collect_share_transfer(node, step) when AST::CopyNode, AST::CloneNode, AST::FreezeNode # COPY / FREEZE do NOT move the source. when AST::CapabilityWrap # Unwrap: S{ field: x } @shared still consumes x. - collect_ownership_transfers(node.value, state, consumed) + collect_ownership_transfers(node.value, step) when AST::BgBlock, AST::BgStreamBlock # Resources captured by BG fibers transfer ownership. node.capture_analysis&.resource_captures&.each do |name| - consumed << name if state[name] + step.consumed << name if step.state[name] end # GIVE x inside the BG body moves the outer x into the fiber. collect_bg_body_gives(node).each do |name| - consumed << name if state[name] + step.consumed << name if step.state[name] end else - collect_explicit_in(node, state, consumed) + collect_explicit_in(node, step) end end # Collect only was_moved identifiers from an expression subtree. # Skips CopyNode children: COPY wraps a was_moved identifier but the # source is NOT consumed (the copy is what transfers ownership). - def collect_explicit_in(node, state, consumed) + def collect_explicit_in(node, step) walk_expr_skip_copy(node) do |n| next unless n.is_a?(AST::Identifier) && n.was_moved name = n.name.to_s - next unless state[name] + next unless step.state[name] next if copy_type?(n) - consumed << name + step.consumed << name end - collect_share_transfers_in(node, state, consumed) + collect_share_transfers_in(node, step) end # Collect only explicitly moved identifiers (was_moved set by annotator). # Used for function calls where non-TAKES args are borrowed, not moved. def collect_explicit_moves(node, state) return [] unless node - consumed = Set.new + step = DataflowStep.new(state: state, consumed: Set.new) walk_expr_skip_copy(node) do |n| next unless n.is_a?(AST::Identifier) && n.was_moved name = n.name.to_s - next unless state[name] + next unless step.state[name] next if copy_type?(n) - consumed << name + step.consumed << name end - collect_share_transfers_in(node, state, consumed) - consumed.to_a + collect_share_transfers_in(node, step) + step.consumed.to_a end - def collect_share_transfers_in(node, state, consumed) + def collect_share_transfers_in(node, step) walk_expr(node) do |n| - collect_share_transfer(n, state, consumed) if n.is_a?(AST::ShareNode) + collect_share_transfer(n, step) if n.is_a?(AST::ShareNode) end end - def collect_share_transfer(node, state, consumed) + def collect_share_transfer(node, step) source = node.value return if source.is_a?(AST::CopyNode) @@ -749,11 +756,11 @@ def collect_share_transfer(node, state, consumed) ti = Type.from_node(source) return if ti&.shared? name = source.name.to_s - consumed << name if state[name] + step.consumed << name if step.state[name] return end - collect_ownership_transfers(source, state, consumed) + collect_ownership_transfers(source, step) end # Collect resource captures from BG blocks nested in function/method call args. @@ -779,10 +786,10 @@ def _walk_bg_captures_in_expr(expr, state, consumed) consumed << name if state[name] end when AST::FuncCall - expr.args&.each { |a| _walk_bg_captures_in_expr(a, state, consumed) } + expr.args.each { |a| _walk_bg_captures_in_expr(a, state, consumed) } when AST::MethodCall _walk_bg_captures_in_expr(expr.object, state, consumed) - expr.args&.each { |a| _walk_bg_captures_in_expr(a, state, consumed) } + expr.args.each { |a| _walk_bg_captures_in_expr(a, state, consumed) } end end @@ -824,8 +831,7 @@ def copy_type?(ident) return false end end - is_atomic_ptr = ti.respond_to?(:sync) && ti.sync == :atomic && - ti.respond_to?(:layout) && ti.layout == :indirect + is_atomic_ptr = ti.sync == :atomic && ti.layout == :indirect ti.primitive? || ti.string? || ti.any? || ti.void? || (ti.any_rc? && !is_atomic_ptr) end @@ -839,10 +845,10 @@ def walk_expr(node, &block) when AST::UnaryOp walk_expr(node.right, &block) when AST::FuncCall - node.args&.each { |a| walk_expr(a, &block) } + node.args.each { |a| walk_expr(a, &block) } when AST::MethodCall walk_expr(node.object, &block) - node.args&.each { |a| walk_expr(a, &block) } + node.args.each { |a| walk_expr(a, &block) } when AST::GetField walk_expr(node.target, &block) when AST::GetIndex @@ -851,9 +857,9 @@ def walk_expr(node, &block) when AST::StructLit node.fields&.each_value { |v| walk_expr(v, &block) } when AST::ListLit - node.items&.each { |i| walk_expr(i, &block) } + node.items.each { |i| walk_expr(i, &block) } when AST::HashLit - node.pairs&.each { |_k, v| walk_expr(v.is_a?(Array) ? v[1] : v, &block) } + node.pairs.each { |_k, v| walk_expr(v.is_a?(Array) ? v[1] : v, &block) } when AST::CopyNode, AST::CloneNode, AST::FreezeNode walk_expr(node.value, &block) when AST::ShareNode @@ -887,10 +893,10 @@ def walk_expr_skip_copy(node, &block) when AST::UnaryOp walk_expr_skip_copy(node.right, &block) when AST::FuncCall - node.args&.each { |a| walk_expr_skip_copy(a, &block) } + node.args.each { |a| walk_expr_skip_copy(a, &block) } when AST::MethodCall walk_expr_skip_copy(node.object, &block) - node.args&.each { |a| walk_expr_skip_copy(a, &block) } + node.args.each { |a| walk_expr_skip_copy(a, &block) } when AST::GetField walk_expr_skip_copy(node.target, &block) when AST::GetIndex @@ -899,9 +905,9 @@ def walk_expr_skip_copy(node, &block) when AST::StructLit node.fields&.each_value { |v| walk_expr_skip_copy(v, &block) } when AST::ListLit - node.items&.each { |i| walk_expr_skip_copy(i, &block) } + node.items.each { |i| walk_expr_skip_copy(i, &block) } when AST::HashLit - node.pairs&.each { |_k, v| walk_expr_skip_copy(v.is_a?(Array) ? v[1] : v, &block) } + node.pairs.each { |_k, v| walk_expr_skip_copy(v.is_a?(Array) ? v[1] : v, &block) } when AST::MoveNode walk_expr_skip_copy(node.value, &block) when AST::CapabilityWrap @@ -1105,10 +1111,10 @@ def check_reads_in_expr(node, state) node.fields&.each_value { |v| check_reads_in_expr(v, state) } when AST::ListLit - node.items&.each { |i| check_reads_in_expr(i, state) } + node.items.each { |i| check_reads_in_expr(i, state) } when AST::HashLit - node.pairs&.each { |_k, v| + node.pairs.each { |_k, v| val = v.is_a?(Array) ? v[1] : v check_reads_in_expr(val, state) } @@ -1213,12 +1219,12 @@ def self.walk_stmt!(stmt) walk_stmts!(stmt.then_branch) walk_stmts!(stmt.else_branch) when AST::MatchStatement - stmt.cases&.each { |c| walk_stmts!(c[:body]) } + stmt.cases.each { |c| walk_stmts!(c[:body]) } walk_stmts!(stmt.default_case) when AST::WithBlock walk_stmts!(stmt.body) when AST::DoBlock - stmt.branches&.each { |b| walk_stmts!(b[:body]) } + stmt.branches.each { |b| walk_stmts!(b[:body]) } end end @@ -1301,18 +1307,18 @@ def self.local_frame_decls(body, _local_names) # Does var_name appear as a value arg to a mutates_receiver call on an outer # container anywhere in the loop body (including nested loops)? def self.escapes_to_outer?(var_name, body, local_names) - found = false + found = T.let(false, T::Boolean) AST.walk_body(body) do |node| - next unless node.respond_to?(:mutates_receiver) && node.mutates_receiver + next unless node.mutates_receiver case node when AST::MethodCall receiver = node.object next unless receiver.is_a?(AST::Identifier) && !local_names.include?(receiver.name) - found = true if node.args&.any? { |a| a.is_a?(AST::Identifier) && a.name == var_name } + found = true if node.args.any? { |a| a.is_a?(AST::Identifier) && a.name == var_name } when AST::FuncCall - receiver = node.args&.first + receiver = node.args.first next unless receiver.is_a?(AST::Identifier) && !local_names.include?(receiver.name) - found = true if node.args&.drop(1)&.any? { |a| a.is_a?(AST::Identifier) && a.name == var_name } + found = true if node.args.drop(1).any? { |a| a.is_a?(AST::Identifier) && a.name == var_name } end end found @@ -1342,10 +1348,10 @@ def self.promote_outer_mutations!(body) AST.walk_body(body) do |n| next if n.is_a?(AST::FunctionDef) - next unless n.respond_to?(:mutates_receiver) && n.mutates_receiver + next unless n.mutates_receiver receiver = case n when AST::MethodCall then n.object - when AST::FuncCall then n.args&.first + when AST::FuncCall then n.args.first end next unless receiver.is_a?(AST::Identifier) decl = receiver.symbol&.reg @@ -1378,12 +1384,12 @@ def self.rhs_references_any?(expr, names) when AST::BinaryOp return rhs_references_any?(expr.left, names) || rhs_references_any?(expr.right, names) when AST::UnaryOp - return rhs_references_any?(expr.operand, names) + return rhs_references_any?(expr.right, names) when AST::FuncCall - return expr.args&.any? { |a| rhs_references_any?(a, names) } || false + return expr.args.any? { |a| rhs_references_any?(a, names) } || false when AST::MethodCall return rhs_references_any?(expr.object, names) || - (expr.args&.any? { |a| rhs_references_any?(a, names) } || false) + (expr.args.any? { |a| rhs_references_any?(a, names) } || false) when AST::GetField return rhs_references_any?(expr.target, names) when AST::GetIndex @@ -1470,7 +1476,7 @@ def self.promote_to_heap!(ident_node) return unless decl_ti.is_a?(Type) return unless decl_ti.list_collection? || decl_ti.map? || decl_ti.array? || decl_ti.string? decl_ti.provenance = :heap - decl_node.storage = :heap if decl_node.respond_to?(:storage=) + decl_node.storage = :heap if decl_node.respond_to?(:value) && decl_node.value.respond_to?(:storage=) decl_node.value.storage = :heap end @@ -1489,12 +1495,12 @@ def self.scan_direct(body, &block) scan_direct(s.then_branch, &block) scan_direct(s.else_branch, &block) when AST::MatchStatement - s.cases&.each { |c| scan_direct(c[:body], &block) } + s.cases.each { |c| scan_direct(c[:body], &block) } scan_direct(s.default_case, &block) when AST::WithBlock scan_direct(s.body, &block) when AST::DoBlock - s.branches&.each { |b| scan_direct(b[:body], &block) } + s.branches.each { |b| scan_direct(b[:body], &block) } end end end @@ -1663,11 +1669,11 @@ def check_stmt(stmt) check_stmts(stmt.body) when AST::MatchStatement - stmt.cases&.each { |c| check_stmts(c[:body]) } + stmt.cases.each { |c| check_stmts(c[:body]) } check_stmts(stmt.default_case) when AST::DoBlock - stmt.branches&.each { |b| check_stmts(b[:body]) } + stmt.branches.each { |b| check_stmts(b[:body]) } when AST::BgBlock, AST::BgStreamBlock # BG resource captures are ownership transfers @@ -1690,7 +1696,7 @@ def handle_with_block(stmt) next unless capability == :RESTRICT || capability == :BORROWED kind = (capability == :RESTRICT) ? :mutable : :immutable - token = (cap[:var_node].respond_to?(:token) ? cap[:var_node].token : nil) || stmt.token + token = cap[:var_node].token || stmt.token # ALIAS_VIOLATION: conflicting borrows existing = @active_borrows[source] @@ -1764,14 +1770,14 @@ def _collect_moves(node, names) # Union constructors (TYPE_ID): payload transfers ownership. # Regular method calls: only was_moved args. if node.object.is_a?(AST::Identifier) && node.object.token&.type == :TYPE_ID - node.args&.each { |a| _collect_moves(a, names) } + node.args.each { |a| _collect_moves(a, names) } else _collect_was_moved(node, names) end when AST::FuncCall _collect_was_moved(node, names) when AST::ListLit - node.items&.each { |i| _collect_moves(i, names) } + node.items.each { |i| _collect_moves(i, names) } when AST::MoveNode inner = node.value names << inner.name.to_s if inner.is_a?(AST::Identifier) @@ -1823,10 +1829,10 @@ def walk_for_was_moved(node, &block) when AST::UnaryOp walk_for_was_moved(node.right, &block) when AST::FuncCall - node.args&.each { |a| walk_for_was_moved(a, &block) } + node.args.each { |a| walk_for_was_moved(a, &block) } when AST::MethodCall walk_for_was_moved(node.object, &block) - node.args&.each { |a| walk_for_was_moved(a, &block) } + node.args.each { |a| walk_for_was_moved(a, &block) } when AST::GetField walk_for_was_moved(node.target, &block) when AST::GetIndex @@ -1835,7 +1841,7 @@ def walk_for_was_moved(node, &block) when AST::StructLit node.fields&.each_value { |v| walk_for_was_moved(v, &block) } when AST::ListLit - node.items&.each { |i| walk_for_was_moved(i, &block) } + node.items.each { |i| walk_for_was_moved(i, &block) } when AST::MoveNode walk_for_was_moved(node.value, &block) when AST::ShareNode @@ -1849,8 +1855,7 @@ def copy_type?(ident) ti = ident.type_info rescue nil return true unless ti ti = Type.new(ti) if !ti.is_a?(Type) - is_atomic_ptr = ti.respond_to?(:sync) && ti.sync == :atomic && - ti.respond_to?(:layout) && ti.layout == :indirect + is_atomic_ptr = ti.sync == :atomic && ti.layout == :indirect ti.primitive? || ti.string? || ti.any? || ti.void? || ((ti.any_rc? rescue false) && !is_atomic_ptr) end end diff --git a/src/mir/escape_analysis.rb b/src/mir/escape_analysis.rb index de4e7d64c..accaf3d4b 100644 --- a/src/mir/escape_analysis.rb +++ b/src/mir/escape_analysis.rb @@ -132,7 +132,7 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) when AST::MethodCall then decl_val.name end if callee2 && heap_fns.include?(callee2) - ret_type = val.respond_to?(:full_type) ? (Type.new(val.full_type) rescue nil) : nil + ret_type = (Type.new(val.full_type) rescue nil) return !!(ret_type&.string? || ret_type&.collection? || ret_type&.map?) end end @@ -202,7 +202,7 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) sym_ti = ident.symbol&.type sym_ti = sym_ti.is_a?(Type) ? sym_ti : (Type.new(sym_ti) rescue nil) if sym_ti&.requires_move? - node.storage = :heap if node.respond_to?(:storage=) + node.storage = :heap ident.symbol.storage = :heap if ident.symbol end end @@ -297,7 +297,7 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) # frame-allocated @list/@pool/@set GIVEn to TAKES of the same type. e2_walk_calls(fn.body) do |call| callee_name = call.name.to_s - callee_fn = fn_nodes[callee_name] || fn_nodes[callee_name.to_sym] + callee_fn = fn_nodes[callee_name] next unless callee_fn&.respond_to?(:params) && callee_fn.params args = call.args || [] @@ -342,8 +342,8 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) case node when AST::BinaryOp promoted = false - if node.op == :ADD && node.respond_to?(:string_concat) && node.string_concat - node.storage = :heap if node.respond_to?(:storage=) + if node.op == :ADD && node.string_concat + node.storage = :heap ti = node.type_info ti.provenance = :heap if ti.is_a?(Type) promoted = true @@ -352,7 +352,7 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) promoted |= e2_promote_frame_concats!(node.right) promoted when AST::StringConcat - node.storage = :heap if node.respond_to?(:storage=) + node.storage = :heap node.parts&.each { |p| e2_promote_frame_concats!(p) } true when AST::StructLit, AST::UnionVariantLit @@ -475,7 +475,7 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) # Promote the loop-local declaration. AST.walk_body(body) do |local_decl| next unless (local_decl.is_a?(AST::VarDecl) || (local_decl.is_a?(AST::BindExpr) && local_decl.mode == :decl)) && local_decl.name.to_s == rhs.name - local_decl.storage = :heap if local_decl.respond_to?(:storage=) + local_decl.storage = :heap decl_ti = local_decl.type_info rescue nil decl_ti = Type.new(decl_ti) if decl_ti && !decl_ti.is_a?(Type) decl_ti.provenance = :heap if decl_ti.is_a?(Type) @@ -489,13 +489,11 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) outer_name = bind.name AST.walk_body(fn.body) do |outer_decl| next unless (outer_decl.is_a?(AST::VarDecl) || (outer_decl.is_a?(AST::BindExpr) && outer_decl.mode == :decl)) && outer_decl.name.to_s == outer_name - outer_decl.storage = :heap if outer_decl.respond_to?(:storage=) + outer_decl.storage = :heap outer_ti = outer_decl.type_info rescue nil outer_ti = Type.new(outer_ti) if outer_ti && !outer_ti.is_a?(Type) outer_ti.provenance = :heap if outer_ti.is_a?(Type) - if outer_decl.respond_to?(:symbol) && outer_decl.symbol - outer_decl.symbol.storage = :heap - end + outer_decl.symbol.storage = :heap if outer_decl.symbol end end end @@ -603,15 +601,15 @@ def self.tag_transitive_provenance!(fn_nodes, heap_fns) node.type_info.provenance = :heap if node.type_info.is_a?(Type) if node.is_a?(AST::BindExpr) && node.mode == :assign decl = e3_find_decl(fn.body, node.name) - decl.type_info.provenance = :heap if decl&.respond_to?(:type_info) && decl.type_info.is_a?(Type) + decl.type_info.provenance = :heap if decl&.type_info.is_a?(Type) end when AST::Assignment val = node.value callee_name = val.is_a?(AST::FuncCall) ? val.name.to_s : nil next unless callee_name && heap_fns.include?(callee_name) - sym = node.name.respond_to?(:symbol) ? node.name.symbol : nil + sym = node.name.symbol decl = sym&.reg - decl.type_info.provenance = :heap if decl&.respond_to?(:type_info) && decl.type_info.is_a?(Type) + decl.type_info.provenance = :heap if decl&.type_info.is_a?(Type) end end end @@ -699,7 +697,7 @@ def self.propagate_caller_sync!(fn_nodes) until stack.empty? node = stack.pop next unless node.is_a?(AST::Locatable) - if node.is_a?(AST::FuncCall) && node.respond_to?(:name) + if node.is_a?(AST::FuncCall) callsites[node.name.to_s] << { args: (node.args || []) } end next if node.is_a?(AST::FunctionDef) || node.is_a?(AST::LambdaLit) @@ -782,7 +780,7 @@ def self.tag_carry_call_sites!(fn_nodes) call_ti.provenance = :heap if call_ti.is_a?(Type) && !call_ti.heap_provenance? bind_ti = stmt.type_info rescue nil bind_ti.provenance = :heap if bind_ti.is_a?(Type) && !bind_ti.heap_provenance? - bt2 = stmt.respond_to?(:full_type) ? stmt.full_type : nil + bt2 = stmt.full_type bt2.provenance = :heap if bt2.is_a?(Type) && !bt2.heap_provenance? end end diff --git a/src/mir/fsm_lowering.rb b/src/mir/fsm_lowering.rb index 7515b3283..b69a8569f 100644 --- a/src/mir/fsm_lowering.rb +++ b/src/mir/fsm_lowering.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" require_relative '../ast/type' require_relative 'fsm_ops' require_relative 'fsm_wrapper_emitter' @@ -37,11 +39,13 @@ module FsmLowering # explicit values in the struct-literal, so extract just the capture # portion (everything after the alloc init). def capture_inits_fsm(capture_inits) + T.bind(self, MIRLowering) rescue nil # Drop leading ".inner = X.inner, .alloc = Y, " portion. parts = capture_inits.split(", ").drop(2) parts.empty? ? "" : parts.join(", ") + "," end def promote_fsm_decls_to_fields(code, promoted_names, ctx_var) + T.bind(self, MIRLowering) rescue nil return code if promoted_names.empty? out = code.dup promoted_names.each do |name| @@ -76,6 +80,7 @@ def promote_fsm_decls_to_fields(code, promoted_names, ctx_var) # below renders the same MIR to Zig text via MIREmitter. The # recursive emit path uses emit_step_stmts. def lower_step_stmts(stmts, no_result:, ctx_id: nil, exit_promote: nil) + T.bind(self, MIRLowering) rescue nil flat_steps = [] stmts.each do |stmt| if stmt.is_a?(AST::ThenChain) @@ -107,7 +112,7 @@ def lower_step_stmts(stmts, no_result:, ctx_id: nil, exit_promote: nil) result_mir.concat(last_pending) last_is_assign = last_step[:expr].is_a?(AST::Assignment) - expr_type = last_step[:expr].respond_to?(:full_type) ? last_step[:expr].full_type : :Void + expr_type = last_step[:expr].full_type || :Void is_step_void = expr_type.nil? || expr_type == :Void || (expr_type.respond_to?(:to_s) && Type.new(expr_type).zig_type == "void") @@ -153,6 +158,7 @@ def lower_step_stmts(stmts, no_result:, ctx_id: nil, exit_promote: nil) # when the underlying lowering fails (e.g. the AST node has no # MIR equivalent yet). def lower_one_step_to_mir(step) + T.bind(self, MIRLowering) rescue nil mir = lower(step[:expr]) return nil if mir.nil? pending = flush_pending @@ -177,12 +183,13 @@ def lower_one_step_to_mir(step) # (MIR::Let, MIR::Set, MIR::IfStmt, MIR::BgBlock, ...) pass # through unchanged. def wrap_step_as_stmt(step, mir) + T.bind(self, MIRLowering) rescue nil return nil if mir.nil? if step[:binding] return MIR::Let.new(step[:binding], mir, false, nil, nil) end return mir if mir.respond_to?(:stmt?) && mir.stmt? - expr_type = step[:expr].respond_to?(:full_type) ? step[:expr].full_type : :Void + expr_type = step[:expr].full_type || :Void is_void_step = expr_type.nil? || expr_type == :Void || (expr_type.respond_to?(:to_s) && Type.new(expr_type).zig_type == "void") MIR::ExprStmt.new(mir, !is_void_step) @@ -194,6 +201,7 @@ def wrap_step_as_stmt(step, mir) # `result` is the trailing `__ctx.inner.result = ;` # assignment ready for splicing into the dispatch). def emit_step_stmts(stmts, no_result:, ctx_id: nil, exit_promote: nil) + T.bind(self, MIRLowering) rescue nil result = lower_step_stmts(stmts, no_result: no_result, ctx_id: ctx_id, exit_promote: exit_promote) if no_result render_mir_list(result) @@ -202,6 +210,7 @@ def emit_step_stmts(stmts, no_result:, ctx_id: nil, exit_promote: nil) end end def render_mir_list(mir_list) + T.bind(self, MIRLowering) rescue nil return "" if mir_list.nil? || mir_list.empty? @_emitter ||= begin require_relative "mir_emitter" @@ -215,8 +224,7 @@ def render_mir_list(mir_list) # Flatten one level so each emit sees a single MIR node. mir_list.flatten(1).filter_map { |n| out = @_emitter.emit(n) - next nil if out.nil? - next nil if out.respond_to?(:strip) && out.strip.empty? + next nil if out.nil? || out.strip.empty? out }.join("\n ") end @@ -227,6 +235,7 @@ def render_mir_list(mir_list) # capture. Consumed by FsmTransform::Emit.expand_lock_segment # (per-cap fan-out) for both single-cap and multi-cap WITH. def fsm_cap_metadata(cap, with_node, ctx_id, captured) + T.bind(self, MIRLowering) rescue nil return nil unless cap[:capability] == :EXCLUSIVE || cap[:capability] == :write_locked_read @@ -237,8 +246,8 @@ def fsm_cap_metadata(cap, with_node, ctx_id, captured) return nil unless captured.key?(lock_var_name) resolved = cap[:resolved_type] - any_rc = resolved&.respond_to?(:any_rc?) && resolved.any_rc? - write_locked = resolved&.respond_to?(:write_locked?) && resolved.write_locked? + any_rc = resolved&.any_rc? + write_locked = resolved&.write_locked? # A polymorphic LOCKED param (REQUIRES c: LOCKED on a `c: T` # signature) carries sync=:locked but ownership=:affine — its # runtime type may still be Arc(Locked(T)) or Rc(Locked(T)), @@ -246,9 +255,9 @@ def fsm_cap_metadata(cap, with_node, ctx_id, captured) # `c.tryLockForFsm()` against the Arc, which has no such method. # Probe via comptime @hasField so the same FSM body works for # bare and Arc/Rc-wrapped callers. - is_param = var_node.respond_to?(:symbol) && var_node.symbol&.is_param - polymorphic_locked = is_param && !any_rc && resolved&.respond_to?(:sync) && - (resolved.sync == :locked || resolved.sync == :write_locked) + is_param = var_node.symbol&.is_param + polymorphic_locked = is_param && !any_rc && + (resolved&.sync == :locked || resolved&.sync == :write_locked) lock_kind = if cap[:capability] == :write_locked_read :rwlock_read elsif write_locked @@ -301,6 +310,7 @@ def fsm_cap_metadata(cap, with_node, ctx_id, captured) def emit_fsm_lock_error_arm_split(clause:, ctx_id:, with_node:, capture_map:, pointer_captures:, bg_rt:, rt_name:) + T.bind(self, MIRLowering) rescue nil line = with_node.token&.line.to_s case clause[:action] when :raise @@ -341,6 +351,7 @@ def emit_fsm_lock_error_arm_split(clause:, ctx_id:, with_node:, # Default error arm body when no clause is present: surface as a # generic LockError (body sets inner.result; exit_kind = :done). def default_fsm_lock_error_arm_split(id) + T.bind(self, MIRLowering) rescue nil { body_zig: "__ctx_#{id}.inner.result = error.LockError;", exit_kind: :done, diff --git a/src/mir/fsm_transform.rb b/src/mir/fsm_transform.rb index 0182d8485..dfb3184cc 100644 --- a/src/mir/fsm_transform.rb +++ b/src/mir/fsm_transform.rb @@ -168,7 +168,7 @@ def collect_body_locals(stmts) visit.call(node.body) when AST::ForEach if node.var_name && !seen[node.var_name] - ct_obj = node.collection&.respond_to?(:full_type) ? node.collection.full_type : nil + ct_obj = node.collection&.full_type ct = ct_obj.is_a?(Type) ? ct_obj : (ct_obj ? Type.new(ct_obj) : nil) # Defer to the FSM ForEach descriptor for the bound var # type (map's `k` is the KEY type, not element_type which @@ -244,7 +244,7 @@ def contains_suspend_anywhere?(stmts) def suspend_value?(value) return true if value.is_a?(AST::NextExpr) return false unless value.is_a?(AST::FuncCall) || value.is_a?(AST::MethodCall) - md = value.respond_to?(:matched_stdlib_def) ? value.matched_stdlib_def : nil + md = value.matched_stdlib_def !!(md && md[:suspends] && md[:fsm_setup]) end diff --git a/src/mir/fsm_transform/emit.rb b/src/mir/fsm_transform/emit.rb index 29676de5e..87e50a892 100644 --- a/src/mir/fsm_transform/emit.rb +++ b/src/mir/fsm_transform/emit.rb @@ -132,7 +132,7 @@ def build_fsm_unified(ctx, segment_specs, promoted_field_decls, lowering) body.concat(d.setup_stmts || []) end body.compact! - body.reject! { |s| s.respond_to?(:strip) && s.strip.empty? } + body.reject! { |s| s.is_a?(String) && s.strip.empty? } rt_suppress = spec[:rt_suppress] || "" @@ -497,7 +497,7 @@ def build_recursive(ctx, segments, liveness, lowering) lowered, all_promoted.uniq, "__ctx_#{id}", ) end - [lowered, *raw_stmts].reject { |s| s.respond_to?(:strip) && s.strip.empty? } + [lowered, *raw_stmts].reject { |s| s.is_a?(String) && s.strip.empty? } end end diff --git a/src/mir/fsm_transform/liveness.rb b/src/mir/fsm_transform/liveness.rb index e2435ef3b..ab4dcc88e 100644 --- a/src/mir/fsm_transform/liveness.rb +++ b/src/mir/fsm_transform/liveness.rb @@ -206,7 +206,7 @@ def collect_defs(stmt, into) def stmt_decl_type(stmt) candidates = [] - candidates << stmt.full_type if stmt.respond_to?(:full_type) + candidates << stmt.full_type candidates << stmt.type if stmt.respond_to?(:type) candidates << stmt.declared_type if stmt.respond_to?(:declared_type) candidates << stmt.value&.full_type if stmt.respond_to?(:value) && stmt.value diff --git a/src/mir/fsm_transform/recursive_splitter.rb b/src/mir/fsm_transform/recursive_splitter.rb index 2e5dd77b7..7ee127c90 100644 --- a/src/mir/fsm_transform/recursive_splitter.rb +++ b/src/mir/fsm_transform/recursive_splitter.rb @@ -339,7 +339,7 @@ def with_unsupported?(with_node) def stmt_unsupported_suspend?(stmt) sus = Segments.classify_suspend(stmt) return false unless sus.is_a?(Segments::NextSuspend) - ft = sus.promise_ast.respond_to?(:full_type) ? sus.promise_ast.full_type : nil + ft = sus.promise_ast.full_type return true if ft.nil? # Type may already be a Type-like object (test fixtures) or a # raw symbol that needs Type.new(...) wrapping. Try to call @@ -473,7 +473,7 @@ def emit_for_range_fragment(for_stmt, after_idx, builder, lowering) # The splitter never inspects the type directly. def emit_for_each_fragment(for_stmt, after_idx, builder, lowering) coll_ast = for_stmt.collection - coll_type = coll_ast.respond_to?(:full_type) ? coll_ast.full_type : nil + coll_type = coll_ast.full_type ct = coll_type.is_a?(Type) ? coll_type : (coll_type ? Type.new(coll_type) : nil) raise UnsupportedShape, "ForEach without resolved coll type" if ct.nil? diff --git a/src/mir/fsm_transform/segments.rb b/src/mir/fsm_transform/segments.rb index 06458abfd..03698c32a 100644 --- a/src/mir/fsm_transform/segments.rb +++ b/src/mir/fsm_transform/segments.rb @@ -291,7 +291,6 @@ def classify_suspend(stmt) # :fsm_setup metadata -- the FSM template tells us how to set # up the suspend. def io_suspending_call?(call_node) - return false unless call_node.respond_to?(:matched_stdlib_def) md = call_node.matched_stdlib_def !!(md && md[:suspends] && md[:fsm_setup]) end @@ -328,17 +327,15 @@ def rewrite_pipeline_io(body) synth_name = "__pipe_v_#{pipe_counter}" pipe_counter += 1 - tok = stmt.left.respond_to?(:token) ? stmt.left.token : nil + tok = stmt.left.token bind = AST::BindExpr.new(tok, synth_name, nil, stmt.left) bind.mode = :decl - bind.full_type = stmt.left.full_type if stmt.left.respond_to?(:full_type) - out << bind + bind.full_type = stmt.left.full_type out << bind ident = AST::Identifier.new(tok, synth_name) - ident.full_type = stmt.left.full_type if stmt.left.respond_to?(:full_type) - + ident.full_type = stmt.left.full_type rewritten = AST::BinaryOp.new(stmt.token, ident, stmt.op, stmt.right) - rewritten.full_type = stmt.full_type if stmt.respond_to?(:full_type) + rewritten.full_type = stmt.full_type out << rewritten else out << stmt diff --git a/src/mir/fsm_transform/suspend_resolvers.rb b/src/mir/fsm_transform/suspend_resolvers.rb index 6e622e5ad..87f196d2a 100644 --- a/src/mir/fsm_transform/suspend_resolvers.rb +++ b/src/mir/fsm_transform/suspend_resolvers.rb @@ -78,7 +78,7 @@ def resolve_io(io_tail, ctx, lowering) result_zig_type = nil if finish_value_mir && result_var && result_var != "_" bind_stmts << MIR::Let.new(result_var, finish_value_mir, false, nil, nil) - ft = io_tail.call_node.respond_to?(:full_type) ? io_tail.call_node.full_type : nil + ft = io_tail.call_node.full_type result_zig_type = ft ? Type.new(ft).zig_type : nil elsif finish_value_mir bind_stmts << MIR::ExprStmt.new(finish_value_mir, true) @@ -170,8 +170,7 @@ def resolve_next(next_tail, ctx, lowering, susp_idx:) ZIG bind_stmts = [MIR::RawZig.new(bind_zig, "fsm_next_bind", nil, nil)] - promise_ft = next_tail.promise_ast.respond_to?(:full_type) ? - next_tail.promise_ast.full_type : nil + promise_ft = next_tail.promise_ast.full_type sp_zig = promise_ft ? Type.new(promise_ft).zig_type : "anyopaque" inner_zig = if promise_ft && (pt = Type.new(promise_ft)).respond_to?(:tense_type) && pt.tense_type diff --git a/src/mir/fsm_wrapper_emitter.rb b/src/mir/fsm_wrapper_emitter.rb index 5e0a7679b..d77eee8d7 100644 --- a/src/mir/fsm_wrapper_emitter.rb +++ b/src/mir/fsm_wrapper_emitter.rb @@ -97,8 +97,7 @@ def render_b1_ctx_struct(s, mir_emitter) def render_run_body(step, mir_emitter) rendered = (step.body_stmts || []).filter_map do |stmt| out = mir_emitter.emit(stmt) - next nil if out.nil? - next nil if out.respond_to?(:strip) && out.strip.empty? + next nil if out.nil? || out.strip.empty? out end body = rendered.map { |l| indent_block(l, 12) }.join("\n") @@ -194,8 +193,7 @@ def render_ctx_struct(s, mir_emitter) def render_step(step, mir_emitter) rendered = (step.body_stmts || []).filter_map do |stmt| out = mir_emitter.emit(stmt) - next nil if out.nil? - next nil if out.respond_to?(:strip) && out.strip.empty? + next nil if out.nil? || out.strip.empty? out end body = rendered.map { |l| indent_block(l, 12) }.join("\n") @@ -222,7 +220,7 @@ def render_resume_fn_cleanups(cleanups) emitter = MIREmitter.new cleanups.filter_map { |stmt| out = emitter.emit(stmt) - next nil if out.nil? || (out.respond_to?(:strip) && out.strip.empty?) + next nil if out.nil? || out.strip.empty? out }.join("\n") end @@ -352,7 +350,7 @@ def render_dispatch_arm(arm, ctx_id) body_lines.concat(err_action) end body_lines << render_tail(arm.tail, ctx_id) - body = body_lines.compact.reject { |l| l.respond_to?(:empty?) && l.empty? }.join("\n") + body = body_lines.compact.reject(&:empty?).join("\n") [ " #{arm.index} => {", @@ -471,8 +469,7 @@ def render_tail(t, ctx_id) def render_member_fn(fn, mir_emitter) rendered = (fn.body_stmts || []).filter_map do |stmt| out = mir_emitter.emit(stmt) - next nil if out.nil? - next nil if out.respond_to?(:strip) && out.strip.empty? + next nil if out.nil? || out.strip.empty? out end body = rendered.map { |l| indent_block(l, 12) }.join("\n") @@ -531,7 +528,7 @@ def render_spawn_setup(s, blk_label) # ----- helpers ------------------------------------------------------------ def empty?(s) - s.nil? || (s.respond_to?(:empty?) && s.empty?) || (s.respond_to?(:strip) && s.strip.empty?) + s.nil? || s.strip.empty? end # Re-indent every line of `text` by `n` spaces. Preserves blank diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb index 8ae9e173c..9b996aa48 100644 --- a/src/mir/mir_checker.rb +++ b/src/mir/mir_checker.rb @@ -252,7 +252,7 @@ def self.check_fsm_structure!(structure, source: nil) end def self.format_fsm_error(invariant, message, source) - loc = source && source.respond_to?(:line) && source.line ? " at line #{source.line}" : "" + loc = source&.line ? " at line #{source.line}" : "" "[FSM checker]#{loc} #{invariant}: #{message}" end diff --git a/src/mir/mir_lowering.rb b/src/mir/mir_lowering.rb index 4a11715e1..dde7826b9 100644 --- a/src/mir/mir_lowering.rb +++ b/src/mir/mir_lowering.rb @@ -339,11 +339,11 @@ def lower(node) when AST::ProfileStmt then lower_profile(node) else - raise "MIRLowering: unhandled node type #{node.class} at #{node.respond_to?(:token) && node.token ? "line #{node.token.line}" : 'unknown'}" + raise "MIRLowering: unhandled node type #{node.class} at #{node.token ? "line #{node.token.line}" : 'unknown'}" end.tap { |mir| # Apply type coercion (int->float, float->int, etc.) when AST node has coerced_type if mir && node.respond_to?(:coerced_type) && node.coerced_type && - node.respond_to?(:full_type) && node.full_type && + node.full_type && node.coerced_type != node.full_type # Skip coercion for stack-allocated fixed-size arrays (SROA) skip = node.is_a?(AST::ListLit) && node.storage == :stack && @@ -376,7 +376,7 @@ def lower_body(stmts) result.concat(pending) # Inject source map comment for this user-visible statement. # Placed after pending (hoisted synthetic temps have no user source line). - line = s.respond_to?(:token) && s.token ? s.token.line : nil + line = s.token&.line result << MIR::Comment.new("CLR:#{line}") if line # lower_var_decl may return [AllocMark, Let, Cleanup] when the binding needs cleanup. if mir.is_a?(Array) @@ -436,7 +436,7 @@ def lower_program(node, use_c_allocator: false, needs_safety: false, use_debug_a node.statements.each do |stmt| lowered = lower(stmt) next unless lowered - line = stmt.respond_to?(:token) && stmt.token ? stmt.token.line : nil + line = stmt.token&.line # Some lowerings (e.g. union with helpers) return arrays of nodes nodes = lowered.is_a?(::Array) ? lowered : [lowered] nodes.each_with_index do |n, i| @@ -463,7 +463,7 @@ def lower_module(node) next if stmt.visibility == :private lowered = lower(stmt) next unless lowered - line = stmt.respond_to?(:token) && stmt.token ? stmt.token.line : nil + line = stmt.token&.line nodes = lowered.is_a?(::Array) ? lowered : [lowered] nodes.each_with_index do |n, i| fn_items << MIR::Comment.new("CLR:#{line}") if line && i == 0 @@ -473,7 +473,7 @@ def lower_module(node) next if stmt.visibility == :private lowered = lower(stmt) next unless lowered - line = stmt.respond_to?(:token) && stmt.token ? stmt.token.line : nil + line = stmt.token&.line nodes = lowered.is_a?(::Array) ? lowered : [lowered] nodes.each_with_index do |n, i| type_items << MIR::Comment.new("CLR:#{line}") if line && i == 0 @@ -486,7 +486,7 @@ def lower_module(node) when AST::ExternFnDecl, AST::ExternStructDecl lowered = lower(stmt) next unless lowered - line = stmt.respond_to?(:token) && stmt.token ? stmt.token.line : nil + line = stmt.token&.line nodes = lowered.is_a?(::Array) ? lowered : [lowered] nodes.each_with_index do |n, i| fn_items << MIR::Comment.new("CLR:#{line}") if line && i == 0 @@ -567,18 +567,15 @@ def resolve_alloc_sym(alloc_sym, receiver_type = nil, target_node = nil, node = # after the Identifier was annotated (Identifier.storage may be stale). needs_heap ||= if node.is_a?(AST::MethodCall) obj = node.object - (obj.respond_to?(:storage) && obj.storage == :heap) || - (obj.respond_to?(:symbol) && obj.symbol&.reg.respond_to?(:storage) && obj.symbol.reg.storage == :heap) - elsif node.respond_to?(:mutates_receiver) && node.mutates_receiver + obj.storage == :heap || obj.symbol&.reg&.storage == :heap + elsif node.mutates_receiver first = node.args&.first - (first&.respond_to?(:storage) && first.storage == :heap) || - (first.respond_to?(:symbol) && first&.symbol&.reg.respond_to?(:storage) && first.symbol.reg.storage == :heap) + first&.storage == :heap || first&.symbol&.reg&.storage == :heap end - needs_heap ||= (node.respond_to?(:storage) && node.storage == :heap) + needs_heap ||= (node.storage == :heap) needs_heap ? :heap : :frame when :node_storage - storage = node.respond_to?(:storage) ? node.storage : nil - storage == :heap ? :heap : :frame + node.storage == :heap ? :heap : :frame else :heap end end @@ -587,7 +584,7 @@ def resolve_alloc_sym(alloc_sym, receiver_type = nil, target_node = nil, node = def extract_root_var_name(node) case node when AST::Identifier - decl = node.respond_to?(:symbol) ? node.symbol&.reg : nil + decl = node.symbol&.reg (@decl_zig_name_map && decl && @decl_zig_name_map[decl.object_id]) || node.name.to_s when AST::GetField then extract_root_var_name(node.target) when AST::GetIndex then extract_root_var_name(node.target) @@ -715,9 +712,9 @@ def build_drop_entry!(entry, ti, source_node) # Resolve the stdlib alloc: symbol for an AllocMark from the FuncCall node's # matched_stdlib_def. Returns nil when not available (falls back to entry alloc). def resolve_decl_stdlib_alloc(node) - val = node.respond_to?(:value) ? node.value : nil + val = node.value return nil unless val - mdef = val.respond_to?(:matched_stdlib_def) ? val.matched_stdlib_def : nil + mdef = val.matched_stdlib_def return nil unless mdef.is_a?(Hash) case mdef[:alloc] when :heap, :frame then mdef[:alloc] @@ -762,7 +759,7 @@ def lower_field_default(node) end def lower_struct_def(node) - @struct_schemas[node.name.to_sym] = node.fields + @struct_schemas[node.name.to_sym] = Schemas::StructSchema.new(fields: node.fields) if node.type_params&.any? # Generic struct: fn Name(comptime T: type) type { return struct { ... }; } @@ -786,7 +783,7 @@ def lower_struct_def(node) end def lower_union_def(node) - @union_schemas[node.name.to_sym] = node.variants + @union_schemas[node.name.to_sym] = Schemas::UnionSchema.new(variants: node.variants) # Track @indirect fields node.variants.each do |var_name, var_data| @@ -1520,7 +1517,7 @@ def lower_func_call(node) # arg on success (param[:takes] || GIVE). That is the SINGLE source of # truth for "callee takes" — the lowering must not re-derive it from # CopyNode/MoveNode wrappers (a COPY into a borrow param is NOT a take). - takes = a.respond_to?(:was_moved) && a.was_moved + takes = a.was_moved arg = hoist_alloc(lower(a), a, err_cleanup: takes) # Array/List args: convert to slice via .items (skip strings - already []const u8). # @@ -1535,7 +1532,7 @@ def lower_func_call(node) ti = a.type_info callee_param = nil if callee_sig - params_list = callee_sig.respond_to?(:params) ? callee_sig.params : (callee_sig[:params] || []) + params_list = callee_sig.params || [] callee_param = params_list[idx] end callee_wants_mutable_list = @@ -1632,7 +1629,7 @@ def lower_method_call(node) args_mir = node.args.map { |a| # Single source of truth: was_moved (set by annotator when callee TAKES). # See note in lower_call_inner. - takes = a.respond_to?(:was_moved) && a.was_moved + takes = a.was_moved arg = hoist_alloc(lower(a), a, err_cleanup: takes) ti = a.type_info if ti&.array? && !ti&.string? && !ti&.pool? && !a.is_a?(AST::CopyNode) && !a.is_a?(AST::MoveNode) @@ -1831,7 +1828,7 @@ def lower_intrinsic(node) # get the correct disambiguated Zig name. if node.is_a?(AST::MethodCall) && node.object.respond_to?(:name) iz.target_var = extract_root_var_name(node.object) - elsif node.respond_to?(:mutates_receiver) && node.mutates_receiver && node.args&.first&.respond_to?(:name) + elsif node.mutates_receiver && node.args&.first&.respond_to?(:name) iz.target_var = extract_root_var_name(node.args.first) # UFCS: first arg is receiver end iz @@ -1854,7 +1851,7 @@ def lower_extern_direct_call(node) alloc_call = alloc_kind == :heap \ ? MIR::MethodCall.new(rt, "heapAlloc", [], false) \ : MIR::MethodCall.new(rt, "frameAlloc", [], false) - n_comptime = node.args.count { |a| a.respond_to?(:full_type) && a.full_type == :Type } + n_comptime = node.args.count { |a| a.full_type == :Type } args = args[0, n_comptime] + [alloc_call] + args[n_comptime..] end mod_prefix = (node.respond_to?(:module_alias) && node.module_alias) ? "#{node.module_alias.gsub('.', '_')}." : "" @@ -1891,7 +1888,7 @@ def build_extern_trampoline_call(node) # Separate comptime type args (full_type == :Type) from runtime args. # Comptime args can't be struct fields (Zig type is `type`, comptime-only). # They are baked directly into the call_zig string. - comptime_args, runtime_ast_args = node.args.partition { |a| a.respond_to?(:full_type) && a.full_type == :Type } + comptime_args, runtime_ast_args = node.args.partition { |a| a.full_type == :Type } comptime_codes = comptime_args.map { |a| emit_expr(lower_extern_arg(a)) } args = runtime_ast_args.map { |a| lower_extern_arg(a) } @@ -2034,17 +2031,18 @@ def call_heap_provenance_from_type?(ti) def lower_lambda(node) sig = node.full_type + sig = sig.raw if sig.is_a?(Type) @lambda_counter = (@lambda_counter || 0) + 1 fn_name = "_lambda_#{@lambda_counter}" - params_list = sig.respond_to?(:params) ? sig.params : sig[:params] || [] + params_list = sig.params || [] params_mir = [MIR::Param.new("_rt", "*Runtime")] + params_list.map { |p| p_type = p[:type] type_str = p_type.is_a?(Type) ? p_type.zig_type(is_param: true) : transpile_type(p_type || :Any, is_param: true) MIR::Param.new(p[:name], type_str) } - ret = sig.respond_to?(:return_type) ? (sig.return_type || :Void) : (sig[:return]&.fetch(:type, nil) || :Void) + ret = sig.return_type || :Void ret_zig = ret.is_a?(Type) ? ret.zig_type : transpile_type(ret) ret_str = if ret_zig.start_with?("!") || ret_zig.include?("anyerror!") || ret_zig.include?("error{") ret_zig @@ -2154,23 +2152,21 @@ def lower_hash_lit(node) alloc_str = "#{rt_name}.heapAlloc()" # For Arc/Rc-wrapped maps, build bare inner type for init, then wrap - is_arc = ti.respond_to?(:shared?) && ti.shared? - is_rc = ti.respond_to?(:multiowned?) && ti.multiowned? + is_arc = ti.shared? + is_rc = ti.multiowned? if is_arc || is_rc # Sharded maps have their sync mode built into the Zig type # (e.g. MutexShardedStringMap), so they need the legacy direct- # composition path that preserves shard_count + sync on bare_ft. # Plain (non-sharded) maps go through compose_capability_wrap for # the unified Group 1 / Group 2 separation. - is_striped = ti.respond_to?(:striped?) && ti.striped? - if is_striped + if ti.striped? bare_ft = Type.new(ti.resolved.to_s) - bare_ft.shard_count = ti.shard_count if ti.respond_to?(:shard_count) && ti.shard_count - bare_ft.sync = ti.sync if ti.respond_to?(:sync) && ti.shard_count && ti.sync + bare_ft.shard_count = ti.shard_count if ti.shard_count + bare_ft.sync = ti.sync if ti.shard_count && ti.sync zig_t = bare_ft.zig_type - needs_alloc = bare_ft.respond_to?(:map_init_needs_alloc?) ? bare_ft.map_init_needs_alloc? : - (!zig_t.include?("PartitionedStringMap") && !zig_t.include?("PartitionedNumericMap") && !zig_t.include?("NumericMapType")) + needs_alloc = bare_ft.map_init_needs_alloc? inner = if needs_alloc MIR::StructInit.new(zig_t, [{ name: "alloc", value: MIR::Ident.new(alloc_str) }]) else @@ -2276,7 +2272,7 @@ def with_cap_sync_storage(var_node) (live = @current_fiber_capture_symbols&.dig(var_node.name)) return [live.sync, live.storage] end - sym = var_node.respond_to?(:symbol) ? var_node.symbol : nil + sym = var_node.symbol [sym&.sync, sym&.storage] end @@ -2408,6 +2404,9 @@ def lower_with_block(node) # Include the WITH node's object_id in the guard name so nested # WITHs on the same variable (permitted via POSSIBLE_DEADLOCK) # don't produce colliding Zig identifiers. + # Use source position (line_col) — stable across process invocations + # and across refactors that shift Ruby object IDs. Two WITHs at the + # same source position can't exist (they'd be the same WITH). guard_var = "__#{var_name}_guard_#{node.object_id.abs}" is_arc = (var_storage == :shared || var_storage == :multiowned) || resolved&.any_rc? # For function parameters, the caller's wrapper is unknown at @@ -2430,6 +2429,9 @@ def lower_with_block(node) end when :write_locked_read next if needs_sort + # Use source position (line_col) — stable across process invocations + # and across refactors that shift Ruby object IDs. Two WITHs at the + # same source position can't exist (they'd be the same WITH). guard_var = "__#{var_name}_guard_#{node.object_id.abs}" is_arc = (var_storage == :shared || var_storage == :multiowned) || resolved&.any_rc? lock_expr = is_arc ? "#{zig_var}.ctrl.data.*" : zig_var @@ -2448,8 +2450,7 @@ def lower_with_block(node) # @multiowned all bind uniformly to a `*T`-shaped value. is_param = with_cap_is_param?(cap[:var_node]) if is_param && (var_sync.nil? || var_sync == :local) && - cap[:var_node].respond_to?(:symbol) && cap[:var_node].symbol && - !cap[:var_node].symbol.mutable + cap[:var_node].symbol && !cap[:var_node].symbol.mutable aliased_value = with_match_unwrap_value(source_zig) bindings << "const #{zig_safe_name(alias_name)} = #{aliased_value};\n_ = &#{zig_safe_name(alias_name)};" else @@ -3161,9 +3162,8 @@ def emit_snapshot_mutable_call(node, with_label) # picks the right Zig error name (AtomicConflict vs # UpdateRetriesExhausted) and the conflict_action emits the # right ErrorName for the setError call. - sym = var_node.respond_to?(:symbol) ? var_node.symbol : nil - is_atomic_ptr = sym && sym.sync == :atomic && - sym.respond_to?(:layout) && sym.layout == :indirect + sym = var_node.symbol + is_atomic_ptr = sym && sym.sync == :atomic && sym.layout == :indirect conflict_error = is_atomic_ptr ? :AtomicConflict : :MvccConflict conflict_action = emit_conflict_action_zig( node.lock_error_clause, with_label, node, conflict_error, @@ -3194,45 +3194,6 @@ def emit_snapshot_mutable_call(node, with_label) end end - # Wrap a `Versioned.update[Multi]` call expression with the user's - # ON MvccConflict handler (and the RETRY(N) THEN outer-retry shape when - # `retries` is set). When retries==nil, emits a plain catch-switch. - # When retries==N, emits a counted while-loop that retries up to N - # times before running the THEN action. - def wrap_with_conflict_handler(core_call, conflict_action, retries, node) - if retries - retry_label = "__snap_retry_#{node.object_id.abs}" - # The conflict_action ALWAYS terminates control flow: - # RAISE / EXIT -> return error.CheatError - # PASS -> break :__with_ - # -> { stmts } -> body + break :__with_ - # So the action itself exits the retry loop -- no trailing break needed. - <<~ZIG.rstrip - { - var __retry: usize = 0; - #{retry_label}: while (true) : (__retry += 1) { - if (#{core_call}) |_| { - break :#{retry_label}; - } else |__err| switch (__err) { - error.UpdateRetriesExhausted => { - if (__retry + 1 < #{retries}) continue; - #{conflict_action} - }, - else => return __err, - } - } - } - ZIG - else - <<~ZIG.rstrip - #{core_call} catch |__err| switch (__err) { - error.UpdateRetriesExhausted => { #{conflict_action} }, - else => return __err, - }; - ZIG - end - end - # MVCC L6.2 + True-Sync-Polymorphism (#324 / #330): emit the Zig # statements for the user's `ON ` clause. # Mirrors `emit_lock_action_zig` but uses `ErrorName.` @@ -3299,11 +3260,11 @@ def build_sorted_acquire_entries(fallible_caps, fallible:, with_node: nil) alias_name = cap[:alias] || var_name resolved = cap[:resolved_type] zig_var = @do_capture_map&.dig(var_name) || var_name - var_storage = cap[:var_node].respond_to?(:symbol) ? cap[:var_node].symbol&.storage : nil + var_storage = cap[:var_node].symbol&.storage is_arc = (var_storage == :shared || var_storage == :multiowned) || resolved&.any_rc? lock_expr = is_arc ? "#{zig_var}.ctrl.data.*" : zig_var addr_expr = is_arc ? "#{zig_var}.ctrl.data" : "&#{zig_var}" - var_sync = cap[:var_node].respond_to?(:symbol) ? cap[:var_node].symbol&.sync : nil + var_sync = cap[:var_node].symbol&.sync panic_method, err_method = case cap[:capability] when :EXCLUSIVE var_sync == :write_locked ? %w[write writeOrErr] : %w[acquire acquireOrErr] @@ -3700,7 +3661,7 @@ def lower_bg_block(node) elsif code.strip.end_with?(";") || code.strip.end_with?("}") code else - expr_type = step[:expr].respond_to?(:full_type) ? step[:expr].full_type : :Void + expr_type = step[:expr].full_type || :Void is_void_step = expr_type.nil? || expr_type == :Void || (expr_type.respond_to?(:to_s) && Type.new(expr_type).zig_type == "void") is_void_step ? "#{code};" : "_ = #{code};" end @@ -4055,7 +4016,7 @@ def lower_yield(node) end def lower_next_expr(node, alloc_sym = :frame) - promise_type = node.expr.respond_to?(:full_type) ? Type.new(node.expr.full_type || :Void) : nil + promise_type = Type.new(node.expr.full_type || :Void) if promise_type&.promise_list? # NEXT on ~T[]@list: iterate the promise list, await each promise, collect results. @@ -4264,7 +4225,8 @@ def merge_module_schemas!(mod) end if mod.union_schemas @union_schemas.merge!(mod.union_schemas) - mod.union_schemas.each do |uname, variants| + mod.union_schemas.each do |uname, schema| + variants = schema.is_a?(Schemas::UnionSchema) ? schema.variants : schema variants.each do |var_name, var_data| next unless var_data.is_a?(Hash) && var_data[:indirect_fields] var_data[:indirect_fields].each do |fname| @@ -4491,7 +4453,7 @@ def lower_identifier(node) # used outside its pipeline context (after the pipeline expression ended, # or in a pipeline that doesn't have a matching AS declaration). if node.name.match?(/\A\$[a-z]/) - line = node.token&.respond_to?(:line) ? node.token.line : "?" + line = node.token&.line || "?" raise "line #{line}: Undefined pipeline binding '#{node.name}'. " \ "Pipeline bindings must be declared with 'AS #{node.name}' " \ "in the same pipeline expression where they are used." @@ -4514,7 +4476,7 @@ def lower_identifier(node) # Use disambiguated Zig name if the declaration was renamed to avoid # same-name collision in the MIR checker (see lower_var_decl). - decl_node = node.respond_to?(:symbol) ? node.symbol&.reg : nil + decl_node = node.symbol&.reg zig_name = (@decl_zig_name_map && decl_node && @decl_zig_name_map[decl_node.object_id]) || zig_safe_name(node.name) ident = MIR::Ident.new(zig_name) @@ -4533,10 +4495,8 @@ def lower_identifier(node) # caller passes the cell ref so the callee body can dispatch via # @hasDecl probes per family. The callee's anytype param accepts # whichever shape lands. - if node.respond_to?(:symbol) && node.symbol&.sync == :atomic && !@atomic_emit_raw - if node.respond_to?(:atomic_borrow) && node.atomic_borrow - return ident - end + if node.symbol&.sync == :atomic && !@atomic_emit_raw + return ident if node.atomic_borrow # AtomicPtr M3.5: @indirect:atomic uses the WITH SNAPSHOT # surface (read returns a Guard via cell.read(rt), MUTABLE # body wraps in cell.update(rt, ..., closure)). The bare @@ -4559,16 +4519,6 @@ def lower_identifier(node) ident end - # Helper for the atomic auto-rewrite path: lower an Identifier without - # the `c.load()` wrap, so we can build `c.fetchAdd(...)` etc. - # Atomics M2.2: with the Arc dropped, the cell IS the binding. - def lower_atomic_cell_ref(name_node) - @atomic_emit_raw = true - ident_mir = lower(name_node) - @atomic_emit_raw = false - ident_mir - end - def lower_unary_op(node) right = lower(node.right) case node.op @@ -4747,9 +4697,9 @@ def unit_variant_access(node) return nil unless node.target.is_a?(AST::Identifier) type_name = node.target.name.to_sym schema = @union_schemas&.dig(type_name) - return nil unless schema - var_data = schema[node.field] - return nil unless schema.key?(node.field) + return nil unless schema.is_a?(Schemas::UnionSchema) + var_data = schema.variants[node.field] + return nil unless schema.variants.key?(node.field) # Unit variants have nil / Symbol / Type variant data. Inline-struct # variants are Hashes with :kind => :inline_struct; payload variants # like `Idle: Int64` are Symbols/Types -- both could appear at this @@ -4819,7 +4769,7 @@ def lower_smooth(node) # COLLECT only needs to call .next() to read the final value. if rhs.is_a?(AST::CollectOp) left = lower(node.left) - ft = if node.left.respond_to?(:full_type) && node.left.full_type + ft = if node.left.full_type node.left.full_type.is_a?(Type) ? node.left.full_type : Type.new(node.left.full_type) else nil @@ -4879,7 +4829,7 @@ def lower_smooth(node) # Capture snapshot for CATCH blocks: store LHS before the failable call snapshot_stmts = nil if @current_fn_has_catch - lhs_type = node.left.respond_to?(:full_type) ? node.left.full_type : nil + lhs_type = node.left.full_type if lhs_type t = Type.new(lhs_type) unless t.void? || t.error_union? @@ -4902,18 +4852,16 @@ def lower_smooth(node) # x |> f -> f(x) synthetic = AST::FuncCall.new(rhs.token, rhs.name, [node.left]) synthetic.full_type = node.full_type - synthetic.storage = node.storage if node.respond_to?(:storage) - synthetic.zig_pattern = rhs.zig_pattern if rhs.respond_to?(:zig_pattern) && rhs.zig_pattern + synthetic.storage = node.storage + synthetic.zig_pattern = rhs.zig_pattern if rhs.zig_pattern lower_func_call(synthetic) elsif rhs.is_a?(AST::FuncCall) # x |> f(y) -> f(x, y) synthetic = AST::FuncCall.new(rhs.token, rhs.name, [node.left] + rhs.args) synthetic.full_type = node.full_type || rhs.full_type - synthetic.storage = node.storage if node.respond_to?(:storage) - synthetic.zig_pattern = rhs.zig_pattern if rhs.respond_to?(:zig_pattern) && rhs.zig_pattern - if rhs.respond_to?(:coerced_type) && rhs.coerced_type - synthetic.coerced_type = rhs.coerced_type - end + synthetic.storage = node.storage + synthetic.zig_pattern = rhs.zig_pattern if rhs.zig_pattern + synthetic.coerced_type = rhs.coerced_type if rhs.coerced_type lower_func_call(synthetic) else raise "MIRLowering: unhandled SMOOTH RHS #{rhs.class}" @@ -4932,7 +4880,7 @@ def lower_smooth(node) # ================================================================ def lower_or_rescue(node) - t_left = node.left.respond_to?(:full_type) && node.left.full_type ? Type.new(node.left.full_type) : nil + t_left = node.left.full_type ? Type.new(node.left.full_type) : nil # CLEAR's auto-propagate strips `!T` from a fallible call's # full_type (so `x = call()` is x: T at the binding level). The # original `!T` is stashed on `error_union_type`. OR-RESCUE needs @@ -4963,7 +4911,7 @@ def lower_or_rescue(node) if is_error rt_name = @rt_name ex = node.right - line = node.respond_to?(:token) && node.token ? node.token.line : 0 + line = node.token&.line || 0 stmts = [] if ex.kind @@ -5033,8 +4981,8 @@ def lower_get_field(node) # Union unit-variant constructor: Type{ .Variant = {} } if node.target.is_a?(AST::Identifier) schema = @union_schemas&.dig(node.target.name.to_sym) - if schema - var_data = schema[node.field] + if schema.is_a?(Schemas::UnionSchema) + var_data = schema.variants[node.field] unless var_data.is_a?(Hash) && var_data[:kind] == :inline_struct return MIR::StructInit.new(node.target.name, [{ name: node.field.to_s, value: MIR::Lit.new("{}") }]) end @@ -5046,7 +4994,7 @@ def lower_get_field(node) if node.target.is_a?(AST::OptionalUnwrap) inner_ti = node.target.type_info # T (unwrapped) inner_mir = lower(node.target.target) - inner_sync = node.target.target.respond_to?(:symbol) ? node.target.target.symbol&.sync : nil + inner_sync = node.target.target.symbol&.sync field_expr = if inner_ti&.multiowned? || inner_ti&.shared? || inner_sync == :locked || inner_sync == :write_locked "_r.ctrl.data.#{node.field}" @@ -5073,7 +5021,7 @@ def lower_get_field(node) is_rc_unwrapped = node.target.is_a?(AST::Identifier) && rc_map.key?(node.target.name) is_locked_unwrapped = node.target.is_a?(AST::Identifier) && locked_map.key?(node.target.name) - target_sync = node.target.respond_to?(:symbol) ? node.target.symbol&.sync : nil + target_sync = node.target.symbol&.sync if (ti&.multiowned? || ti&.shared?) && !is_rc_unwrapped # target.ctrl.data.field ctrl = MIR::FieldGet.new(target, "ctrl") @@ -5099,10 +5047,11 @@ def lower_get_field(node) # matching (vs. plain struct-field access). target_type_sym = ti&.resolved&.to_s&.to_sym union_schema = target_type_sym && @union_schemas&.dig(target_type_sym) + union_variants = union_schema.is_a?(Schemas::UnionSchema) ? union_schema.variants : nil field_str = node.field.to_s - if union_schema && (union_schema.key?(node.field) || - union_schema.key?(field_str) || - union_schema.key?(field_str.to_sym)) + if union_variants && (union_variants.key?(node.field) || + union_variants.key?(field_str) || + union_variants.key?(field_str.to_sym)) return MIR::UnionVariantGet.new(target, field_str, ti.zig_type) end @@ -5188,7 +5137,7 @@ def lower_get_index(node) MIR::IndexGet.new(items, cast_idx) elsif ti && direct_indexable_collection_type?(ti) direct_index_get(target, index, node.target, ti) || begin - builtin = INDEX_OPS.dig(ti&.dispatch_key, :get, :builtin) || :getAt + builtin = INDEX_OPS.dig(ti.dispatch_key, :get, :builtin) || :getAt emit_builtin(builtin, [target, index]) end else @@ -5216,7 +5165,9 @@ def lower_struct_lit(node) needs_items = vt&.list_collection? && !v.is_a?(AST::CopyNode) && !(v.respond_to?(:target_is_list_field) && v.target_is_list_field) # BORROWED fields: source may be ArrayList but field expects slice - field_def = @struct_schemas&.dig(node.name.to_sym, k) + schema_for_name = @struct_schemas&.dig(node.name.to_sym) + schema_fields = schema_for_name.is_a?(Schemas::StructSchema) ? schema_for_name.fields : schema_for_name + field_def = schema_fields&.[](k) if field_def.is_a?(Hash) && field_def[:borrowed] && vt&.array? && !needs_items val = MIR::ItemsAccess.new(val, true) elsif needs_items @@ -5272,7 +5223,7 @@ def lower_struct_lit(node) def lower_union_variant_lit(node) schema = @union_schemas&.dig(node.union_name.to_sym) - var_data = schema&.dig(node.variant_name) + var_data = schema.is_a?(Schemas::UnionSchema) ? schema.variants[node.variant_name] : schema&.dig(node.variant_name) indirect = (var_data.is_a?(Hash) && var_data[:indirect_fields]) || Set.new # Collect hoisted statements for @indirect fields (same pattern as lower_struct_lit). @@ -5402,7 +5353,7 @@ def lower_assert(node) # follows the assertion's condition. Normalize to "assertion failed" # so the user-visible message isn't the literal string "Any". raw = node.message - msg_str = if raw.nil? || raw == :Any || (raw.respond_to?(:empty?) && raw.empty?) + msg_str = if raw.nil? || raw == :Any || raw.empty? "assertion failed" else raw.to_s @@ -5790,10 +5741,7 @@ def lower_var_decl(node) # flip it to `const`. Without this, &binding produces *const T, # which doesn't unify with the body's `*T` parameter and the # mutation never reaches the caller. - if node.respond_to?(:symbol) && node.symbol&.respond_to?(:poly_borrow_target) && - node.symbol.poly_borrow_target - is_mutable = true - end + is_mutable = true if node.symbol&.poly_borrow_target # Post-dataflow cleanup entry (cleanup_decisions! refinements are correct here). # For same-name vars in different scopes, alloc is overridden per-declaration @@ -5803,18 +5751,16 @@ def lower_var_decl(node) has_mir_drop = binding_entry && binding_entry[:needs_cleanup] && !binding_entry[:match_as] actually_mutated = is_mutable && node.respond_to?(:var_mutated) && node.var_mutated == true - has_mutable_cleanup = has_mir_drop || ft&.collection? || ft&.dynamic_stream? || ft&.bounded_stream? || ft&.shared_promise? || - ft&.open_stream? || ft&.inf_stream? || (ft&.array? && ft&.dynamic?) || - ft&.heap_provenance? || ft&.resource? || node.resource_close_zig + has_mutable_cleanup = has_mir_drop || ft.collection? || ft.dynamic_stream? || ft.bounded_stream? || ft.shared_promise? || + ft.open_stream? || ft.inf_stream? || (ft.array? && ft.dynamic?) || + ft.heap_provenance? || ft.resource? || node.resource_close_zig forced_var = is_mutable && has_mutable_cleanup # True-Sync-Polymorphism Gate 3: a binding whose address is taken # at a universal-poly call site MUST emit Zig `var` so &binding is # *T (not *const T). The local-mutation analyzer would otherwise # downgrade this to `const` when the binding is only "field-mutated" # via the polymorphic body -- which is invisible to var_mutated. - poly_borrow = node.respond_to?(:symbol) && node.symbol && - node.symbol.respond_to?(:poly_borrow_target) && - node.symbol.poly_borrow_target == true + poly_borrow = node.symbol&.poly_borrow_target == true keyword_mutable = if !is_mutable false elsif actually_mutated || forced_var || poly_borrow @@ -6152,7 +6098,7 @@ def lower_indexed_assignment(node) rt_name = @rt_name # Resolve INDEX_OPS :set entry via dispatch_key - kind = ti&.dispatch_key + kind = ti.dispatch_key op = kind && INDEX_OPS.dig(kind, :set) target = lower(target_node) @@ -6902,7 +6848,7 @@ def lower_return(node) ] MIR::ScopeBlock.new(stmts) elsif needs_string_dupe && value - ret_type = node.value.respond_to?(:full_type) ? Type.new(node.value.full_type) : nil + ret_type = node.value.full_type ? Type.new(node.value.full_type) : nil if ret_type&.string? MIR::ScopeBlock.new([ MIR::AllocMark.new("__ret_dupe", :heap, nil), @@ -6969,12 +6915,12 @@ def universal_poly_arg_needs_addr?(arg_node, callee_sig, idx) pname = (param[:name] || param["name"]).to_s fams = callee_sig.requires[pname] # Universal poly: REQUIRES key present AND the family-set is empty. - return false unless fams && fams.respond_to?(:empty?) && fams.empty? + return false unless fams && fams.empty? # The arg must be a plain (no-sync, no-Arc, no-pointer) struct # binding so & flips it into *T territory. Identifier lookup gives # us the SymbolEntry; bail if anything's missing. return false unless arg_node.is_a?(AST::Identifier) - sym = arg_node.respond_to?(:symbol) ? arg_node.symbol : nil + sym = arg_node.symbol return false unless sym # Skip if the binding is already pointer-shaped or sync-wrapped -- # the helper handles those via comptime dispatch. diff --git a/src/mir/mir_pass.rb b/src/mir/mir_pass.rb index 71e3995bc..d9330c79b 100644 --- a/src/mir/mir_pass.rb +++ b/src/mir/mir_pass.rb @@ -9,6 +9,10 @@ require_relative "escape_analysis" class MIRPass + # Read-only context threaded through transform_body / recurse_branches!. + # Reek flagged the (bindings, promo) carry-down across many call sites. + WalkCtx = Data.define(:bindings, :promo) + # cleanup_bindings: { fn_name => { var_name => entry_hash } } # Exposed for specs that test classification directly. attr_reader :cleanup_bindings @@ -146,16 +150,17 @@ def transform_function!(fn, promo) @fn_has_catch = has_catch @current_transform_fn = fn - fn.body = transform_body(fn.body, bindings, promo) + fn.body = transform_body(fn.body, WalkCtx.new(bindings: bindings, promo: promo)) @current_transform_fn = nil # Transform catch clause bodies so string returns are annotated for heap-dupe. if has_catch + empty_ctx = WalkCtx.new(bindings: nil, promo: nil) fn.catch_clauses.each do |clause| - clause[:body] = transform_body(clause[:body], nil, nil) if clause[:body] + clause[:body] = transform_body(clause[:body], empty_ctx) if clause[:body] end if fn.default_catch.is_a?(Array) - fn.default_catch = transform_body(fn.default_catch, nil, nil) + fn.default_catch = transform_body(fn.default_catch, empty_ctx) end end @fn_has_catch = false @@ -211,12 +216,14 @@ def walk_for_bg_captures(stmts, bindings) # Recursively transform a statement list, inserting MIR nodes. # Returns a new array (does not mutate the input). - def transform_body(stmts, bindings, promo) + def transform_body(stmts, ctx) return stmts unless stmts.is_a?(Array) result = [] + bindings = ctx.bindings + promo = ctx.promo stmts.each do |stmt| # Recurse into nested control flow first. - recurse_branches!(stmt, bindings, promo) + recurse_branches!(stmt, ctx) # Insert Return (escape markers) + Promote before ReturnNode. if stmt.is_a?(AST::ReturnNode) @@ -280,33 +287,33 @@ def transform_body(stmts, bindings, promo) end # Recurse into control flow branches to transform nested bodies. - def recurse_branches!(stmt, bindings, promo) + def recurse_branches!(stmt, ctx) case stmt when AST::IfStatement - stmt.then_branch = transform_body(stmt.then_branch, bindings, promo) if stmt.then_branch - stmt.else_branch = transform_body(stmt.else_branch, bindings, promo) if stmt.else_branch + stmt.then_branch = transform_body(stmt.then_branch, ctx) if stmt.then_branch + stmt.else_branch = transform_body(stmt.else_branch, ctx) if stmt.else_branch when AST::WhileLoop - stmt.do_branch = transform_body(stmt.do_branch, bindings, promo) if stmt.do_branch + stmt.do_branch = transform_body(stmt.do_branch, ctx) if stmt.do_branch when AST::WhileBindLoop - stmt.do_branch = transform_body(stmt.do_branch, bindings, promo) if stmt.do_branch + stmt.do_branch = transform_body(stmt.do_branch, ctx) if stmt.do_branch when AST::IfBind - stmt.then_branch = transform_body(stmt.then_branch, bindings, promo) if stmt.then_branch - stmt.else_branch = transform_body(stmt.else_branch, bindings, promo) if stmt.else_branch && !stmt.else_branch.empty? + stmt.then_branch = transform_body(stmt.then_branch, ctx) if stmt.then_branch + stmt.else_branch = transform_body(stmt.else_branch, ctx) if stmt.else_branch && !stmt.else_branch.empty? when AST::ForRange, AST::ForEach - stmt.body = transform_body(stmt.body, bindings, promo) if stmt.body + stmt.body = transform_body(stmt.body, ctx) if stmt.body when AST::MatchStatement - stmt.cases&.each { |c| c[:body] = transform_body(c[:body], bindings, promo) if c[:body] } + stmt.cases&.each { |c| c[:body] = transform_body(c[:body], ctx) if c[:body] } if stmt.default_case - stmt.default_case = transform_body(stmt.default_case, bindings, promo) + stmt.default_case = transform_body(stmt.default_case, ctx) end when AST::WithBlock - stmt.body = transform_body(stmt.body, bindings, promo) if stmt.body + stmt.body = transform_body(stmt.body, ctx) if stmt.body when AST::DoBlock stmt.branches&.each do |b| - b[:body] = transform_body(b[:body], bindings, promo) if b[:body] + b[:body] = transform_body(b[:body], ctx) if b[:body] end when AST::BgBlock, AST::BgStreamBlock - stmt.body = transform_body(stmt.body, bg_inner_bindings(stmt, bindings), promo) if stmt.body + stmt.body = transform_body(stmt.body, ctx.with(bindings: bg_inner_bindings(stmt, ctx.bindings))) if stmt.body end # Process BgBlock bodies found in expression positions (MethodCall/FuncCall # args, VarDecl/BindExpr values). AST.walk_body misses these since it doesn't @@ -314,14 +321,14 @@ def recurse_branches!(stmt, bindings, promo) # BgStreamBlock (generator fiber has special YIELD handling). case stmt when AST::VarDecl, AST::BindExpr, AST::Assignment - val = stmt.respond_to?(:value) ? stmt.value : nil + val = stmt.value if val.is_a?(AST::BgBlock) && val.body - val.body = transform_body(val.body, bg_inner_bindings(val, bindings), promo) + val.body = transform_body(val.body, ctx.with(bindings: bg_inner_bindings(val, ctx.bindings))) end when AST::MethodCall, AST::FuncCall stmt.args&.each do |a| if a.is_a?(AST::BgBlock) && a.body - a.body = transform_body(a.body, bg_inner_bindings(a, bindings), promo) + a.body = transform_body(a.body, ctx.with(bindings: bg_inner_bindings(a, ctx.bindings))) end end end @@ -472,7 +479,7 @@ def annotate_bg_exit_promote!(bg_node) return unless last_expr return if last_expr.is_a?(AST::Assignment) - ft = last_expr.respond_to?(:full_type) ? last_expr.full_type : nil + ft = last_expr.full_type return unless ft t = ft.is_a?(Type) ? ft : Type.new(ft) return if t.void? @@ -490,11 +497,8 @@ def bg_exit_needs_string_dupe?(expr, t) return false if t.heap? || t.rodata? return true if t.frame? # No explicit provenance: check the stdlib def for frame allocation. - if expr.respond_to?(:matched_stdlib_def) - msd = expr.matched_stdlib_def - return true if msd.is_a?(Hash) && msd[:return_alloc] == :frame - end - false + msd = expr.matched_stdlib_def + msd.is_a?(Hash) && msd[:return_alloc] == :frame end # Annotate YieldExpr nodes inside a BgStreamBlock that yield frame-allocated strings. @@ -507,7 +511,7 @@ def walk_stream_yields(stmts) return unless stmts.is_a?(Array) stmts.each do |stmt| if stmt.is_a?(AST::YieldExpr) - ft = stmt.expr.respond_to?(:full_type) ? stmt.expr.full_type : nil + ft = stmt.expr.full_type t = ft.is_a?(Type) ? ft : (ft ? Type.new(ft) : nil) stmt.yield_dupe = true if t && bg_exit_needs_string_dupe?(stmt.expr, t) else @@ -555,7 +559,7 @@ def insert_bg_escape_promote!(result, stmt) # MIR::Promote(:catch_string_dupe) pending-flag mechanism. def insert_catch_string_dupe!(result, ret_node) return unless ret_node.value - ft = ret_node.value.respond_to?(:full_type) ? ret_node.value.full_type : nil + ft = ret_node.value.full_type return unless ft t = Type.new(ft) return unless t.string? @@ -691,7 +695,7 @@ def walk_consumed(node, names, bindings) node.args.each do |a| if a.is_a?(AST::MoveNode) && a.value.is_a?(AST::Identifier) add_if_consumed(a.value, names, bindings, true) - elsif a.respond_to?(:was_moved) && a.was_moved && a.is_a?(AST::Identifier) + elsif a.is_a?(AST::Identifier) && a.was_moved add_if_consumed(a, names, bindings, true) elsif a.is_a?(AST::StructLit) || a.is_a?(AST::CapabilityWrap) walk_consumed(a, names, bindings) @@ -710,8 +714,7 @@ def add_if_consumed(ident, names, bindings, is_move) ti = ident.type_info return if ti&.string? - is_atomic_ptr = ti && ti.respond_to?(:sync) && ti.sync == :atomic && - ti.respond_to?(:layout) && ti.layout == :indirect + is_atomic_ptr = ti && ti.sync == :atomic && ti.layout == :indirect # RC types: only consume on explicit GIVE. AtomicPtr is represented # as shared for escape/lifetime purposes, but its runtime value is a # unique heap cell pointer, not an Arc handle. @@ -917,8 +920,7 @@ def collect_escaping_ids(node) when AST::MoveNode then collect_escaping_ids(node.value) when AST::StructLit then node.fields.values.flat_map { |v| collect_escaping_ids(v) } when AST::FuncCall, AST::MethodCall - node.args.select { |a| a.respond_to?(:was_moved) && a.was_moved } - .flat_map { |a| collect_escaping_ids(a) } + node.args.select(&:was_moved).flat_map { |a| collect_escaping_ids(a) } when AST::CopyNode, AST::CloneNode, AST::FreezeNode then [] else [] end diff --git a/src/mir/ownership_graph.rb b/src/mir/ownership_graph.rb index 4fc350d9a..618109a31 100644 --- a/src/mir/ownership_graph.rb +++ b/src/mir/ownership_graph.rb @@ -270,8 +270,8 @@ def record_move_site(node, at_token, action) node.move_action = action return unless at_token - node.move_line = at_token.respond_to?(:line) ? at_token.line : nil - node.move_col = at_token.respond_to?(:column) ? at_token.column : nil + node.move_line = at_token.line + node.move_col = at_token.column end def collect_descendants(path, result) diff --git a/src/mir/promotion_plan.rb b/src/mir/promotion_plan.rb index 4ac0fe43b..3cf03d7db 100644 --- a/src/mir/promotion_plan.rb +++ b/src/mir/promotion_plan.rb @@ -86,8 +86,8 @@ def self.classify(fn_node, schema_lookup:) end if needs_promote ret_schema = schema_lookup.call(ret_type.resolved) rescue nil - if ret_schema.is_a?(Hash) && ret_schema[:kind] == :union - has_heap = (ret_schema[:variants] || {}).any? { |_, vt| Type.variant_has_heap?(vt) } + if (us = Schemas.as_union_schema(ret_schema)) + has_heap = (us.variants || {}).any? { |_, vt| Type.variant_has_heap?(vt) } if has_heap struct_promote ||= zig_type_for(ret_type) promote_return_ids << ret_node.object_id @@ -117,7 +117,7 @@ def self.classify(fn_node, schema_lookup:) end schema = schema_lookup.call(ret_type.resolved) rescue nil - is_union = schema.is_a?(Hash) && schema[:kind] == :union + is_union = (schema = Schemas.as_union_schema(schema)) unhandled_fields = nil if struct_promote.nil? if var_promotes.any? || handled_fields.any? @@ -184,9 +184,8 @@ def self.needs_promote?(plan, ret_node) return false unless schema_lookup && ti resolved = ti.resolved schema = schema_lookup.call(resolved) rescue nil - return false unless schema.is_a?(Hash) && !schema[:kind] # structs only (no :kind key) - schema.any? do |k, v| - next false if k.is_a?(Symbol) + return false unless (schema = Schemas.as_struct_schema(schema)) + schema.fields.any? do |_, v| ft = v.is_a?(Hash) ? v[:type] : v t = ft.is_a?(Type) ? ft : (Type.new(ft || :Any) rescue nil) next false unless t @@ -203,11 +202,10 @@ def self.needs_promote?(plan, ret_node) private_class_method def self.compute_struct_promote(ret_type, schema_lookup, handled_fields) resolved = ret_type.resolved schema = schema_lookup.call(resolved) rescue nil - return [nil, nil] unless schema.is_a?(Hash) && !schema[:kind] + return [nil, nil] unless (schema = Schemas.as_struct_schema(schema)) unhandled = [] - schema.each do |fname, fdef| - next if fname.is_a?(Symbol) + schema.fields.each do |fname, fdef| next if handled_fields.include?(fname.to_s) ft = fdef.is_a?(Type) ? fdef : Type.new(fdef.is_a?(Hash) ? (fdef[:type] || :Any) : (fdef || :Any)) unhandled << fname.to_s if ft.needs_escape_promotion? @@ -419,7 +417,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) result = [] case stmt when AST::VarDecl, AST::BindExpr, AST::Assignment - val = stmt.respond_to?(:value) ? stmt.value : nil + val = stmt.value result << val.body if val.is_a?(AST::BgBlock) && val.body when AST::MethodCall stmt.args&.each { |a| result << a.body if a.is_a?(AST::BgBlock) && a.body } @@ -470,11 +468,11 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) schema = schema_lookup.call(ti.resolved) rescue nil # Schema-driven kinds: resource (close_zig) and union (heap variants). - if schema.is_a?(Hash) && schema[:kind] == :resource - return entry(:resource, resource_close_zig: schema[:close_zig]) + if (rs = Schemas.as_resource_schema(schema)) + return entry(:resource, resource_close_zig: rs.close_zig) end - if schema.is_a?(Hash) && schema[:kind] == :union - has_heap = (schema[:variants] || {}).any? { |_, vt| Type.variant_has_heap?(vt) } + if (us = Schemas.as_union_schema(schema)) + has_heap = (us.variants || {}).any? { |_, vt| Type.variant_has_heap?(vt) } return has_heap ? entry(:takes_union) : nil end @@ -503,7 +501,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) source_ti = Type.new(source_ti) if source_ti && !source_ti.is_a?(Type) union_lookup = source_ti&.generic_instance? ? source_ti.generic_base : source_ti&.resolved schema = schema_lookup.call(union_lookup) rescue nil - next unless schema.is_a?(Hash) && schema[:kind] == :union + next unless (schema = Schemas.as_union_schema(schema)) (node.cases || []).each do |c| next unless c[:binding] @@ -514,7 +512,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) end next unless variant_name - variant_type = (schema[:variants] || {})[variant_name] + variant_type = (schema.variants || {})[variant_name] next unless variant_type if variant_type.is_a?(Hash) && variant_type[:kind] == :inline_struct @@ -746,7 +744,7 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) e = entry(:rc, rc_variant: :standard, rc_alloc: rc_alloc) if ti.any_rc? && !ti.sync schema = schema_lookup.call(ti.resolved) rescue nil - if schema.is_a?(Hash) && !schema[:kind] + if (schema = Schemas.as_struct_schema(schema)) e[:needs_release_fields] = true e[:base_zig] = base_zig end @@ -772,13 +770,12 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) return entry(:heap_slice) if ti.array? && !ti.collection? schema = schema_lookup.call(ti.resolved) rescue nil - if schema.is_a?(Hash) && schema[:kind] == :union - has_heap = (schema[:variants] || {}).any? { |_, vt| Type.variant_has_heap?(vt) } + if (us = Schemas.as_union_schema(schema)) + has_heap = (us.variants || {}).any? { |_, vt| Type.variant_has_heap?(vt) } return entry(:heap_union) if has_heap end - if schema.is_a?(Hash) && !schema[:kind] - has_escapable = schema.any? do |k, v| - next false if k.is_a?(Symbol) + if (ss = Schemas.as_struct_schema(schema)) + has_escapable = ss.fields.any? do |_, v| ft = v.is_a?(Type) ? v : Type.new(v.is_a?(Hash) ? (v[:type] || :Any) : (v || :Any)) ft.needs_escape_promotion? end @@ -809,13 +806,12 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) # Consult schema to pick the correct cleanup kind. schema = schema_lookup.call(ti.resolved) rescue nil - if schema.is_a?(Hash) && schema[:kind] == :union - has_heap = (schema[:variants] || {}).any? { |_, vt| Type.variant_has_heap?(vt) } + if (us = Schemas.as_union_schema(schema)) + has_heap = (us.variants || {}).any? { |_, vt| Type.variant_has_heap?(vt) } return entry(:heap_union) if has_heap end - if schema.is_a?(Hash) && !schema[:kind] - has_escapable = schema.any? do |k, v| - next false if k.is_a?(Symbol) + if (ss = Schemas.as_struct_schema(schema)) + has_escapable = ss.fields.any? do |_, v| ft = v.is_a?(Type) ? v : Type.new(v.is_a?(Hash) ? (v[:type] || :Any) : (v || :Any)) ft.needs_escape_promotion? end @@ -827,11 +823,10 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) private_class_method def self.classify_struct_cleanup_fields(ti, node, schema_lookup) schema = schema_lookup.call(ti.resolved) rescue nil - return nil unless schema.is_a?(Hash) && !schema[:kind] + return nil unless (schema = Schemas.as_struct_schema(schema)) struct_lit = node.respond_to?(:value) && node.value.is_a?(AST::StructLit) ? node.value : nil - has_cleanup = schema.any? do |k, v| - next false if k.is_a?(Symbol) + has_cleanup = schema.fields.any? do |k, v| ft = v.is_a?(Hash) ? v[:type] : v t = ft.is_a?(Type) ? ft : Type.new(ft || :Any) next true if t.link? || t.any_rc? @@ -852,10 +847,10 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) private_class_method def self.classify_non_copy_union(ti, schema_lookup) schema = schema_lookup.call(ti.resolved) rescue nil - return nil unless schema.is_a?(Hash) && schema[:kind] == :union + return nil unless (schema = Schemas.as_union_schema(schema)) is_copy = ti.implicitly_copyable? { |t| schema_lookup.call(t) rescue nil } rescue true return nil if is_copy - has_heap_variants = (schema[:variants] || {}).any? { |_, vt| Type.variant_has_heap?(vt) } + has_heap_variants = (schema.variants || {}).any? { |_, vt| Type.variant_has_heap?(vt) } alloc = has_heap_variants ? :heap : (ti.provenance_alloc || :frame) entry(:non_copy_union, alloc: alloc) end @@ -870,20 +865,18 @@ def self.stamp_field_pre_cleanups!(body, bindings, schema_lookup: nil) # Deeper check for union/struct elements: strings in union payloads are # always heap-duped even without explicit heap_provenance? on the type. elem_schema = schema_lookup.call(et.resolved) rescue nil - return false unless elem_schema.is_a?(Hash) - if elem_schema[:kind] == :union - (elem_schema[:variants] || {}).any? { |_, vt| Type.variant_has_heap?(vt) } - elsif !elem_schema[:kind] # struct - elem_has_string_fields?(elem_schema) + if (us = Schemas.as_union_schema(elem_schema)) + (us.variants || {}).any? { |_, vt| Type.variant_has_heap?(vt) } + elsif (ss = Schemas.as_struct_schema(elem_schema)) + elem_has_string_fields?(ss) else false end end private_class_method def self.elem_has_string_fields?(schema) - return false unless schema.is_a?(Hash) && !schema[:kind] - schema.any? do |k, v| - next false if k.is_a?(Symbol) + return false unless (schema = Schemas.as_struct_schema(schema)) + schema.fields.any? do |_, v| ft = v.is_a?(Hash) ? v[:type] : v t = ft.is_a?(Type) ? ft : Type.new(ft || :Any) t.string? diff --git a/src/mir/test_lowering.rb b/src/mir/test_lowering.rb index 21b70ada5..45f37bd72 100644 --- a/src/mir/test_lowering.rb +++ b/src/mir/test_lowering.rb @@ -1,3 +1,5 @@ +# typed: true +require "sorbet-runtime" # TestLowering — MIR-side handling of CLEAR's test grammar. # # Mixed into MIRLowering. Covers: @@ -39,6 +41,7 @@ module TestLowering ZIG def lower_test_block(node) + T.bind(self, MIRLowering) rescue nil ctx = TestBlockCtx.new(node, self) tests = [] ctx.emit_all_hooks(node.before_all, "__before_all", "", tests) @@ -56,6 +59,7 @@ def lower_test_block(node) # AFTER ALL hooks). Mutates @active_stubs around the body so # WHEN-local STUBs don't leak to sibling WHENs. def lower_when_block(when_block, ctx, tests) + T.bind(self, MIRLowering) rescue nil when_desc = when_block.description prev_stubs = (@active_stubs || {}).dup @@ -117,6 +121,7 @@ def lower_when_block(when_block, ctx, tests) # around the body, and prepends only the LET decls actually # referenced by the body or the active hook bodies (lazy). def lower_test_that(test_that, env) + T.bind(self, MIRLowering) rescue nil full_name = "#{env.ctx.test_name}: #{env.when_desc}: #{test_that.description}#{env.tag_suffix}" if test_that.pending @@ -168,6 +173,7 @@ def lower_test_that(test_that, env) # Scope @current_bindings to the cleanup-classifier's synthetic FN # wrapper for the duration of the block. Restored unconditionally. def with_test_that_bindings(test_that) + T.bind(self, MIRLowering) rescue nil prev = @current_bindings synth_fn = test_that.respond_to?(:synthetic_fn) ? test_that.synthetic_fn : nil @current_bindings = (synth_fn&.cleanup_bindings) || {} @@ -235,6 +241,7 @@ def emit_all_hooks(bodies, name_kind, desc_prefix, tests) # built-in substring filter — no custom test runner required. # Returns an empty string when there are no tags. def format_tag_suffix(tags) + T.bind(self, MIRLowering) rescue nil return "" unless tags && !tags.empty? " " + tags.map { |t| "##{t}" }.join(" ") end @@ -246,6 +253,7 @@ def format_tag_suffix(tags) # entries: outer LETs occupy their original indices unless replaced # by an inner LET, which then takes the outer's slot. def build_let_ast_map(lets, base: {}) + T.bind(self, MIRLowering) rescue nil out = base.dup (lets || []).each { |let_node| out[let_node.name] = let_node } out @@ -258,6 +266,7 @@ def build_let_ast_map(lets, base: {}) # order so the emitted Zig declarations resolve cleanly # (later LETs may reference earlier ones). def compute_used_let_names(let_ast_map, ast_subtrees) + T.bind(self, MIRLowering) rescue nil return [] if let_ast_map.empty? # Direct references in test body + hook bodies @@ -266,7 +275,7 @@ def compute_used_let_names(let_ast_map, ast_subtrees) # Transitive: each referenced LET's RHS may name other LETs. # Loop until fixed point. - changed = true + changed = T.let(true, T::Boolean) while changed changed = false referenced.to_a.each do |name| @@ -286,6 +295,7 @@ def compute_used_let_names(let_ast_map, ast_subtrees) # Walk an AST subtree gathering names from AST::Identifier nodes # whose name appears in `name_set`. Adds to the `out` set. def collect_identifier_refs(node, name_set, out) + T.bind(self, MIRLowering) rescue nil return if node.nil? || node.is_a?(Symbol) || node.is_a?(String) || node.is_a?(Integer) || node.is_a?(Float) || node.is_a?(TrueClass) || node.is_a?(FalseClass) @@ -300,6 +310,7 @@ def collect_identifier_refs(node, name_set, out) end def lower_assert_raises(node) + T.bind(self, MIRLowering) rescue nil rt_name = @rt_name kind = node.kind # TEST-INFRA: ASSERT_RAISES expression assembled as raw Zig; not program memory. @@ -328,6 +339,7 @@ def lower_assert_raises(node) # on. Locals that would otherwise become "unused" after stub # replacement get an explicit MIR::Suppress (`_ = &name;`). def stub_intercept_for(fn_name, receiver, args) + T.bind(self, MIRLowering) rescue nil stub_info = (@active_stubs || {})[fn_name] return nil unless stub_info @@ -381,10 +393,11 @@ def stub_intercept_for(fn_name, receiver, args) # match the actual Zig var (cleanup-classification may suffix-rename # locals as `name_LN` to disambiguate same-name decls in distinct scopes). def stub_local_idents(node) + T.bind(self, MIRLowering) rescue nil return [] unless node.is_a?(AST::Identifier) name = node.name return [] unless name.is_a?(String) - decl = node.respond_to?(:symbol) ? node.symbol&.reg : nil + decl = node.symbol&.reg renamed = (@decl_zig_name_map && decl && @decl_zig_name_map[decl.object_id]) || (@fn_name_rename_map && @fn_name_rename_map[name]) || name @@ -392,6 +405,7 @@ def stub_local_idents(node) end def lower_stub_decl(node) + T.bind(self, MIRLowering) rescue nil fn_name = node.function_name stub_var = "__stub_#{fn_name}" @active_stubs ||= {} @@ -435,14 +449,17 @@ def lower_stub_decl(node) end def lower_benchmark(node) + T.bind(self, MIRLowering) rescue nil MIR::Comment.new("benchmark lowering placeholder") end def lower_smash(node) + T.bind(self, MIRLowering) rescue nil MIR::Comment.new("smash test placeholder") end def lower_profile(node) + T.bind(self, MIRLowering) rescue nil MIR::Comment.new("profile placeholder") end end diff --git a/src/tools/atomic_migration_suggester.rb b/src/tools/atomic_migration_suggester.rb index 4351b2b39..db51b1128 100644 --- a/src/tools/atomic_migration_suggester.rb +++ b/src/tools/atomic_migration_suggester.rb @@ -59,7 +59,7 @@ def analyze(source) # to a single Atomic primitive). def candidate_decl_info(node, annotator) return nil unless node.is_a?(AST::VarDecl) || node.is_a?(AST::BindExpr) - return nil unless node.respond_to?(:name) && node.name.is_a?(String) + return nil unless node.name.is_a?(String) val = node.value # The parser wraps `Counter{...} @shared:locked` as a CapabilityWrap # around the StructLit; the StructLit lives on `.value`. Bare @@ -68,24 +68,22 @@ def candidate_decl_info(node, annotator) val = val.value if val.is_a?(AST::CapabilityWrap) return nil unless val.is_a?(AST::StructLit) - ti = node.respond_to?(:type_info) ? node.type_info : nil + ti = node.type_info ti = Type.new(ti) unless ti.is_a?(Type) - return nil unless ti.respond_to?(:sync) && ti.sync == :locked + return nil unless ti.sync == :locked schema = annotator.respond_to?(:lookup_type_schema) ? annotator.lookup_type_schema(ti.resolved) : nil - return nil unless schema.is_a?(Hash) && !schema[:methods] - # Schemas mix String field names with Symbol meta-keys (:methods, - # :visibility, :kind, ...). Keep only String keys -- those are the - # actual fields. - fields = schema.select { |k, _| k.is_a?(String) } + schema = Schemas.as_struct_schema(schema) + return nil unless schema && schema.methods.empty? + fields = schema.fields return nil unless fields.size == 1 field_name, field_type = fields.first field_resolved = field_type.is_a?(Type) ? field_type.resolved : field_type return nil unless ATOMIC_ELIGIBLE_FIELD_TYPES.include?(field_resolved) - line = node.respond_to?(:token) && node.token ? node.token.line : nil + line = node.token&.line { name: node.name.to_s, decl_node: node, @@ -132,7 +130,7 @@ def stmt_eligible?(stmt, alias_name, field_name) when AST::Assignment, AST::BindExpr # `alias.field = expr` — Assignment's target / BindExpr's name # is the GetField path; the RHS lives on `.value`. - target = stmt.respond_to?(:target) ? stmt.target : stmt.name + target = stmt.name return false unless target.is_a?(AST::GetField) return false unless target.target.is_a?(AST::Identifier) && target.target.name == alias_name return false unless target.field.to_s == field_name diff --git a/src/tools/atomic_ptr_migration_suggester.rb b/src/tools/atomic_ptr_migration_suggester.rb index e6a021be6..db0e1c7e6 100644 --- a/src/tools/atomic_ptr_migration_suggester.rb +++ b/src/tools/atomic_ptr_migration_suggester.rb @@ -54,16 +54,16 @@ def analyze(source) # for the :versioned case. def candidate_decl_info(node, _annotator) return nil unless node.is_a?(AST::VarDecl) || node.is_a?(AST::BindExpr) - return nil unless node.respond_to?(:name) && node.name.is_a?(String) + return nil unless node.name.is_a?(String) val = node.value val = val.value if val.is_a?(AST::CapabilityWrap) return nil unless val.is_a?(AST::StructLit) - ti = node.respond_to?(:type_info) ? node.type_info : nil + ti = node.type_info return nil unless ti ti = Type.new(ti) unless ti.is_a?(Type) - syn = ti.respond_to?(:sync) ? ti.sync : nil + syn = ti.sync # Three sync families are atomic-ptr-fit candidates: # - @locked / @writeLocked (replace-the-lock with rcu-publish) # - @versioned (M3.16: upgrade-from-MVCC when the cell only does @@ -72,7 +72,7 @@ def candidate_decl_info(node, _annotator) return nil unless syn == :locked || syn == :write_locked || syn == :versioned return nil unless ti.respond_to?(:struct?) && ti.struct? - line = node.respond_to?(:token) && node.token ? node.token.line : nil + line = node.token&.line { name: node.name.to_s, decl_node: node, @@ -150,7 +150,7 @@ def stmt_eligible?(stmt, alias_name, struct_name) # `let-style` bind on the alias root would shadow; on a field, # it's a read. Only reads are eligible here; assignment-bind is # caught by the general references_alias? check below. - target = stmt.respond_to?(:target) ? stmt.target : stmt.name + target = stmt.name return false if target.is_a?(AST::GetField) && target.target.is_a?(AST::Identifier) && target.target.name == alias_name diff --git a/src/tools/formatter.rb b/src/tools/formatter.rb index 1ae2ab7d8..4d9d1f3ff 100644 --- a/src/tools/formatter.rb +++ b/src/tools/formatter.rb @@ -260,6 +260,10 @@ class Formatter::Emitter OUTDENT_LEADING = Formatter::OUTDENT_LEADING BLANK_BEFORE = Formatter::BLANK_BEFORE + # State for emitting one FN signature. Reek flagged the + # (toks, start, arrow_idx, po, pc) clump across 5 methods. + FnSig = Data.define(:toks, :start, :arrow_idx, :po, :pc) + def initialize(tokens) @tokens = tokens end @@ -705,14 +709,15 @@ def emit_fn_block(out, toks, start) end po, pc = find_fn_parens(toks, start, arrow_idx) + sig = FnSig.new(toks: toks, start: start, arrow_idx: arrow_idx, po: po, pc: pc) # A function signature with REQUIRES or EFFECTS uses the metadata-wrap # layout: each clause keyword (RETURNS, REQUIRES, EFFECTS) on its own # line at 1-space indent (HALF_INDENT), then `->` at FN level. Mirrors # the visual scope of Ruby's public/private outdent. - if has_fn_signature_metadata?(toks, pc, arrow_idx) - emit_fn_signature_metadata_wrapped(out, toks, start, arrow_idx, po, pc) - elsif should_wrap_fn_sig?(toks, start, arrow_idx, po, pc) - emit_fn_signature_wrapped(out, toks, start, arrow_idx, po, pc) + if has_fn_signature_metadata?(sig) + emit_fn_signature_metadata_wrapped(out, sig) + elsif should_wrap_fn_sig?(sig) + emit_fn_signature_wrapped(out, sig) else (start..arrow_idx).each { |j| out << toks[j] } end @@ -826,10 +831,10 @@ def find_fn_parens(toks, fn_idx, arrow_idx) # Wrap triggers (§3.1): # (a) source already has NL between `(` and `)` (preserve wrap). # (b) projected single-line length > 120 chars. - def should_wrap_fn_sig?(toks, start, arrow_idx, po, pc) - return false unless po && pc - return true if (po + 1 ... pc).any? { |j| toks[j].type == :NL } - inline = toks[start..arrow_idx].reject { |t| t.type == :NL } + def should_wrap_fn_sig?(sig) + return false unless sig.po && sig.pc + return true if (sig.po + 1 ... sig.pc).any? { |j| sig.toks[j].type == :NL } + inline = sig.toks[sig.start..sig.arrow_idx].reject { |t| t.type == :NL } format_line_body(inline).length > 120 end @@ -839,17 +844,18 @@ def should_wrap_fn_sig?(toks, start, arrow_idx, po, pc) # p2: T # ) # RETURNS T -> - def emit_fn_signature_wrapped(out, toks, start, arrow_idx, po, pc) + def emit_fn_signature_wrapped(out, sig) + toks = sig.toks # Tokens from FN through and including `(`. - (start..po).each { |j| out << toks[j] } + (sig.start..sig.po).each { |j| out << toks[j] } insert_nl(out) out << phantom(:INDENT_OPEN) depth = 0 - j = po + 1 + j = sig.po + 1 j = skip_nls(toks, j) - while j < pc + while j < sig.pc t = toks[j] if t.type == :SYM case t.raw @@ -874,13 +880,13 @@ def emit_fn_signature_wrapped(out, toks, start, arrow_idx, po, pc) out << phantom(:INDENT_CLOSE) insert_nl(out) - out << toks[pc] # `)` + out << toks[sig.pc] # `)` insert_nl(out) # Emit RETURNS ... -> on its own line. - j = pc + 1 + j = sig.pc + 1 j = skip_nls(toks, j) - while j <= arrow_idx + while j <= sig.arrow_idx t = toks[j] if t.type == :NL j += 1 @@ -899,10 +905,10 @@ def emit_fn_signature_wrapped(out, toks, start, arrow_idx, po, pc) # True when the signature between `)` (pc) and `->` (arrow_idx) contains # at least one trigger keyword (REQUIRES or EFFECTS today). - def has_fn_signature_metadata?(toks, pc, arrow_idx) - return false unless pc && arrow_idx - (pc + 1...arrow_idx).any? do |j| - toks[j].type == :KEYWORD && FN_METADATA_TRIGGERS.include?(toks[j].raw) + def has_fn_signature_metadata?(sig) + return false unless sig.pc && sig.arrow_idx + (sig.pc + 1...sig.arrow_idx).any? do |j| + sig.toks[j].type == :KEYWORD && FN_METADATA_TRIGGERS.include?(sig.toks[j].raw) end end @@ -915,22 +921,23 @@ def has_fn_signature_metadata?(toks, pc, arrow_idx) # # The `->` lands at FN-level (column 0) on its own line. Body indent # then opens at +1 from FN-level via OPEN_TERMINAL on `->`. - def emit_fn_signature_metadata_wrapped(out, toks, start, arrow_idx, po, pc) + def emit_fn_signature_metadata_wrapped(out, sig) + toks = sig.toks # 1. Emit `FN name(...)`. Reuse existing param-wrapping rules so a # long param list still expands correctly. - if po && pc && should_wrap_fn_sig?(toks, start, arrow_idx, po, pc) - emit_fn_params_only_wrapped(out, toks, start, po, pc) - elsif pc - (start..pc).each { |j| out << toks[j] } + if sig.po && sig.pc && should_wrap_fn_sig?(sig) + emit_fn_params_only_wrapped(out, sig) + elsif sig.pc + (sig.start..sig.pc).each { |j| out << toks[j] } else - (start..arrow_idx - 1).each { |j| out << toks[j] } + (sig.start..sig.arrow_idx - 1).each { |j| out << toks[j] } end insert_nl(out) # 2. One line per metadata clause. Each clause runs from its keyword # to (but not including) the next clause keyword or `->`. - if pc - collect_fn_metadata_clauses(toks, pc + 1, arrow_idx).each do |clause_toks| + if sig.pc + collect_fn_metadata_clauses(toks, sig.pc + 1, sig.arrow_idx).each do |clause_toks| out << phantom(:HALF_INDENT) clause_toks.each { |t| out << t unless t.type == :NL } insert_nl(out) @@ -938,21 +945,22 @@ def emit_fn_signature_metadata_wrapped(out, toks, start, arrow_idx, po, pc) end # 3. `->` on its own line at FN level. - out << toks[arrow_idx] + out << toks[sig.arrow_idx] end # Emit the FN keyword through to `)` only, wrapping params if the # existing rules say to. Mirrors emit_fn_signature_wrapped but stops at # `)` instead of running through `->`. - def emit_fn_params_only_wrapped(out, toks, start, po, pc) - (start..po).each { |j| out << toks[j] } + def emit_fn_params_only_wrapped(out, sig) + toks = sig.toks + (sig.start..sig.po).each { |j| out << toks[j] } insert_nl(out) out << phantom(:INDENT_OPEN) depth = 0 - j = po + 1 + j = sig.po + 1 j = skip_nls(toks, j) - while j < pc + while j < sig.pc t = toks[j] if t.type == :SYM case t.raw @@ -977,7 +985,7 @@ def emit_fn_params_only_wrapped(out, toks, start, po, pc) out << phantom(:INDENT_CLOSE) insert_nl(out) - out << toks[pc] + out << toks[sig.pc] end # Split tokens between `)` and `->` into per-clause groups. Each clause diff --git a/src/tools/migration_suggester_helpers.rb b/src/tools/migration_suggester_helpers.rb index e94985a14..1dfec6a93 100644 --- a/src/tools/migration_suggester_helpers.rb +++ b/src/tools/migration_suggester_helpers.rb @@ -79,7 +79,7 @@ def walk_recursive(body, &visitor) yield node case node when AST::VarDecl, AST::BindExpr, AST::Assignment - v = node.respond_to?(:value) ? node.value : nil + v = node.value if v.is_a?(AST::BgBlock) || v.is_a?(AST::BgStreamBlock) walk_recursive(v.body, &visitor) end diff --git a/tools/dead_nil_check_finder.rb b/tools/dead_nil_check_finder.rb new file mode 100755 index 000000000..de77588e8 --- /dev/null +++ b/tools/dead_nil_check_finder.rb @@ -0,0 +1,282 @@ +#!/usr/bin/env ruby +# Find dead nil checks in src/*.rb using Prism. +# +# Detects patterns where a `&.` or `.nil?` is provably redundant +# because the receiver is already known to be non-nil in the +# enclosing scope. +# +# Patterns detected: +# +# 1. Truthy-guard scope: +# if foo | return unless foo +# foo&.bar # DEAD | foo&.bar # DEAD +# end | +# +# 2. is_a? narrowing: +# if foo.is_a?(SomeClass) +# foo&.method # DEAD (is_a? implies non-nil) +# end +# +# 3. unless foo.nil? / unless foo.nil?: +# unless foo.nil? +# foo&.bar # DEAD +# end +# +# 4. Reassignment from literal: +# foo = "string" +# foo&.length # DEAD +# +# Pure read-only — prints a report; does not modify files. +# +# Usage: +# bundle exec ruby tools/dead_nil_check_finder.rb [src/] +# +# Output format (sortable by file/line): +# src/foo.rb:42 PATTERN receiver&.method reason + +require "prism" + +class DeadCheckFinder + Finding = Struct.new(:file, :line, :pattern, :code, :reason) + + LITERAL_NON_NIL_TYPES = [ + Prism::StringNode, Prism::SymbolNode, Prism::IntegerNode, + Prism::FloatNode, Prism::TrueNode, Prism::FalseNode, + Prism::ArrayNode, Prism::HashNode, Prism::RegularExpressionNode, + Prism::LambdaNode, + ].freeze + + def initialize + @findings = [] + end + + def scan(file) + src = File.read(file) + parsed = Prism.parse(src) + return if parsed.failure? + visit_methods(file, parsed.value, []) + end + + def report + @findings.sort_by { |f| [f.file, f.line] }.each do |f| + puts "#{f.file}:#{f.line} #{f.pattern} #{f.code.strip[0..70]} -- #{f.reason}" + end + puts + by_pattern = @findings.group_by(&:pattern).map { |p, fs| [p, fs.size] } + puts "Total: #{@findings.size}" + by_pattern.sort_by { |_, n| -n }.each { |p, n| puts " #{n}\t#{p}" } + end + + private + + # Walk every DefNode and analyze its body with a flow-sensitive scan. + def visit_methods(file, node, ancestors) + case node + when Prism::DefNode + analyze_method(file, node) + end + return unless node.respond_to?(:compact_child_nodes) + node.compact_child_nodes.each { |c| visit_methods(file, c, ancestors + [node]) } + end + + # Per-method: walk statements, tracking which locals are known non-nil. + # `non_nil` is a Set of local-var names known non-nil at this point. + def analyze_method(file, def_node) + body = def_node.body + return unless body.is_a?(Prism::StatementsNode) + walk_stmts(file, body.body, Set.new) + end + + def walk_stmts(file, stmts, non_nil) + stmts.each do |stmt| + check_dead_calls(file, stmt, non_nil) + update_facts!(stmt, non_nil) + end + end + + # Update non_nil set based on a statement's effect. + def update_facts!(stmt, non_nil) + case stmt + when Prism::LocalVariableWriteNode + name = stmt.name.to_s + if non_nil_value?(stmt.value) + non_nil.add(name) + else + non_nil.delete(name) + end + when Prism::ReturnNode, Prism::BreakNode, Prism::NextNode + # No-op for facts continuing + when Prism::IfNode + # `return unless x` / `raise unless x` patterns: + # IfNode where condition uses .nil? + body has return/raise + handle_guard_modifier(stmt, non_nil) + when Prism::UnlessNode + handle_unless_modifier(stmt, non_nil) + end + end + + # `return unless x` is parsed as: IfNode { predicate: x; statements: [Return] } + # Wait — actually `return unless x` is UnlessNode in Prism with the return as body. + # Let me handle the 2 modifier shapes: + # `unless x then return end` → UnlessNode predicate=x, statements=[ReturnNode] + # `return unless x` → UnlessModifier — same UnlessNode shape + def handle_unless_modifier(unless_node, non_nil) + pred = unless_node.predicate + stmts = unless_node.statements&.body || [] + if stmts.any? { |s| terminator?(s) } && (name = simple_var_name(pred)) + # After this point, we know `name` was truthy (otherwise we'd have terminated). + non_nil.add(name) + end + end + + def handle_guard_modifier(if_node, non_nil) + pred = if_node.predicate + stmts = if_node.statements&.body || [] + return unless stmts.any? { |s| terminator?(s) } + # `if x.nil? then return end` → after, x is non-nil + if (name = nil_check_var(pred)) + non_nil.add(name) + end + end + + def terminator?(node) + node.is_a?(Prism::ReturnNode) || node.is_a?(Prism::BreakNode) || + node.is_a?(Prism::NextNode) || + (node.is_a?(Prism::CallNode) && [:raise, :error!, :fixable!].include?(node.name)) + end + + # Recurse into structural nodes that contain executable code. + # On entering an `if cond` body, we add facts about cond. + def check_dead_calls(file, node, non_nil) + return unless node.respond_to?(:compact_child_nodes) + case node + when Prism::CallNode + check_safe_nav(file, node, non_nil) + node.compact_child_nodes.each { |c| check_dead_calls(file, c, non_nil) } + when Prism::IfNode + branch_facts = if_branch_facts(node.predicate) + then_facts = non_nil + branch_facts[:then_non_nil] + walk_stmts_in_branch(file, node.statements&.body || [], then_facts) + # else / elsif + visit_else(file, node.consequent, non_nil + branch_facts[:else_non_nil]) + when Prism::UnlessNode + branch_facts = if_branch_facts(node.predicate) + # `unless x` body runs when x is FALSY + then_facts = non_nil + branch_facts[:else_non_nil] + else_facts = non_nil + branch_facts[:then_non_nil] + walk_stmts_in_branch(file, node.statements&.body || [], then_facts) + visit_else(file, node.consequent, else_facts) + when Prism::CaseNode, Prism::CaseMatchNode, Prism::WhileNode, Prism::UntilNode + node.compact_child_nodes.each { |c| check_dead_calls(file, c, non_nil) } + when Prism::BlockNode, Prism::LambdaNode + # Block body: separate scope for new locals; existing facts carry in + walk_stmts_in_branch(file, (node.body.is_a?(Prism::StatementsNode) ? node.body.body : []), non_nil.dup) + else + node.compact_child_nodes.each { |c| check_dead_calls(file, c, non_nil) } + end + end + + def walk_stmts_in_branch(file, stmts, non_nil) + stmts.each do |s| + check_dead_calls(file, s, non_nil) + update_facts!(s, non_nil) + end + end + + def visit_else(file, node, non_nil) + return unless node + case node + when Prism::ElseNode + walk_stmts_in_branch(file, node.statements&.body || [], non_nil) + when Prism::IfNode, Prism::UnlessNode + check_dead_calls(file, node, non_nil) + end + end + + # Check if this CallNode is a `receiver&.method` and the receiver is non-nil. + def check_safe_nav(file, call, non_nil) + if call.safe_navigation? + recv = call.receiver + if recv + if recv.is_a?(Prism::SelfNode) + add_finding(file, call, "DEAD_SAFE_NAV_SELF", "self is never nil") + else + name = simple_var_name(recv) + if name && non_nil.include?(name) + add_finding(file, call, "DEAD_SAFE_NAV", "#{name} known non-nil at this scope") + end + end + end + end + # Dead .nil? on a known-non-nil receiver + if call.name == :nil? && call.receiver + name = simple_var_name(call.receiver) + if name && non_nil.include?(name) + add_finding(file, call, "DEAD_NIL_CHECK", "#{name} known non-nil — .nil? always false") + end + end + end + + # Returns { then_non_nil: [names], else_non_nil: [names] } given a predicate. + def if_branch_facts(pred) + facts = { then_non_nil: [], else_non_nil: [] } + case pred + when Prism::LocalVariableReadNode + facts[:then_non_nil] << pred.name.to_s + when Prism::CallNode + # x.is_a?(Type) → then x non-nil + if pred.name == :is_a? && (name = simple_var_name(pred.receiver)) + facts[:then_non_nil] << name + end + # x.nil? → else x non-nil + if pred.name == :nil? && (name = simple_var_name(pred.receiver)) + facts[:else_non_nil] << name + end + # !x.nil? handled as unary ! + # x.respond_to?(:foo) → then x non-nil + if pred.name == :respond_to? && (name = simple_var_name(pred.receiver)) + facts[:then_non_nil] << name + end + when Prism::AndNode + facts[:then_non_nil].concat(if_branch_facts(pred.left)[:then_non_nil]) + facts[:then_non_nil].concat(if_branch_facts(pred.right)[:then_non_nil]) + end + facts + end + + def simple_var_name(node) + node.is_a?(Prism::LocalVariableReadNode) ? node.name.to_s : nil + end + + def nil_check_var(pred) + if pred.is_a?(Prism::CallNode) && pred.name == :nil? + simple_var_name(pred.receiver) + end + end + + def non_nil_value?(node) + return false unless node + return true if LITERAL_NON_NIL_TYPES.any? { |k| node.is_a?(k) } + # Constructor calls: SomeClass.new(...) is non-nil (unless impl returns nil) + if node.is_a?(Prism::CallNode) && node.name == :new + return true + end + false + end + + def add_finding(file, call, pattern, reason) + line = call.location.start_line + @findings << Finding.new(file, line, pattern, source_at(file, line), reason) + end + + def source_at(file, line) + @file_lines ||= {} + @file_lines[file] ||= File.readlines(file) + @file_lines[file][line - 1] || "" + end +end + +dir = ARGV[0] || "src" +finder = DeadCheckFinder.new +Dir.glob("#{dir}/**/*.rb").each { |f| finder.scan(f) } +finder.report diff --git a/tools/dead_nil_check_fixer.rb b/tools/dead_nil_check_fixer.rb new file mode 100755 index 000000000..1930a4b75 --- /dev/null +++ b/tools/dead_nil_check_fixer.rb @@ -0,0 +1,41 @@ +#!/usr/bin/env ruby +# Apply fixes for dead-nil-check findings. +# +# Reads stdin (output of dead_nil_check_finder.rb) and rewrites +# `receiver&.method` to `receiver.method` at each flagged site. +# +# Usage: +# bundle exec ruby tools/dead_nil_check_finder.rb src/ | +# bundle exec ruby tools/dead_nil_check_fixer.rb + +require "set" + +# Parse "src/foo.rb:42 PATTERN code -- reason" +sites = Hash.new { |h, k| h[k] = [] } # file => [line, ...] +STDIN.each_line do |line| + next unless (m = line.match(/^(\S+\.rb):(\d+)\s+DEAD_SAFE_NAV/)) + sites[m[1]] << m[2].to_i +end + +total = 0 +sites.each do |path, lines| + next unless File.exist?(path) + src = File.read(path) + src_lines = src.lines.dup + lines.uniq.each do |ln| + idx = ln - 1 + before = src_lines[idx] + next unless before + # Replace ALL `&.` on this line — multiple findings on the same line + # all reference the same receiver narrowing, so all `&.` on the line + # are dead together. + after = before.gsub(/&\./, ".") + if after != before + src_lines[idx] = after + total += 1 + end + end + File.write(path, src_lines.join) + puts " #{path}: #{lines.uniq.size} sites" +end +puts "Total lines patched: #{total}" diff --git a/tools/dead_trailing_if.rb b/tools/dead_trailing_if.rb new file mode 100755 index 000000000..e56b5d74a --- /dev/null +++ b/tools/dead_trailing_if.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +# Detect `expr&.method ... if expr` patterns where the &. receiver +# is textually identical to the trailing-if guard expression. +require 'prism' + +# Prism parses `foo&.bar if foo` as IfNode { predicate: foo, statements: [CallNode(safe_nav: true, recv: foo)] } +# We extract source text for the receiver and guard, compare strings. + +findings = [] +Dir.glob("src/**/*.rb").each do |path| + src = File.read(path) + parsed = Prism.parse(src) + next if parsed.failure? + + walk = ->(node) { + if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode) + pred = node.predicate + stmts = node.statements&.body || [] + # Look for a single safe-nav call as the body + stmts.each do |stmt| + # Walk inside stmt looking for safe-nav whose receiver source == predicate source + walk_inner = ->(n) { + if n.is_a?(Prism::CallNode) && n.safe_navigation? && n.receiver + recv_src = n.receiver.location.slice + pred_src = pred&.location&.slice + if pred_src && recv_src == pred_src + # IfNode body: dead because pred is truthy + # UnlessNode body: skip — pred is falsy so &. would CORRECTLY return nil + if node.is_a?(Prism::IfNode) + findings << [path, n.location.start_line, src.lines[n.location.start_line - 1].chomp] + end + end + end + n.compact_child_nodes.each(&walk_inner) if n.respond_to?(:compact_child_nodes) + } + walk_inner.call(stmt) + end + end + node.compact_child_nodes.each(&walk) if node.respond_to?(:compact_child_nodes) + } + walk.call(parsed.value) +end + +findings.uniq! +findings.each { |f, l, c| puts "#{f}:#{l} #{c.strip}" } +puts +puts "Total: #{findings.size}" diff --git a/tools/gen_attr_rbi.rb b/tools/gen_attr_rbi.rb new file mode 100755 index 000000000..e13392fa1 --- /dev/null +++ b/tools/gen_attr_rbi.rb @@ -0,0 +1,127 @@ +#!/usr/bin/env ruby +require 'prism' + +declared = Hash.new { |h, k| h[k] = [] } + +walk_block_for_attrs = lambda do |block_node, class_path| + return unless block_node + return unless block_node.body.is_a?(Prism::StatementsNode) + block_node.body.body.each do |stmt| + next unless stmt.is_a?(Prism::CallNode) + next unless [:attr_accessor, :attr_reader, :attr_writer].include?(stmt.name) + kind = stmt.name + names = (stmt.arguments&.arguments || []).filter_map do |a| + if a.is_a?(Prism::SymbolNode); a.value + elsif a.is_a?(Prism::StringNode); a.content + else nil + end + end + names.each { |n| declared[class_path] << [kind, n] } + end +end + +walk = nil +walk = lambda do |node, scope| + return unless node + case node + when Prism::ModuleNode, Prism::ClassNode + name = node.constant_path.is_a?(Prism::ConstantReadNode) ? node.constant_path.name : node.constant_path.full_name + walk.(node.body, scope + [name.to_s]) + when Prism::ConstantWriteNode + if node.value.is_a?(Prism::CallNode) && + node.value.name == :new && + node.value.receiver.is_a?(Prism::ConstantReadNode) && + node.value.receiver.name == :Struct + class_path = (scope + [node.name.to_s]).join('::') + walk_block_for_attrs.(node.value.block, class_path) if node.value.block + end + when Prism::DefNode + return + end + if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |c| walk.(c, scope) } + end +end + +# Also catch attr_accessor at class scope (not inside Struct.new). +class_walk = nil +class_walk = lambda do |node, scope| + return unless node + case node + when Prism::ClassNode + name = node.constant_path.is_a?(Prism::ConstantReadNode) ? node.constant_path.name : node.constant_path.full_name + new_scope = scope + [name.to_s] + class_path = new_scope.join('::') + if node.body.is_a?(Prism::StatementsNode) + node.body.body.each do |stmt| + next unless stmt.is_a?(Prism::CallNode) + next unless [:attr_accessor, :attr_reader, :attr_writer].include?(stmt.name) + kind = stmt.name + names = (stmt.arguments&.arguments || []).filter_map do |a| + if a.is_a?(Prism::SymbolNode); a.value + elsif a.is_a?(Prism::StringNode); a.content + else nil + end + end + names.each { |n| declared[class_path] << [kind, n] } + end + end + class_walk.(node.body, new_scope) + when Prism::ModuleNode + name = node.constant_path.is_a?(Prism::ConstantReadNode) ? node.constant_path.name : node.constant_path.full_name + class_walk.(node.body, scope + [name.to_s]) + end + if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |c| class_walk.(c, scope) } + end +end + +Dir.glob('src/**/*.rb').sort.each do |f| + parsed = Prism.parse_file(f) + next unless parsed.success? + walk.(parsed.value, []) + class_walk.(parsed.value, []) +end + +total = declared.values.map(&:uniq).map(&:size).sum +warn "RBI generation: #{declared.size} classes, #{total} attr_* (uniq) across src/" + +puts <<~HDR + # typed: true + # frozen_string_literal: true + # + # AUTO-GENERATED. Do not edit by hand. Regenerate with: + # bundle exec ruby tools/gen_attr_rbi.rb > sorbet/rbi/clear-attr-accessors.rbi + # + # Sorbet's automatic Struct.new typing only surfaces the positional + # Struct fields, not `attr_accessor`/`attr_reader`/`attr_writer` + # declarations inside the do-block. Without this shim, every file + # that flips to `# typed: true` and reads such an attribute trips a + # `Method does not exist` error. This file declares T.untyped sigs + # so the per-file typing rollout can proceed. + # + # As the Self-host preparation tracker (TODO.md) advances, individual + # T.untyped sigs are tightened to real types (Symbol for identifiers + # per task #1, etc.). When all attrs are typed, this shim can be + # deleted and replaced by inline T::Sig declarations on the classes + # themselves. + +HDR + +declared.keys.sort.each do |cls| + attrs = declared[cls].uniq.sort_by { |kind, n| [n.to_s, kind.to_s] } + next if attrs.empty? + puts "class #{cls}" + attrs.each do |kind, name| + if kind == :attr_reader || kind == :attr_accessor + puts " sig { returns(T.untyped) }" + puts " def #{name}; end" + end + if kind == :attr_writer || kind == :attr_accessor + puts " sig { params(value: T.untyped).returns(T.untyped) }" + puts " def #{name}=(value); end" + end + end + puts "end" + puts +end diff --git a/tools/gen_struct_fields_rbi.rb b/tools/gen_struct_fields_rbi.rb new file mode 100755 index 000000000..7e8ad8adf --- /dev/null +++ b/tools/gen_struct_fields_rbi.rb @@ -0,0 +1,158 @@ +#!/usr/bin/env ruby +# Generate sorbet/rbi/ast-struct-fields.rbi. +# +# Sorbet auto-types `Struct.new(:foo, :bar)` accessors as T.untyped, +# which means typed-true source code can never get dead-check signals +# (7034 / 7016) on patterns like `node.token&.line` -- Sorbet has +# nothing to compare against. RBI overrides DO take precedence over +# Struct's auto-generated sigs (verified in a sandbox), so this +# generator emits a per-class override declaring each Struct field. +# +# Two-level policy: +# +# 1. Field type (TYPE_POLICY, by field name): +# :token -> Token +# :args -> T::Array[T.untyped] +# ... +# unmapped -> T.untyped (no nilability fire from Sorbet anyway) +# +# 2. Nilability (per-(class, field), from construction-site prover): +# prover says NON_NIL -> emit `Type` directly (fires 7034 on dead &.) +# prover says NILABLE -> emit `T.nilable(Type)` +# prover says UNTESTED -> emit `T.nilable(Type)` (conservative) +# +# Output is written to sorbet/rbi/ast-struct-fields.rbi. The prover +# is tools/struct_field_nilability.rb -- run it first to refresh data. +# +# Usage: +# bundle exec ruby tools/struct_field_nilability.rb > /tmp/nilability.tsv +# bundle exec ruby tools/gen_struct_fields_rbi.rb > sorbet/rbi/ast-struct-fields.rbi + +require 'prism' + +# field_name (Symbol) -> sig return type (String, ready to splice +# inside `sig { returns(...) }`). +TYPE_POLICY = { + token: 'Token', + args: 'T::Array[T.untyped]', + body: 'T::Array[T.untyped]', + items: 'T::Array[T.untyped]', + branches: 'T::Array[T.untyped]', + cases: 'T::Array[T.untyped]', + arms: 'T::Array[T.untyped]', + # :fields varies between Hash and Array per class — leave T.untyped. + params: 'T::Array[T.untyped]', + type_params: 'T::Array[T.untyped]', + pairs: 'T::Array[T.untyped]', + statements: 'T::Array[T.untyped]', + captures: 'T::Array[T.untyped]', + capabilities: 'T::Array[T.untyped]', +}.freeze + +# Per-(class, field) overrides for fields whose type varies by class. +# Falls back to TYPE_POLICY (global by field name), then DEFAULT_TYPE. +PER_CLASS_POLICY = { + # Identifier.name is always a String (from token.value or string interp). + # Other AST nodes' :name fields can be GetIndex / GetField / etc., so + # they stay T.untyped until per-call narrowing is feasible. + ['AST::Identifier', :name] => 'String', +}.freeze +DEFAULT_TYPE = 'T.untyped' + +# Load the prover output if present. Format is TSV: class \t idx \t +# field \t nil_count \t nonnil_count \t verdict. +PROVER_PATH = '/tmp/nilability.tsv' +proven_non_nil = {} # [class_path, field_sym] => true +if File.exist?(PROVER_PATH) + File.readlines(PROVER_PATH).drop(1).each do |line| + cls, _idx, field, _nl, _nn, verdict = line.chomp.split("\t") + proven_non_nil[[cls, field.to_sym]] = true if verdict == 'NON_NIL' + end + warn "Loaded #{proven_non_nil.size} (class, field) NON_NIL proofs from #{PROVER_PATH}" +else + warn "WARNING: #{PROVER_PATH} not found. All fields will be T.nilable. " \ + "Run tools/struct_field_nilability.rb first." +end + +# class_path (String) -> Array of Struct field names in order. +discovered = {} + +walk = nil +walk = lambda do |node, scope| + return unless node + case node + when Prism::ModuleNode, Prism::ClassNode + name = node.constant_path.is_a?(Prism::ConstantReadNode) ? node.constant_path.name : node.constant_path.full_name + walk.(node.body, scope + [name.to_s]) + return # body already walked with namespaced scope; don't double-walk + when Prism::ConstantWriteNode + if node.value.is_a?(Prism::CallNode) && + node.value.name == :new && + node.value.receiver.is_a?(Prism::ConstantReadNode) && + node.value.receiver.name == :Struct + class_path = (scope + [node.name.to_s]).join('::') + args = node.value.arguments&.arguments || [] + fields = args.filter_map do |a| + a.value.to_sym if a.is_a?(Prism::SymbolNode) + end + discovered[class_path] = fields if fields.any? + end + when Prism::DefNode + return + end + if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |c| walk.(c, scope) } + end +end + +# Walk only the AST and schemas source files. Other Struct.new calls +# elsewhere in src/ (helpers, tools) don't need typed Struct fields +# yet -- adding them later is a one-line change to the glob. +SOURCES = %w[src/ast/ast.rb src/ast/schemas.rb].freeze + +SOURCES.each do |f| + parsed = Prism.parse_file(f) + next unless parsed.success? + walk.(parsed.value, []) +end + +warn "Struct-field RBI: #{discovered.size} classes, #{discovered.values.map(&:size).sum} fields" + +puts <<~HDR + # 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. + +HDR + +discovered.keys.sort.each do |cls| + fields = discovered[cls] + next if fields.empty? + puts "class #{cls}" + fields.each do |field| + base_type = PER_CLASS_POLICY[[cls, field]] || TYPE_POLICY.fetch(field, DEFAULT_TYPE) + # T.untyped already accepts nil; wrapping it adds nothing. + # Otherwise wrap in T.nilable unless the prover proved non-nil. + type = if base_type == 'T.untyped' || proven_non_nil[[cls, field]] + base_type + else + "T.nilable(#{base_type})" + end + puts " sig { returns(#{type}) }" + puts " def #{field}; end" + end + puts "end" + puts +end diff --git a/tools/normalize_zig.rb b/tools/normalize_zig.rb new file mode 100755 index 000000000..86c062bd8 --- /dev/null +++ b/tools/normalize_zig.rb @@ -0,0 +1,106 @@ +#!/usr/bin/env ruby +# tools/normalize_zig.rb +# +# Normalize auto-generated counter-based identifiers in transpiled .zig +# files to stable, content-deterministic names. Used by the +# `#TRANSPILE_PURE` workflow before diffing two trees. +# +# Background: the transpiler uses `node.object_id.abs` to disambiguate +# nested-WITH guard variables, MATCH binding aliases, snapshot guards, +# acquire-block labels, etc. `object_id` is process-instance-specific +# and shifts whenever Ruby allocation order changes — any refactor that +# adds or removes object allocations produces different numbers even +# when the generated Zig is functionally identical. +# +# We can't replace `object_id` in the transpiler itself: object_id +# uniquely identifies cloned AST subtrees, which pipeline rewriting can +# produce. Source-position-based naming would silently collide on clones +# that share (line, column). +# +# This script keeps the transpiler unchanged and normalizes the OUTPUT +# instead. For each file independently: +# 1. Find every `___` identifier where digits is large +# enough (MIN_DIGITS) to plausibly be an object_id. +# 2. Number unique values by FIRST OCCURRENCE per `` prefix. +# 3. Replace each occurrence with `___N`. +# +# Two files that produce structurally-equivalent but allocation-shifted +# identifiers normalize to the same content. Two files with semantically +# different counter counts or orderings normalize to different content +# (the diff still surfaces real changes). +# +# Collision safety: each unique original number gets its OWN index, so +# two distinct identifiers in the source remain distinct in the +# normalized output. We never merge identifiers that were unique. +# +# Usage: +# bundle exec ruby tools/normalize_zig.rb ... +# # paths can be files or directories; .zig files processed in place. + +require "find" + +# Match: `___` where the digits aren't preceded by an +# alpha-num (so the `__` boundary is real), and are followed by `_`, +# end-of-string, or any non-identifier char (so we cleanly split +# concatenated counter names like `__acq_2760___c_guard_2760` into two +# matches: `__acq_2760` and `__c_guard_2760`). +# +# `\b` doesn't work here because `_` is a word character in Ruby regex, +# so word boundaries don't fire between underscored counter prefixes. +# +# MIN_DIGITS=4 because object_id.abs values seen in practice are 4-5 +# digits. User numeric literals in transpiled Zig (array sizes, loop +# bounds) are typically 1-3 digits and rarely use the `___` +# leading-context. The leading `__` is the primary filter. +MIN_DIGITS = 4 +PATTERN = /(?..." + exit 1 + end + + files = collect_files(paths) + changed = 0 + files.each { |f| changed += 1 if normalize_file(f) } + puts "Normalized #{changed} of #{files.size} .zig file(s)." +end diff --git a/tools/respond_to_inventory.rb b/tools/respond_to_inventory.rb new file mode 100755 index 000000000..c0c965fed --- /dev/null +++ b/tools/respond_to_inventory.rb @@ -0,0 +1,342 @@ +#!/usr/bin/env ruby +# tools/respond_to_inventory.rb +# +# Phase 0 of the respond_to? purge plan (TODO #9). Produces three +# artifacts in tmp/respond_to_inventory/: +# +# attrs_by_class.csv — class, attr, source (member|Locatable|attr_accessor) +# sites.csv — file, line, attr, method, params, caller_kind +# summary.md — top-N attrs, caller_kind distribution +# +# Caller-kind heuristic: +# generic_walker — enclosing method's first param is a generic name +# (node, stmt, expr, body, value, n, v, item) AND +# the method has no Sorbet sig narrowing it +# typed — Sorbet sig on enclosing method names a concrete +# AST::X type for the param being queried +# unknown — neither pattern matches; needs eyeball review +# +# Run: +# bundle exec ruby tools/respond_to_inventory.rb + +require "prism" +require "csv" +require "fileutils" + +ROOT = File.expand_path("..", __dir__) +OUT_DIR = File.join(ROOT, "tmp", "respond_to_inventory") +FileUtils.mkdir_p(OUT_DIR) + +# Generic names that signal "this method visits any value", not a typed node. +GENERIC_PARAM_NAMES = %w[ + node stmt expr body value val + n v stmts items children + item arg arg_node element child +].freeze + +# ------------------------------------------------------------------------- +# Pass 1: attrs per AST class +# ------------------------------------------------------------------------- +# Walks src/ast/ast.rb and src/ast/scope.rb (for SymbolEntry attrs that +# also flow through generic walkers). Collects: +# - Struct.new(:a, :b, ...) → members +# - include Locatable → adds Locatable's accessors +# - attr_accessor :x → adds x +# +# Locatable is loaded by reading the module body and listing its +# attr_accessor / attr_reader names. + +class ClassAttrCollector < Prism::Visitor + attr_reader :classes # { "AST::Foo" => Set[attrs] } + + def initialize(locatable_attrs) + super() + @classes = Hash.new { |h, k| h[k] = [] } + @locatable_attrs = locatable_attrs + @class_stack = [] + end + + def visit_module_node(node) + name = node.constant_path.slice + @class_stack.push(name) + super + @class_stack.pop + end + + def visit_class_node(node) + name = qualified(node.constant_path.slice) + visit_class_or_struct(name) { super } + end + + # Form: Foo = Struct.new(:a, :b, :c) [do ... end] + def visit_constant_write_node(node) + val = node.value + return super unless val.is_a?(Prism::CallNode) && val.name == :new + + receiver = val.receiver + return super unless receiver.is_a?(Prism::ConstantReadNode) && + receiver.name == :Struct + + cls = qualified(node.name.to_s) + members = (val.arguments&.arguments || []).filter_map do |arg| + arg.is_a?(Prism::SymbolNode) ? arg.unescaped : nil + end + @classes[cls].concat(members) + + # The Struct.new block may include Locatable / attr_accessor. + if val.block + visit_class_or_struct(cls) { val.block.accept(self) } + end + end + + def visit_call_node(node) + if @class_stack.any? + cls = qualified_current + case node.name + when :include + node.arguments&.arguments&.each do |arg| + mod = arg.respond_to?(:slice) ? arg.slice : nil + if mod == "Locatable" || mod == "AST::Locatable" + @classes[cls].concat(@locatable_attrs) + end + end + when :attr_accessor, :attr_reader, :attr_writer + node.arguments&.arguments&.each do |arg| + @classes[cls] << arg.unescaped if arg.is_a?(Prism::SymbolNode) + end + end + end + super + end + + # `def foo` or `def foo=` inside a class/struct/module body counts as a + # defined attr — Locatable defines `full_type` / `full_type=` this way. + def visit_def_node(node) + if @class_stack.any? + cls = qualified_current + @classes[cls] << node.name.to_s.sub(/=\z/, "") + end + super + end + + private + + def visit_class_or_struct(name) + @class_stack.push(name) + yield + @class_stack.pop + end + + def qualified_current + @class_stack.last + end + + def qualified(short) + return short if short.start_with?("AST::") || short.include?("::") + return "AST::#{short}" if @class_stack.last == "AST" + short + end +end + +# Locatable's attrs are needed first (for `include Locatable` in other classes). +def collect_locatable_attrs(ast_path) + src = File.read(ast_path) + result = Prism.parse(src) + attrs = [] + result.value.statements.body.each do |top| + walk = ->(node) { + if node.is_a?(Prism::ModuleNode) && node.constant_path.slice == "Locatable" + node.body.body.each do |s| + if s.is_a?(Prism::CallNode) && + [:attr_accessor, :attr_reader, :attr_writer].include?(s.name) + s.arguments&.arguments&.each do |a| + attrs << a.unescaped if a.is_a?(Prism::SymbolNode) + end + elsif s.is_a?(Prism::DefNode) + attrs << s.name.to_s.sub(/=\z/, "") + end + end + elsif node.respond_to?(:child_nodes) + node.child_nodes.compact.each(&walk) + end + } + walk.call(top) + end + attrs.uniq +end + +ast_path = File.join(ROOT, "src", "ast", "ast.rb") +locatable_attrs = collect_locatable_attrs(ast_path) + +attr_collector = ClassAttrCollector.new(locatable_attrs) +[ast_path].each do |path| + src = File.read(path) + Prism.parse(src).value.accept(attr_collector) +end + +# Write attrs_by_class.csv +attrs_csv_path = File.join(OUT_DIR, "attrs_by_class.csv") +CSV.open(attrs_csv_path, "w") do |csv| + csv << ["class", "attr"] + attr_collector.classes.sort_by { |k, _| k }.each do |cls, attrs| + attrs.uniq.sort.each { |a| csv << [cls, a] } + end +end + +# Build reverse map: attr → [classes] +attr_to_classes = Hash.new { |h, k| h[k] = [] } +attr_collector.classes.each do |cls, attrs| + attrs.uniq.each { |a| attr_to_classes[a] << cls } +end + +# ------------------------------------------------------------------------- +# Pass 2: respond_to? sites +# ------------------------------------------------------------------------- + +class RespondToCollector < Prism::Visitor + attr_reader :sites + + def initialize(file) + super() + @file = file + @sites = [] + @method_stack = [] + end + + def visit_def_node(node) + params = (node.parameters&.requireds || []).map { |p| p.name.to_s } + @method_stack.push({ name: node.name.to_s, params: params, line: node.location.start_line }) + super + @method_stack.pop + end + + def visit_call_node(node) + if node.name == :respond_to? && node.arguments&.arguments&.length == 1 + arg = node.arguments.arguments.first + if arg.is_a?(Prism::SymbolNode) + receiver = node.receiver&.slice + method = @method_stack.last + @sites << { + file: @file, + line: node.location.start_line, + attr: arg.unescaped, + receiver: receiver, + method_name: method&.dig(:name), + method_params: method&.dig(:params)&.join(",") + } + end + end + super + end +end + +site_records = [] +Dir.glob(File.join(ROOT, "src/**/*.rb")).sort.each do |path| + rel = path.sub(ROOT + "/", "") + src = File.read(path) + collector = RespondToCollector.new(rel) + Prism.parse(src).value.accept(collector) + site_records.concat(collector.sites) +end + +# ------------------------------------------------------------------------- +# Caller-kind classification +# ------------------------------------------------------------------------- + +def classify_caller(site) + params = (site[:method_params] || "").split(",") + receiver = site[:receiver] || "" + return "unknown" if params.empty? + + # If the receiver is a method param AND that param has a generic name, + # this is a generic walker. + return "generic_walker" if GENERIC_PARAM_NAMES.include?(receiver) && + params.include?(receiver) + + # If the receiver is some chained access (e.g. `node.value`, `obj.target`), + # the immediate receiver isn't the param — but the root probably is one. + # Treat as walker if any param matches a generic name. + return "generic_walker" if params.any? { |p| GENERIC_PARAM_NAMES.include?(p) } + + "typed_or_unclear" +end + +site_records.each { |s| s[:caller_kind] = classify_caller(s) } + +# ------------------------------------------------------------------------- +# Output +# ------------------------------------------------------------------------- + +sites_csv_path = File.join(OUT_DIR, "sites.csv") +CSV.open(sites_csv_path, "w") do |csv| + csv << %w[file line attr receiver method caller_kind classes_with_attr] + site_records.each do |s| + classes = attr_to_classes[s[:attr]] || [] + csv << [ + s[:file], s[:line], s[:attr], s[:receiver], + s[:method_name], s[:caller_kind], classes.size + ] + end +end + +# Summary +total = site_records.size +by_attr = site_records.group_by { |s| s[:attr] }.transform_values(&:size) + .sort_by { |_, n| -n } +by_kind = site_records.group_by { |s| s[:caller_kind] }.transform_values(&:size) + +# Attrs that no AST class defines — those are likely external-input duck-typing +unknown_attrs = by_attr.reject { |a, _| attr_to_classes.key?(a) } + +summary = +<<~MD + # respond_to? Inventory (Phase 0) + + Generated by `tools/respond_to_inventory.rb`. Source for #9 planning. + + ## Totals + + - Total respond_to? sites: **#{total}** + - Unique attrs queried: **#{by_attr.size}** + - AST classes inventoried: **#{attr_collector.classes.size}** + - Locatable attrs: #{locatable_attrs.size} — #{locatable_attrs.first(8).join(", ")}#{locatable_attrs.size > 8 ? ", ..." : ""} + + ## Caller-kind distribution + + | kind | count | %% | + |---|---:|---:| +MD + +by_kind.sort_by { |_, n| -n }.each do |kind, count| + pct = (100.0 * count / total).round(1) + summary << "| #{kind} | #{count} | #{pct}%% |\n" +end + +summary << "\n## Top 30 attrs by site count\n\n" +summary << "| attr | sites | classes_with_attr | candidate? |\n" +summary << "|---|---:|---:|---|\n" +by_attr.first(30).each do |attr, count| + classes = attr_to_classes[attr] || [] + candidate = if classes.empty? + "external (no AST class)" + elsif classes.size == 1 + "tighten signature → 1 class" + else + "shared trait (#{classes.size} classes)" + end + summary << "| `#{attr}` | #{count} | #{classes.size} | #{candidate} |\n" +end + +summary << "\n## Attrs with no AST class match (external duck-typing)\n\n" +summary << "These query attrs that no class in `src/ast/ast.rb` defines.\n" +summary << "Likely interactions with String, Hash, Symbol, FFI types.\n\n" +unknown_attrs.first(20).each do |attr, count| + summary << "- `#{attr}` (#{count} sites)\n" +end + +summary_path = File.join(OUT_DIR, "summary.md") +File.write(summary_path, summary) + +puts "Wrote:" +puts " #{attrs_csv_path} (#{attr_collector.classes.size} classes)" +puts " #{sites_csv_path} (#{site_records.size} sites)" +puts " #{summary_path}" diff --git a/tools/respond_to_narrowing.rb b/tools/respond_to_narrowing.rb new file mode 100755 index 000000000..654e8cb4d --- /dev/null +++ b/tools/respond_to_narrowing.rb @@ -0,0 +1,258 @@ +#!/usr/bin/env ruby +# tools/respond_to_narrowing.rb +# +# Per-site receiver-type narrowing analysis for `respond_to?(:X)`. +# Walks each call site and inspects the surrounding AST for prior +# is_a? checks, case/when arms, and assignments that constrain the +# receiver's possible type. +# +# Output: tmp/respond_to_inventory/narrowing.csv with columns +# file, line, attr, receiver, narrowing, classification +# +# narrowing: comma-separated list of facts found ("is_a?(AST::X) at L42", +# "case AST::Y arm", "from .value of Y", "param of method M") +# classification: one of +# ast_locatable — receiver is constrained to AST::Locatable subtype; +# Locatable attrs (`value`, `full_type`, etc.) are +# universal — guard is dead +# typed_specific — receiver is constrained to specific AST classes; +# check inventory if attr is in their fields +# from_locatable_attr — receiver = some_ast.value (and value's type +# is locatable per ast.rb), so receiver is AST +# walker_yielded — inside a generic walker block; receiver is +# whatever yields produced (could be heterogeneous) +# unknown — no narrowing facts found +# +# Usage: bundle exec ruby tools/respond_to_narrowing.rb [attr1 attr2 ...] +# Default attrs: value full_type +require "prism" +require "csv" +require "fileutils" + +ROOT = File.expand_path("..", __dir__) +OUT_DIR = File.join(ROOT, "tmp", "respond_to_inventory") +FileUtils.mkdir_p(OUT_DIR) + +TARGET_ATTRS = ARGV.empty? ? %w[value full_type] : ARGV + +# Walker names that signal "I yield arbitrary values": +WALKER_BLOCK_NAMES = %w[ + walk_body walk_idents walk_expr walk_expr_skip_copy + each_bg_block each_bg_block_in_stmt each_capture_analysis + each_pair AST.walk_body each +].freeze + +# attrs that AST::Locatable provides (or all AST nodes have via member); +# load from existing attrs_by_class.csv inventory. +def load_locatable_attrs + csv_path = File.join(OUT_DIR, "attrs_by_class.csv") + return [] unless File.exist?(csv_path) + rows = CSV.read(csv_path, headers: true) + by_attr = Hash.new { |h, k| h[k] = 0 } + classes = rows.map { |r| r["class"] }.uniq + rows.each { |r| by_attr[r["attr"]] += 1 } + total = classes.size + # An attr present on every (or nearly every) class is essentially + # Locatable-universal. Use 95% threshold to allow a few specialty + # nodes that aren't AST proper. + by_attr.select { |_, n| n >= total * 0.95 }.keys +end + +LOCATABLE_ATTRS = load_locatable_attrs + +class SiteAnalyzer < Prism::Visitor + attr_reader :findings + + def initialize(file, target_lines) + super() + @file = file + @targets = target_lines # Set of line numbers to analyze + @findings = {} # line -> { receiver:, attr:, narrowing: [], scope: [] } + @scope_stack = [] + end + + def visit_def_node(node) + @scope_stack.push({ kind: :def, name: node.name.to_s, params: + (node.parameters&.requireds || []).map(&:name).map(&:to_s), + location: node.location.start_line }) + super + @scope_stack.pop + end + + def visit_block_node(node) + block_params = begin + (node.parameters&.parameters&.requireds || []).map(&:name).map(&:to_s) + rescue StandardError + [] + end + @scope_stack.push({ kind: :block, params: block_params, location: node.location.start_line }) + super + @scope_stack.pop + end + + def visit_case_node(node) + @scope_stack.push({ kind: :case, location: node.location.start_line }) + super + @scope_stack.pop + end + + def visit_when_node(node) + types = (node.conditions || []).filter_map do |c| + c.respond_to?(:slice) ? c.slice : nil + end + @scope_stack.push({ kind: :when, types: types, + location: node.location.start_line }) + super + @scope_stack.pop + end + + def visit_if_node(node) + cond_text = node.predicate&.slice + @scope_stack.push({ kind: :if, cond: cond_text, + location: node.location.start_line }) + super + @scope_stack.pop + end + + def visit_call_node(node) + if node.name == :respond_to? && node.arguments&.arguments&.length == 1 + arg = node.arguments.arguments.first + if arg.is_a?(Prism::SymbolNode) && @targets.include?(node.location.start_line) + receiver = node.receiver&.slice + @findings[node.location.start_line] = { + receiver: receiver, + attr: arg.unescaped, + scope: @scope_stack.dup + } + end + end + super + end +end + +# Build a per-file map of (file, [line]) for all target sites. +sites_csv = File.join(OUT_DIR, "sites.csv") +unless File.exist?(sites_csv) + abort "Missing #{sites_csv} — run tools/respond_to_inventory.rb first." +end + +target_sites = [] +CSV.foreach(sites_csv, headers: true) do |row| + next unless TARGET_ATTRS.include?(row["attr"]) + target_sites << row.to_h +end + +by_file = target_sites.group_by { |s| s["file"] } + +results = [] +by_file.each do |file, sites| + abs = File.join(ROOT, file) + next unless File.exist?(abs) + src = File.read(abs) + lines = src.lines # 1-indexed via [n-1] + target_lines = sites.map { |s| s["line"].to_i }.to_set + + visitor = SiteAnalyzer.new(file, target_lines) + Prism.parse(src).value.accept(visitor) + + sites.each do |s| + line = s["line"].to_i + f = visitor.findings[line] + receiver = f&.dig(:receiver) || s["receiver"] + scope = f&.dig(:scope) || [] + + # Narrow by walking back through the file looking for is_a? guards + # or assignments on this receiver. + narrowing = [] + + # 1. when AST::X arm above this line, in the enclosing case + when_scope = scope.reverse.find { |s| s[:kind] == :when } + if when_scope + narrowing << "in `when #{when_scope[:types].join(', ')}` arm" + end + + # 2. if/elsif with is_a? on the receiver above + (scope.reverse.find { |s| s[:kind] == :if && s[:cond]&.include?("is_a?") }&.dig(:cond)).then do |cond| + narrowing << "in `if #{cond}` block" if cond + end + + # 3. Local lines preceding the call: look for `is_a?(AST::Y)` checks + # on the same receiver name in the prior 30 lines (within scope). + enclosing = scope.reverse.find { |s| s[:kind] == :def || s[:kind] == :block } + method_start = enclosing&.dig(:location) || (line - 30) + scan_start = [method_start, line - 30].max + if receiver + ((scan_start)..(line - 1)).each do |ln| + text = lines[ln - 1].to_s + # is_a?(AST::Y) on this receiver + if md = text.match(/\b#{Regexp.escape(receiver)}\.is_a\?\(([A-Z][\w:]+)\)/) + narrowing << "is_a?(#{md[1]}) at L#{ln}" + end + # receiver = something.value or .X (Locatable attr return) + if md = text.match(/\b#{Regexp.escape(receiver)}\s*=\s*([\w.]+)\.([a-z_]+)\b/) + if LOCATABLE_ATTRS.include?(md[2]) + narrowing << "= .#{md[2]} of #{md[1]} at L#{ln}" + end + end + # case receiver / when AST::Y on this receiver + if text =~ /\bcase\s+#{Regexp.escape(receiver)}\b/ + # Look forward for the matching when arm covering this line. + # Heuristic only — full match would need richer AST inspection. + narrowing << "in case-on-#{receiver} starting at L#{ln}" + end + end + end + + # 4. Walker-context: enclosing block/method has a generic param + # name AND receiver is that param. + if enclosing && receiver + params = enclosing[:params] || [] + if params.include?(receiver) + if params.any? { |p| %w[node stmt expr value val n v item arg].include?(p) } + narrowing << "param of #{enclosing[:kind]}(#{params.join(',')})" + end + end + end + + # Classify + classification = + if narrowing.any? { |n| n.start_with?("is_a?(AST::") || n.include?("when AST::") || n.include?("in `when AST::") } + "typed_specific" + elsif narrowing.any? { |n| n.start_with?("= .") } + "from_locatable_attr" + elsif narrowing.any? { |n| n.start_with?("param of") } + "walker_yielded" + else + "unknown" + end + + results << { + file: file, line: line, attr: s["attr"], + receiver: receiver, narrowing: narrowing.join("; "), + classification: classification + } + end +end + +out_csv = File.join(OUT_DIR, "narrowing.csv") +CSV.open(out_csv, "w") do |csv| + csv << %w[file line attr receiver narrowing classification] + results.each do |r| + csv << [r[:file], r[:line], r[:attr], r[:receiver], r[:narrowing], r[:classification]] + end +end + +# Summary +by_class = results.group_by { |r| r[:classification] }.transform_values(&:size) +puts "Wrote #{out_csv} (#{results.size} sites for attrs: #{TARGET_ATTRS.join(', ')})" +puts "" +puts "Classification breakdown:" +by_class.sort_by { |_, n| -n }.each { |k, n| puts " #{k.ljust(20)} #{n}" } + +# Print suggestions per class +puts "\nLikely-dead candidates (typed_specific + from_locatable_attr):" +likely_dead = results.select { |r| %w[typed_specific from_locatable_attr].include?(r[:classification]) } +likely_dead.first(15).each do |r| + puts " #{r[:file]}:#{r[:line]} #{r[:receiver]}.#{r[:attr]} -> #{r[:narrowing]}" +end +puts " ... (+#{likely_dead.size - 15} more)" if likely_dead.size > 15 diff --git a/tools/srb_nil_origins.rb b/tools/srb_nil_origins.rb new file mode 100755 index 000000000..131797ad4 --- /dev/null +++ b/tools/srb_nil_origins.rb @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby +# Aggregate Sorbet nil/method-not-found errors by ORIGIN site. +# +# Sorbet emits a trace like: +# src/foo.rb:100: Method `bar` does not exist on `NilClass` +# ... +# Got `NilClass` originating from: +# src/foo.rb:50: Possibly uninitialized (`NilClass`) in: +# 50 | def some_method(arg) +# +# The ORIGIN is the source of the nil — fix that method's signature +# (declare nilable, or initialize the variable, or stop returning nil) +# and ALL downstream errors disappear at once. +# +# Usage: bundle exec ruby tools/srb_nil_origins.rb [/path/to/srb_output.txt] +# SRB_YES=1 bundle exec srb tc 2>&1 | bundle exec ruby tools/srb_nil_origins.rb + +input = ARGV[0] ? File.read(ARGV[0]) : STDIN.read + +# Strip ANSI color codes (Sorbet emits them even when piped) +input = input.gsub(/\e\[[0-9;]*m/, '') + +# Parse: each error block looks like +# src/X.rb:LINE: Method `M` does not exist on `T` https://... +# ... +# Got `T` originating from: +# src/Y.rb:OL: Possibly uninitialized (`T`) in: +# OL | ... +# OR +# src/Y.rb:OL: +# OL | some_expr +# +# We group by the ORIGIN (file:line) and count downstream errors. +origins = Hash.new { |h, k| h[k] = [] } +current_error = nil + +input.each_line do |line| + if (m = line.match(/^(src\/[^:]+\.rb):(\d+):.*does not exist on/)) + current_error = "#{m[1]}:#{m[2]}" + elsif current_error && (m = line.match(/^\s+(src\/[^:]+\.rb):(\d+):/)) + origin = "#{m[1]}:#{m[2]}" + origins[origin] << current_error + current_error = nil # only count first origin per error + end +end + +ranked = origins.sort_by { |_, errs| -errs.size } +total = ranked.sum { |_, errs| errs.size } + +puts "═══ Nil-origin aggregation ═══" +puts "Total errors with origins: #{total}" +puts "Distinct origins: #{ranked.size}" +puts +puts "ORIGIN → DOWNSTREAM ERROR COUNT" +puts "─" * 70 +ranked.each do |origin, errs| + puts format(" %4d %s", errs.size, origin) +end +puts +puts "Top 10 origins explain #{ranked.first(10).sum { |_, errs| errs.size }} / #{total} errors." diff --git a/tools/struct_field_nilability.rb b/tools/struct_field_nilability.rb new file mode 100755 index 000000000..e1a26737d --- /dev/null +++ b/tools/struct_field_nilability.rb @@ -0,0 +1,131 @@ +#!/usr/bin/env ruby +# For each Struct.new(:a, :b, ...) AST/MIR/Schemas class, scan every +# `ClassName.new(...)` construction site in src/ and report whether +# any positional argument is `nil` (literal). Fields proven non-nil +# can be added to TYPE_POLICY in tools/gen_struct_fields_rbi.rb. +# +# Output: TSV — class\tfield_index\tfield_name\tnil_count\tnonnil_count\tverdict + +require 'prism' + +# Step 1: discover all Struct.new declarations in the AST/schemas sources. +SOURCES = %w[src/ast/ast.rb src/ast/schemas.rb].freeze + +# class_path => [field_name, ...] in positional order +class_fields = {} + +walk = nil +walk = lambda do |node, scope| + return unless node + case node + when Prism::ModuleNode, Prism::ClassNode + name = node.constant_path.is_a?(Prism::ConstantReadNode) ? node.constant_path.name : node.constant_path.full_name + walk.(node.body, scope + [name.to_s]) + return + when Prism::ConstantWriteNode + if node.value.is_a?(Prism::CallNode) && + node.value.name == :new && + node.value.receiver.is_a?(Prism::ConstantReadNode) && + node.value.receiver.name == :Struct + cls = (scope + [node.name.to_s]).join('::') + args = node.value.arguments&.arguments || [] + fields = args.filter_map { |a| a.value.to_sym if a.is_a?(Prism::SymbolNode) } + class_fields[cls] = fields if fields.any? + end + when Prism::DefNode + return + end + if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |c| walk.(c, scope) } + end +end + +SOURCES.each do |f| + parsed = Prism.parse_file(f) + next unless parsed.success? + walk.(parsed.value, []) +end + +# Build [field_index, field_name] => Hash counters per class. +# nil_count / nonnil_count tracked per (class, position). +counters = {} +class_fields.each do |cls, fields| + fields.each_with_index do |fname, idx| + counters[[cls, idx, fname]] = { nil: 0, nonnil: 0, sites: [] } + end +end + +# Step 2: scan every Ruby file in src/ for `ClassName.new(...)` calls. +# Match the constant-path against the discovered class names. +# Build a quick lookup: short class name (last segment) => full class path. +short_to_full = {} +class_fields.each_key do |cls| + short = cls.split('::').last + short_to_full[short] ||= [] + short_to_full[short] << cls +end + +scan_walk = nil +scan_walk = lambda do |node, file| + return unless node + if node.is_a?(Prism::CallNode) && node.name == :new + recv = node.receiver + # Build the receiver's textual constant path (e.g. "AST::FuncCall") + recv_path = const_path_text(recv) + if recv_path + short = recv_path.split('::').last + candidates = (short_to_full[short] || []).select do |full| + full == recv_path || full.end_with?("::" + recv_path) + end + if candidates.size == 1 + cls = candidates.first + fields = class_fields[cls] + positional_args = (node.arguments&.arguments || []).reject do |a| + a.is_a?(Prism::KeywordHashNode) || a.is_a?(Prism::SplatNode) || + a.is_a?(Prism::BlockArgumentNode) || a.is_a?(Prism::AssocNode) + end + positional_args.each_with_index do |arg, idx| + break if idx >= fields.size + key = [cls, idx, fields[idx]] + if arg.is_a?(Prism::NilNode) + counters[key][:nil] += 1 + counters[key][:sites] << "#{file}:#{node.location.start_line}" + else + counters[key][:nonnil] += 1 + end + end + end + end + end + if node.respond_to?(:child_nodes) + node.child_nodes.compact.each { |c| scan_walk.(c, file) } + end +end + +def const_path_text(node) + case node + when Prism::ConstantReadNode then node.name.to_s + when Prism::ConstantPathNode + parent = const_path_text(node.parent) if node.parent + parent ? "#{parent}::#{node.name}" : node.name.to_s + else nil + end +end + +Dir.glob('src/**/*.rb').each do |f| + parsed = Prism.parse_file(f) + next unless parsed.success? + scan_walk.(parsed.value, f) +end + +# Step 3: report. +# Verdict: NON_NIL if 0 nil and ≥1 nonnil. NILABLE if any nil seen. +# UNTESTED if no construction sites observed. +puts "class\tidx\tfield\tnil\tnonnil\tverdict" +counters.sort_by { |(cls, idx, _f), _c| [cls, idx] }.each do |(cls, idx, fname), c| + verdict = if c[:nil] == 0 && c[:nonnil] > 0 then "NON_NIL" + elsif c[:nil] > 0 then "NILABLE" + else "UNTESTED" + end + puts "#{cls}\t#{idx}\t#{fname}\t#{c[:nil]}\t#{c[:nonnil]}\t#{verdict}" +end