Skip to content

fix(gc): root rest-args and same-module call arguments (#7270, by @jdalton) - #7271

Merged
proggeramlug merged 4 commits into
mainfrom
restack/7270-rest-same-module-rooting
Aug 2, 2026
Merged

fix(gc): root rest-args and same-module call arguments (#7270, by @jdalton)#7271
proggeramlug merged 4 commits into
mainfrom
restack/7270-rest-same-module-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Authored by @jdalton (#7270), restacked onto main with authorship preserved, plus three small merge-time fixes. #7270 is the PR of record for review discussion; this exists to land it.

The work (jdalton's)

The follow-ups #7240/#7252 named and would not ship unmeasured:

  1. extern_func.rs's has_rest arm — two unprotected registers where the non-rest arm had one. The accumulator is the serious one: current was a raw *mut ArrayHeader in a bare SSA register, holding the only reference to every argument pushed so far while the next argument's expression — arbitrary user code — was lowered. A minor landing in that window was free to sweep it, not merely move it. temp_root::rooted_array_begin's doc has named this exact shape as "the shape behind every variadic / spread / rest argument list" since gc: console.log argument temporaries are not precise roots — a precise-roots-only collection drops string-literal args (minimal repro, no evacuation needed) #6951; this path never adopted it.
  2. func_ref.rs's same-module arms — all four. fix(gc): root every argument of a cross-module direct call — registry --help 28/30 → 30/30 #7240's regression test needed a two-file fixture precisely because a same-file callee never reaches extern_func.rs; it resolves through Expr::FuncRef(fid), so the identical defect sat one else away.
  3. The checker change that makes this class visible to gc_root_dominance_check.py in the first place — modelling a string-literal handle load as a heap-value source.

Merge-time fixes I added

  1. Registered both new witnesses in test-parity/gc_repsel_corpus.txt. They were dark. This is the fourth instance of add-a-witness-forget-the-line (fix(codegen): make every GC root store dominate the collection points after it #7192, fix(gc): bind the catch parameter and the closure this/new.target slots, and snapshot the Object.assign string source #7216, fix(gc): root every argument of a cross-module direct call (#7240, by @jdalton) #7252 are documented in that file directly above). fix(gc): root every argument of a cross-module direct call (#7240, by @jdalton) #7252's was caught only because ci(gc): give the moving-GC matrix a main-line run, and gate the wiring (#7194) #7253 gave the moving-GC matrix a main-line trigger hours earlier — before that it would have sat dark indefinitely. Four occurrences is a process problem, not four mistakes; worth a test_gap_gc_* check at PR time rather than post-merge.
  2. cargo fmt violation in lower_call/mod.rs:308. This would have landed red on main and pushed the already-failing required lint job further from green.
  3. Renamed the changelog fragment 7241-…7270-… to match the PR-keyed convention.

Not verified

CI is the first execution of both new witnesses. I did not build locally — three agents are working this machine. If they go red, that is the tests doing their job on first contact, and it would be the first independent exercise of this rooting fix. A dark test is strictly worse than a red one.

Closes #7270.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed crashes and incorrect values in same-module and cross-module calls involving rest parameters, arguments, and garbage collection.
    • Improved handling of string and module values during moving garbage collection.
  • Tests

    • Added regression coverage for rest-argument calls under allocation and moving-GC stress.
    • Expanded validation for stale-value detection and garbage-collection safety.

jdalton and others added 4 commits August 2, 2026 20:50
#7240 fixed `lower_call/extern_func.rs`'s cross-module NON-rest arm and named
two siblings it would not ship unmeasured. These are those two.

`extern_func.rs`'s `has_rest` arm had TWO unprotected registers where the
non-rest arm had one. The fixed parameters, as before -- except their window
does not close when the last argument is lowered, because the rest array is
materialized afterwards and materializing it runs `js_array_alloc` plus one
`js_array_push_f64` per trailing argument. And the ACCUMULATOR, which has no
analogue in the non-rest arm: `current` is a raw `*mut ArrayHeader` in a bare
SSA register, threaded through the push loop, holding the only reference to
every argument pushed so far while the next argument's expression -- arbitrary
user code -- is lowered. Nothing rooted it, so a minor landing in that window
was free to SWEEP the array, not merely move it.

`func_ref.rs`'s same-module arms, all four, had the identical defect. #7240's
regression test needed a two-file fixture precisely because a same-file callee
does not reach `extern_func.rs` at all: it resolves through `Expr::FuncRef(fid)`
into `func_ref.rs`, so the bug sat one `else` away, unreached by that PR's test.
It was not folded into #7240 because `func_ref.rs` threads its lowered arguments
through four specialized-ABI dispatch paths, each a fast/fallback diamond with a
phi at the merge; the temp-root release has to sit in the merge block that
post-dominates all five call sites.

The release is emitted AFTER `implicit_this_restore`, and that order is
load-bearing. `implicit_this_save` (#7211) runs below the argument lowering, so
its slot sits ABOVE this group, and `js_gc_temp_root_truncate` drops `base` and
everything above it. Releasing first drops the saved receiver, and
`js_gc_temp_root_get` answers an out-of-range read with `0` -- so the restore
would rebind the enclosing method's `this` to the NUMBER 0. That is a
miscompile, not a rooting bug, and it fires whenever a same-module callee reads
dynamic `this` and at least one argument takes a real slot.

All five arms now share one `lower_call/mod.rs` helper. Each argument is still
gated by `temp_root::operand_protection`, so a list of scalars emits the IR it
emitted before.

Measured per gap test, compiled AND run with `PERRY_GC_MOVING_LOOP_POLLS=1`:

  arm                              parent (6aeef5b)   this commit
  polls only                       bad 0 10/10          bad 0 10/10
  polls + PERRY_GC_ZEAL=1          0/10, SIGSEGV        bad 0 10/10
  polls + zeal + PERRY_GEN_GC=0    bad 0 10/10          bad 0 10/10

The first row is why both test files carry a `parity-env:` line: without it the
harness runs them in the default configuration, the broken compiler prints
`bad 0`, and the files gate nothing. Polls are off by default since #7161, so
the IR has no back-edge safepoint to collect on, and without zeal the only
collections are allocation-triggered, which take
`ManualGcScanGuard::force_full_scan` and make the copying minor ineligible --
nothing moves, so a stale register still names a live object.
`run_parity_tests.sh` applies `parity-env` to the perry compile AND the perry
run, which is what `PERRY_GC_MOVING_LOOP_POLLS` needs, since it is read at both.
The `PERRY_GEN_GC=0` row is the control that proves the tests track collector
mode rather than being flaky.

Statically, over the 116-source corpus emitted by the parent compiler and read
with the parent checker (so this is the codegen delta alone):
`--stale-registers --moving-only` 110 -> 62, and `--moving-only --fatal-sinks`
32 -> 0. Those 32 were all `source=alloc sink=js_array_push_f64` -- the
unrooted rest accumulator.

Refs #7154.
…source

`--stale-registers` classified a heap-value SOURCE as an `ALLOC_RE` call or a
shadow-slot load. A `load double, ptr @...str.N.handle` is neither, so the
register it defines was never tracked and no stale use could be attributed to
it -- which is the blind spot #7240 shipped its fix through, as that PR's own
writeup says.

The pattern already existed and was defined twice, in effect: `--unrooted-
allocas` had `REWRITTEN_LOAD_RE` and used it, while `--stale-registers` had only
`GLOBAL_ROOT_RE` and knew about `@perry_global_*` alone. The two modes disagreed
about what a collector-rewritten load is, and the narrower one was wrong. There
is now one definition and both modes read it. Unlike #7226's
`js_implicit_this_set` and #7227's `js_regexp_new`, this could not be closed by
adding a name to `ALLOC_RE`: the source is a `load`, not a `call`.

Strictly additive by construction -- `GLOBAL_ROOT_RE` is consulted first, so no
previously reported source changes kind.

Measured over the 116-source / 136-module corpus, emitted twice, once by the
parent compiler and once by the commit below, so the checker delta and the
codegen delta can be read separately:

  corpus from     mode                            parent    this
  parent codegen  --stale-registers               2914      4805  (+1891 strh)
  parent codegen  --moving-only                    110       158  (+48 strh)
  parent codegen  --moving-only --fatal-sinks       32        32
  this codegen    --stale-registers               2858      4693  (+1835 strh)
  this codegen    --moving-only                     62        62  (+0)
  this codegen    --moving-only --fatal-sinks        0         0

Read the two middle rows together, because that is the whole result. On the
parent's IR the widening exposes 48 stale uses that reach a moving minor, and
ALL 48 are in the two gap tests added in the commit below -- every one a
`load double, ptr @...str.N.handle` feeding `joinRest` or `joinSameRest` below
the rest-array construction, which is precisely the defect that commit fixes.
There are none anywhere else in the corpus. On the fixed IR the same widening
adds ZERO `--moving-only` uses. The modelling is therefore not too broad: it
found one population, that population was real, and it is now empty.

The CI gate is untouched -- `gc-root-dominance.yml` runs the bind-anchored mode,
not `--stale-registers`, and exits 0 with 0 violations and 40/40 seeded
violations caught on both corpora with both checkers.

`--self-test` asserts the new source in both directions and under
`--moving-only`, so the widening cannot silently stop working.

Recorded rather than hidden: the shared `REWRITTEN_LOAD_RE` also names
`@perry_class_keys_*`, which `--unrooted-allocas` has always used. It
contributes 0 hits in `--stale-registers` over this corpus, so that arm is
currently carried by the shared definition rather than exercised by it.

Refs #7154.
@proggeramlug
proggeramlug merged commit 57aef24 into main Aug 2, 2026
26 of 39 checks passed
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 14633309-1b5b-4d74-8211-9480b6a0c744

📥 Commits

Reviewing files that changed from the base of the PR and between a250774 and 711c096.

📒 Files selected for processing (10)
  • changelog.d/7270-rest-and-same-module-call-argument-rooting.md
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/extern_func.rs
  • crates/perry-codegen/src/lower_call/func_ref.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • scripts/gc_root_dominance_check.py
  • test-files/fixtures/gc_call_arg_rooting_pkg/rest_callee.ts
  • test-files/test_gap_gc_rest_argument_rooting.ts
  • test-files/test_gap_gc_same_module_call_argument_rooting.ts
  • test-parity/gc_repsel_corpus.txt

📝 Walkthrough

Walkthrough

The change roots call arguments across rest-array construction and same-module call paths. It delays root release until after implicit_this_restore. It adds moving-GC regression tests and extends stale-register analysis for rewritten string-handle loads.

Changes

GC-safe call argument lowering

Layer / File(s) Summary
Rooted call argument lowering
crates/perry-codegen/src/expr/temp_root.rs, crates/perry-codegen/src/lower_call/...
Shared helpers root operands and rest-array accumulators across allocations. Direct and cross-module calls release argument roots after implicit_this_restore.
Moving-GC regression coverage
test-files/fixtures/gc_call_arg_rooting_pkg/*, test-files/test_gap_gc_*, test-parity/gc_repsel_corpus.txt
New tests exercise cross-module rest calls and same-module plain/rest calls with literal, local, and empty-rest arguments under moving GC.
Rewritten load analysis
scripts/gc_root_dominance_check.py, changelog.d/7270-rest-and-same-module-call-argument-rooting.md
The checker shares rewritten-load detection and classifies string-handle loads as heap-value sources. Self-tests cover stale-register reporting and reload-after-collection behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant CallLowering
  participant RootedOperands
  participant RestCallHelper
  participant RestArrays
  CallLowering->>RootedOperands: root lowered arguments
  CallLowering->>RestCallHelper: lower rest call arguments
  RestCallHelper->>RestArrays: build rooted rest arrays
  RestCallHelper-->>CallLowering: return arguments and guard
  CallLowering->>CallLowering: restore implicit_this
  CallLowering->>RootedOperands: release argument roots
Loading

Possibly related issues

  • PerryTS/perry issue 6988: The change extends RootedOperands usage for GC-safe call arguments.
  • PerryTS/perry issue 7210: The change fixes the staging-array rooting hazard in cross-module rest calls.
  • PerryTS/perry issue 7248: The change shares GC-rooting analysis infrastructure, but targets call arguments rather than binary-operator operands.

Possibly related PRs

  • PerryTS/perry#7240: Extends earlier cross-module call-argument rooting to rest-parameter calls.
  • PerryTS/perry#7252: Applies shared rooted lowering to additional same-module and rest-call paths.
  • PerryTS/perry#7263: Registers related GC call-argument rooting tests in the representation-selection corpus.

Suggested reviewers: jdalton

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch restack/7270-rest-same-module-rooting

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

❤️ Share

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

@proggeramlug
proggeramlug deleted the restack/7270-rest-same-module-rooting branch August 2, 2026 18:52
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 3, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
proggeramlug pushed a commit that referenced this pull request Aug 3, 2026
Four PRs in a row shipped a test file that ran nowhere. #7192 and #7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; #7252 added a
third; #7270/#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs #7192, #7216, #7252, #7270, #7271.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants