diff --git a/CLAUDE.md b/CLAUDE.md
index d7348143dc..1e4ecd3eaf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.
-**Current Version:** 0.5.1602
+**Current Version:** 0.5.1603
## TypeScript Parity Status
diff --git a/Cargo.lock b/Cargo.lock
index 9ddd47a904..8d6a3d63a2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e"
[[package]]
name = "perry"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -5687,7 +5687,7 @@ dependencies = [
[[package]]
name = "perry-api-manifest"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-dispatch",
"serde",
@@ -5695,7 +5695,7 @@ dependencies = [
[[package]]
name = "perry-audio-miniaudio"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"cc",
"libc",
@@ -5704,7 +5704,7 @@ dependencies = [
[[package]]
name = "perry-codegen"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"aho-corasick",
"anyhow",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "perry-codegen-arkts"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-hir",
@@ -5729,7 +5729,7 @@ dependencies = [
[[package]]
name = "perry-codegen-glance"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-hir",
@@ -5737,7 +5737,7 @@ dependencies = [
[[package]]
name = "perry-codegen-js"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-dispatch",
@@ -5746,7 +5746,7 @@ dependencies = [
[[package]]
name = "perry-codegen-swiftui"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-hir",
@@ -5754,7 +5754,7 @@ dependencies = [
[[package]]
name = "perry-codegen-wasm"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -5766,7 +5766,7 @@ dependencies = [
[[package]]
name = "perry-codegen-wear-tiles"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-hir",
@@ -5774,7 +5774,7 @@ dependencies = [
[[package]]
name = "perry-container-compose"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"async-trait",
"clap",
@@ -5798,14 +5798,14 @@ dependencies = [
[[package]]
name = "perry-container-e2e"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
]
[[package]]
name = "perry-diagnostics"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"serde",
"serde_json",
@@ -5813,7 +5813,7 @@ dependencies = [
[[package]]
name = "perry-dispatch"
-version = "0.5.1602"
+version = "0.5.1603"
[[package]]
name = "perry-doc-fixture-my-bindings"
@@ -5824,7 +5824,7 @@ dependencies = [
[[package]]
name = "perry-doc-tests"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"clap",
@@ -5839,7 +5839,7 @@ dependencies = [
[[package]]
name = "perry-ext-ads"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"block2",
"objc2",
@@ -5849,7 +5849,7 @@ dependencies = [
[[package]]
name = "perry-ext-argon2"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"argon2",
"perry-ffi",
@@ -5858,7 +5858,7 @@ dependencies = [
[[package]]
name = "perry-ext-axios"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"reqwest",
@@ -5867,7 +5867,7 @@ dependencies = [
[[package]]
name = "perry-ext-bcrypt"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"bcrypt",
"perry-ffi",
@@ -5875,7 +5875,7 @@ dependencies = [
[[package]]
name = "perry-ext-better-sqlite3"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"rusqlite",
@@ -5883,7 +5883,7 @@ dependencies = [
[[package]]
name = "perry-ext-cheerio"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"scraper",
@@ -5891,7 +5891,7 @@ dependencies = [
[[package]]
name = "perry-ext-commander"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"perry-runtime",
@@ -5899,7 +5899,7 @@ dependencies = [
[[package]]
name = "perry-ext-cron"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"chrono",
"cron",
@@ -5909,7 +5909,7 @@ dependencies = [
[[package]]
name = "perry-ext-dayjs"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"chrono",
"perry-ffi",
@@ -5917,7 +5917,7 @@ dependencies = [
[[package]]
name = "perry-ext-decimal"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"rust_decimal",
@@ -5925,7 +5925,7 @@ dependencies = [
[[package]]
name = "perry-ext-dotenv"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"serde_json",
@@ -5933,7 +5933,7 @@ dependencies = [
[[package]]
name = "perry-ext-ethers"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"rand 0.10.2",
@@ -5941,7 +5941,7 @@ dependencies = [
[[package]]
name = "perry-ext-events"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"perry-runtime",
@@ -5949,14 +5949,14 @@ dependencies = [
[[package]]
name = "perry-ext-exponential-backoff"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
]
[[package]]
name = "perry-ext-fastify"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"bytes",
"http-body-util",
@@ -5973,7 +5973,7 @@ dependencies = [
[[package]]
name = "perry-ext-fetch"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"bytes",
"lazy_static",
@@ -5986,7 +5986,7 @@ dependencies = [
[[package]]
name = "perry-ext-http"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -6018,7 +6018,7 @@ dependencies = [
[[package]]
name = "perry-ext-ioredis"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"lazy_static",
"perry-ffi",
@@ -6028,7 +6028,7 @@ dependencies = [
[[package]]
name = "perry-ext-lru-cache"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"lru",
"perry-ffi",
@@ -6037,7 +6037,7 @@ dependencies = [
[[package]]
name = "perry-ext-moment"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"chrono",
"perry-ffi",
@@ -6045,7 +6045,7 @@ dependencies = [
[[package]]
name = "perry-ext-mongodb"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"bson",
"futures-util",
@@ -6057,7 +6057,7 @@ dependencies = [
[[package]]
name = "perry-ext-mysql2"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"chrono",
"perry-ffi",
@@ -6069,7 +6069,7 @@ dependencies = [
[[package]]
name = "perry-ext-nanoid"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"nanoid",
"perry-ffi",
@@ -6078,7 +6078,7 @@ dependencies = [
[[package]]
name = "perry-ext-net"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"bytes",
"perry-ffi",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "perry-ext-node-forge"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"const-oid 0.10.2",
"der 0.8.2",
@@ -6112,7 +6112,7 @@ dependencies = [
[[package]]
name = "perry-ext-nodemailer"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"lettre",
"perry-ffi",
@@ -6122,7 +6122,7 @@ dependencies = [
[[package]]
name = "perry-ext-parcel-watcher"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"notify",
"perry-ffi",
@@ -6134,7 +6134,7 @@ dependencies = [
[[package]]
name = "perry-ext-pdf"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"printpdf",
@@ -6142,7 +6142,7 @@ dependencies = [
[[package]]
name = "perry-ext-pg"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"sqlx",
@@ -6151,7 +6151,7 @@ dependencies = [
[[package]]
name = "perry-ext-qs"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"perry-runtime",
@@ -6160,7 +6160,7 @@ dependencies = [
[[package]]
name = "perry-ext-ratelimit"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"governor",
"perry-ffi",
@@ -6168,7 +6168,7 @@ dependencies = [
[[package]]
name = "perry-ext-sharp"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"fast_image_resize",
"image",
@@ -6179,7 +6179,7 @@ dependencies = [
[[package]]
name = "perry-ext-streams"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"lazy_static",
"perry-ffi",
@@ -6188,7 +6188,7 @@ dependencies = [
[[package]]
name = "perry-ext-typescript"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-ffi",
@@ -6208,7 +6208,7 @@ dependencies = [
[[package]]
name = "perry-ext-undici"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"perry-runtime",
@@ -6217,7 +6217,7 @@ dependencies = [
[[package]]
name = "perry-ext-uuid"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-ffi",
"uuid",
@@ -6225,7 +6225,7 @@ dependencies = [
[[package]]
name = "perry-ext-ws"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"futures-util",
"lazy_static",
@@ -6238,7 +6238,7 @@ dependencies = [
[[package]]
name = "perry-ext-zlib"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"brotli",
"flate2",
@@ -6248,7 +6248,7 @@ dependencies = [
[[package]]
name = "perry-ffi"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"dashmap 6.2.1",
"once_cell",
@@ -6258,7 +6258,7 @@ dependencies = [
[[package]]
name = "perry-hir"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-api-manifest",
@@ -6278,11 +6278,11 @@ dependencies = [
[[package]]
name = "perry-native-registration"
-version = "0.5.1602"
+version = "0.5.1603"
[[package]]
name = "perry-parser"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"perry-diagnostics",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "perry-perex"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perex",
"regex",
@@ -6303,7 +6303,7 @@ dependencies = [
[[package]]
name = "perry-runtime"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"ahash",
"base64 0.22.1",
@@ -6361,14 +6361,14 @@ dependencies = [
[[package]]
name = "perry-runtime-static"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-runtime",
]
[[package]]
name = "perry-stdlib"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"aes 0.8.4",
"aes 0.9.1",
@@ -6455,21 +6455,21 @@ dependencies = [
[[package]]
name = "perry-stdlib-static"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-stdlib",
]
[[package]]
name = "perry-transform"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"perry-hir",
]
[[package]]
name = "perry-ui"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"dirs",
"perry-ffi",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "perry-ui-android"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"jni",
@@ -6494,7 +6494,7 @@ dependencies = [
[[package]]
name = "perry-ui-geisterhand"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"rand 0.10.2",
"serde",
@@ -6504,7 +6504,7 @@ dependencies = [
[[package]]
name = "perry-ui-gtk4"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"cairo-rs 0.22.9",
@@ -6527,7 +6527,7 @@ dependencies = [
[[package]]
name = "perry-ui-ios"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"block2",
@@ -6544,7 +6544,7 @@ dependencies = [
[[package]]
name = "perry-ui-macos"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"block2",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "perry-ui-model"
-version = "0.5.1602"
+version = "0.5.1603"
[[package]]
name = "perry-ui-test"
@@ -6572,11 +6572,11 @@ dependencies = [
[[package]]
name = "perry-ui-testkit"
-version = "0.5.1602"
+version = "0.5.1603"
[[package]]
name = "perry-ui-tvos"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"block2",
@@ -6593,7 +6593,7 @@ dependencies = [
[[package]]
name = "perry-ui-visionos"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"block2",
@@ -6610,7 +6610,7 @@ dependencies = [
[[package]]
name = "perry-ui-watchos"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"block2",
"libc",
@@ -6624,7 +6624,7 @@ dependencies = [
[[package]]
name = "perry-ui-windows"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"libc",
@@ -6643,7 +6643,7 @@ dependencies = [
[[package]]
name = "perry-ui-windows-winui"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"base64 0.22.1",
"libc",
@@ -6656,7 +6656,7 @@ dependencies = [
[[package]]
name = "perry-updater"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -6671,7 +6671,7 @@ dependencies = [
[[package]]
name = "perry-wasm-host"
-version = "0.5.1602"
+version = "0.5.1603"
dependencies = [
"wasmi",
]
diff --git a/Cargo.toml b/Cargo.toml
index 5bc2032d98..db69ec9ec0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -335,7 +335,7 @@ codegen-units = 1
codegen-units = 1
[workspace.package]
-version = "0.5.1602"
+version = "0.5.1603"
edition = "2021"
license = "MIT"
repository = "https://github.com/PerryTS/perry"
diff --git a/changelog.d/10719-unrooted-local-shape-blind-spots.md b/changelog.d/10719-unrooted-local-shape-blind-spots.md
new file mode 100644
index 0000000000..2b83118bd8
--- /dev/null
+++ b/changelog.d/10719-unrooted-local-shape-blind-spots.md
@@ -0,0 +1,97 @@
+**GC tooling: `unrooted_local_shape.py` had two independent ways of not firing.**
+Both are the fourth shape in CLAUDE.md's "★ Four ways a gate can be unable to fail" —
+the job is genuinely green.
+
+**#10713 — `--no-raise-vs ` never looked at the code.** It read the merge
+base's recorded baseline and the checked-out one and compared *those two numbers*.
+A branch that added findings without touching the baseline therefore compared 561
+against 561 and printed `no ceiling raised`, while `--check`, seconds later in the
+same `run_lint_gates.sh` invocation on the same worktree, failed with
+`REGRESSION: 563 findings exceeds baseline 561`. Reproduced by appending five
+planted shapes to `perry-ext-http/src/response_headers.rs` and leaving the baseline
+alone: `--check` exits 1, `--no-raise-vs origin/main` exits 0. The variant whose
+whole purpose is catching a rise against the base could not see one, because the
+only number it ever read was one the diff had no reason to move. It now scans the
+worktree and compares the measured total and per-file counts against the base's
+recorded ceilings — the same yardstick `--check` uses, so the two forms agree — and
+prints the resolved base SHA with both the recorded and the measured totals, so a
+reader can tell a real comparison from a vacuous one. The comparison is skipped only
+across the audited schema-1 migration, where the two sides were measured by
+different detectors.
+
+**#10713, second hole — `--no-raise-vs ""` compared nothing and exited 0.** The
+dispatch was `if args.no_raise_vs:`, testing *truthiness*, and the empty string an
+unset `$BASE_SHA` expands to is falsy. The mode was never entered: no ref resolved,
+no baseline read. The script fell through to the plain report, printed an ordinary
+finding table and passed. Now `is not None`, and `resolve_ref` rejects an empty ref
+in the same words it rejects an unfetched one — `raw_handle_debt.py`'s `git_show`
+made the argument first: *a comparison that did not happen, reported as a pass, must
+be a RED build instead*. An unresolvable ref was already handled; an empty one was
+not, because nothing called the guard.
+
+**#10715 — the detector was line-oriented, so `rustfmt` could hide a finding.**
+`LET_BIND` was matched per line. Once a binding sits a few levels deep, or carries a
+type annotation, rustfmt breaks it after the `=` and the head line has no right-hand
+side: nothing matched, the local was never tracked, and the finding vanished. That
+is a false negative bought with an indent, and it made the deepest-nested code —
+where rooting bugs live — the least scanned. It bit for real on #10668, where a
+genuine rooting fix had to be hoisted into a top-level function (`build_set_cookie_array`)
+purely to keep its binding on one line and stay visible. A `let` is now folded back
+into one statement before matching. Statements containing a brace are still read line
+by line, on purpose: a closure, `match` or struct-literal initializer carries its own
+bindings and collection points, and folding those into a single expression would
+trade this blind spot for a strictly larger one.
+
+**The measured surface rises from 558 to 581 findings across 85 files** (was 80), and
+the baseline is re-pinned at 581 under an audited **schema 2 → 3 migration**. This is
+not a loosened ratchet, and the distinction matters: *the old 561 was produced by a
+weaker detector*. Comparing 581 against it compares two different yardsticks, which is
+exactly why the script already carries the audited-migration exemption — the same
+situation as the 1 → 2 migration, for the same reason. The ratchet's job is unchanged:
+it still fails on finding 582, verified by planting one
+(`REGRESSION: 582 findings exceeds baseline 581`).
+
+The exemption is now an explicit `AUDITED_MIGRATIONS` list rather than a single
+hard-coded `(1, BASELINE_SCHEMA)` pair, so each migration is named with its reason and
+every unlisted schema change is still rejected. `--self-test` asserts both that 2 → 4
+is refused and that `BASELINE_SCHEMA` cannot be bumped without naming its own
+migration — otherwise a renumber would exempt every PR from the ratchet.
+
+The number moved in both directions:
+
+- **+34 newly visible**, led by `perry-stdlib/src/events.rs` (6 → 13),
+ `perry-ext-node-forge` (20 → 24) and five files that recorded nothing at all.
+ **Two were inspected and are genuine unrooted-across-allocation shapes**;
+ `perry-ext-fastify/src/context.rs:750` is one: `let obj: *mut ObjectHeader =`
+ wrapped by its own type annotation, with `obj` then held across `alloc_string` in
+ the loop below it. Nothing about that code was safe; only its line breaks hid it.
+ The other 32 are **unaudited exposure surface, not known bugs** — the number has
+ always been a surface, not a bug count, and these 32 have simply never been looked
+ at because no instrument could see them.
+- **−11 false positives** in `perry-stdlib/src/ioredis.rs` (14 → 3), the same defect
+ inverted: a wrapped *shadowing* `let err_str =` matched nothing either, so the dead
+ identity from the earlier binding of that name stayed live and every use of the
+ fresh one was reported against it.
+
+**Self-test.** The old `--self-test` passed on the day the live check was fooled,
+which is the whole problem, so each fix plants the defect it fixes and fails without
+it, verified by reverting each one in isolation:
+
+- `planted_wrapped_binding` — `let object =` with the initializer on the next line,
+ taken from the live `perry-ext-ws` `js_ws_server_address` site. Not flagged before
+ the fold.
+- `clean_wrapped_shadow_rebinds` — the ioredis shape. Flagged before the fold.
+- `planted_inside_wrapped_closure` — a binding inside a multi-line closure body,
+ which must stay visible; it goes dark if the fold is ever let past a brace.
+- `_self_test_no_raise_vs` — drives the real `no_raise_vs` over the observed
+ combination: both recorded baselines identical at 561, worktree measuring 563.
+ Returns 0 without the measured comparison.
+- `_self_test_empty_ref_dispatch` — `--no-raise-vs ""` through `main()`. Returns 0
+ under the truthiness dispatch. Guarding inside `resolve_ref` alone does not cover
+ this, because nothing called it, and `git rev-parse` rejects an empty ref anyway.
+
+**Every previous green from the `--no-raise-vs` arm was vacuous**, including the one
+that ran on #10668. The gate's history is not evidence about the code it ran over.
+
+Closes #10713
+Closes #10715
diff --git a/changelog.d/10721-raw-handle-relocation.md b/changelog.d/10721-raw-handle-relocation.md
new file mode 100644
index 0000000000..04d8e569c6
--- /dev/null
+++ b/changelog.d/10721-raw-handle-relocation.md
@@ -0,0 +1,54 @@
+**The raw-handle debt ledger can now express a pure file move, so a debt-carrying
+module can be split for the 2000-line cap.** `raw_handle_debt.py --no-raise-vs`
+compares recorded ceilings strictly per path and treats any path absent at the
+merge base as a raise from zero. That is right for new debt and wrong for a
+relocation — and `scripts/check_file_size.sh` forces relocations regularly.
+Splitting a listed module makes the *bare* run demand the emptied source's line be
+deleted (rule 3, "ceiling of 4 matches nothing — DELETE its line") and the
+destination listed, whereupon `--no-raise-vs` fails with
+`vtable_access.rs: ceiling raised to 4 (was not listed at the merge base)` although
+the total never moved and the moved bodies are byte-identical. The two required
+invocations of one gate disagreed about the same tree. #10565 escaped only by luck:
+all four of `object/native_module.rs`'s sites sat in one block, so a different split
+carried none — a file whose debt is spread across it could not be split at all
+without first paying it down.
+
+A ledger entry may now declare where its debt came from:
+
+```
+4 crates/…/object/native_module/vtable_access.rs # moved-from: crates/…/object/native_module.rs
+```
+
+`--no-raise-vs` credits the destination with what the source **actually surrendered
+between the merge base and head** (`base ceiling − head ceiling`, floored at zero),
+and with nothing else. Monotonicity is preserved on every axis the gate owns: the
+total check is untouched so the sum still cannot rise; the credit is bounded by a
+real reduction in the same diff, so a relocation cannot launder new sites; two
+destinations naming one source drain a shared pool rather than each claiming it
+whole; and the annotation goes inert once the move lands, because base and head then
+agree and the source surrenders 0 — a stale annotation is a comment, not a standing
+permit. What it deliberately does *not* prove is that the moved bodies are the same
+bodies: per-path monotonicity becomes total monotonicity plus one declared,
+reviewable transfer that names its source in the diff. A text ratchet cannot tell a
+move from a rewrite, and the docstring says so rather than implying otherwise.
+
+Two supporting details, both of which would have silently revoked a relocation the
+same commit declared: a malformed annotation (`moved_from:`, or any other trailing
+comment on an entry) is now a hard parse failure instead of an ignored comment —
+otherwise the typo surfaces as "was not listed at the merge base", a diagnostic
+naming the destination and never the typo; and `--update`, which rewrites the ledger
+wholesale, now carries surviving entries' annotations through (the writer is split
+out as `render_ledger` so the round trip can be asserted).
+
+`--self-test` grows twelve cases: the undeclared move is still rejected (so
+relocation support is not the per-path rule being deleted), the declared move and a
+legal 1+1 three-way split pass, and laundering, an over-draw, a double-spend, a
+stale annotation and a self-reference are each rejected by their own diagnostic.
+Each anti-laundering case was checked against three plausible *wrong*
+implementations — "declared ⇒ allowed", "credit the source's whole base ceiling",
+and "correct credit but re-read per destination instead of draining a pool" — and
+each is caught, so the cases fail against the feature written badly and not only
+against its absence. The laundering case holds the total flat so the total rule
+cannot be what fires. No ceiling in `scripts/raw_handle_debt_files.txt` changed
+(906, baseline 906); only its header, which documents the new form. (#10583, found
+while landing merge train 216)
diff --git a/changelog.d/10722-cron-cronjob-barrier.md b/changelog.d/10722-cron-cronjob-barrier.md
new file mode 100644
index 0000000000..0904ddd72a
--- /dev/null
+++ b/changelog.d/10722-cron-cronjob-barrier.md
@@ -0,0 +1,47 @@
+**`test_gap_cron_cronjob` no longer races the wall clock inside `pr-gate`'s
+scope.** The fixture started a `* * * * * *` CronJob, then waited with
+`while ((ticks < 2 || autoTicks < 2) && Date.now() < tickDeadline)` against a fixed
+`Date.now() + 10_000`. That deadline is a *timeout*, not a barrier: when it expired
+first the loop exited and the fixture printed `false` on two lines that are expected
+to read `true`, and dropped the `tick 1` / `tick 2` lines entirely — a four-line
+output divergence the harness classifies as a `parity_fail`, i.e. reports as a
+compiler regression. The header comment's claim that the output was "deterministic
+despite the timing" held only while two ticks of a one-per-second schedule landed
+inside ten seconds. It cost real work: the fixture failed in a merge-queue
+validation and #10530 was held out of a train on the strength of it, after which a
+`--trace llvm` A/B showed byte-identical IR.
+
+The wait is now a **barrier with no deadline**. The printed text becomes a function
+of CronJob's behaviour alone: either the ticks arrive and the fixture prints its one
+expected output, or nothing dispatches and the harness's own `PERRY_RUN_TIMEOUT`
+kills the run — which it classifies as a CRASH/timeout, distinctly from a parity
+mismatch, so a contended runner can no longer make this look like a miscompile.
+There is deliberately no fallback bound: any bound that prints, throws or exits
+differently on expiry reintroduces the same defect at a different threshold, and a
+`false`-printing 30s deadline is the identical bug with a longer fuse. The old
+number was in any case unreachable — `PERRY_RUN_TIMEOUT` is itself 10s, so the
+fixture's deadline could only ever fire in a photo finish with the kill.
+
+Nothing is weakened. The assertion moved from a printed comparison into the loop's
+exit condition, which the program cannot pass without satisfying; the four-arg
+`start=true` form, the non-auto-starting two-arg form and `start()`/`stop()`
+dispatch are all still exercised, and the `tick 1` / `tick 2` lines remain in the
+diff as positive evidence that the manual job fired. The never-started job's line
+got *stronger*: it printed a hardcoded `true`, and now prints `neverTicks === 0`
+from a real counter, checked after the barrier — i.e. after at least two cron
+seconds have demonstrably elapsed with that job unstarted. Output bytes are
+unchanged.
+
+Verified by injecting an identical 11-second synchronous event-loop stall into the
+old and new fixtures at the same point (a deterministic stand-in for the loaded
+runner). Under Node 26.5.1 and under Perry v0.5.1598 alike, the old fixture prints
+the four-line divergence and the new one is byte-identical to the unstalled oracle.
+The real fixture passes the harness (`run_parity_tests.sh --filter
+test_gap_cron_cronjob`, exit 0, journal `status: pass`), and eight consecutive Node
+runs gave one distinct output in 1.86–2.04 s — roughly a fifth of the run budget.
+
+Two sibling fixtures have the same shape and are *not* touched here:
+`test_gap_9592_child_timeout_threads` (a 1 s deadline whose expiry prints
+`timeout threads released: false`; Linux-only, short-circuited elsewhere) and
+`test_gap_9493_child_stdin_backpressure` (a watchdog that `resolve(false)`s). Both
+are in gate scope. (#10581)
diff --git a/changelog.d/10728-property-fn-param-callback-lock.md b/changelog.d/10728-property-fn-param-callback-lock.md
new file mode 100644
index 0000000000..4a1808439d
--- /dev/null
+++ b/changelog.d/10728-property-fn-param-callback-lock.md
@@ -0,0 +1,32 @@
+### Testing
+
+- Lock commander's `_displayError` indirection in the gap suite: an object-property
+ function (`outputError(str, write)`) invoking a second object-property function
+ handed to it as a parameter (`writeErr`). #10711 reports that Perry silently drops
+ that inner call and loses commander's error text; it does not reproduce. The
+ reporter's own isolated repro matches Node 26.5.1 on current `main` (v0.5.1598), on
+ the `main` commit their branch forks from (`8df83f8c`), and on their actual tree
+ (PR #10712 over #10699) — each a full `-p perry -p perry-runtime-static
+ -p perry-stdlib-static` build with `PERRY_RUNTIME_DIR` pinned, so no arm could have
+ linked a stale archive. Real commander 14.0.3 compiled from source through
+ `perry.compilePackages` is byte-identical to Node across the whole surface the issue
+ names — `--help`, `--version`, missing required argument, unknown option, unknown
+ command and `program.error()` — under the default output configuration and under a
+ `configureOutput()` override, as are 32 further spellings of the same indirection
+ (method shorthand, class field, spread, nested receiver, cross-object writer,
+ computed key, getter, `Object.create` chain, `Object.freeze`, destructuring, three
+ levels, async caller, nested closure, loop, and the shape inside a CommonJS module).
+
+ The fixture is therefore a regression lock, not a fix, and it passes on unfixed
+ `main`. The shape still earns a gate: #10689 — an inherited property read folding to
+ the constant `undefined` on a scalar-replaced object — landed one commit before
+ #10711 was filed, is the same family, and was silent in the same way, and nothing in
+ `test-files/` covered this indirection.
+
+ Two cases keep it from passing vacuously. One traces `before` / `typeof write` /
+ `after` around the inner call so that "the outer body ran and the inner call
+ evaporated" cannot read as a pass. The other omits the writer and asserts a
+ `TypeError`, because a missing callee being loud is the property that keeps this bug
+ class from presenting as a plausible wrong answer rather than a crash. Every writer
+ sinks to stdout: the parity harness merges stdout and stderr into one compared
+ stream, so a fixture using both would race on the interleaving.
diff --git a/scripts/raw_handle_debt.py b/scripts/raw_handle_debt.py
index 5f9aefceb5..6a3d0618f6 100755
--- a/scripts/raw_handle_debt.py
+++ b/scripts/raw_handle_debt.py
@@ -35,6 +35,42 @@
the total, an existing module's ceiling, or a module that was not listed at all.
Unchanged and lower both pass, so paying debt down stays a one-step change.
+RELOCATIONS: `# moved-from:` (#10583)
+=====================================
+
+Strict per-path monotonicity cannot express a pure FILE MOVE, and the 2000-line
+cap (`scripts/check_file_size.sh`) forces moves regularly. Splitting a listed
+module makes the bare run demand the emptied source's line be deleted (rule 3,
+"matches nothing") and the destination listed -- and `--no-raise-vs` then fails
+with "was not listed at the merge base" although the TOTAL never moved and the
+bodies are byte-identical. #10565 only escaped it by luck: all four of that
+file's sites sat in one block, so a different split carried none. A module whose
+debt is spread across it could not be split at all without first paying it down.
+
+A ledger line may therefore declare where its debt came from:
+
+ 4 crates/perry-runtime/src/object/native_module/vtable_access.rs # moved-from: crates/perry-runtime/src/object/native_module.rs
+
+`--no-raise-vs` then credits the destination with what the SOURCE ACTUALLY GAVE
+UP between the merge base and head (`base ceiling - head ceiling`, floored at
+zero), and nothing else. That keeps the ratchet monotone:
+
+ * the total check is untouched, so the sum still cannot rise;
+ * a relocation cannot launder new sites, because the credit is bounded by a
+ real reduction somewhere else in the same diff;
+ * two destinations splitting one source SHARE one pool -- the same surrendered
+ count cannot be spent twice;
+ * the annotation goes inert the moment the move lands. Once base and head
+ agree about both paths the source surrenders 0, so a later raise on the
+ destination is rejected exactly as before. A stale annotation is a comment,
+ not a standing permit.
+
+What it does NOT prove is that the moved bodies are the same bodies: a diff that
+genuinely cleans four sites in A while adding four unrelated sites in a new B
+can spell that as a relocation. Per-path monotonicity becomes total monotonicity
+plus ONE declared, reviewable transfer that names its source in the diff. That
+is the deliberate boundary -- a text ratchet cannot tell a move from a rewrite.
+
Usage:
scripts/raw_handle_debt.py # report, fail if above the baseline
scripts/raw_handle_debt.py --update # rewrite the baseline (must go DOWN)
@@ -71,17 +107,68 @@ def count():
FILES = ROOT / "scripts" / "raw_handle_debt_files.txt"
+# The ONE annotation a ledger entry may carry (#10583). Anchored to the end of
+# the line so it cannot be confused with a path.
+MOVED_FROM = re.compile(r"#\s*moved-from:\s*(\S+)\s*$")
-def load_ceilings():
- """`{path: ceiling}` from the per-module file. Comments and blanks ignored."""
- out = {}
- for line in FILES.read_text(encoding="utf-8").splitlines():
- line = line.strip()
+
+def parse_ledger(text):
+ """`({path: ceiling}, {path: moved_from})` from the per-module file's TEXT.
+
+ Whole-line comments and blanks are ignored. A trailing comment on an ENTRY
+ must be a well-formed `# moved-from: `; anything else raises. That
+ strictness is the point: a typo (`moved_from:`, `moved-from :`) would
+ otherwise be silently dropped as a plain comment and the relocation it was
+ meant to declare would be rejected as new debt -- with a diagnostic naming
+ the destination, which is the one place the author would not look.
+ """
+ ceilings, moves = {}, {}
+ for raw in text.splitlines():
+ line = raw.strip()
if not line or line.startswith("#"):
continue
+ moved = None
+ if "#" in line:
+ moved = MOVED_FROM.search(line)
+ if not moved:
+ raise SystemExit(
+ f"::error::{FILES.name}: unrecognised trailing comment on "
+ f"the ledger entry {raw.strip()!r}. The only annotation an "
+ f"entry may carry is `# moved-from: ` (#10583)."
+ )
+ line = line[: moved.start()].strip()
n, path = line.split(None, 1)
- out[path.strip()] = int(n)
- return out
+ path = path.strip()
+ ceilings[path] = int(n)
+ if moved:
+ moves[path] = moved.group(1)
+ return ceilings, moves
+
+
+def load_ceilings():
+ """`{path: ceiling}` from the per-module file. Comments and blanks ignored."""
+ return parse_ledger(FILES.read_text(encoding="utf-8"))[0]
+
+
+def load_moves():
+ """`{path: moved_from}` declared by the CHECKED-OUT per-module file."""
+ return parse_ledger(FILES.read_text(encoding="utf-8"))[1]
+
+
+def render_ledger(header, per_file, moves):
+ """The per-module file's TEXT for `per_file`, keeping `moves` annotations.
+
+ Separated from `--update` so the round trip through `parse_ledger` can be
+ asserted: a writer that loses the annotation would revoke a relocation the
+ same commit declared, and nothing else in the gate would notice.
+ """
+ return (
+ "\n".join(header) + "\n"
+ + "".join(
+ f"{n} {p}" + (f" # moved-from: {moves[p]}" if p in moves else "") + "\n"
+ for p, n in sorted(per_file.items())
+ )
+ )
def check_per_module(per_file):
@@ -117,25 +204,29 @@ def check_per_module(per_file):
def parse_ceilings(text):
- """`{path: ceiling}` from the per-module file's TEXT (any revision of it)."""
- out = {}
- for line in text.splitlines():
- line = line.strip()
- if not line or line.startswith("#"):
- continue
- n, path = line.split(None, 1)
- out[path.strip()] = int(n)
- return out
+ """`{path: ceiling}` from the per-module file's TEXT (any revision of it).
+
+ The BASE revision's own `moved-from` annotations are deliberately dropped:
+ credit is claimed by the head ledger and paid out of the base's numbers, so
+ a relocation the base already recorded is just two ordinary ceilings.
+ """
+ return parse_ledger(text)[0]
-def compare_across_base(base_total, base_ceilings, head_total, head_ceilings):
+def compare_across_base(base_total, base_ceilings, head_total, head_ceilings,
+ head_moves=None):
"""Violations for a diff that RAISES recorded debt relative to its base.
A module absent from the base's ceilings counts as 0, so adding a line is a
raise from zero rather than a fresh start. Removals and decreases are
silent: the ratchet exists to stop the number going up.
+
+ `head_moves` is `{destination: source}` from the head ledger's `moved-from:`
+ annotations (#10583). A destination may be raised by at most what its source
+ SURRENDERED between the base and head ledgers -- see the module docstring.
"""
bad = []
+ head_moves = head_moves or {}
if base_total is None and not base_ceilings:
# The merge base recorded nothing at all -- the gate did not exist yet
# on that side. There is no number to ratchet against, so every head
@@ -150,11 +241,40 @@ def compare_across_base(base_total, base_ceilings, head_total, head_ceilings):
f"RuntimeHandle::across_{{mut,const,nanbox}} / "
f"with_{{mut,const}}_ptr instead of recording them."
)
+ # One credit pool per declared source, sized by what that source ACTUALLY
+ # gave up. Two destinations naming the same source therefore share it --
+ # spending the same surrendered count twice is the obvious way to launder
+ # new debt through a relocation, so the pool is drained, not re-read.
+ pool = {}
+ for src in set(head_moves.values()):
+ pool[src] = max(0, base_ceilings.get(src, 0) - head_ceilings.get(src, 0))
+
for path, ceiling in sorted(head_ceilings.items()):
was = base_ceilings.get(path, 0)
- if ceiling > was:
- where = "was not listed" if path not in base_ceilings else f"ceiling was {was}"
+ if ceiling <= was:
+ continue
+ where = "was not listed" if path not in base_ceilings else f"ceiling was {was}"
+ src = head_moves.get(path)
+ if src is None:
bad.append(f"{path}: ceiling raised to {ceiling} ({where} at the merge base)")
+ continue
+ if src == path:
+ bad.append(
+ f"{path}: declares `moved-from: {src}`, which is its own path. A "
+ f"relocation must name the module the sites came FROM."
+ )
+ continue
+ need = ceiling - was
+ if pool[src] < need:
+ bad.append(
+ f"{path}: ceiling raised to {ceiling} ({where} at the merge base) "
+ f"declaring `moved-from: {src}`, but {src} surrendered only "
+ f"{pool[src]} site(s) between the merge base and head (needs "
+ f"{need}). A relocation credits only what its source actually "
+ f"gave up, so it cannot launder new debt."
+ )
+ continue
+ pool[src] -= need
return bad
@@ -194,17 +314,25 @@ def no_raise_vs(ref):
base_ceilings = parse_ceilings(base_files) if base_files else {}
head_total = int(BASELINE.read_text().split()[0])
- head_ceilings = load_ceilings()
+ head_ceilings, head_moves = parse_ledger(FILES.read_text(encoding="utf-8"))
- bad = compare_across_base(base_total, base_ceilings, head_total, head_ceilings)
+ bad = compare_across_base(base_total, base_ceilings, head_total, head_ceilings,
+ head_moves)
if bad:
print(f"::error::recorded raw-handle debt rose vs. {ref}: {len(bad)} violation(s)")
for b in bad:
print(f" {b}")
return 1
+ relocated = ""
+ if head_moves:
+ relocated = (
+ f", {len(head_moves)} declared relocation(s): "
+ + ", ".join(f"{src} -> {dst}" for dst, src in sorted(head_moves.items()))
+ )
print(
f"recorded debt vs. {ref}: baseline {base_total} -> {head_total}, "
f"{len(base_ceilings)} -> {len(head_ceilings)} module ceiling(s), none raised"
+ f"{relocated}"
)
return 0
@@ -294,6 +422,111 @@ def self_test():
if compare_across_base(bt, bc, ht, hc):
print(f"self-test FAILED: merge-base rule fired on a legal diff: {label}")
return 1
+
+ # #10583: a pure FILE MOVE. The shape the 2000-line cap forces -- `a.rs`
+ # emptied of its two sites, `split.rs` listing them, total unchanged.
+ moved_base = {"a.rs": 2, "b.rs": 1}
+ moved_head = {"split.rs": 2, "b.rs": 1}
+ # (i) It MUST be rejected without the annotation -- otherwise the relocation
+ # support below is indistinguishable from having deleted the rule.
+ if not any("was not listed" in v for v in
+ compare_across_base(998, moved_base, 998, moved_head)):
+ print("self-test FAILED: an UNDECLARED relocation was accepted; the "
+ "per-path rule is gone, not relaxed")
+ return 1
+ # (ii) ...and accepted with it, because `a.rs` really did surrender two.
+ declared = compare_across_base(998, moved_base, 998, moved_head,
+ {"split.rs": "a.rs"})
+ if declared:
+ print(f"self-test FAILED: a declared relocation was rejected: {declared}")
+ return 1
+ # (iii) A relocation cannot LAUNDER new debt: `a.rs` keeps its two sites and
+ # `split.rs` claims two more anyway. (The total is held flat here so
+ # the total rule cannot be what fires -- this must be the per-path
+ # credit, or the laundering case passes the day the totals differ.)
+ launder = compare_across_base(998, moved_base, 998,
+ {"a.rs": 2, "b.rs": 1, "split.rs": 2},
+ {"split.rs": "a.rs"})
+ if not any("surrendered only 0" in v for v in launder):
+ print(f"self-test FAILED: a relocation laundered new debt: {launder}")
+ return 1
+ # (iv) Nor may it over-draw: `a.rs` gave up one of its two, `split.rs` wants
+ # both.
+ overdraw = compare_across_base(998, moved_base, 998,
+ {"a.rs": 1, "b.rs": 1, "split.rs": 2},
+ {"split.rs": "a.rs"})
+ if not any("surrendered only 1" in v and "needs 2" in v for v in overdraw):
+ print(f"self-test FAILED: a relocation over-drew its source: {overdraw}")
+ return 1
+ # (v) Nor may two destinations spend one source's surrender twice. A 2-site
+ # module split THREE ways is legal; claiming 2+2 out of it is not.
+ three_way = compare_across_base(998, moved_base, 998,
+ {"b.rs": 1, "x.rs": 1, "y.rs": 1},
+ {"x.rs": "a.rs", "y.rs": "a.rs"})
+ if three_way:
+ print(f"self-test FAILED: a legal 1+1 split of a 2-site module was "
+ f"rejected: {three_way}")
+ return 1
+ double = compare_across_base(998, moved_base, 998,
+ {"b.rs": 1, "x.rs": 2, "y.rs": 2},
+ {"x.rs": "a.rs", "y.rs": "a.rs"})
+ if not any("y.rs" in v and "surrendered only 0" in v for v in double):
+ print(f"self-test FAILED: one source's surrender was spent twice: {double}")
+ return 1
+ # (vi) A STALE annotation is inert, not a standing permit. Once the move has
+ # landed (base and head agree about both paths) the source surrenders
+ # nothing, so a later raise on the destination is rejected as before.
+ landed = {"split.rs": 2, "b.rs": 1}
+ stale = compare_across_base(998, landed, 998, {"split.rs": 4, "b.rs": 1},
+ {"split.rs": "a.rs"})
+ if not any("split.rs" in v and "surrendered only 0" in v for v in stale):
+ print(f"self-test FAILED: a stale moved-from annotation still granted "
+ f"credit: {stale}")
+ return 1
+ # (vii) A self-referential annotation is a typo, not a relocation.
+ selfmove = compare_across_base(998, moved_base, 998, {"a.rs": 3, "b.rs": 1},
+ {"a.rs": "a.rs"})
+ if not any("its own path" in v for v in selfmove):
+ print(f"self-test FAILED: a self-referential relocation was not "
+ f"rejected: {selfmove}")
+ return 1
+
+ # #10583, the parser. The annotation shares a line with the path, so a
+ # parser that does not strip it records a ceiling for a path that does not
+ # exist -- which rule 3 would then report as "matches nothing" forever.
+ parsed, parsed_moves = parse_ledger(
+ "# header\n"
+ "2 crates/x/split.rs # moved-from: crates/x/a.rs\n"
+ "1 crates/x/b.rs\n"
+ )
+ if parsed != {"crates/x/split.rs": 2, "crates/x/b.rs": 1}:
+ print(f"self-test FAILED: the annotation leaked into the parsed "
+ f"ceilings: {parsed}")
+ return 1
+ if parsed_moves != {"crates/x/split.rs": "crates/x/a.rs"}:
+ print(f"self-test FAILED: the annotation did not parse: {parsed_moves}")
+ return 1
+ # A malformed annotation must RAISE rather than read as a plain comment: a
+ # silently-dropped `moved_from:` becomes "was not listed at the merge base",
+ # a diagnostic that names the destination and never mentions the typo.
+ for typo in ("2 x.rs # movedfrom: a.rs\n", "2 x.rs # see #10583\n"):
+ try:
+ parse_ledger(typo)
+ except SystemExit:
+ pass
+ else:
+ print(f"self-test FAILED: a malformed entry comment parsed "
+ f"silently: {typo!r}")
+ return 1
+ # `--update` rewrites this file wholesale; a writer that drops the
+ # annotation would revoke the relocation its own commit is declaring.
+ round_tripped = parse_ledger(
+ render_ledger(["# header"], {"x.rs": 2, "b.rs": 1}, {"x.rs": "a.rs"})
+ )
+ if round_tripped != ({"x.rs": 2, "b.rs": 1}, {"x.rs": "a.rs"}):
+ print(f"self-test FAILED: --update's writer loses moved-from "
+ f"annotations: {round_tripped}")
+ return 1
# The failure mode this rule is most likely to die of: an unfetched merge
# base makes every file read as absent, which is indistinguishable from
# "the gate did not exist there" -- i.e. a silent pass. Resolving the ref
@@ -308,7 +541,12 @@ def self_test():
print(f"self-test ok ({total} sites across {len(per_file)} files); "
f"all three per-module rules fire, clean case silent; "
- f"merge-base rule rejects all three raises and passes four legal diffs")
+ f"merge-base rule rejects all three raises and passes four legal "
+ f"diffs; relocations credit a real surrender (declared move and a "
+ f"1+1 three-way split pass) and reject an undeclared move, "
+ f"laundering, an over-draw, a double-spend, a stale annotation and a "
+ f"self-reference; the annotation parses, survives --update's writer, "
+ f"and a malformed one raises")
return 0
def main():
@@ -329,17 +567,18 @@ def main():
return 1
BASELINE.write_text(f"{total}\n")
# Rewrite the per-module ceilings too, preserving the header. Entries
- # that reached zero simply do not come back -- rule 3.
+ # that reached zero simply do not come back -- rule 3. `moved-from:`
+ # annotations on surviving entries are CARRIED OVER: dropping them here
+ # would silently revoke the relocation the same commit is declaring, and
+ # `--no-raise-vs` would then reject the tree `--update` just wrote.
+ existing_moves = load_moves()
header = []
for line in FILES.read_text(encoding="utf-8").splitlines():
if line.startswith("#") or not line.strip():
header.append(line)
else:
break
- FILES.write_text(
- "\n".join(header) + "\n"
- + "".join(f"{n} {p}\n" for p, n in sorted(per_file.items()))
- )
+ FILES.write_text(render_ledger(header, per_file, existing_moves))
print(f"baseline set to {total}" + (f" (was {prev})" if prev is not None else ""))
print(f"per-module ceilings rewritten: {len(per_file)} entries")
return 0
diff --git a/scripts/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt
index 35b059a95f..7a31f53f6b 100644
--- a/scripts/raw_handle_debt_files.txt
+++ b/scripts/raw_handle_debt_files.txt
@@ -22,6 +22,21 @@
# genuinely needs a new bare read in an unlisted file, the honest move is to
# convert a pair elsewhere and say so in the PR, not to add a line here.
#
+# RELOCATIONS (#10583). Rules 2 and 3 are per-path, and the 2000-line cap
+# regularly forces a listed module to be SPLIT. Moving debt-carrying code out
+# deletes the source's line (rule 3) and adds the destination's -- which
+# `--no-raise-vs` would otherwise reject as "was not listed at the merge base"
+# even though the total never moved. Declare it on the destination's own line:
+#
+# 4 crates/…/native_module/vtable_access.rs # moved-from: crates/…/native_module.rs
+#
+# The destination is then credited with what the source ACTUALLY surrendered
+# between the merge base and head, and nothing more: a relocation cannot launder
+# new sites, two destinations splitting one source share one credit, and once
+# the move has landed the annotation is inert documentation. It is the ONLY
+# comment an entry may carry -- a malformed one is a build failure, not a
+# silently-ignored comment.
+#
# ONE shape may join the list instead of converting: a LOOP whose collection
# window is a user-visible trap/getter call (Proxy traps, accessors, valueOf)
# re-reads every live handle at the top of each iteration. That re-read IS the
diff --git a/scripts/unrooted_local_shape.py b/scripts/unrooted_local_shape.py
index 7434310add..2361ded6a6 100755
--- a/scripts/unrooted_local_shape.py
+++ b/scripts/unrooted_local_shape.py
@@ -21,12 +21,26 @@
This is a REPORT, not a proof. Rust has no effect system marking "this call may
allocate", so the collection-point list is a curated denylist and the binding
-detection is line-order over source text. Expect false positives where the
+detection is statement-order over source text. Expect false positives where the
allocation provably cannot trigger a collection, and false negatives wherever a
pointer flows through a shape this does not spell. The number is useful as an
EXPOSURE SURFACE -- how much of the surface no instrument is watching -- not as
a bug count.
+STATEMENT-order, not line-order, since #10715: a `let` whose initializer
+rustfmt wrapped onto the following lines is folded back into one statement
+before matching. It used to be matched per line, so a binding broken after its
+`=` -- a function of indentation depth and identifier length, not of anything
+about the code -- was simply not counted. Deeply nested code, which is where
+rooting bugs live, was the least scanned, and the totals were in part a measure
+of formatting. Statements containing a brace are still read line by line, on
+purpose: see `join_let_statement`.
+
+`--no-raise-vs` SCANS THE WORKTREE, since #10713. It used to compare the merge
+base's recorded baseline against the checked-out one and nothing else, so a
+branch that added findings without touching the baseline passed it while
+`--check` failed on the same tree, seconds later, in the same run.
+
Per CLAUDE.md, a new gate has never been green, so this ships as a report and
`--check` compares against a recorded baseline rather than demanding zero.
@@ -49,7 +63,22 @@
ROOT = Path(__file__).resolve().parent.parent
BASELINE = ROOT / "scripts" / "unrooted_local_shape_baseline.json"
-BASELINE_SCHEMA = 2
+BASELINE_SCHEMA = 3
+
+# (base, head) schema pairs across which the DETECTOR itself changed, so the two
+# sides were measured with different yardsticks and their numbers are not
+# comparable. Each entry is a deliberate, reviewed act: the ratchet cannot tell
+# "the detector got better" from "the debt got worse" by looking at the totals,
+# so a migration is the one place the recorded number is allowed to rise, and it
+# is named here rather than inferred. Every schema pair NOT listed is rejected.
+#
+# 1 -> 2 #8253's ordinary-use blind spot, plus NaN-box pointer sources.
+# 2 -> 3 #10715's wrapped-`let` fold. The line-oriented matcher did not see a
+# binding rustfmt broke after its `=`, so the old ceilings were
+# produced by a detector that could not count the surface the new one
+# counts. 558 -> 581 on the same tree: 34 bindings that were invisible
+# minus 11 that were reported against a dead identity.
+AUDITED_MIGRATIONS = frozenset({(1, 2), (2, 3)})
# Crate families outside `raw_handle_debt.py`'s scope -- the whole point.
SCAN_GLOBS = (
@@ -134,8 +163,78 @@
FN_START = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:const\s+|async\s+|unsafe\s+|extern\s+\"[^\"]*\"\s+)*fn\s+(\w+)")
LET_BIND = re.compile(r"^\s*let\s+(?:mut\s+)?(\w+)\s*(?::[^=]+)?=\s*(.+)$")
+LET_HEAD = re.compile(r"^\s*let\s")
IDENT = re.compile(r"\b\w+\b")
+# A wrapped `let` cannot span more lines than this before the fold gives up and
+# the statement is read one line at a time again. A bound purely so a malformed
+# or unterminated statement cannot walk to the end of the function.
+JOIN_MAX_LINES = 40
+
+
+def statement_is_complete(text: str) -> bool:
+ """True when TEXT holds a whole statement: a `;` outside every bracket."""
+ depth = 0
+ for ch in text:
+ if ch in "([":
+ depth += 1
+ elif ch in ")]":
+ depth -= 1
+ elif ch == ";" and depth <= 0:
+ return True
+ return False
+
+
+def join_let_statement(body: list[str], offset: int) -> tuple[str, int]:
+ """Fold a `let` whose initializer rustfmt wrapped onto the lines after it.
+
+ #10715: `LET_BIND` was matched per line, so `rustfmt` breaking a binding
+ after the `=` left a head line with nothing on its right-hand side. Nothing
+ matched, the local was never tracked, and the finding disappeared -- a false
+ negative produced by indentation depth and identifier length rather than by
+ anything about the code. That made the deepest-nested code, which is exactly
+ where rooting bugs live, the least scanned, and made the totals partly a
+ measure of formatting. Returns the folded statement and the last line it ate.
+
+ Statements containing a brace are deliberately left alone. A `{` means a
+ closure, `match`, block or struct-literal initializer whose body carries its
+ OWN bindings and collection points; folding those into a single expression
+ would hide every one of them, trading this blind spot for a worse one. Line
+ order is already correct for that shape, since the head line keeps a
+ non-empty right-hand side.
+ """
+ head = body[offset]
+ if "{" in head or "}" in head or statement_is_complete(head):
+ return head, offset
+ text = head.rstrip()
+ for j in range(offset + 1, min(offset + 1 + JOIN_MAX_LINES, len(body))):
+ nxt = body[j]
+ if "{" in nxt or "}" in nxt:
+ return head, offset
+ text = f"{text} {nxt.strip()}"
+ if statement_is_complete(text):
+ return text, j
+ return head, offset
+
+
+def fold_wrapped_lets(body: list[str]) -> tuple[dict[int, str], set[int]]:
+ """Return (folded text by head offset, offsets absorbed into a head).
+
+ Absorbed lines are scanned as empty rather than dropped, so every line still
+ contributes its brace delta to the lexical depth and the reported line
+ numbers stay the function's own.
+ """
+ joined: dict[int, str] = {}
+ absorbed: set[int] = set()
+ for offset, line in enumerate(body):
+ if offset in absorbed or not LET_HEAD.match(line):
+ continue
+ text, last = join_let_statement(body, offset)
+ if last > offset:
+ joined[offset] = text
+ absorbed.update(range(offset + 1, last + 1))
+ return joined, absorbed
+
def strip_comments(text: str) -> list[str]:
"""Blank out // comments and string literals, preserving line numbering."""
@@ -195,7 +294,12 @@ def scan_function(name: str, lines: list[str], start: int, end: int):
findings = []
lexical_depth = 0
runtime_scopes: list[tuple[str, int]] = []
- for offset, line in enumerate(body):
+ joined, absorbed = fold_wrapped_lets(body)
+ for offset, source_line in enumerate(body):
+ # A folded continuation is scanned as empty: its text was already read
+ # as part of the `let` at the head offset, and reading it twice would
+ # report the same use once per line it wrapped onto.
+ line = "" if offset in absorbed else joined.get(offset, source_line)
m = LET_BIND.match(line)
expression = m.group(2) if m else line
runtime_scopes = [
@@ -237,7 +341,10 @@ def scan_function(name: str, lines: list[str], start: int, end: int):
crossed.pop(local, None)
if calls_any(rhs, POINTER_SOURCES) and not calls_any(rhs, ROOT_HANDLE_BINDINGS):
bound[local] = offset
- lexical_depth += line.count("{") - line.count("}")
+ # Depth comes from the SOURCE line, never the folded text: an absorbed
+ # line is scanned as empty but still owns its braces. Folded statements
+ # are brace-free by construction, so this is the pre-#10715 arithmetic.
+ lexical_depth += source_line.count("{") - source_line.count("}")
return findings
@@ -325,20 +432,46 @@ def collect(root: Path = ROOT):
let _other = js_array_alloc(0);
stale
}
+
+unsafe fn planted_wrapped_binding() -> *mut ObjectHeader {
+ let object =
+ js_object_alloc_with_shape(shape, 3, keys.as_ptr(), keys.len() as u32);
+ js_object_set_field(object, 0, first);
+ js_object_set_field(object, 1, second);
+ object
+}
+
+unsafe fn clean_wrapped_shadow_rebinds() -> usize {
+ let err_str = js_string_from_bytes(first.as_ptr(), first.len() as u32);
+ let _other = js_array_alloc(0);
+ let err_str =
+ js_string_from_bytes(second.as_ptr(), second.len() as u32);
+ string_len(err_str)
+}
+
+unsafe fn planted_inside_wrapped_closure() {
+ let handler = move |arg: f64| {
+ let inner = js_array_alloc(1);
+ let _other = js_array_alloc(0);
+ consume(inner);
+ };
+ register(handler);
+}
'''
def compare_baselines(base: dict, head: dict) -> list[str]:
"""Return recorded-debt increases from BASE to HEAD.
- Schema 1 is the detector merged by #8253. Schema 2 fixes that detector's
- ordinary-use blind spot and adds NaN-box pointer sources, so its initial
- re-pin necessarily increases the measured surface. That one migration is
- explicit; after it lands, both total and per-file ceilings only go down.
+ A detector change makes the re-pin necessarily raise the measured surface,
+ and the ratchet cannot distinguish that from real debt by reading totals. So
+ the schema pairs where it happened are enumerated in `AUDITED_MIGRATIONS`
+ and exempted by name; every other change of schema is rejected outright.
+ Between migrations, both total and per-file ceilings only go down.
"""
base_schema = int(base.get("schema_version", 1))
head_schema = int(head.get("schema_version", 1))
- if (base_schema, head_schema) == (1, BASELINE_SCHEMA):
+ if (base_schema, head_schema) in AUDITED_MIGRATIONS:
return []
if base_schema != head_schema:
return [f"baseline schema changed {base_schema} -> {head_schema} without an audited migration"]
@@ -355,8 +488,56 @@ def compare_baselines(base: dict, head: dict) -> list[str]:
return bad
-def git_show_baseline(ref: str) -> dict | None:
- """Read the baseline at REF, failing closed when REF was not fetched."""
+def compare_measured(base: dict, total: int, per_file: dict[str, int]) -> list[str]:
+ """Return rises of the MEASURED worktree over BASE's recorded ceilings.
+
+ #10713: this comparison did not exist, and its absence was the whole bug.
+ `--no-raise-vs` read two recorded BASELINE FILES -- the merge base's and the
+ checked-out one -- and never scanned a line of source. A branch that ADDED
+ findings without touching the baseline therefore compared 561 against 561
+ and printed "no ceiling raised" while `--check`, seconds later in the same
+ `run_lint_gates.sh` run on the same worktree, failed with
+ `REGRESSION: 563 findings exceeds baseline 561`. The variant whose entire
+ purpose is catching a rise against the base could not see one, because the
+ only number it ever looked at was one the diff had left alone.
+
+ Measuring against the base's RECORDED ceilings rather than the base's own
+ measurement is the closest comparison available without a second checkout,
+ and it is the same yardstick `--check` uses, so the two forms now agree.
+ """
+ bad = []
+ base_total = int(base["total"])
+ if total > base_total:
+ bad.append(f"measured total {total} exceeds merge-base recorded total {base_total}")
+ base_files = base.get("per_file", {})
+ for path, count in sorted(per_file.items()):
+ ceiling = int(base_files.get(path, 0))
+ if count > ceiling:
+ where = "not recorded at the merge base" if path not in base_files else f"ceiling {ceiling}"
+ bad.append(f"{path}: {count} measured findings exceeds {where}")
+ return bad
+
+
+def resolve_ref(ref: str) -> str:
+ """Return REF's commit SHA, failing closed on an empty or unfetched ref.
+
+ Resolving FIRST is the point, and `raw_handle_debt.py`'s `git_show` makes
+ the argument: a merge base the runner never fetched reports every file as
+ absent, which reads as "the base recorded nothing" -- a comparison that did
+ not happen, reported as a pass. It must be a RED build instead.
+
+ The empty string gets the same treatment, and for the same reason. It used
+ to get worse: `--no-raise-vs ""` is falsy, so `if args.no_raise_vs` was
+ False, the vs-base mode never ran at all, and the script fell through to the
+ plain report, which exits 0. An unset `$BASE_SHA` thus printed a perfectly
+ ordinary-looking finding table and passed, having compared nothing.
+ """
+ if not ref.strip():
+ raise SystemExit(
+ "::error::--no-raise-vs was given an empty ref. That is an unset "
+ "$BASE_SHA, not a request to skip the comparison -- failing rather "
+ "than passing on a comparison that did not happen."
+ )
resolved = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
cwd=ROOT,
@@ -367,8 +548,14 @@ def git_show_baseline(ref: str) -> dict | None:
raise SystemExit(
f"::error::cannot resolve {ref}. The merge base was not fetched, so "
"the unrooted-local ratchet cannot compare against it -- failing "
- "rather than passing on a comparison that did not happen."
+ "rather than passing on a comparison that did not happen. Fetch it "
+ "with `git fetch --no-tags --depth=1 origin `."
)
+ return resolved.stdout.strip()
+
+
+def git_show_baseline(ref: str) -> dict | None:
+ """Read the baseline at REF. REF must already be resolved."""
proc = subprocess.run(
["git", "show", f"{ref}:scripts/unrooted_local_shape_baseline.json"],
cwd=ROOT,
@@ -381,29 +568,44 @@ def git_show_baseline(ref: str) -> dict | None:
def no_raise_vs(ref: str) -> int:
- base = git_show_baseline(ref)
+ resolved = resolve_ref(ref)
+ base = git_show_baseline(resolved)
if base is None:
- print(f"{ref} has no unrooted-local baseline; no recorded debt to compare")
+ print(f"{ref} ({resolved}) has no unrooted-local baseline; no recorded debt to compare")
return 0
head = json.loads(BASELINE.read_text(encoding="utf-8"))
base_schema = int(base.get("schema_version", 1))
head_schema = int(head.get("schema_version", 1))
+ migration = (base_schema, head_schema) in AUDITED_MIGRATIONS
bad = compare_baselines(base, head)
+
+ # Scan the worktree too. Without this the whole mode is recorded-vs-recorded
+ # (#10713). Skipped only across the audited schema migration, where the two
+ # sides were measured by different detectors and the numbers are not
+ # comparable -- the same exemption `compare_baselines` already makes.
+ measured = "worktree not scanned (different detectors either side)"
+ if not migration:
+ results = collect()
+ total = sum(len(v) for v in results.values())
+ measured = f"measured {total}"
+ bad += compare_measured(base, total, {k: len(v) for k, v in results.items()})
+
+ where = (
+ f"vs. {ref} ({resolved}): recorded {base['total']} -> {head['total']}, "
+ f"{measured}"
+ )
if bad:
- print(f"::error::recorded unrooted-local debt rose vs. {ref}: {len(bad)} violation(s)")
+ print(f"::error::unrooted-local debt rose {where}: {len(bad)} violation(s)")
for violation in bad:
print(f" {violation}")
return 1
- if (base_schema, head_schema) == (1, BASELINE_SCHEMA):
+ if migration:
print(
- f"recorded unrooted-local debt vs. {ref}: audited schema migration "
- f"{base_schema} -> {head_schema}, baseline {base['total']} -> {head['total']}"
+ f"unrooted-local debt {where}: audited schema migration "
+ f"{base_schema} -> {head_schema}"
)
else:
- print(
- f"recorded unrooted-local debt vs. {ref}: {base['total']} -> {head['total']}, "
- "no ceiling raised"
- )
+ print(f"unrooted-local debt {where}, no ceiling raised")
return 0
@@ -423,6 +625,16 @@ def self_test() -> int:
"planted_later_rhs",
"planted_after_transient_scope",
"planted_after_runtime_scope",
+ # #10715. `let object =` with the initializer on the next line, the
+ # shape rustfmt produces once the binding is nested a few levels deep.
+ # The line-oriented matcher saw no right-hand side, never tracked
+ # `object`, and reported nothing -- a false negative bought with an
+ # indent. Taken from a live site (perry-ext-ws `js_ws_server_address`).
+ "planted_wrapped_binding",
+ # The fold must stop at a brace. A closure body carries its own
+ # bindings and collection points; swallowing it into one expression
+ # would trade #10715's blind spot for a strictly larger one.
+ "planted_inside_wrapped_closure",
}
missing = required - names
if missing:
@@ -433,6 +645,11 @@ def self_test() -> int:
"clean_use_on_first_collection",
"clean_ffi_transient_root",
"clean_active_runtime_scope",
+ # The same #10715 defect in the other direction: a WRAPPED shadowing
+ # `let` matched nothing, so the dead identity from the earlier binding
+ # of that name stayed live and every use of the fresh one was reported.
+ # Eleven of the findings in perry-stdlib/src/ioredis.rs were this.
+ "clean_wrapped_shadow_rebinds",
} & names
if forbidden:
print(f"SELF-TEST FAIL: flagged clean control(s): {sorted(forbidden)}", file=sys.stderr)
@@ -445,7 +662,10 @@ def self_test() -> int:
{"schema_version": 2, "total": 2, "per_file": {"a.rs": 1, "new.rs": 1}},
"was not listed",
),
- ({"schema_version": 3, "total": 2, "per_file": {"a.rs": 2}}, "schema changed"),
+ # An UNAUDITED schema pair is still rejected. 2 -> 4 rather than 2 -> 3,
+ # because 2 -> 3 is now a named migration: the exemption is a list, not
+ # a licence to renumber, and this asserts the rest of the space is shut.
+ ({"schema_version": 4, "total": 2, "per_file": {"a.rs": 2}}, "schema changed"),
)
for head, needle in comparisons:
if not any(needle in violation for violation in compare_baselines(base, head)):
@@ -454,19 +674,142 @@ def self_test() -> int:
if compare_baselines(base, base):
print("SELF-TEST FAIL: unchanged baseline reported an increase", file=sys.stderr)
ok = False
- if compare_baselines({"total": 218, "per_file": {}}, {"schema_version": 2, "total": 999, "per_file": {}}):
- print("SELF-TEST FAIL: audited schema-1 migration was rejected", file=sys.stderr)
+ for pair, head in (
+ ((1, 2), {"schema_version": 2, "total": 999, "per_file": {}}),
+ ((2, 3), {"schema_version": 3, "total": 999, "per_file": {"a.rs": 999}}),
+ ):
+ older = {"schema_version": pair[0], "total": 218, "per_file": {"a.rs": 218}}
+ if compare_baselines(older, head):
+ print(f"SELF-TEST FAIL: audited schema migration {pair} was rejected", file=sys.stderr)
+ ok = False
+ if BASELINE_SCHEMA != max(head for _, head in AUDITED_MIGRATIONS):
+ print(
+ f"SELF-TEST FAIL: BASELINE_SCHEMA is {BASELINE_SCHEMA} but the newest audited "
+ f"migration ends at {max(head for _, head in AUDITED_MIGRATIONS)}; a schema bump "
+ "must name its own migration or every PR is exempt from the ratchet",
+ file=sys.stderr,
+ )
+ ok = False
+
+ # #10713. Everything above this line passed on the day `--no-raise-vs`
+ # returned green on a worktree `--check` rejected, which is the point: a
+ # self-test that does not cover the failing mode is not evidence about it.
+ measured_cases = (
+ ((3, {"a.rs": 3}), "measured total 3 exceeds merge-base recorded total 2"),
+ ((2, {"a.rs": 1, "new.rs": 1}), "new.rs: 1 measured findings exceeds not recorded"),
+ )
+ for (total, per_file), needle in measured_cases:
+ if not any(needle in violation for violation in compare_measured(base, total, per_file)):
+ print(f"SELF-TEST FAIL: measured rule did not fire for {needle!r}", file=sys.stderr)
+ ok = False
+ if compare_measured(base, 2, {"a.rs": 2}):
+ print("SELF-TEST FAIL: an unchanged worktree reported a measured rise", file=sys.stderr)
+ ok = False
+
+ # An empty ref is an unset $BASE_SHA -- a comparison that did not happen,
+ # which must be red. `resolve_ref` says so in those words; `git rev-parse`
+ # would reject it regardless, so this pair asserts the message, not the
+ # behaviour. The behaviour is the DISPATCH, checked below.
+ for bad_ref in ("", " "):
+ try:
+ resolve_ref(bad_ref)
+ except SystemExit:
+ continue
+ print(f"SELF-TEST FAIL: resolve_ref({bad_ref!r}) did not fail closed", file=sys.stderr)
ok = False
+ ok = _self_test_empty_ref_dispatch() and ok
+ ok = _self_test_no_raise_vs() and ok
+
if ok:
print(
- "self-test OK: collecting/plain-return/later-RHS sites flagged, "
- "clean controls ignored, baseline increases rejected"
+ "self-test OK: collecting/plain-return/later-RHS/wrapped sites flagged, "
+ "clean controls ignored, baseline and measured increases rejected"
)
return 0
return 1
+def _self_test_empty_ref_dispatch() -> bool:
+ """`--no-raise-vs ""` must fail closed, not fall through to the report.
+
+ The hole was in the dispatch, one character wide: `if args.no_raise_vs`
+ tests TRUTHINESS, and the empty string an unset `$BASE_SHA` expands to is
+ falsy. The vs-base mode was therefore never entered at all -- no ref was
+ resolved, no baseline read, nothing compared -- and the script printed an
+ ordinary finding table and exited 0. Guarding inside `resolve_ref` alone
+ does not cover this, because nothing called it.
+ """
+ import contextlib
+ import io
+
+ saved = sys.argv
+ outcome: object = None
+ try:
+ sys.argv = ["unrooted_local_shape.py", "--no-raise-vs", ""]
+ with contextlib.redirect_stdout(io.StringIO()):
+ outcome = main()
+ except SystemExit:
+ return True
+ finally:
+ sys.argv = saved
+ print(
+ f"SELF-TEST FAIL: `--no-raise-vs \"\"` returned {outcome!r} instead of failing "
+ "closed; an unset $BASE_SHA compared nothing and passed -- this is #10713",
+ file=sys.stderr,
+ )
+ return False
+
+
+def _self_test_no_raise_vs() -> bool:
+ """Drive the real `--no-raise-vs` over the tree that fooled it (#10713).
+
+ Reproduces the observed combination exactly: the merge base and the
+ checked-out baseline both recording 561, and a worktree measuring 563. That
+ is a pass for a recorded-vs-recorded comparison and a `REGRESSION` for
+ `--check`, and both ran seconds apart in one `run_lint_gates.sh` invocation.
+ Stubs stand in for git and the scan so the case is a fixture rather than a
+ property of whatever this repo happens to measure today.
+ """
+ import contextlib
+ import io
+ import tempfile
+
+ recorded = {"schema_version": BASELINE_SCHEMA, "total": 561, "per_file": {"a.rs": 561}}
+ scope = globals()
+ saved = {k: scope[k] for k in ("resolve_ref", "git_show_baseline", "collect", "BASELINE")}
+ out = io.StringIO()
+ try:
+ with tempfile.TemporaryDirectory() as tmp:
+ head = Path(tmp) / "baseline.json"
+ head.write_text(json.dumps(recorded), encoding="utf-8")
+ scope["BASELINE"] = head
+ scope["resolve_ref"] = lambda ref: "0" * 40
+ scope["git_show_baseline"] = lambda ref: recorded
+ with contextlib.redirect_stdout(out):
+ scope["collect"] = lambda root=None: {"a.rs": [None] * 563}
+ regressed = no_raise_vs("planted-base")
+ scope["collect"] = lambda root=None: {"a.rs": [None] * 561}
+ unchanged = no_raise_vs("planted-base")
+ finally:
+ scope.update(saved)
+
+ ok = True
+ if regressed != 1:
+ print(
+ "SELF-TEST FAIL: --no-raise-vs passed a worktree measuring 563 against "
+ "a merge base recording 561 (both baselines identical) -- this is #10713",
+ file=sys.stderr,
+ )
+ ok = False
+ if unchanged != 0:
+ print("SELF-TEST FAIL: --no-raise-vs failed an unchanged worktree", file=sys.stderr)
+ ok = False
+ if not ok:
+ print(out.getvalue(), file=sys.stderr)
+ return ok
+
+
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--check", action="store_true", help="fail if the count exceeds the baseline")
@@ -478,7 +821,10 @@ def main() -> int:
if args.self_test:
return self_test()
- if args.no_raise_vs:
+ # `is not None`, not truthiness: `--no-raise-vs ""` is an unset $BASE_SHA,
+ # and dropping through to the report on it is a pass without a comparison
+ # (#10713). `resolve_ref` rejects it.
+ if args.no_raise_vs is not None:
return no_raise_vs(args.no_raise_vs)
results = collect()
diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json
index 947dbdac54..5ecbec01f0 100644
--- a/scripts/unrooted_local_shape_baseline.json
+++ b/scripts/unrooted_local_shape_baseline.json
@@ -7,8 +7,10 @@
"crates/perry-ext-decimal/src/lib.rs": 1,
"crates/perry-ext-events/src/lib.rs": 12,
"crates/perry-ext-events/src/module_iterators.rs": 2,
+ "crates/perry-ext-events/src/module_on.rs": 3,
"crates/perry-ext-events/src/tests.rs": 2,
- "crates/perry-ext-fastify/src/upgrade.rs": 4,
+ "crates/perry-ext-fastify/src/context.rs": 2,
+ "crates/perry-ext-fastify/src/upgrade.rs": 6,
"crates/perry-ext-fetch/src/lib.rs": 14,
"crates/perry-ext-fetch/src/tests.rs": 14,
"crates/perry-ext-http/src/agent.rs": 3,
@@ -21,29 +23,32 @@
"crates/perry-ext-mongodb/src/lib.rs": 2,
"crates/perry-ext-mysql2/src/lib.rs": 9,
"crates/perry-ext-net/src/classes.rs": 2,
+ "crates/perry-ext-net/src/jsvalue.rs": 1,
"crates/perry-ext-net/src/lifecycle.rs": 1,
- "crates/perry-ext-node-forge/src/lib.rs": 20,
+ "crates/perry-ext-node-forge/src/lib.rs": 24,
"crates/perry-ext-pg/src/lib.rs": 7,
"crates/perry-ext-ratelimit/src/lib.rs": 4,
- "crates/perry-ext-streams/src/lib.rs": 2,
+ "crates/perry-ext-streams/src/lib.rs": 4,
"crates/perry-ext-uuid/src/lib.rs": 2,
+ "crates/perry-ext-ws/src/server.rs": 3,
"crates/perry-ext-zlib/src/stream.rs": 3,
"crates/perry-stdlib/src/cheerio.rs": 6,
"crates/perry-stdlib/src/commander.rs": 3,
"crates/perry-stdlib/src/cron.rs": 2,
"crates/perry-stdlib/src/crypto/kdf.rs": 9,
+ "crates/perry-stdlib/src/crypto/keys.rs": 1,
"crates/perry-stdlib/src/crypto/sign.rs": 22,
"crates/perry-stdlib/src/crypto/util.rs": 2,
"crates/perry-stdlib/src/domain.rs": 3,
"crates/perry-stdlib/src/ethers.rs": 5,
- "crates/perry-stdlib/src/events.rs": 6,
+ "crates/perry-stdlib/src/events.rs": 13,
"crates/perry-stdlib/src/events/constructors.rs": 1,
"crates/perry-stdlib/src/events/events_on.rs": 17,
"crates/perry-stdlib/src/events/module_helpers.rs": 1,
"crates/perry-stdlib/src/events/once_helpers.rs": 1,
"crates/perry-stdlib/src/events/warnings.rs": 1,
"crates/perry-stdlib/src/fetch/mod.rs": 6,
- "crates/perry-stdlib/src/ioredis.rs": 14,
+ "crates/perry-stdlib/src/ioredis.rs": 3,
"crates/perry-stdlib/src/lodash.rs": 21,
"crates/perry-stdlib/src/mongodb.rs": 4,
"crates/perry-stdlib/src/mysql2/result.rs": 39,
@@ -54,12 +59,12 @@
"crates/perry-stdlib/src/querystring.rs": 2,
"crates/perry-stdlib/src/ratelimit.rs": 4,
"crates/perry-stdlib/src/readline/mod.rs": 4,
- "crates/perry-stdlib/src/sqlite/backup.rs": 7,
+ "crates/perry-stdlib/src/sqlite/backup.rs": 8,
"crates/perry-stdlib/src/sqlite/better.rs": 18,
"crates/perry-stdlib/src/sqlite/bind.rs": 4,
"crates/perry-stdlib/src/sqlite/dispatch.rs": 6,
- "crates/perry-stdlib/src/sqlite/node_stmt_session.rs": 2,
- "crates/perry-stdlib/src/sqlite/node_tag_store.rs": 1,
+ "crates/perry-stdlib/src/sqlite/node_stmt_session.rs": 4,
+ "crates/perry-stdlib/src/sqlite/node_tag_store.rs": 3,
"crates/perry-stdlib/src/streams.rs": 25,
"crates/perry-stdlib/src/streams/byob.rs": 12,
"crates/perry-stdlib/src/streams/pipe.rs": 13,
@@ -68,9 +73,9 @@
"crates/perry-stdlib/src/streams/transform.rs": 8,
"crates/perry-stdlib/src/streams/writable.rs": 2,
"crates/perry-stdlib/src/string_decoder.rs": 4,
- "crates/perry-stdlib/src/tls.rs": 3,
+ "crates/perry-stdlib/src/tls.rs": 4,
"crates/perry-stdlib/src/webcrypto/aes.rs": 2,
- "crates/perry-stdlib/src/webcrypto/encapsulation.rs": 8,
+ "crates/perry-stdlib/src/webcrypto/encapsulation.rs": 10,
"crates/perry-stdlib/src/webcrypto/jwk.rs": 2,
"crates/perry-stdlib/src/webcrypto/key_object.rs": 1,
"crates/perry-stdlib/src/webcrypto/keys.rs": 40,
@@ -78,8 +83,8 @@
"crates/perry-stdlib/src/worker_threads.rs": 12,
"crates/perry-stdlib/src/worker_threads/direct_message.rs": 2,
"crates/perry-stdlib/src/worker_threads/worker_surface.rs": 7,
- "crates/perry-stdlib/src/zlib.rs": 2
+ "crates/perry-stdlib/src/zlib.rs": 3
},
- "schema_version": 2,
- "total": 558
+ "schema_version": 3,
+ "total": 580
}
diff --git a/test-files/test_gap_10711_property_fn_param_callback.ts b/test-files/test_gap_10711_property_fn_param_callback.ts
new file mode 100644
index 0000000000..05bf4c13f5
--- /dev/null
+++ b/test-files/test_gap_10711_property_fn_param_callback.ts
@@ -0,0 +1,127 @@
+// #10711: a function read from an object property must not drop its own call
+// to a second function handed to it as a parameter (also read from an object
+// property).
+//
+// This is commander's `_displayError` shape. `lib/command.js` builds a default
+// output configuration holding two function properties —
+//
+// writeErr: (str) => process.stderr.write(str),
+// outputError: (str, write) => write(str),
+//
+// — and every error path calls
+// `this._outputConfiguration.outputError(msg, this._outputConfiguration.writeErr)`:
+// an object-property function invoking a SECOND object-property function that
+// was passed to it as a parameter. If the inner `write(str)` evaporates, the
+// program still runs and still throws the right CommanderError — it just
+// prints nothing. A silently dropped call is the worst failure mode there is,
+// so this fixture asserts the inner call RAN, not merely that something got
+// printed.
+//
+// Every writer sinks to stdout on purpose. The parity harness merges stdout
+// and stderr into one compared stream, so a fixture that used both would race
+// on the interleaving; the stream is incidental to the indirection under test.
+
+function out(s: string): void {
+ process.stdout.write(s);
+}
+
+// ── 1. The reported shape, verbatim: object-literal arrow properties, both
+// reached through one level of plain-function call. ──────────────────
+const config: any = {
+ writeErr: (str: string) => out(str),
+ outputError: (str: string, write: (s: string) => void) => write(str),
+};
+
+function fireError(cfg: any, message: string) {
+ cfg.outputError(message, cfg.writeErr);
+}
+
+fireError(config, "1 verbatim: error: something went wrong\n");
+
+// ── 2. The inner call is observably entered and left. Printing "before" and
+// "after" around it is what separates "the call ran" from "the outer
+// body ran and the inner call vanished" — the two look identical when
+// only the payload is checked. ──────────────────────────────────────
+const traced: any = {
+ writeErr: (str: string) => out(" inner: " + str),
+ outputError: (str: string, write: (s: string) => void) => {
+ out("2 before, typeof write=" + typeof write + "\n");
+ write(str);
+ out("2 after\n");
+ },
+};
+fireError(traced, "payload\n");
+
+// ── 3. Method-shorthand spelling of the same object. ────────────────────────
+const shorthand: any = {
+ writeErr(str: string) {
+ out(str);
+ },
+ outputError(str: string, write: (s: string) => void) {
+ write(str);
+ },
+};
+fireError(shorthand, "3 shorthand: error: something went wrong\n");
+
+// ── 4. commander's real home for it: a class field holding the config, the
+// receiver reached as `this.` inside a method. ────────────────
+class Reporter {
+ _outputConfiguration: any = {
+ writeOut: (str: string) => out(str),
+ writeErr: (str: string) => out(str),
+ outputError: (str: string, write: (s: string) => void) => write(str),
+ getOutHelpWidth: () => 80,
+ };
+ configureOutput(cfg: any): Reporter {
+ Object.assign(this._outputConfiguration, cfg);
+ return this;
+ }
+ error(message: string): void {
+ this._outputConfiguration.outputError(
+ `${message}\n`,
+ this._outputConfiguration.writeErr,
+ );
+ }
+}
+
+const reporter = new Reporter();
+reporter.error("4 class field: error: something went wrong");
+
+// ── 5. `configureOutput` replaces the writer after construction — the call
+// must reach the REPLACEMENT, not a value baked in at literal-creation
+// time. ────────────────────────────────────────────────────────────
+reporter.configureOutput({ writeErr: (str: string) => out("[override]" + str) });
+reporter.error("5 after configureOutput");
+
+// ── 6. Spread-built config, nested receiver, and a writer taken from a
+// DIFFERENT object than the one holding `outputError`. ──────────────
+const defaults: any = {
+ writeErr: (str: string) => out(str),
+ outputError: (str: string, write: (s: string) => void) => write(str),
+};
+const nested: any = { io: { ...defaults } };
+nested.io.outputError("6 nested spread: ok\n", nested.io.writeErr);
+
+const sink: any = { writeErr: (str: string) => out("[other]" + str) };
+nested.io.outputError("6 cross-object writer\n", sink.writeErr);
+
+// ── 7. Repeated dispatch: the shape must survive a loop, where the call site
+// is re-entered and any per-site caching gets a second look. ─────────
+for (let i = 0; i < 3; i++) {
+ fireError(config, "7 loop " + i + "\n");
+}
+
+// ── 8. And when the writer really is missing, the call must be LOUD. A
+// TypeError here is the property that keeps every future instance of
+// this bug class from presenting as a plausible wrong answer. (Only the
+// error's name is printed: Node names the callee — "write is not a
+// function" — where Perry says "value is not a function".) ───────────
+const noWriter: any = {
+ outputError: (str: string, write: (s: string) => void) => write(str),
+};
+try {
+ fireError(noWriter, "never printed\n");
+ out("8 MISSING WRITER SILENTLY DROPPED THE CALL\n");
+} catch (e: any) {
+ out("8 threw " + e.name + "\n");
+}
diff --git a/test-files/test_gap_cron_cronjob.ts b/test-files/test_gap_cron_cronjob.ts
index d3e0b52bda..9e80f6dd68 100644
--- a/test-files/test_gap_cron_cronjob.ts
+++ b/test-files/test_gap_cron_cronjob.ts
@@ -1,8 +1,21 @@
// Gap test: the npm `cron` package's CronJob class (distinct from
// node-cron's schedule() factory). `new CronJob(expr, fn)` must NOT
// auto-start; the 4-arg form with start=true must; start()/stop() must
-// dispatch. Tick counts are asserted as booleans and only the first two
-// manual ticks print, so output is deterministic despite the timing.
+// dispatch. Only the first two manual ticks print, so the output does not
+// depend on how many ticks land.
+//
+// #10581: the wait below is a BARRIER, not a deadline. It used to race a fixed
+// 10-second wall clock against a one-per-second schedule and print `false` when
+// the clock won -- classified as a `parity_fail`, i.e. read as a miscompile, on
+// a loaded runner. (`PERRY_RUN_TIMEOUT` is itself 10s, so that deadline could
+// only ever fire in a photo finish with the harness's own kill.) With no
+// deadline the printed text is a function of CronJob's behaviour alone: either
+// the ticks arrive and the output below is produced, or nothing dispatches and
+// the harness kills the run -- which it classifies as a CRASH/timeout,
+// distinctly from a parity mismatch. Deliberately no fallback bound: any bound
+// that prints, throws or exits differently on expiry reintroduces exactly this
+// defect at a different threshold. The fixture is not permitted to decide it
+// has waited long enough; two ticks of `* * * * * *` take ~2s.
import { CronJob } from "cron";
@@ -16,8 +29,12 @@ async function main() {
});
console.log("constructed, ticks now:", ticks);
- // A never-started job must not fire (would print below and break the diff).
+ // A never-started job must not fire (the log below would break the diff, and
+ // the counter is asserted after the barrier, i.e. after >= 2 cron seconds
+ // have demonstrably elapsed).
+ let neverTicks = 0;
const never = new CronJob("* * * * * *", () => {
+ neverTicks++;
console.log("SHOULD-NOT-RUN");
});
@@ -33,8 +50,7 @@ async function main() {
);
job.start();
- const tickDeadline = Date.now() + 10_000;
- while ((ticks < 2 || autoTicks < 2) && Date.now() < tickDeadline) {
+ while (ticks < 2 || autoTicks < 2) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
job.stop();
@@ -42,7 +58,7 @@ async function main() {
console.log("manual ticked at least twice:", ticks >= 2);
console.log("auto ticked at least twice:", autoTicks >= 2);
- console.log("never-started stayed quiet:", true);
+ console.log("never-started stayed quiet:", neverTicks === 0);
console.log("done");
}