ci(test): fail on dark tests — a test file no registry runs - #7278
Conversation
📝 WalkthroughWalkthroughThis change adds a checker for four registry-driven test suites, runs it in required lint, and documents registration rules, exclusions, commands, and diagnostics. ChangesDark Test Registration
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Lint
participant RegistrationChecker
participant RepositoryTree
participant TestRegistries
Lint->>RegistrationChecker: run self-test and validation
RegistrationChecker->>RepositoryTree: discover candidate files
RegistrationChecker->>TestRegistries: read registrations and exclusions
TestRegistries-->>RegistrationChecker: return registry paths
RegistrationChecker-->>Lint: return diagnostics and status
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
f5e7365 to
d2f712f
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CONTRIBUTING.md`:
- Line 80: Update the test-registration paragraph in CONTRIBUTING.md to include
benchmarks/compiler_output/workloads.toml as the fourth explicit registry
example, preserving the existing examples and guidance so the stated count and
listed mechanisms match the documentation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1017c0cb-55e0-441f-9c6d-6b88bc741642
📒 Files selected for processing (9)
.github/workflows/test.ymlCONTRIBUTING.mdbenchmarks/compiler_output/workloads.tomlchangelog.d/7278-dark-test-registration-gate.mddocs/src/SUMMARY.mddocs/src/testing/test-registration.mdscripts/check_test_registration.pytest-features/feature_matrix.tomltest-parity/gc_repsel_corpus.txt
d2f712f to
95f58e2
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
scripts/check_test_registration.py (5)
606-610: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOptional: keep the docstring layout in
--help.
argparsereflowsdescriptionby default, so the module docstring loses its structure. Passformatter_class=argparse.RawDescriptionHelpFormatterto preserve it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 606 - 610, Update the ArgumentParser construction in main to use argparse.RawDescriptionHelpFormatter via formatter_class, preserving the module docstring’s layout in --help while leaving the existing arguments unchanged.
536-544: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCase 5 replaces the real exclusions instead of adding to them.
evaluatereceives only the bogus entry, so the mechanism's real exclusions are dropped for this case. The assertion still passes because it only looks forSTALE EXCLUSION. Merging the bogus entry intom.exclusionskeeps the case closer to the real configuration.♻️ Proposed change
stale = evaluate( - Tree(root), m, exclusions={"no/such/file.ts": "deliberately bogus"} + Tree(root), + m, + exclusions={**m.exclusions, "no/such/file.ts": "deliberately bogus"}, )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 536 - 544, Update Case 5 in the test registration checks to merge the bogus “no/such/file.ts” entry into m.exclusions before passing exclusions to evaluate, preserving the mechanism’s real exclusions while still exercising stale-exclusion detection.
161-169: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winConsider scoping the TOML key match to the entry tables.
The regex matches
path/sourceat any position in the file, including unrelated tables. If a future table adds apathkey, a dark probe can be masked as registered. The docstring justifies the line-oriented reader, so this is optional. One cheap hardening is to require the key inside the expected table by tracking the current[table]header while scanning lines.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 161 - 169, Update _read_toml_paths to track the current TOML table while scanning the manifest and only collect matching key values from the expected entry table, ignoring identical keys in unrelated tables. Preserve the existing set[str] return type and line-oriented parsing behavior.
429-436: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueReuse the registry read in the reverse check.
m.registered(tree)runs a second time here after Line 414. Store the first result and reuse it. The rust branch does not set it, so compute it once above the branch or keep a local variable.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 429 - 436, Store the result of m.registered(tree) from the earlier registry-check flow in a local variable and reuse it in the reverse-check block guarded by m.check_reverse and m.entry_to_path. Ensure the value is initialized or computed once for both branches, including the rust branch, while preserving the existing sorted iteration and missing-path validation.
199-212: 🎯 Functional Correctness | 🔵 Trivial | ⚖️ Poor tradeoffThe ancestor walk can accept an unrelated
moddeclaration.The loop searches for
mod <stem>in every ancestormod.rsor<dir>.rsup to the crate root. A declaration with the same stem in an unrelated ancestor module marks the file as registered. The docstring states that the wide walk is deliberate to avoid false positives, so this is a trade-off note, not a defect. If you want to tighten it later, verify that the matched declaration resolves to this file path, either through the inline module chain or through#[path].🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 199 - 212, The ancestor walk in the registration check intentionally allows matching mod declarations from unrelated ancestor modules; preserve this behavior and update the surrounding documentation or comment to explicitly describe it as a deliberate false-positive trade-off. Keep the existing inline-module and #[path] handling unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/src/testing/test-registration.md`:
- Around line 82-86: Update the sentence following the statement that the check
runs as a step in lint so it says no separate job or required context is added,
rather than claiming the step does not exist. Keep the surrounding explanation
about branch protection and the existing lint context unchanged.
In `@scripts/check_test_registration.py`:
- Around line 410-417: Update the `rust-test-modules` branch so `dark` excludes
entries present in `exclusions`, matching the non-Rust registration path. Ensure
`registered_keys` and the subsequent `n_registered`/`n_excluded` counts remain
consistent with the adjusted dark set, while preserving
`_rust_module_is_declared` behavior for non-excluded files.
- Around line 117-122: Define a MissingRegistry exception and update the
registry-reading flow used by evaluate to translate FileNotFoundError from read
into MissingRegistry containing the missing relative path. Catch MissingRegistry
around m.registered(tree), append the specified missing-registry problem using
m.registry, m.id, and the missing path, then continue with an empty
registered_keys set.
---
Nitpick comments:
In `@scripts/check_test_registration.py`:
- Around line 606-610: Update the ArgumentParser construction in main to use
argparse.RawDescriptionHelpFormatter via formatter_class, preserving the module
docstring’s layout in --help while leaving the existing arguments unchanged.
- Around line 536-544: Update Case 5 in the test registration checks to merge
the bogus “no/such/file.ts” entry into m.exclusions before passing exclusions to
evaluate, preserving the mechanism’s real exclusions while still exercising
stale-exclusion detection.
- Around line 161-169: Update _read_toml_paths to track the current TOML table
while scanning the manifest and only collect matching key values from the
expected entry table, ignoring identical keys in unrelated tables. Preserve the
existing set[str] return type and line-oriented parsing behavior.
- Around line 429-436: Store the result of m.registered(tree) from the earlier
registry-check flow in a local variable and reuse it in the reverse-check block
guarded by m.check_reverse and m.entry_to_path. Ensure the value is initialized
or computed once for both branches, including the rust branch, while preserving
the existing sorted iteration and missing-path validation.
- Around line 199-212: The ancestor walk in the registration check intentionally
allows matching mod declarations from unrelated ancestor modules; preserve this
behavior and update the surrounding documentation or comment to explicitly
describe it as a deliberate false-positive trade-off. Keep the existing
inline-module and #[path] handling unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 6b6f408a-5a51-47fd-a7dc-ac496e754d7b
📒 Files selected for processing (9)
.github/workflows/test.ymlCONTRIBUTING.mdbenchmarks/compiler_output/workloads.tomlchangelog.d/7278-dark-test-registration-gate.mddocs/src/SUMMARY.mddocs/src/testing/test-registration.mdscripts/check_test_registration.pytest-features/feature_matrix.tomltest-parity/gc_repsel_corpus.txt
🚧 Files skipped from review as they are similar to previous changes (7)
- test-features/feature_matrix.toml
- CONTRIBUTING.md
- benchmarks/compiler_output/workloads.toml
- test-parity/gc_repsel_corpus.txt
- changelog.d/7278-dark-test-registration-gate.md
- .github/workflows/test.yml
- docs/src/SUMMARY.md
95f58e2 to
abce569
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (3)
scripts/check_test_registration.py (2)
117-122: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winHandle a missing registry file with a clear failure.
read()callsPath.read_textdirectly: return (self.root / rel).read_text(encoding="utf-8", errors="ignore"). If a registry file is deleted or renamed,m.registered(tree)raises an unhandledFileNotFoundError, and the script exits with a traceback instead of naming the missing registry. The PR objectives list missing registry files as a validated condition, so this should be an explicit, named problem, not a crash.🛠️ Proposed fix
def read(self, rel: str) -> str: if rel in self.overrides: return self.overrides[rel] if rel in self.added: return "" - return (self.root / rel).read_text(encoding="utf-8", errors="ignore") + path = self.root / rel + if not path.is_file(): + raise MissingRegistry(rel) + return path.read_text(encoding="utf-8", errors="ignore")Define
MissingRegistryand translate it into a problem insideevaluate()around them.registered(tree)call.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 117 - 122, Define a MissingRegistry exception and update evaluate() to catch FileNotFoundError raised by m.registered(tree), translate it into a named validation problem that identifies the missing registry file, and preserve the existing handling for other errors.
410-417: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winExclusions are ignored for the
rust-test-modulesmechanism.In this branch,
darkis computed only from_rust_module_is_declared, andexclusionsis never subtracted: dark = [k for k, p in sorted(keys.items()) if not _rust_module_is_declared(tree, p)]. If a contributor adds a Rust exclusion, the file stays reported as dark, so the documented second way out ("exclude it, with a reason") does not work for this mechanism.🛠️ Proposed fix
if m.id == "rust-test-modules": - dark = [k for k, p in sorted(keys.items()) if not _rust_module_is_declared(tree, p)] - registered_keys: set[str] = set(keys) - set(dark) + undeclared = [ + k for k, p in sorted(keys.items()) if not _rust_module_is_declared(tree, p) + ] + registered_keys: set[str] = set(keys) - set(undeclared) + dark = [k for k in undeclared if k not in exclusions] else: registered_keys = m.registered(tree) dark = sorted(set(keys) - registered_keys - set(exclusions))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 410 - 417, Update the rust-test-modules branch in the registration logic to remove keys listed in exclusions when computing dark, while preserving the existing _rust_module_is_declared check. Ensure excluded Rust test modules are not reported as dark and continue contributing to the existing exclusion count.docs/src/testing/test-registration.md (1)
82-86: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winFix the contradictory sentence.
This bullet states the check runs where it blocks. It is a step in
lint, which is already a required context. The final sentence then states "The step does not exist here, so it cannot be forgotten," which reads as contradicting the first sentence. The intended meaning is that no separate branch-protection step is needed, not that thelintstep itself is absent. Reword so the two statements agree.📝 Proposed wording
- branch protection is hazard 2, and `gc-root-dominance` sat red and blocking - nothing for days because of it. The step does not exist here, so it cannot be - forgotten. + branch protection is hazard 2, and `gc-root-dominance` sat red and blocking + nothing for days because of it. This gate adds no new job, so there is no + branch-protection step left to forget.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/src/testing/test-registration.md` around lines 82 - 86, Reword the final sentence in the “It runs where it blocks” bullet so it clarifies that no separate branch-protection job or step is needed, while preserving that the check runs as a required step within `lint`.
🧹 Nitpick comments (1)
scripts/check_test_registration.py (1)
429-436: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueAvoid re-reading the registry for the reverse check.
registered_keysis already computed viam.registered(tree)at Line 414 for non-Rust mechanisms. This loop callsm.registered(tree)again: for entry in sorted(m.registered(tree)):, re-parsing the same registry file. Reuseregistered_keysinstead of recomputing it.if m.check_reverse and m.entry_to_path is not None: - for entry in sorted(m.registered(tree)): + for entry in sorted(registered_keys): rel = m.entry_to_path(entry)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 429 - 436, Update the reverse-check loop guarded by m.check_reverse and m.entry_to_path in the registration validation flow to iterate over the existing registered_keys value rather than calling m.registered(tree) again. Preserve the current sorting and path-existence validation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@docs/src/testing/test-registration.md`:
- Around line 82-86: Reword the final sentence in the “It runs where it blocks”
bullet so it clarifies that no separate branch-protection job or step is needed,
while preserving that the check runs as a required step within `lint`.
In `@scripts/check_test_registration.py`:
- Around line 117-122: Define a MissingRegistry exception and update evaluate()
to catch FileNotFoundError raised by m.registered(tree), translate it into a
named validation problem that identifies the missing registry file, and preserve
the existing handling for other errors.
- Around line 410-417: Update the rust-test-modules branch in the registration
logic to remove keys listed in exclusions when computing dark, while preserving
the existing _rust_module_is_declared check. Ensure excluded Rust test modules
are not reported as dark and continue contributing to the existing exclusion
count.
---
Nitpick comments:
In `@scripts/check_test_registration.py`:
- Around line 429-436: Update the reverse-check loop guarded by m.check_reverse
and m.entry_to_path in the registration validation flow to iterate over the
existing registered_keys value rather than calling m.registered(tree) again.
Preserve the current sorting and path-existence validation behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 83d30782-44ab-4a3a-a353-ce029a14b02a
📒 Files selected for processing (9)
.github/workflows/test.ymlCONTRIBUTING.mdbenchmarks/compiler_output/workloads.tomlchangelog.d/7278-dark-test-registration-gate.mddocs/src/SUMMARY.mddocs/src/testing/test-registration.mdscripts/check_test_registration.pytest-features/feature_matrix.tomltest-parity/gc_repsel_corpus.txt
🚧 Files skipped from review as they are similar to previous changes (7)
- test-features/feature_matrix.toml
- benchmarks/compiler_output/workloads.toml
- docs/src/SUMMARY.md
- CONTRIBUTING.md
- test-parity/gc_repsel_corpus.txt
- .github/workflows/test.yml
- changelog.d/7278-dark-test-registration-gate.md
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.
abce569 to
f5f2a5a
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (3)
docs/src/testing/test-registration.md (2)
49-49: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse the same runner label as the script.
The script sets
runner="scripts/gen_feature_matrix.py (feature-matrix.yml)"forfeature-matrix-probes. This table says(feature-matrix).--listis described as authoritative, so match its text.📝 Proposed wording
-| `test-features/feature_matrix.toml` | `test-features/probes/**/*.ts` | `scripts/gen_feature_matrix.py` (`feature-matrix`) | +| `test-features/feature_matrix.toml` | `test-features/probes/**/*.ts` | `scripts/gen_feature_matrix.py` (`feature-matrix.yml`) |🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/src/testing/test-registration.md` at line 49, Update the runner label in the test-registration table for feature-matrix probes to exactly match the authoritative value emitted by the script’s --list output: scripts/gen_feature_matrix.py (feature-matrix.yml), replacing the current feature-matrix text.
8-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAlign the runtime claim with the script docstring.
This page states "about a fifth of a second".
scripts/check_test_registration.pystates "~1 second" in its module docstring. Pick one figure so the two sources agree.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/src/testing/test-registration.md` around lines 8 - 10, Align the runtime estimate in docs/src/testing/test-registration.md with the “~1 second” figure documented in the check_test_registration.py module docstring, updating only the conflicting “about a fifth of a second” wording.scripts/check_test_registration.py (1)
431-440: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSuppress the DARK TEST flood when the registry file is missing.
If
m.registered(tree)raisesMissingRegistry,registered_keysbecomes empty. Every candidate then also appears as aDARK TESTline. Forgc-repsel-corpusthat is 45 or more extra lines on top of the one real problem. The exit status is still correct, so this is report quality only. Skip the dark scan when the registry is absent.♻️ Proposed change
else: + registry_missing = False try: registered_keys = m.registered(tree) except MissingRegistry as exc: res.problems.append( "MISSING REGISTRY %s: mechanism %s reads it, but %s does not " "exist. Restore the file, or point the mechanism elsewhere." % (m.registry, m.id, exc.args[0]) ) registered_keys = set() - dark = sorted(set(keys) - registered_keys - set(exclusions)) + registry_missing = True + dark = ( + [] + if registry_missing + else sorted(set(keys) - registered_keys - set(exclusions)) + )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/check_test_registration.py` around lines 431 - 440, Update the MissingRegistry handling in the registration-check flow around m.registered(tree) to record that the registry is unavailable, and skip the subsequent dark-test calculation/reporting for that mechanism. Preserve the existing missing-registry problem and exit-status behavior while preventing candidates from being emitted as DARK TEST entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@docs/src/testing/test-registration.md`:
- Line 49: Update the runner label in the test-registration table for
feature-matrix probes to exactly match the authoritative value emitted by the
script’s --list output: scripts/gen_feature_matrix.py (feature-matrix.yml),
replacing the current feature-matrix text.
- Around line 8-10: Align the runtime estimate in
docs/src/testing/test-registration.md with the “~1 second” figure documented in
the check_test_registration.py module docstring, updating only the conflicting
“about a fifth of a second” wording.
In `@scripts/check_test_registration.py`:
- Around line 431-440: Update the MissingRegistry handling in the
registration-check flow around m.registered(tree) to record that the registry is
unavailable, and skip the subsequent dark-test calculation/reporting for that
mechanism. Preserve the existing missing-registry problem and exit-status
behavior while preventing candidates from being emitted as DARK TEST entries.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 59958286-32d0-4449-bb69-d4097cea039a
📒 Files selected for processing (9)
.github/workflows/test.ymlCONTRIBUTING.mdbenchmarks/compiler_output/workloads.tomlchangelog.d/7278-dark-test-registration-gate.mddocs/src/SUMMARY.mddocs/src/testing/test-registration.mdscripts/check_test_registration.pytest-features/feature_matrix.tomltest-parity/gc_repsel_corpus.txt
🚧 Files skipped from review as they are similar to previous changes (7)
- docs/src/SUMMARY.md
- test-parity/gc_repsel_corpus.txt
- test-features/feature_matrix.toml
- .github/workflows/test.yml
- benchmarks/compiler_output/workloads.toml
- CONTRIBUTING.md
- changelog.d/7278-dark-test-registration-gate.md
Fourth time is a missing gate
#7192, #7216, #7252 and #7270/#7271 each shipped a test file that ran nowhere. All four added a
test_gap_gc_*witness totest-files/and no line totest-parity/gc_repsel_corpus.txt, so the file was compiled by nothing and executed by nothing. The last one was caught by hand at merge.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 CI run and reads the two together as "covered". Nothing says otherwise, because nothing ran. That is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject never does.
Two registration checks already existed and neither could catch the pull request that needed it:
scripts/gc_repsel_matrix.shauto-detects unregisteredtest_gap_repsel_*/test_gap_specabi_*gc-moving-witnesses.ymladds thetest_gap_gc_*prefixBoth 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. The logic was right; the placement made it unable to fire in time.
What this adds
scripts/check_test_registration.py— 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.2 s, so it runs on every PR.Every registration mechanism in the repo
I surveyed the tree for anything that enumerates test files rather than globbing them. Four qualify:
gc-repsel-corpustest-files/test_gap_{gc,repsel,specabi}_*.tstest-parity/gc_repsel_corpus.txtgc_repsel_matrix.sh(gc-stress,gc-moving-witnesses)feature-matrix-probestest-features/probes/**/*.tstest-features/feature_matrix.tomlgen_feature_matrix.py(feature-matrix)compiler-output-workloadsbenchmarks/compiler_output/fixtures/**/*.tsbenchmarks/compiler_output/workloads.tomlcompiler_output_regression.pyrust-test-modulescrates/*/**/tests/**/*.rsbelow a suite rootmoddeclaration in the parent modulecargo testThe last one is the Rust analogue of the same failure and is worth naming: cargo auto-discovers
crates/<c>/tests/<suite>.rs, but a file one level deeper — a suite's module directory, or a#[cfg(test)]submodule undersrc/— compiles only if amoddeclaration names it. Without one, rustc never parses the file. It is not dead code; it is not code. No warning fires.Everything else is glob-driven and cannot go dark (
run_parity_tests.sh,gc_root_dominance_corpus.sh,public_baseline.py, cargo's suite roots).--listnames those too, so "considered and safe" is distinguishable from "never looked at".Today's dark set, and what happened to each
Zero currently-dark files across all four mechanisms — #7263 and #7271 registered the last of the known ones, so this is green on
mainfrom the first run. Five candidates are excluded, each named with a reason (never a count):test-features/probes/type_only_imports/model.tsprobes/type_only_imports/basic.ts, which IS registeredtest-features/probes/modules/support/type-only-values.tsprobes/modules/type-only-imports.ts, which IS registeredtest-features/probes/dynamic_import/mod.tsimport("./mod.ts")under test inprobes/dynamic_import/basic.tsbenchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.tsraw_numeric_layoutsworkload spec inscripts/run_memory_stability_tests.sh'srun_target_collector_architecture_gates. Not dark, just driven elsewhere..../native_memory_fixture_project/node_modules/@perry-fixtures/native-memory-fixture/index.tsnode_modulesby the fixture that imports itI did not bulk-register anything.
One real finding the gate produced while I was writing it
The first draft's Rust mechanism reported
crates/perry/src/commands/compile/resolve/tests/declaration_sidecar_tests/compile_package.rsas dark. It is not — itsmod compile_package;lives inside an inlinemod declaration_sidecar_tests { … }block two levels up inresolve/tests.rs, so the declaration is nowhere near the immediate parent. The checker now walks up to the crate root, and the self-test pins that exact shape as a false-positive guard. A gate that cries wolf gets disabled, which is its own way of being unable to fail.How it is proven able to fail
Against all four hazards in CLAUDE.md:
continue-on-error, no|| true— thelintstep's exit status is the gate.lintalready scopescancel-in-progresstopull_request.checked N files against M registries.--self-test(32 cases, run inlintalongside the check) plants an unregistered file into each of the four mechanisms — over the real registries, through an in-memory overlay so the checkout is never mutated — asserts the gate names it, then removes it and asserts green. It also asserts that a collapsed candidate set fails, a stale exclusion fails, and a registry entry whose file is gone fails.Exclusions are justified, not counted. A numeric threshold cannot tell a new dark file from an old one: fix one, add one, tally unchanged. And a stale exclusion — one matching no file on disk — is itself a failure, so an excuse cannot outlive the file it excuses (the rule
gc_root_dominance_allowlist.jsonalready uses).Verified against live behaviour, on disk
Not just through the overlay. For each of the four mechanisms I created a real unregistered file in the working tree and ran the gate:
Then the full cycle, to prove the red was the plant and not ambient breakage:
50 candidates, 50 registered)ROTTED ENTRY … lists 'test_gap_gc_PLANTED_dark_witness' but test-files/test_gap_gc_PLANTED_dark_witness.ts does not existSame red-then-green cycle confirmed for a planted probe, a planted compiler-output fixture and a planted
.rsmodule. Working tree left clean.A repo admin does not have to do anything for this to block merges. Worth reading the reason rather than taking my word for it, because the opposite mistake is why we are here.
The gate is a step in the existing
lintjob, which is already in branch protection's required contexts (lint,cargo-test,api-docs-drift,security-audit). It is not a new workflow and not a new job, so there is no new context to promote.That placement is the whole point. CLAUDE.md's hazard 2 is "not in branch protection's required contexts", and its corollary — a new gate must be run once and then promoted — names the second step people forget.
gc-root-dominancesat red onmainfor days blocking nothing because that step was left undone. This gate is put where the step does not exist, so it cannot be skipped.The corollary's other half is also satisfied: a new gate that has never been green blocks every open PR the moment it becomes required. This one is green on the current
mainfor all four mechanisms today (output above), so it costs open PRs nothing.If a reviewer would rather see it as its own promotable context, say so and I will split it into a standalone workflow — but that reintroduces exactly the follow-up step this placement removes, so I would argue against it.
linthas been silently skipping seven gates since 2026-07-29I hit this while verifying the gate on CI, and it is worth its own attention because it is a sixth way a gate can be unable to fail.
lintis a sequence of unrelated gates, and a failing step takes every later step in the job toskipped.Public benchmark evidence freshnesshas failed onmainon every run from 2026-07-29 onward — five consecutive nightlies, confirmed on runs30428100984,30519299737,30610234977,30687607155,30735945650. Everything after it never executed:The job reports red, so it does block — but it blocks for one reason while seven other gates say nothing at all. The failure is not from this PR: I restored
benchmarks/andCargo.tomlto pristineorigin/mainin my tree andbenchmarks/ci_public_baseline_check.pystill reportspublic artifact benchmark inputs changed, and none of this PR's nine files is in that check's fingerprint set. It needs./benchmarks/run_public_baseline.shre-run, which is a maintainer job (pinned node 22.23.1 + bun 1.3.14).Two consequences for this PR. First, this PR's own
lintrun is red at that same pre-existing step, not at mine — see the step list on the run. Second, I gave this gateif: ${{ !cancelled() }}so it executes regardless of what failed above it. It costs 0.2 s and shares no state with any earlier step, so there is no reason for it to be hostage to the benchmark artifact.I did not add
!cancelled()to the other steps — that is a second logical change and touches six gates I did not otherwise modify. I would suggest doing it, and it should probably be asserted bygc_gate_wiring_check.py, which today checks job-level wiring but not "is this step reachable given the steps before it".Confirmed on this PR's own CI run (job 91545136411) — the guard does what it says:
So the
lintred on this PR is the pre-existing benchmark-artifact staleness, and the new gate is the one step after it that still executed and reported.Documentation
The rule is written where a contributor will actually hit it, in four places:
docs/src/testing/test-registration.md(new, linked fromSUMMARY.md) — the full page: why it exists, the four mechanisms, the two legitimate ways out of a failure, and what is deliberately not covered.CONTRIBUTING.md, in "what goes in a PR", right under the existing "new behavior needs a test" bullet.gc_repsel_corpus.txt,feature_matrix.toml,workloads.tomleach now open with "A NEW TEST FILE MUST BE REGISTERED HERE OR IT WILL NOT RUN", the enforcement chain cheapest-first, and a pointer to the exclusion list.gc_repsel_corpus.txt's header previously described only the two prefixes the matrix auto-detects, which is precisely the gap all four dark witnesses fell through.Deliberately out of scope
tests/*.sh,tests/*.py,tests/*.ts: 143 of the 171 files there are referenced by nothing in the tree. I found that while surveying and it is a genuine finding, but it is not this bug. There is no registry to diff against, so "unregistered" is not even well defined; each file needs triage (wire it up, or delete it). Inventing a registry retroactively would make this gate red on day one for reasons unrelated to the four dark witnesses.--listprints the count and the reasoning rather than leaving the silence.Scope
Script + CI + docs. No Rust touched, so
cargo testis unaffected — nothing was rebuilt or needed to be. No version lines touched (per CONTRIBUTING, the maintainer bumps at merge). Largest new file is 656 lines, well under the 2000-line cap that #7256 just finished clearing.Refs #7192, #7216, #7252, #7270, #7271.
Summary by CodeRabbit
New Features
Documentation
Chores