Skip to content

Reject a method whose self is not the first parameter - #397

Merged
0xGeorgii merged 1 commit into
mainfrom
fix/377-self-must-be-first-parameter
Aug 12, 2026
Merged

Reject a method whose self is not the first parameter#397
0xGeorgii merged 1 commit into
mainfrom
fix/377-self-must-be-first-parameter

Conversation

@0xGeorgii

@0xGeorgii0xGeorgii commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes#377.

What was wrong

A struct method could declare its self/mut self receiver at any parameter position. The parser accepted it, the type checker accepted it, and the two halves of codegen then disagreed about the ABI:

  • the callee emitted its WASM parameters in source declaration order (compiler.rs, visit_function_definition_body);
  • every instance call pushed the receiver first, then the written arguments (lower_instance_method_call, plus the two sret call paths).

So for the issue's program the receiver pointer bound to delta and the literal 2 bound to self. Nothing in between noticed: has_self was computed with args.iter().any(..) — position-blind, and contradicting its own documented contract — while param_types filtered the receiver out of whatever index it sat at, so arity and per-argument checks passed.

Three observed outcomes, all at exit 0:

shapebefore
fn plus(delta: i32, self)returns 65520 (a stack pointer) instead of 42
fn get(k: i32, self), called with 1000000wasm trap: out of bounds memory access — the mis-bound integer is read as an address
value: i64, fn plus(delta: i64, self)emits a module that fails WebAssembly validation (expected i32, found i64)

The emitted Rocq .v faithfully encoded the same broken ABI, so a proof built over it would have been a proof about the mis-lowered program.

Scope

The defect surface is struct methods only (top-level and spec-inner, which register through the same site). Standalone fn and external fn already rejected a self parameter at any position, and fn m(self, self) was already fatal. Confirmed by grep that no .inf fixture or inline test source in the repo declared a non-leading self, so the new rejection breaks nothing in the corpus.

What changes

Frontend rejectioncore/type-checker/. New TypeCheckError::SelfReferenceNotFirstParameter, emitted from the single struct-method registration site by one positional scan, exactly once per malformed method and anchored at the receiver to move:

4:25: `self` must be the first parameter of method `Number::plus`
note: a method call passes the receiver ahead of the arguments written at the call site, so the
first parameter position is reserved for it; move `self` (or `mut self`) to the front of the
parameter list

has_self stays deliberately position-blind (.any(..), now folded into the same position(..) scan): a misplaced receiver still classifies the function as an instance method for the rest of checking, so the declaration-site error is not joined by an AssociatedFunctionCalledAsMethod at every call site of a method that is already rejected. MethodInfo's doc block now states the invariant it actually has — true for every program that passes type checking, with the check_with_diagnostics recovery path named explicitly as the exception.

Defense in depthcore/wasm-codegen/. A hard assert_eq!(local_idx, u32::from(is_sret)) in the ArgKind::SelfRef parameter arm, in the house style of the two sibling "the type-checker should have rejected …" asserts beside it. This covers the library route (type_check_with_diagnosticscodegen) that bypasses the fatal type-check boundary, and it is ordered after the duplicate-name check so a repeated receiver reports the duplicate rather than the misplacement. The three receiver-push sites now each carry a comment naming the invariant they depend on.

Testing

19 new tests. tests/src/type_checker/self_parameter_position.rs (16):

  • rejection, structured-variant assertions with pinned spans: receiver last of two, mut self between two parameters (span covers the whole mut self), receiver in the fourth slot, i64-width shape, struct-returning (sret) method, spec-inner struct, and a multi-file case asserting the diagnostic is attributed to the importing file. Two of these assert errors.len() == 1 to pin the no-cascade property.
  • acceptance, compiled and executed under embedded wasmtime: fn m(self), fn m(self, x), fn m(mut self, x), an associated function with no receiver, an sret method, and the issue's own program with self moved to the front returning 42.
  • unchanged behavior: fn twice(self, self) still yields the duplicate-registration diagnostic and not the new one; standalone and external fn receivers keep their existing diagnostics.

tests/src/codegen/wasm/negative.rs (3): the codegen assert is proven reachable via catch_unwind on the diagnostics-ignoring path — at index 1, at index 2 behind an sret pointer, and a duplicated receiver landing on the duplicate message instead.

Full default workspace cargo test: 5526 passed, 0 failed (exit 0). Codegen goldens byte-identical — git status -- tests/test_data is empty, and both compile_mode_corpus_validates_as_wasm_1_0 and the method golden family pass unchanged. Clippy clean (the only 4 workspace warnings are pre-existing in tests/src/ast/builder_features.rs). Every table row above was re-verified end to end against a rebuilt target/debug/infc — including rebuilding the pre-fix binary to confirm all three failure modes, and checking that the rejecting cases now emit no artifact and surface identically through infs build. The -v Rocq path on accepted programs is unaffected.

Reviewed by a separate adversarial pass, which found no correctness defect and five documentation/consistency nits; all five are fixed in this PR.

Docs

New SelfReferenceNotFirstParameter entry in core/type-checker/docs/errors.md, and a ### Breaking CHANGELOG entry.

Deliberately out of scope

  • Duplicate self keeps its leaky wording (error registering variable \self`: Variable `self` already declared in this scope`). Already fatal, so not a miscompile — worth its own issue.
  • A spec-shadowed struct rejected as a duplicate name never reaches this check, because reject_duplicate_spec_struct_or_enumcontinues past the whole Def::Struct arm. Not unsound (the duplicate-struct error is itself fatal, so nothing is emitted, and the codegen assert backstops the library path), but it costs a second round-trip. This is a pre-existing property of that continue affecting every diagnostic in the arm, not something this change introduces.
  • self inside a fn(i32, self) type. Only the parser can see it — lower.rs discards fn-type parameters entirely as a pinned parity quirk — so it never reaches lowering and cannot miscompile.
  • register_definition_from_external silently drops struct methods, so the rule would be vacuous there. Currently dead code (load_prelude has no production caller); relevant to whoever revives the prelude path.

Confidence Score: 5/5

The PR appears safe to merge; no concrete changed-code defect or independently actionable non-blocking issue remains.

The frontend check covers struct-method registration, valid receiver layouts satisfy the backend slot assertion for both ordinary and sret functions, and malformed diagnostics-recovery contexts are stopped before WASM emission.

Important Files Changed

FilenameOverview
core/type-checker/src/type_checker.rsAdds the declaration-site receiver-position check while retaining instance-method classification for diagnostic recovery.
core/type-checker/src/errors.rsDefines, locates, formats, and tests the new structured type-checking diagnostic.
core/type-checker/src/symbol_table.rsDocuments the leading-receiver invariant and the explicitly supported diagnostics-recovery exception.
core/wasm-codegen/src/compiler.rsAdds a backend assertion that accounts for both ordinary and sret method parameter layouts.
tests/src/type_checker/self_parameter_position.rsCovers rejection spans, accepted execution, sret, multi-file and spec cases, and neighboring diagnostics.
tests/src/codegen/wasm/negative.rsVerifies malformed recovery contexts hit the backend guard without changing duplicate-receiver precedence.

Sequence Diagram

sequenceDiagram
participant Source as Method declaration
participant TC as Type checker
participant Context as TypedContext
participant Gen as WASM codegen
Source->>TC: fn method(parameters)
TC->>TC: Locate self receiver
alt self is absent or first
TC->>Context: Register valid method metadata
Context->>Gen: Generate function
Gen->>Gen: Assert receiver occupies first ABI slot
Gen-->>Source: Emit valid WASM
else self appears later
TC-->>Source: SelfReferenceNotFirstParameter
opt Diagnostics-recovery context reaches codegen
TC->>Context: Return context with diagnostic
Context->>Gen: Attempt generation
Gen-->>Source: Abort on receiver-slot assertion
end
end
Loading

Reviews (1): Last reviewed commit: "Reject a method whose self is not the fi..." | Re-trigger Greptile

Context used (3)

@0xGeorgii0xGeorgii self-assigned this Aug 12, 2026
@0xGeorgii0xGeorgii added bug Something isn't working static analysis Static code analysis labels Aug 12, 2026
@codecov

codecovBot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.00000% with 1 line in your changes missing coverage. Please review.

Files with missing linesPatch %Lines
core/type-checker/src/errors.rs88.88%1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@0xGeorgii
0xGeorgii merged commit 9b69e94 into mainAug 12, 2026
8 checks passed
@0xGeorgii
0xGeorgii deleted the fix/377-self-must-be-first-parameter branch August 12, 2026 06:54
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugSomething isn't workingstatic analysisStatic code analysis

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-leading self silently corrupts the method-call ABI

1 participant

@0xGeorgii