Skip to content

fix(gc): root every argument of a cross-module direct call (#7240, by @jdalton) - #7252

Merged
proggeramlug merged 1 commit into
mainfrom
restack/7240-cross-module-call-arg-rooting
Aug 2, 2026
Merged

fix(gc): root every argument of a cross-module direct call (#7240, by @jdalton)#7252
proggeramlug merged 1 commit into
mainfrom
restack/7240-cross-module-call-arg-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Authored by @jdalton. This is #7240's own commit, restacked onto main with authorship preserved (git log shows author=jdalton). Opened as a separate branch only because #7240 could not be updated in place — see below. #7240 is the PR of record for review discussion; this exists to land it.

Why a new branch

#7240 stacked on #7227, which stacked #7226. Both landed as squash merges, which rewrote their commits — so #7240's branch still carried the pre-squash 28dc70d27 and 4e99c1bad, and GitHub reported CONFLICTING/DIRTY. The content of those two is already on main.

git rebase --onto origin/main 28dc70d27 replays only #7240's own commit and applies cleanly, no conflicts. The result is 1 commit / 5 files, exactly the diff #7240 describes:

changelog.d/7240-cross-module-call-argument-rooting.md
crates/perry-codegen/src/lower_call/extern_func.rs
crates/perry-codegen/src/lower_call/mod.rs
test-files/fixtures/gc_call_arg_rooting_pkg/callee.ts
test-files/test_gap_gc_call_argument_rooting.ts

Force-pushing the restack to the fork branch would have been the tidier route and was deliberately not taken.

What I verified

  • Rebase applies with zero conflicts; resulting diff is the 5 files above and nothing else.
  • cargo fmt --all -- --check clean.
  • cargo check -p perry-codegen clean.
  • Commit author is preserved as @jdalton.

What I did NOT verify

Closes #7240.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of cross-module function calls when argument evaluation triggers garbage collection.
    • Preserved string literals and local values during calls that allocate or may move objects in memory.
  • Tests

    • Added regression coverage for cross-module calls with mixed literal and local arguments.
    • Added repeated verification to detect incorrect reconstructed results.

An argument list is evaluated left to right and each finished value sits in
a bare SSA register while the later ones are lowered.
`lower_call/extern_func.rs`'s generic `perry_fn_<src>__<name>` path lowered
the whole list in a plain `for a in args` loop with no protection at all, so
`f(A, B, {…}, Schema.array(), body => …)` leaves A and B naming
pre-collection addresses the moment an evacuating minor lands in argument 3,
4 or 5 — and it does: argument 3 allocates an object, argument 4 runs user
code with its own back-edge polls, argument 5 allocates a closure.

This is #7227's residual. In the registry it is `src/lib/api/alerts.ts`'s
module init calling `defineApiCall(url, method, …)` across the module
boundary; the fault surfaces one frame down inside `js_regexp_test`, because
the stale `url` is what `/\[[a-zA-Z]+\]/.test(url)` hands it. Measured at the
fault rather than read off the disassembly: the string literal's
`__perry_init_strings_*` handle global held the post-move address evacuation
wrote back, while the register held the retired from-space one.

A string-literal argument therefore takes `OperandProtection::Reload` — its
handle global is a registered root, so the string is never swept, and
re-emitting the load below the collection point costs no runtime call. Other
arguments take a real temp root. Both come from `lower_exprs_rooted`, which
already does this for the `new C(…)` list, and each argument is gated on
`any_later_ref_may_trigger_gc` so lists with nothing allocating after them
emit the IR they emitted before.

The static checker missed it because it classifies a heap-value source as an
`ALLOC_RE` call or a shadow-slot load; a load of a string-literal handle
global is neither. Over the new gap test's IR it reports 24 `--moving-only`
stale uses at the call and names none of them `%r9`/`%r10`, the two literals
that actually faulted.

Refs #7154, #7226, #7227, #7161
@proggeramlug
proggeramlug merged commit 061b241 into main Aug 2, 2026
24 of 39 checks passed
@proggeramlug
proggeramlug deleted the restack/7240-cross-module-call-arg-rooting branch August 2, 2026 11:04
@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: a58744a9-5840-4661-908e-82adf8b178e9

📥 Commits

Reviewing files that changed from the base of the PR and between c7893c4 and 1dfa932.

📒 Files selected for processing (5)
  • changelog.d/7240-cross-module-call-argument-rooting.md
  • crates/perry-codegen/src/lower_call/extern_func.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • test-files/fixtures/gc_call_arg_rooting_pkg/callee.ts
  • test-files/test_gap_gc_call_argument_rooting.ts

📝 Walkthrough

Walkthrough

Changes

Cross-module argument rooting

Layer / File(s) Summary
Rooted argument lowering
crates/perry-codegen/src/lower_call/mod.rs, changelog.d/...
Adds GC-aware argument lowering and rooted call emission. String literals reload from rooted handles. Other values use temporary roots when later arguments may allocate.
Cross-module call integration
crates/perry-codegen/src/lower_call/extern_func.rs
Non-rest cross-module calls use the rooted lowering and emission paths. Omitted parameters still receive undefined padding.
GC regression coverage
test-files/fixtures/gc_call_arg_rooting_pkg/callee.ts, test-files/test_gap_gc_call_argument_rooting.ts, changelog.d/...
Adds a five-argument cross-module fixture and repeated tests for literal and newly allocated string arguments during allocation-triggering calls.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7240 — Directly covers the cross-module direct-call argument-rooting change.
  • PerryTS/perry#7214 — Addresses GC rooting for call arguments through a different call path.
  • PerryTS/perry#7116 — Uses reload and root protection for string operands across allocation points.

Suggested reviewers: thehypnoo

✨ 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/7240-cross-module-call-arg-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 added a commit that referenced this pull request Aug 2, 2026
Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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