Skip to content

fix(hir): keep the null-guard for an optional method call on a process.env read - #6058

Merged
proggeramlug merged 1 commit into
mainfrom
fix/optchain-env-receiver-guard
Jul 6, 2026
Merged

fix(hir): keep the null-guard for an optional method call on a process.env read#6058
proggeramlug merged 1 commit into
mainfrom
fix/optchain-env-receiver-guard

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Problem

process.env?.[k]?.method() — an optional method call whose receiver is an inline process.env read — silently drops the per-receiver null-guard on the ?. before the method. The method is then invoked on the undefined an unset variable reads as, and the result is the string "undefined" (from stringifying the missing value) instead of a short-circuit to undefined.

process.env?.["MISSING"]?.trim()        // => "undefined"  (should be: undefined)
process.env.MISSING?.trim() ?? "x"      // => "undefined"  (should be: "x")
const e = process.env; e?.["MISSING"]?.trim()   // => undefined  (correct)

This shape is everywhere: SDKs read config via readEnv(k)?.trim(), and a common HTTP-client base-URL default is process.env.BASE_URL?.trim() ?? "https://…" — which silently became the string "undefined" and then produced new URL("undefined/…") (Invalid URL) at request time.

Root cause

process.env[k] lowers to IndexGet { object: ProcessEnv, .. }. The a?.b?.method() lowering in arm_optchain.rs re-adds the receiver null-guard only when the receiver is opt_call_receiver_repeatable (it appears twice — in the guard and in the call — so it must be safe to evaluate more than once). That predicate whitelisted LocalGet/GlobalGet/This/literals/PropertyGet/IndexGet but not ProcessEnv, so IndexGet { object: ProcessEnv, .. } was deemed non-repeatable → the guard was skipped → process.env[k].method() dereferenced the unguarded undefined.

The HIR shows it directly — buggy (one guard) vs. the env-in-a-local form (two guards):

// process.env?.[k]?.trim()   — BUGGY (missing the process.env[k] == null guard)
Conditional{ ProcessEnv == null ? undefined : process.env[k].trim() }

// const e = process.env; e?.[k]?.trim()   — correct
Conditional{ e == null ? undefined : Conditional{ e[k] == null ? undefined : e[k].trim() } }

Fix

Env reads are pure, side-effect-free, and stable within an expression, so they are safe to evaluate more than once. Add them to opt_call_receiver_repeatable:

Expr::ProcessEnv | Expr::EnvGet(_) => true,
Expr::EnvGetDynamic(key) => opt_call_receiver_repeatable(key),

Test

crates/perry/tests/issue_optchain_env_receiver_guard.rs compiles+runs the computed-key and static-key forms with an unset var (must short-circuit to the ?? default), a set var (must flow through — proves we didn't just null everything), and a raw missing read (must be JS undefined, typeof === "undefined", not the string): dyn=DEFAULT stat=DEFAULT set=hello rawIsUndef=true rawType=undefined. perry-hir lib tests remain 221/0.

Note (separate, larger issue)

The underlying pattern — "receiver of a?.b?.method() is not repeatable → drop the guard" — is also wrong for any side-effecting mid-chain receiver (e.g. sideEffect()?.[k]?.m()), which should bind the receiver to a temp rather than drop the guard or duplicate the effect. This PR only fixes the pure process.env family (the common, safe case); the general temp-binding fix is a separate, bigger change.

Summary by CodeRabbit

  • Bug Fixes

    • Improved optional chaining behavior for environment-variable reads so inline process.env and similar expressions preserve the expected nullish guard.
    • Fixed cases where missing environment values now short-circuit correctly instead of being treated as a string value.
    • Verified that present environment values continue to flow through and produce the expected trimmed output.
  • Tests

    • Added regression coverage for optional chaining with environment-based receivers.

…ss.env` read

`process.env?.[k]?.method()` (env read accessed inline) silently dropped the
per-receiver null-guard on the `?.` before the method, so the method was
invoked on the `undefined` an unset variable reads as. The result was the
STRING "undefined" (from stringifying the missing value) instead of a
short-circuit to `undefined`.

Cause: `process.env[k]` lowers to `IndexGet { object: ProcessEnv, .. }`, and
the `a?.b?.method()` lowering in `arm_optchain` only re-adds the receiver
null-guard when the receiver is `opt_call_receiver_repeatable`. That predicate
did not list `ProcessEnv` / env reads, so the receiver was deemed unsafe to
evaluate twice and the guard was skipped — leaving `process.env[k].method()`
to dereference the unguarded `undefined`. (Hoisting the env read into a local
first — `const e = process.env; e?.[k]?.method()` — worked, because a
`LocalGet` receiver is repeatable and kept the guard.)

Env reads are pure, side-effect-free, and stable within an expression, so they
are safe to evaluate more than once (guard + call). Add `ProcessEnv`,
`EnvGet`, and `EnvGetDynamic` (repeatable iff its key is) to
`opt_call_receiver_repeatable`.

This shape is ubiquitous: SDKs read config via `readEnv(k)?.trim()`, and a
common HTTP-client base-URL default is `process.env.BASE_URL?.trim() ?? "…"`,
which silently became the string "undefined" and produced
`new URL("undefined/…")` failures.

Adds an e2e test covering computed/static keys, a set var flowing through, and
a raw missing read being JS `undefined` (not the string).
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a5f0c889-5aff-4e25-9cb7-4d2e7bed12f2

📥 Commits

Reviewing files that changed from the base of the PR and between c5246ec and 1ef4a4e.

📒 Files selected for processing (2)
  • crates/perry-hir/src/lower/lower_expr/helpers.rs
  • crates/perry/tests/issue_optchain_env_receiver_guard.rs

📝 Walkthrough

Walkthrough

The change extends the opt_call_receiver_repeatable predicate in Perry's HIR lowering to treat process.env reads (ProcessEnv, EnvGet, EnvGetDynamic) as repeatable expressions, ensuring optional-call null guards are correctly preserved. A regression test validates the fix end-to-end.

Changes

Optional-call env receiver guard fix

Layer / File(s) Summary
Repeatability logic for env-access expressions
crates/perry-hir/src/lower/lower_expr/helpers.rs
Adds match arms treating Expr::ProcessEnv and Expr::EnvGet(_) as repeatable, and Expr::EnvGetDynamic(key) as repeatable when its key is repeatable.
Regression test for process.env optional chaining
crates/perry/tests/issue_optchain_env_receiver_guard.rs
Adds documentation, helper functions (perry_bin, compile_and_run), and a test compiling/running TypeScript with optional-chained process.env reads and .trim(), verifying missing env vars yield undefined rather than the string "undefined".

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • PerryTS/perry#5570: Both PRs address optional-call/optional-chain lowering to preserve correct receiver nullish short-circuiting via repeatable/side-effect-free receiver detection.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: preserving the null-guard for optional method calls on inline process.env reads.
Description check ✅ Passed The description is detailed and covers problem, root cause, fix, and test coverage, though it does not follow the template headings exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/optchain-env-receiver-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug merged commit ad8507d into main Jul 6, 2026
25 checks passed
@proggeramlug
proggeramlug deleted the fix/optchain-env-receiver-guard branch July 6, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant