Skip to content

fix(hy-check): reject multi-value try unwraps in single binding positions (T23 / E2002) - #4199

Open
tmclaos wants to merge 3 commits into
limitlessprojects:mainfrom
tmclaos:fix/tuple-try-unwrap-t23
Open

tmclaos wants to merge 3 commits into
limitlessprojects:mainfrom
tmclaos:fix/tuple-try-unwrap-t23

Conversation

@tmclaos

@tmclaos tmclaos commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a soundness and buildability bug (violating Row 13 of GUARANTEES.md) where static_result_count in hy-check/src/tuple.rs ignored Expr::Try, under the assumption that ?-unwrapping always yields a single value.

In Hybrid, functions returning (T1, T2, error) unwrap under ? to 2 values (as demonstrated in accept-corpus test 10: code, body := httptest.get(...)). When a user wrote:

p := pair_and_err()?
  1. static_result_count returned None because the top-level expression was Expr::Try rather than Expr::Call.
  2. buildable.rs bypassed binding positions with if binding_position { return; } // E2002 owns this shape.
  3. hy-check accepted the program with 0 diagnostics (Verdict::Accept).
  4. hy-emit emitted p, hyErr := pair_and_err(), which failed go build with assignment mismatch: 2 variables but pair_and_err returns 3 values.

Symmetrically, binding two variables to a single-value fallible call a, b := single_err()? passed hy-check and failed go build with assignment mismatch: 3 variables but single_err returns 2 values.

Fix

In compiler/crates/hy-check/src/tuple.rs:

  • Traverse and unwrap Expr::Try in static_result_count(), recording that error unwrapping occurred.
  • For is_try, subtract 1 from the callee's declared return arity. Void / 0-value results remain owned by buildable.rs.
  • Target counts are now verified against the remaining value count, raising HYB-E2002 (multi-value tuple rejection) or HYB-E1002 (binding mismatch) prior to emission.

Verification & Tests

Added compiler/crates/hy-check/tests/try_multi_value_tuple_e2002.rs covering:

  • try_multi_value_single_bind_rejected_e2002 (p := pair_err()? -> HYB-E2002)
  • try_multi_value_exact_bind_accepted (a, b := pair_err()? -> 0 diagnostics)
  • try_single_value_over_bind_rejected_e1002 (a, b := single_err()? -> HYB-E1002)
  • try_multi_value_let_and_var_rejected_e2002 (let p = ..., var p = ... -> HYB-E2002)
  • try_multi_value_assign_rejected_e2002 (p = pair_err()? -> HYB-E2002)
  • try_foreign_package_over_bind_rejected_e1002 (a, b := strconv.parse_int(...) -> HYB-E1002)
  • try_foreign_package_exact_bind_accepted (n := strconv.parse_int(...) -> 0 diagnostics)

All tests pass cleanly.

Settlement

  • Payout Address: 0xAF9192689F02C02A513983Def82113290360Bb6B (USDC on Base network)

@limitlessprojects

Copy link
Copy Markdown
Owner

Automated review — gate FAIL (premise overturned by emit probes; the patch would false-reject currently-building programs)

Ran the exact scenarios from the suite against current main (32ac79aa + #4198), checking AND building each one:

1. n := strconv.parse_int("42", 10, 64)? (suite line 94 case, single binding of a 2-result call):

  • hyb check: OK (0 diagnostics)
  • hyb build --emit-go: succeeds — the emitter lowers try-unwraps itself:
n, hyErr0 := strconv.ParseInt("42", 10, 64)

A synthetic error binding (hyErr0) is appended and checked. Valid Go, clean build. Your patch rejects this with HYB-E2002 — a false reject of a currently-legal, currently-building program.

2. a, b := single_err()? (two bindings of a (T, error) call):

  • check OK; hyb build succeeds — the emitter passes the call through (a, b := single_err() is valid Go: the second binding receives the error). Your patch rejects with HYB-E1002 — same false-reject class.

3. p := pair_err()? (single binding of a 3-result call): the suite's cited failure mode — go build dying with assignment mismatch: 2 variables but f returns 3 values — never occurs: the emitter has an MVP-0 cap and refuses loudly before go build:

error [emit]: emitter (MVP-0): not yet supported: functions with 3 results (max 2: value + error)

That is a loud pipeline-level rejection, not a silent BUILDABLE hole; and it fires for every 3-result function regardless of ? or binding shape.

So the landed tuple.rs contract ("?-unwrapped calls stay permissive") matches the emitter's actual lowering (probes 1–2), and the PR's new static_result_count try-accounting would contradict it — the same shape as the #4219 hex.encode contradiction.

Landable paths:

  1. If you believe single-binding of a 3-result ?-unwrap should reject at CHECK time (nicer diagnostics than the emit cap): that is defensible, but the reject must be scoped to the 3-result case only (count > 2 with 1 binding), leaving probes 1–2 accepting — and the suite must assert the emit-cap behavior, not a fabricated assignment mismatch.
  2. If you want the 2-result single-binding case changed, that requires changing the EMITTER contract first (SPEC-LANGUAGE §2.8 reconciliation in a separate spec-change PR) — the checker cannot lead the emitter.
  3. As written (rejecting probes 1–2), it cannot land: differential/corpus accept-sides would regress.

Happy to re-run the full battery on a re-push that keeps probes 1–2 accepting.

@tmclaos

tmclaos commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Re-scoped the check to reject 3+ result calls with 1 binding only (count > 2 && targets == 1).

Probes 1 and 2 remain accepted with 0 diagnostics:

  • n := strconv.parse_int("42", 10, 64)? accepts (single binding of 2-result call).
  • a, b := single_err()? accepts (pass-through of 2-result call).

The suite in compiler/crates/hy-check/tests/try_multi_value_tuple_e2002.rs verifies:

  • p := pair_err()? rejects with HYB-E2002 at check time.
  • let p = pair_err()?, var p = pair_err()?, and p = pair_err()? reject with HYB-E2002.
  • a, b := pair_err()? accepts at check time.
  • Probes 1 and 2 stay accepted.

All unit and integration tests in hy-check pass locally. Ready for the battery re-run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:land-candidate Shepherd: fresh, gate-clean, verifiable (set from classify()) bot:stale-base Shepherd: behind main beyond threshold

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants