Skip to content

fix(hir,stdlib): narrow the #7726 spread bail and complete the querystring bridge - #7734

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7720-followup-narrow-spread-bail
Aug 9, 2026
Merged

fix(hir,stdlib): narrow the #7726 spread bail and complete the querystring bridge#7734
proggeramlug merged 3 commits into
mainfrom
fix/7720-followup-narrow-spread-bail

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #7726 (issue #7720). Opening as a draft until the baseline gap-suite arm finishes; the substance below is already measured.

#7726 was merged before my validation finished. It carried two regressions. This corrects them at the root and narrows the predicate to what is measured, not derived.

How this was measured

A 32-case A/B matrix over the node-core spread surface, run on one host, three ways per case:

#7726 as merged this PR
FIXED 17 17
same 10 14
wrong → wrong 3 1
correct → wrong 2 0

1. querystring.escape(...args) — regressed to undefined

The root cause is older and wider than the spread bail. nm_dispatch_querystring advertises the whole set:

("querystring", "unescapeBuffer" | "unescape" | "escape" | "stringify" | "encode" | "parse" | "decode") => {JS_NATIVE_QUERYSTRING_DISPATCH}

…but the stdlib bridge it calls, js_querystring_native_dispatch, implemented only unescapeBuffer and fell to _ => undefined for the other six. So on main, before any of this, every indirect form was already silently undefined:

qs.escape("a b")                      // "a%20b"  — static dispatch, always fine
const e = qs.escape; e("a b")         // undefined
const d: any = qs; d.escape("a b")    // undefined

#7726 only routed spread calls onto that pre-existing hole. Every js_querystring_* entry point already existed; only the bridge was missing. Wiring the six names up (encode/decode are Node's aliases for stringify/parse) fixes the regression and the captured/dynamic forms in one go.

2. fs.promises / dns.promises are not dispatch buckets

The predicate recursed through any sub-namespace receiver and accepted any <module>/<export> that happened to be a node-core module name. But nm_module_index has dotted tags only for path.posix, path.win32, util.types, crypto.subtle/webcrypto, punycode.ucs2. There is no fs.promises bucket, so diverting them produced:

  • dns.promises.lookup(...args) → silent undefined (was a throw)
  • import { promises } from "node:fs"; promises.readFile(...args) → synchronous TypeError: value is not a function where a rejected promise used to arrive — the worst of the four, since a .catch() no longer catches it

Replaced the derivation with an explicit allowlist, and rejected the slash sub-module tags. The direct import (import fsp from "node:fs/promises") needs no help from the bail — it already reached the generic tail before #7726 (measured: identical HIR and ENOENT on both arms) — so excluding the slash tags costs nothing.

Known and deliberate

events.listenerCount(...args) still turns a bogus ERR_INVALID_ARG_TYPE throw into undefined. nm_dispatch_events implements only init and EventEmitterAsyncResource, so there is no arm to reach; both the old and new behaviour are wrong (node returns a count). Completing that dispatcher is the same shape of change as the querystring bridge here and deserves its own PR. Excluding events from the bail would be a one-line denylist entry if you would rather have the loud failure in the meantime — say the word.

Tests

  • sub_namespace_allowlist_is_the_runtime_bucket_set — pins the allowlist against exactly the re-derivation that shipped (asserts fs.promises / dns.promises / stream.web are not in it)
  • bucketless_sub_namespaces_keep_their_lowering — the two HIR verdicts that changed. fs.promises.readFile(...) is deliberately absent: it already reached the generic tail before fix(hir): route node-core module spread calls through the variadic dispatch #7726, so asserting on it would test nothing
  • node-suite/querystring/aliases/dynamic-dispatch.ts — static / captured / dynamic / spread forms of six querystring methods, byte-compared against node; verified identical
  • cargo test -p perry-hir --lib — 9/9 in the spread suite, 287 in the crate

Summary by CodeRabbit

  • Bug Fixes

    • Improved Node.js query-string compatibility for dynamic calls to escape, unescape, stringify/encode, and parse/decode.
    • Prevented unsupported namespace variants from being routed through incorrect runtime handling.
    • Preserved expected behavior for unknown query-string methods.
  • Tests

    • Added coverage for static, dynamic, captured, type-erased, and spread query-string calls.
  • Documentation

    • Updated the documented release version and changelog.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change narrows native node-core sub-namespace dispatch to supported buckets and completes dynamic node:querystring method routing, including aliases. It adds parity coverage and updates the workspace version, documentation, and changelog.

Changes

Querystring native dispatch

Layer / File(s) Summary
Native sub-namespace dispatch
crates/perry-hir/src/lower/expr_call/native_module.rs, crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs
Native lowering now validates top-level modules and allowlisted dispatch buckets. Tests cover supported namespaces and excluded fs.promises, dns.promises, and slash submodules.
Querystring dynamic method bridge
crates/perry-stdlib/src/querystring.rs, test-parity/node-suite/querystring/aliases/dynamic-dispatch.ts
The dispatcher now handles querystring methods and aliases. Parity tests cover direct, captured, dynamic, and spread calls.
Version and changelog updates
Cargo.toml, CLAUDE.md, changelog.d/7734-narrow-spread-bail-querystring-bridge.md
The workspace and documented version change to 0.5.1426. The changelog records dispatch validation results and the remaining discrepancy.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Test
  participant NativeDispatcher
  participant Querystring
  Test->>NativeDispatcher: invoke dynamic querystring method
  NativeDispatcher->>Querystring: route method or alias
  Querystring-->>NativeDispatcher: return result
  NativeDispatcher-->>Test: return value or undefined
Loading

Possibly related PRs

  • PerryTS/perry#6670: Both changes modify HIR native-module lowering for spread-call handling.

Suggested labels: bug, parity

Suggested reviewers: thehypnoo, jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the HIR spread-bail fix and completion of the querystring bridge.
Description check ✅ Passed The description provides detailed scope, rationale, related issues, measurements, tests, and known limitations, but omits the template headings and checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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/7720-followup-narrow-spread-bail

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 pushed a commit that referenced this pull request Aug 9, 2026
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
@proggeramlug
proggeramlug force-pushed the fix/7720-followup-narrow-spread-bail branch from fced7b7 to 405a00c Compare August 9, 2026 20:15
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Validation complete — flipping out of draft

Gap suite, both arms, same host (511 tests; base = this tree with the guard forced to false, i.e. pre-#7726 lowering):

base branch
pass 485 484
output mismatch 20 20
crash 6 7

The 20-mismatch sets are byte-identical — all pre-existing, and none of those 20 files contains a spread call argument at all (only rest-param declarations and comments), so the guard cannot reach them. They are this host's known profile: npm-package tests needing node_modules (moment/dayjs/cron/slugify/backoff/ratelimiter), the zlib link flake, and socket-sandbox hangs.

The single crash-count difference is test_gap_gc_alloc_point_no_move, which crashed only in the branch run — that run overlapped two other parity suites and a cargo build on the same box. Re-run in isolation it is 3/3 clean on both arms and byte-identical to node, so it is load-induced, not a regression.

Final A/B matrix (32 cases, base vs this branch vs node 26.5.1): 17 fixed / 14 same / 1 wrong→wrong / 0 regressed. The one wrong→wrong is the events.listenerCount case described above.

Other checks: cargo test -p perry-hir --lib 287 pass (9 in the spread suite), cargo fmt --all --check clean, scripts/check_file_size.sh clean, and node-suite/querystring/aliases/dynamic-dispatch.ts byte-identical to node.

Ralph Küpper added 3 commits August 9, 2026 22:32
…tring bridge

Follow-up to #7726, which routed node-core module spread calls through the
variadic runtime dispatch. A 32-case A/B matrix (this tree vs. the same tree
with the guard forced to `false`, both against node 26.5.1) found two calls that
were CORRECT before and `undefined` after, plus two wrong-to-differently-wrong
conversions. All four are addressed here; the matrix is now 17 fixed / 13 same /
2 wrong-to-wrong / 0 regressed.

1. `querystring.escape(...args)` regressed to `undefined`. Root cause is older
   and wider than the spread bail: `nm_dispatch_querystring` advertises
   escape/unescape/stringify/encode/parse/decode, but the stdlib bridge it calls
   (`js_querystring_native_dispatch`) implemented ONLY `unescapeBuffer` and fell
   to `_ => undefined` for everything else. So on main every indirect form was
   already silently undefined — `const d: any = qs; d.escape("a b")`,
   `const e = qs.escape; e("a b")` — while the statically dispatched
   `qs.escape("a b")` was correct. #7726 merely routed spread calls onto that
   hole. Wire the remaining six names to the `js_querystring_*` entry points
   that already existed (`encode`/`decode` are Node's aliases for
   `stringify`/`parse`), which fixes the regression and the pre-existing
   captured/dynamic forms together.

2. `fs.promises` / `dns.promises` are not dispatch buckets. The predicate
   recursed through any sub-namespace receiver and treated any
   `<module>/<export>` that happened to be a node-core module name as a
   namespace. `nm_module_index` has DOTTED tags only for `path.posix`,
   `path.win32`, `util.types`, `crypto.subtle`/`webcrypto` and `punycode.ucs2`;
   there is no `fs.promises` bucket. Diverting the bucket-less ones produced a
   silent `undefined` (`dns.promises.lookup(...args)`) and a synchronous
   `TypeError: value is not a function` where a rejected promise used to arrive
   (`import { promises } from "node:fs"`). Replace the derivation with an
   explicit allowlist, and reject the slash sub-module tags: the direct import
   (`import fsp from "node:fs/promises"`) already reaches the generic tail
   without the bail, measured identical on both arms, so excluding them costs
   nothing.

Known and deliberate: `events.listenerCount(...args)` still changes a bogus
`ERR_INVALID_ARG_TYPE` throw into `undefined` — `nm_dispatch_events` implements
only `init` and `EventEmitterAsyncResource`, so the dispatcher has no arm to
reach. Both forms are wrong (node returns a count); completing that dispatcher
is its own change.

Tests: `sub_namespace_allowlist_is_the_runtime_bucket_set` pins the allowlist
against exactly the re-derivation that shipped, `bucketless_sub_namespaces_keep_
their_lowering` pins the two HIR verdicts that changed, and
`node-suite/querystring/aliases/dynamic-dispatch.ts` byte-compares the static,
captured, dynamic and spread forms of six querystring methods against node.
@proggeramlug
proggeramlug force-pushed the fix/7720-followup-narrow-spread-bail branch from 405a00c to 8af631f Compare August 9, 2026 20:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Cargo.toml`:
- Line 318: Revert the workspace package version change at Cargo.toml lines
318-318 and restore the prior value. Also revert the Current Version change in
CLAUDE.md lines 11-11; both release metadata updates must be removed while
retaining the PR-keyed changelog fragment.

In `@crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs`:
- Around line 142-148: Add the ("crypto", "webcrypto") pair to the
module/submodule cases in sub_namespace_has_dispatch_bucket, preserving the
existing cases so the test covers every allowed dispatch bucket.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 12b4c5d2-8b60-481c-8c46-dc2ebac00476

📥 Commits

Reviewing files that changed from the base of the PR and between 1a7d8f7 and 8af631f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7734-narrow-spread-bail-querystring-bridge.md
  • crates/perry-hir/src/lower/expr_call/native_module.rs
  • crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs
  • crates/perry-stdlib/src/querystring.rs
  • test-parity/node-suite/querystring/aliases/dynamic-dispatch.ts

Comment thread Cargo.toml

[workspace.package]
version = "0.5.1425"
version = "0.5.1426"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep release metadata out of the contributor change.

The PR-keyed changelog fragment is present, so the contributor change should not also update release/version metadata. Maintainers must own the merge or release bump.

  • Cargo.toml#L318-L318: Revert the [workspace.package].version change.
  • CLAUDE.md#L11-L11: Revert the Current Version change.

As per coding guidelines, external contributors must not modify the workspace version. Based on learnings, maintainers own the corresponding CLAUDE.md and Cargo.toml release metadata when a PR-keyed changelog fragment is present.

📍 Affects 2 files
  • Cargo.toml#L318-L318 (this comment)
  • CLAUDE.md#L11-L11
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Cargo.toml` at line 318, Revert the workspace package version change at
Cargo.toml lines 318-318 and restore the prior value. Also revert the Current
Version change in CLAUDE.md lines 11-11; both release metadata updates must be
removed while retaining the PR-keyed changelog fragment.

Sources: Coding guidelines, Learnings

Comment on lines +142 to +148
for (module, sub) in [
("path", "posix"),
("path", "win32"),
("util", "types"),
("crypto", "subtle"),
("punycode", "ucs2"),
] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover crypto.webcrypto in the allowlist test.

sub_namespace_has_dispatch_bucket allows ("crypto", "webcrypto"), but this test omits it. Add the pair so the test covers the complete allowlist.

Proposed fix
         ("util", "types"),
         ("crypto", "subtle"),
+        ("crypto", "webcrypto"),
         ("punycode", "ucs2"),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (module, sub) in [
("path", "posix"),
("path", "win32"),
("util", "types"),
("crypto", "subtle"),
("punycode", "ucs2"),
] {
for (module, sub) in [
("path", "posix"),
("path", "win32"),
("util", "types"),
("crypto", "subtle"),
("crypto", "webcrypto"),
("punycode", "ucs2"),
] {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower/expr_call/native_module_spread_tests.rs` around
lines 142 - 148, Add the ("crypto", "webcrypto") pair to the module/submodule
cases in sub_namespace_has_dispatch_bucket, preserving the existing cases so the
test covers every allowed dispatch bucket.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1426 — and I should have caught these before merging #7726

I merged #7726 on its own validation plus my verification of its vacuity finding. I did not run the node-core spread surface myself, and your A/B matrix found two correct → wrong regressions in it. That's on me, not on you for opening the follow-up.

The matrix is the right instrument, and it isolates the variable properly — same stdlib in both arms, guard forced false for base, so the comparison is purely the HIR change:

#7726 as merged this PR
FIXED 17 17
same 10 14
wrong → wrong 3 1
correct → wrong 2 0

Holding the 17 fixes while taking the regressions to zero is the outcome that matters.

The querystring finding is much older than #7726, and I verified it

nm_dispatch_querystring advertises seven names. js_querystring_native_dispatch on main is:

match name {
    "unescapeBuffer" => {}
    _ => undefined,
}

One arm. So every indirect form — const e = qs.escape; e("a b"), const d: any = qs; d.escape(...) — has been silently returning undefined for as long as that bridge has existed. #7726 didn't create the hole, it routed spread calls onto it. Wiring the six names up (with encode/decode as Node's aliases for stringify/parse) fixes the regression and the captured/dynamic forms at once, which is the right root to fix at.

The fs.promises case is the worse of the two

import { promises } from "node:fs"; promises.readFile(...args) went from a rejected promise to a synchronous TypeError: value is not a function — so a .catch() no longer catches it. Turning an awaitable failure into a synchronous throw is a nastier regression than a wrong value, because it breaks the caller's error handling rather than its result.

Replacing the derivation with an explicit allowlist is right: nm_module_index has dotted tags only for path.posix, path.win32, util.types, crypto.subtle/webcrypto, punycode.ucs2, and deriving "is this a bucket?" from "does this name look like a module?" was never going to track that set.

Two things I want to note about the tests

sub_namespace_allowlist_is_the_runtime_bucket_set pins the allowlist against exactly the re-derivation that shipped, asserting fs.promises/dns.promises/stream.web are not in it — so the next person who re-derives instead of listing gets a red test rather than a silent regression.

And fs.promises.readFile(...) is deliberately absent from bucketless_sub_namespaces_keep_their_lowering, because it already reached the generic tail before #7726 — asserting on it would test nothing. Leaving a case out for a stated reason is worth more than padding the test with it.

On events.listenerCount

Leave it as-is for now and give it its own PR, as you suggest. nm_dispatch_events implements only init and EventEmitterAsyncResource, so both behaviours are wrong and neither is a regression from this work — a denylist entry would trade one wrong answer for a different wrong answer while making the predicate less honest about what it covers.

cargo test -p perry-hir --lib: 290 passed, 0 failed. Gates 21/21.

@proggeramlug
proggeramlug merged commit f28657d into main Aug 9, 2026
1 of 16 checks passed
@proggeramlug
proggeramlug deleted the fix/7720-followup-narrow-spread-bail branch August 9, 2026 20:39
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