From 4641dd89363a8c1ec438d45483c9feac9d1855e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:24:08 +0200 Subject: [PATCH 01/10] fix(tooling): close two blind spots in unrooted_local_shape.py #10713: `--no-raise-vs ` compared the merge base's recorded baseline with the checked-out one and never scanned the tree, so a branch that added findings without touching the baseline compared 561 against 561 and printed "no ceiling raised" while `--check` failed on the same worktree with `REGRESSION: 563 findings exceeds baseline 561`. It now scans the worktree and compares the measured total and per-file counts against the base's recorded ceilings, and prints the resolved base SHA with both totals. #10713, second hole: the dispatch was `if args.no_raise_vs:`, so the empty string an unset $BASE_SHA expands to was falsy, the mode was never entered, and the script fell through to the plain report and exited 0. Now `is not None`, with `resolve_ref` rejecting an empty ref the way raw_handle_debt.py's `git_show` rejects an unfetched one. #10715: `LET_BIND` was matched per line, so a binding rustfmt broke after the `=` -- a function of indentation depth and identifier length, not of anything about the code -- was never tracked. A `let` is now folded back into one statement first. Statements containing a brace stay line-oriented on purpose, so a closure body's own bindings do not go dark. The measured surface moves 558 -> 581 across 85 files (+34 newly visible, -11 false positives in ioredis.rs where a wrapped SHADOWING `let` failed to reset the identity). The baseline is deliberately NOT re-pinned: it still records 561, so both forms are red pending an audited schema migration. Each fix plants the defect it fixes in `--self-test`, verified by reverting each one in isolation. The old self-test passed on the day the live check was fooled, which was the point. Refs #10713, #10715. --- .../10713-unrooted-local-shape-blind-spots.md | 77 ++++ scripts/unrooted_local_shape.py | 352 +++++++++++++++++- 2 files changed, 410 insertions(+), 19 deletions(-) create mode 100644 changelog.d/10713-unrooted-local-shape-blind-spots.md diff --git a/changelog.d/10713-unrooted-local-shape-blind-spots.md b/changelog.d/10713-unrooted-local-shape-blind-spots.md new file mode 100644 index 0000000000..a0b39ce49d --- /dev/null +++ b/changelog.d/10713-unrooted-local-shape-blind-spots.md @@ -0,0 +1,77 @@ +**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 deliberately **not** re-pinned in this change — it still records 561, +so `--check` and `--no-raise-vs` are both red until the count is re-audited and +migrated. 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. + `perry-ext-fastify/src/context.rs:750` is representative: `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. +- **−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. + +Refs #10713, #10715. diff --git a/scripts/unrooted_local_shape.py b/scripts/unrooted_local_shape.py index 7434310add..bbb0f124d1 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. @@ -134,8 +148,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 +279,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 +326,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,6 +417,31 @@ 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); +} ''' @@ -355,8 +472,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 +532,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 +552,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) == (1, BASELINE_SCHEMA) 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 = "not measured (schema migration)" + if not migration: + results = collect() + total = sum(len(v) for v in results.values()) + measured = str(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 {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 +609,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 +629,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) @@ -458,15 +659,125 @@ def self_test() -> int: print("SELF-TEST FAIL: audited schema-1 migration was rejected", 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 +789,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() From 1ac34052a697cf5d60b59d3623b09e73e8784db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:29:41 +0200 Subject: [PATCH 02/10] tooling: re-pin unrooted-local baseline at 581 (schema 2 -> 3) The wrapped-`let` fold changes what the detector can count, so the recorded 561 and the measured 581 are two different yardsticks. That is what the script's audited-migration exemption is for, and it is the same situation as the 1 -> 2 migration. The ratchet is unchanged: it still fails on finding 582, verified by planting one. The exemption becomes an explicit AUDITED_MIGRATIONS list naming each migration and its reason, instead of a hard-coded (1, BASELINE_SCHEMA) pair. Every unlisted schema change is still rejected, and --self-test now asserts 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. 581 = 558 + 34 newly visible - 11 false positives. Two of the 34 were inspected and are genuine unrooted-across-allocation shapes; the other 32 are unaudited exposure surface, not known bugs. --- .../10713-unrooted-local-shape-blind-spots.md | 30 ++++++++-- scripts/unrooted_local_shape.py | 58 ++++++++++++++----- scripts/unrooted_local_shape_baseline.json | 31 +++++----- 3 files changed, 88 insertions(+), 31 deletions(-) diff --git a/changelog.d/10713-unrooted-local-shape-blind-spots.md b/changelog.d/10713-unrooted-local-shape-blind-spots.md index a0b39ce49d..2b83118bd8 100644 --- a/changelog.d/10713-unrooted-local-shape-blind-spots.md +++ b/changelog.d/10713-unrooted-local-shape-blind-spots.md @@ -43,15 +43,31 @@ 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 deliberately **not** re-pinned in this change — it still records 561, -so `--check` and `--no-raise-vs` are both red until the count is re-audited and -migrated. The number moved in both directions: +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. - `perry-ext-fastify/src/context.rs:750` is representative: `let obj: *mut ObjectHeader =` + **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 @@ -74,4 +90,8 @@ it, verified by reverting each one in isolation: 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. -Refs #10713, #10715. +**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/scripts/unrooted_local_shape.py b/scripts/unrooted_local_shape.py index bbb0f124d1..2361ded6a6 100755 --- a/scripts/unrooted_local_shape.py +++ b/scripts/unrooted_local_shape.py @@ -63,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 = ( @@ -448,14 +463,15 @@ def collect(root: Path = ROOT): 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"] @@ -560,23 +576,23 @@ def no_raise_vs(ref: str) -> int: 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) == (1, BASELINE_SCHEMA) + 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 = "not measured (schema migration)" + measured = "worktree not scanned (different detectors either side)" if not migration: results = collect() total = sum(len(v) for v in results.values()) - measured = str(total) + 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 {measured}" + f"{measured}" ) if bad: print(f"::error::unrooted-local debt rose {where}: {len(bad)} violation(s)") @@ -646,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)): @@ -655,8 +674,21 @@ 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` 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 } From d92bdae6e32c18a354f21c87d0bfd838296eb75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:31:00 +0200 Subject: [PATCH 03/10] changelog: key the unrooted-local fragment on PR #10719 --- ...e-blind-spots.md => 10719-unrooted-local-shape-blind-spots.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10713-unrooted-local-shape-blind-spots.md => 10719-unrooted-local-shape-blind-spots.md} (100%) diff --git a/changelog.d/10713-unrooted-local-shape-blind-spots.md b/changelog.d/10719-unrooted-local-shape-blind-spots.md similarity index 100% rename from changelog.d/10713-unrooted-local-shape-blind-spots.md rename to changelog.d/10719-unrooted-local-shape-blind-spots.md From 69f2cc626a1877a9b58269bee3a1ae789b2084e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:31:01 +0200 Subject: [PATCH 04/10] tooling: let the raw-handle ledger declare a relocation (#10583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `raw_handle_debt.py --no-raise-vs` compares ceilings strictly per path, so a pure file move — which the 2000-line cap forces regularly — reads as new debt: the bare run demands the emptied source's line be deleted, and the merge-base run then rejects the destination as "was not listed at the merge base", though the total never moved and the bodies are byte-identical. A ledger entry may now carry `# moved-from: `. The destination is credited with what the source actually surrendered between the base and head ledgers, and nothing else: the total check is untouched, the credit is bounded by a real reduction in the same diff, two destinations sharing one source drain one pool, and the annotation goes inert once the move lands. A malformed entry comment is now a parse failure rather than an ignored comment, and `--update` carries surviving annotations through (writer split out as `render_ledger` so the round trip is assertable). `--self-test` +12 cases: undeclared move still rejected, declared move and a 1+1 three-way split pass, laundering / over-draw / double-spend / stale annotation / self-reference each rejected by their own diagnostic. No ceiling changed. --- changelog.d/10583-raw-handle-relocation.md | 54 ++++ scripts/raw_handle_debt.py | 293 +++++++++++++++++++-- scripts/raw_handle_debt_files.txt | 15 ++ 3 files changed, 335 insertions(+), 27 deletions(-) create mode 100644 changelog.d/10583-raw-handle-relocation.md diff --git a/changelog.d/10583-raw-handle-relocation.md b/changelog.d/10583-raw-handle-relocation.md new file mode 100644 index 0000000000..04d8e569c6 --- /dev/null +++ b/changelog.d/10583-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/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 From 9c96889c95e637162da191acea90a210f775a7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:36:55 +0200 Subject: [PATCH 05/10] changelog: key the fragment on PR #10721 --- ...83-raw-handle-relocation.md => 10721-raw-handle-relocation.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10583-raw-handle-relocation.md => 10721-raw-handle-relocation.md} (100%) diff --git a/changelog.d/10583-raw-handle-relocation.md b/changelog.d/10721-raw-handle-relocation.md similarity index 100% rename from changelog.d/10583-raw-handle-relocation.md rename to changelog.d/10721-raw-handle-relocation.md From 5f8e69d234a14beef29374c02ffbf6838bcdbfe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:34:46 +0200 Subject: [PATCH 06/10] test: make test_gap_cron_cronjob wait on a barrier, not a deadline (#10581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture raced a `* * * * * *` CronJob against a fixed `Date.now() + 10_000`. The deadline is a timeout, not a barrier: when it expired first the loop exited and the fixture printed `false` on two lines expected to read `true`, dropping `tick 1`/`tick 2` as well — a four-line divergence the harness reports as a `parity_fail`, i.e. as a miscompile. It is inside pr-gate's gap shards and absent from gap_snapshot.json, and it already held #10530 out of a train. The wait now has no deadline, so the printed text is a function of CronJob's behaviour alone: the ticks arrive, or PERRY_RUN_TIMEOUT kills the run and the harness classifies that as a crash/timeout rather than a parity mismatch. No fallback bound — any bound that prints or throws on expiry is the same defect with a longer fuse, and the old 10s was unreachable anyway because PERRY_RUN_TIMEOUT is also 10s. The never-started job now prints `neverTicks === 0` from a real counter instead of a hardcoded `true`, checked after the barrier. Output bytes unchanged. Verified with an identical 11s event-loop stall injected into both the old and new fixtures: under Node 26.5.1 and Perry v0.5.1598 the old one diverges and the new one is byte-identical to the unstalled oracle. Harness run exits 0 with journal status `pass`; 8 Node runs gave one distinct output in 1.86-2.04s. --- changelog.d/10581-cron-cronjob-barrier.md | 47 +++++++++++++++++++++++ test-files/test_gap_cron_cronjob.ts | 28 +++++++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 changelog.d/10581-cron-cronjob-barrier.md diff --git a/changelog.d/10581-cron-cronjob-barrier.md b/changelog.d/10581-cron-cronjob-barrier.md new file mode 100644 index 0000000000..0904ddd72a --- /dev/null +++ b/changelog.d/10581-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/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"); } From 1c04ac0244a3e1cd2b4e2f17f2c4ea8a2a487c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 12:36:59 +0200 Subject: [PATCH 07/10] changelog: key the fragment on PR #10722 --- ...0581-cron-cronjob-barrier.md => 10722-cron-cronjob-barrier.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10581-cron-cronjob-barrier.md => 10722-cron-cronjob-barrier.md} (100%) diff --git a/changelog.d/10581-cron-cronjob-barrier.md b/changelog.d/10722-cron-cronjob-barrier.md similarity index 100% rename from changelog.d/10581-cron-cronjob-barrier.md rename to changelog.d/10722-cron-cronjob-barrier.md From 5d37eacc802a7ef38ebd7fca2c5f5915664658c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 13:12:34 +0200 Subject: [PATCH 08/10] test(gap): lock commander's outputError/writeErr indirection (#10711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10711 reports that a function read from an object property silently drops its own call to a second function passed to it as a parameter — commander's `_displayError` shape, where `outputError(str, write)` invokes the `writeErr` it was handed: this._outputConfiguration.outputError( message, this._outputConfiguration.writeErr); It does not reproduce. The reporter's own isolated repro prints the expected text on all three trees that matter — current main (v0.5.1598), the main commit their branch forks from (8df83f8c12), and their actual tree (PR #10712 on top of #10699, head 463c4fa5) — and real commander 14.0.3 compiled from source via `perry.compilePackages` matches Node 26.5.1 byte for byte across the whole output surface the issue names: `--help`, `--version`, missing required argument, unknown option, unknown command and `program.error()`, under both the default output configuration and a `configureOutput()` override. 32 further shapes of the same indirection agree with Node too. So this adds the regression lock rather than a fix. The shape is worth gating: #10689 — an inherited property read folding to the constant `undefined` on a scalar-replaced object — landed one commit before this issue was filed and is the same family, silent in the same way. The fixture covers the reported form verbatim plus the method-shorthand, class-field, `configureOutput`-override, spread, nested-receiver, cross-object-writer and in-loop spellings. Two of the cases exist to keep the fixture from passing vacuously. One traces `before` / `typeof write` / `after` around the inner call, so "the outer body ran and the inner call evaporated" cannot read as a pass. The other omits the writer entirely and asserts a TypeError: that a missing callee is LOUD is the property that keeps this bug class from ever presenting as a plausible wrong answer. Every writer sinks to stdout because the parity harness merges stdout and stderr into one compared stream; the stream is incidental to the indirection. Refs #10711 --- ...st_gap_10711_property_fn_param_callback.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 test-files/test_gap_10711_property_fn_param_callback.ts 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"); +} From 8598cbd85cd8605f6c4e8be8a08d947d566bbcd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 13:14:17 +0200 Subject: [PATCH 09/10] changelog: fragment for #10728 (property-fn param callback lock) --- .../10728-property-fn-param-callback-lock.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 changelog.d/10728-property-fn-param-callback-lock.md 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. From a7f08c2f618e60a7ac4a42ca9c94a2a1bfbef018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 16:44:39 +0200 Subject: [PATCH 10/10] chore: release merge train 224 as v0.5.1603 --- CLAUDE.md | 2 +- Cargo.lock | 156 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 80 insertions(+), 80 deletions(-) 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"