From f5f2a5a46356af2bafd3fd15bc2e01ab74cf5735 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 2 Aug 2026 16:21:24 -0400 Subject: [PATCH] =?UTF-8?q?ci(test):=20fail=20on=20dark=20tests=20?= =?UTF-8?q?=E2=80=94=20a=20test=20file=20no=20registry=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//tests/.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. --- .github/workflows/test.yml | 38 + CONTRIBUTING.md | 1 + benchmarks/compiler_output/workloads.toml | 12 + .../7278-dark-test-registration-gate.md | 59 ++ docs/src/SUMMARY.md | 1 + docs/src/testing/test-registration.md | 122 +++ scripts/check_test_registration.py | 707 ++++++++++++++++++ test-features/feature_matrix.toml | 10 + test-parity/gc_repsel_corpus.txt | 28 +- 9 files changed, 971 insertions(+), 7 deletions(-) create mode 100644 changelog.d/7278-dark-test-registration-gate.md create mode 100644 docs/src/testing/test-registration.md create mode 100644 scripts/check_test_registration.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index afe5630e64..2169e1c9c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -219,6 +219,44 @@ jobs: python3 scripts/gc_matrix_liveness_check.py --self-test python3 scripts/gc_matrix_liveness_check.py --check-registry + # Dark-test gate. Four of this repo's suites are driven by an explicit + # registry rather than a glob, and a test file added without its registry + # line runs NOWHERE while its PR stays green — #7192, #7216, #7252 and + # #7270/#7271 all shipped that way against test-parity/gc_repsel_corpus.txt. + # + # Registration checks already existed for two of those prefixes, but both + # live behind a 90-minute compiler build, behind a changed-paths relevance + # filter, and in workflows that are NOT in branch protection — so the check + # could not run on the pull request that needed it. This is the pure + # filesystem-and-text half (~0.2s, no compiler, no Node), placed in `lint` + # BECAUSE `lint` is already a required context: hazard 2 is the step people + # forget, so this gate is put where that step does not exist. + # + # The self-test plants an unregistered file into each mechanism and asserts + # the gate names it, then removes it and asserts green — over the real + # registries, through an in-memory overlay, so the checkout is never + # mutated. Each mechanism also floors its candidate set, so a stale glob + # fails loudly instead of making every future run vacuously green. + # + # `!cancelled()` is hazard 4 in a costume nobody has named yet: `lint` is a + # SEQUENCE of independent gates, and a step that fails takes every later + # step in the job to `skipped`. That is not hypothetical here — `Public + # benchmark evidence freshness` has failed on `main` on every run from + # 2026-07-29 onward, so `File size limit`, `GC store-site inventory`, + # `Address-classification audit`, `Gap snapshot checker self-test` and + # `Platform-aware parity allowlist self-test` have all been skipped for + # days while the job dutifully reported red for an unrelated reason. A gate + # that never executes cannot fail on its own subject. This step costs 0.2s + # and shares no state with anything above it, so it always speaks. + # (`!cancelled()` rather than `always()`: a cancelled run should stay + # cancelled.) The five steps above deserve the same treatment; that is a + # separate change from this one. + - name: Test registration (dark tests) + if: ${{ !cancelled() }} + run: | + python3 scripts/check_test_registration.py --self-test + python3 scripts/check_test_registration.py + # --------------------------------------------------------------------------- # Clippy — enforces the deny-level lints in [workspace.lints] (root # Cargo.toml). `cargo clippy` exits nonzero only on `deny` lints, so diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f08390e8a1..296f64436b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,7 @@ match the checkout. - One logical change. Small PRs land faster. If you find yourself writing "also, I fixed …", split. - Tests. New behavior needs a test; a bug fix needs a regression test. For compiler changes, drop a `.ts` file under [`test-files/`](test-files/) exercising the path. For runtime/stdlib, `#[test]` in the relevant crate. +- **A new test file must be registered in its suite's registry, or it will not run.** Most suites glob their inputs, but four read an explicit list — a `test_gap_gc_*` witness needs a line in [`test-parity/gc_repsel_corpus.txt`](test-parity/gc_repsel_corpus.txt), a feature probe needs an entry in `test-features/feature_matrix.toml`, a compiler-output fixture needs an entry in `benchmarks/compiler_output/workloads.toml`, and a Rust test file below a suite root needs a `mod` declaration. An unregistered file is not a failing test, it is *no test at all*: your PR goes green having run nothing. `python3 scripts/check_test_registration.py` catches this in `lint` in under a second; `--list` names every registry. Full page: [`docs/src/testing/test-registration.md`](docs/src/testing/test-registration.md). - Docs where user-visible. New CLI flags, new perry.toml fields, new stdlib APIs → update [`docs/src/`](docs/src/). ### What does NOT go in a PR (maintainer handles these at merge) diff --git a/benchmarks/compiler_output/workloads.toml b/benchmarks/compiler_output/workloads.toml index 0b043e65d9..b4529260d0 100644 --- a/benchmarks/compiler_output/workloads.toml +++ b/benchmarks/compiler_output/workloads.toml @@ -1,3 +1,15 @@ +# Registry for the compiler-output regression workloads. +# +# ***A NEW FIXTURE MUST BE REGISTERED HERE OR IT WILL NOT RUN.*** +# scripts/compiler_output_regression.py reads this file, not the fixtures +# directory, so a .ts under fixtures/ with no `source =` entry has no IR checks +# and no runtime budgets attached to it — it is compiled by nothing. +# scripts/check_test_registration.py fails `lint` on an unregistered fixture, +# and on a `source` here that no longer exists. A fixture driven by a DIFFERENT +# registry (raw_numeric_layout_smoke.ts is a target-collector workload in +# scripts/run_memory_stability_tests.sh) belongs in that script's +# `compiler-output-workloads` exclusions, with a reason. + schema_version = 1 [workloads.image_convolution] diff --git a/changelog.d/7278-dark-test-registration-gate.md b/changelog.d/7278-dark-test-registration-gate.md new file mode 100644 index 0000000000..e6835d1896 --- /dev/null +++ b/changelog.d/7278-dark-test-registration-gate.md @@ -0,0 +1,59 @@ +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 `test-parity/gc_repsel_corpus.txt` +line; #7252 added a third; #7270/#7271 added two more, caught by hand at merge. +An unregistered file is not a failing test, it is no test at all — the PR goes +green having run it zero times. + +Registration checks existed for two of the three prefixes and neither could +catch the pull request that needed it: `gc_repsel_matrix.sh` and +`gc-moving-witnesses.yml` both sit behind a 90-minute release build, behind a +changed-paths relevance filter, and in workflows that are not in branch +protection. + +`scripts/check_test_registration.py` is the cheap half, 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: the GC/repsel corpus, +`test-features/feature_matrix.toml`, `benchmarks/compiler_output/workloads.toml`, +and Rust test files below a suite root, which compile only if a `mod` +declaration names them (rustc never parses an undeclared one — not dead code, +not code, no warning). + +It runs in `lint`, which is ALREADY a required context, so no branch-protection +change is needed. That placement is the point: forgetting to promote a new job +is CLAUDE.md hazard 2, and it is what left `gc-root-dominance` red and blocking +nothing for days. + +The step carries `if: ${{ !cancelled() }}`, which is hazard 4 wearing a costume +this repo has not named yet: `lint` is a SEQUENCE of unrelated gates, and one +failing step takes every later step to `skipped`. Not hypothetical — `Public +benchmark evidence freshness` has failed on `main` on every run from 2026-07-29 +onward, so `File size limit`, `GC store-site inventory`, +`Address-classification audit`, `Gap snapshot checker self-test` and +`Platform-aware parity allowlist self-test` have all been skipped for days while +the job reported red for an unrelated reason. The five steps above deserve the +same treatment; that is a separate change from this one, and the stale public +benchmark artifact needs regenerating either way. + +Built so it cannot pass vacuously: each mechanism floors its candidate set and +fails if the glob stops matching, and every run prints +`checked N files against M registries`. Exclusions are named with reasons rather +than counted (a threshold cannot tell a new dark file from an old one), and a +stale exclusion or a registry entry whose file is gone both fail. `--self-test` +(39 cases, also run in `lint`) plants an unregistered file into each mechanism +over the real registries, asserts the gate names it, then removes it and asserts +green. It also asserts that excluding the planted file clears it and that a +deleted or renamed registry fails by name instead of crashing with a raw +`FileNotFoundError`. + +Zero dark files today across all four mechanisms, so it is green on `main` from +the first run. Five candidates are excluded with reasons: four helper modules +imported by a registered test, and `raw_numeric_layout_smoke.ts`, which is +registered in a different registry (the `raw_numeric_layouts` target-collector +workload in `scripts/run_memory_stability_tests.sh`). + +Out of scope and said out loud in `--list`: `tests/*.sh|py|ts`, where 143 of 171 +files are referenced by nothing in the tree. That has no registry to diff +against and needs per-file triage, not a gate. + +The rule is documented in a new `docs/src/testing/test-registration.md`, in +CONTRIBUTING.md, and in each of the three registry files' own headers. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 9b5cc5cbdc..c0a2f7cf95 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -147,6 +147,7 @@ # Testing +- [Test Registration (dark tests)](testing/test-registration.md) - [Geisterhand (UI Fuzzer)](testing/geisterhand.md) - [Node Compatibility Matrix](testing/node-compat-matrix.md) diff --git a/docs/src/testing/test-registration.md b/docs/src/testing/test-registration.md new file mode 100644 index 0000000000..b19dca1480 --- /dev/null +++ b/docs/src/testing/test-registration.md @@ -0,0 +1,122 @@ +# Test Registration (dark tests) + +> **A new test file must be registered in its suite's registry, or it will not +> run.** Most of Perry's suites glob their inputs, but four do not — they read an +> explicit list. A file added to one of those without its registry line is not a +> failing test, it is *no test at all*. + +`scripts/check_test_registration.py` enforces this. It runs in the `lint` job on +every pull request, takes about a fifth of a second, and needs no compiler, no +Node and no build. + +```bash +python3 scripts/check_test_registration.py # the gate +python3 scripts/check_test_registration.py --list # what is in scope, and what is not +python3 scripts/check_test_registration.py --self-test # prove the gate can still fail +``` + +## Why this exists + +A dark test is invisible in exactly the way that matters. The PR is green. The +reviewer sees a witness in the diff and a passing CI run next to it and reads +the two together as "covered". Nothing says otherwise, because nothing ran. + +It happened four times against `test-parity/gc_repsel_corpus.txt` alone: + +| PR | What went dark | +|----|----------------| +| #7192 | a `test_gap_gc_*` stale-root witness, dark from merge | +| #7216 | a second one, same shape | +| #7252 | `test_gap_gc_call_argument_rooting`, caught only once #7192/#7216's own registration assert reached `main` | +| #7270 / #7271 | two more (rest-argument and same-module call-argument rooting), caught by the maintainer at merge | + +Two partial gates already existed and neither could catch the pull request that +needed it. `scripts/gc_repsel_matrix.sh` auto-detects unregistered +`test_gap_repsel_*` / `test_gap_specabi_*` files, and `gc-moving-witnesses.yml` +adds `test_gap_gc_*` — but both sit behind a full release build of the compiler, +behind a changed-paths relevance filter, and in workflows that are not in branch +protection's required contexts. This script is the cheap half of those checks, +pulled out to somewhere it can block a merge, and generalised to the other three +places in the tree with the same shape. + +## Registry-driven suites + +Run `--list` for the authoritative version with every exclusion and its reason. + +| Registry | Candidate files | Runner | +|----------|-----------------|--------| +| `test-parity/gc_repsel_corpus.txt` | `test-files/test_gap_{gc,repsel,specabi}_*.ts` | `scripts/gc_repsel_matrix.sh` (`gc-stress`, `gc-moving-witnesses`) | +| `test-features/feature_matrix.toml` | `test-features/probes/**/*.ts` | `scripts/gen_feature_matrix.py` (`feature-matrix`) | +| `benchmarks/compiler_output/workloads.toml` | `benchmarks/compiler_output/fixtures/**/*.ts` | `scripts/compiler_output_regression.py` (`compiler-output-regression`) | +| a `mod` declaration in the parent module | `crates/*/**/tests/**/*.rs` below a suite root | `cargo test` | + +The last one is the Rust analogue and it is worth spelling out: cargo +auto-discovers `crates//tests/.rs`, but a file one level deeper — +a suite's module directory, or a `#[cfg(test)]` submodule under `src/` — only +compiles if a `mod` declaration 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. `--list` names those too, so +"considered and safe" is distinguishable from "never looked at". + +## What the gate does, and what it refuses to do + +Per CLAUDE.md's *four ways a gate can be unable to fail*: + +- **It cannot pass vacuously.** Every mechanism declares a floor on its + candidate set and fails if the glob stops matching. "0 dark files over 0 + candidates" and "0 dark files over 157 candidates" print the same verdict and + mean opposite things, so the summary always names the counts: + `checked 157 files against 4 registries`. +- **It is proven able to fail.** `--self-test` plants an unregistered file into + each of the four mechanisms — over the real registries, via an in-memory + overlay, so nothing touches your working tree — asserts the gate names it, + then removes it and asserts the gate goes green again. It also asserts a + collapsed candidate set fails, a stale exclusion fails, and a registry entry + whose file is gone fails. +- **Exclusions are named, not counted.** A numeric threshold cannot tell a new + dark file from an old one: fix one, add one, and the tally is unchanged. Every + non-registered candidate is listed in the script with a reason. A stale + exclusion — one that matches no file on disk — is itself a failure, so an + excuse cannot outlive the file it excuses. +- **It runs where it blocks.** It is a step in `lint`, which is already a + required context. That placement is deliberate: forgetting to add a new job to + 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. + +## When it fires + +You will see something like: + +``` +TEST REGISTRATION: a test file exists that nothing runs. + + - DARK TEST test-files/test_gap_gc_rest_argument_rooting.ts + exists on disk but is not registered in test-parity/gc_repsel_corpus.txt, so + scripts/gc_repsel_matrix.sh (gc-stress, gc-moving-witnesses) never runs it. + Register it there, or add it to this script's `gc-repsel-corpus` exclusions + with a reason. +``` + +Two ways out, and only two: + +1. **Register it.** Add the line to the named registry. This is almost always + the right answer — you wrote the file to run. +2. **Exclude it, with a reason.** If the file is genuinely a helper (a fixture + imported by a registered test, a vendored dependency, a workload driven by a + *different* registry), add it to that mechanism's `exclusions` dict in + `scripts/check_test_registration.py` and say why in prose. Reviewers read + that text; "excluded" on its own is not an answer. + +There is deliberately no third way. No threshold to bump, no `--allow-dark`, no +environment variable. + +## Not covered + +`tests/*.sh`, `tests/*.py` and `tests/*.ts` have no registry to diff against — +143 of the 171 files there are referenced by nothing in the tree. That is a +separate archaeology problem (triage each one: wire it up, or delete it), not an +unregistered-file problem, and inventing a registry for it retroactively would +make this gate red on day one for reasons that have nothing to do with the four +dark witnesses it was written for. diff --git a/scripts/check_test_registration.py b/scripts/check_test_registration.py new file mode 100644 index 0000000000..48334c823d --- /dev/null +++ b/scripts/check_test_registration.py @@ -0,0 +1,707 @@ +#!/usr/bin/env python3 +"""Fail when a test file exists on disk but no registry lists it — a DARK TEST. + +WHY THIS EXISTS +--------------- +Some of this repo's suites are driven by an explicit registry — a corpus file, a +TOML manifest, a `mod` declaration — rather than by a glob. A test file added +without its registry line is not a failing test. It is *no test at all*: it +compiles nowhere, runs nowhere, and reports nothing. The PR that added it is +green, the reviewer sees a witness in the diff, and the defect it was written to +catch stays uncovered. + +That has now happened four times, all against `test-parity/gc_repsel_corpus.txt`: + + * #7192 and #7216 — two `test_gap_gc_*` stale-root witnesses, each of which + says in its own header that it is LIVE BY CONSTRUCTION, both dark from merge; + * #7252 — a third (`test_gap_gc_call_argument_rooting`), caught only once the + `gc-moving-witnesses` job's own registration assert reached `main`; + * #7270/#7271 — two more (rest-argument and same-module call-argument + rooting), caught by the maintainer while merging. + +Four occurrences of one mistake is not carelessness, it is a missing gate. Two +partial gates already existed and neither could catch a PR: +`scripts/gc_repsel_matrix.sh` auto-detects unregistered `test_gap_repsel_*` / +`test_gap_specabi_*` files, and `gc-moving-witnesses.yml` adds the +`test_gap_gc_*` prefix — but BOTH live behind a full release build of the +compiler (a 90-minute job), behind a changed-paths relevance filter, and in +workflows that are not in branch protection's required contexts. The check that +mattered could not run on the pull request that needed it. + +This script is the cheap half, pulled out to where it can actually block: pure +filesystem and text, no compiler, no Node, ~1 second, run from `lint`, which IS +a required context. It also generalises past that one corpus — the same shape +exists in three other places in the tree (see MECHANISMS). + +HOW IT IS BUILT TO BE ABLE TO FAIL (CLAUDE.md, "four ways a gate can be unable +to fail") +------------------------------------------------------------------------------ +1. No `continue-on-error`, no `|| true`: the `lint` step is this script's exit + status. +2. Branch protection needs NO change, because the step lives inside `lint`, + which is already required. That is deliberate: hazard 2 is the step people + forget, so this gate is placed where the step does not exist. +3. The `lint` job's concurrency already cancels pull-request runs only. +4. **The subject is asserted live.** Every mechanism declares a floor on its + candidate set and FAILS if the glob stops matching, so "0 dark files over 0 + candidates" and "0 dark files over 177 candidates" cannot print the same + verdict. The summary always names the counts: `checked N files against M + registries`. `--self-test` plants an unregistered file into each mechanism + and asserts the gate goes red, then removes it and asserts it goes green. + +WHY AN EXCLUSION LIST AND NOT A COUNT +------------------------------------- +A numeric threshold cannot tell a new dark file from an old one: fix one, add +one, and the tally is unchanged. Every non-registered candidate is named here +with a reason. A stale exclusion — one that no longer matches a file on disk — +is itself a FAILURE, so an exclusion cannot outlive the file it excuses (the +same rule `scripts/gc_root_dominance_allowlist.json` uses). + +Usage: + python3 scripts/check_test_registration.py # check the repo + python3 scripts/check_test_registration.py --self-test # check the checker + python3 scripts/check_test_registration.py --list # describe the scope +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Iterable + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class MissingRegistry(Exception): + """A mechanism's registry file is absent from the tree it is checking. + + Raised by `Tree.read` instead of letting `FileNotFoundError` propagate, so + a deleted or renamed registry is a named problem in the report — not a + traceback that also hides which file was missing. + """ + + +# --------------------------------------------------------------------------- +# Tree — the repo as the checker sees it. +# +# Every read goes through here so `--self-test` can plant, hide and rewrite +# files WITHOUT touching the working tree. That matters twice over: a checkout +# mutated by its own gate is a bad neighbour to every other CI step, and a +# self-test that runs against a synthetic fixture instead of the real corpus +# proves the fixture is well-formed, not that the gate works. This runs the real +# mechanism definitions over the real registries with one file added. +# --------------------------------------------------------------------------- +class Tree: + def __init__( + self, + root: Path, + added: Iterable[str] = (), + removed: Iterable[str] = (), + overrides: dict[str, str] | None = None, + ) -> None: + self.root = root + self.added = set(added) + self.removed = set(removed) + self.overrides = dict(overrides or {}) + + def glob(self, pattern: str) -> list[str]: + hits = { + p.relative_to(self.root).as_posix() + for p in self.root.glob(pattern) + if p.is_file() + } + hits |= {a for a in self.added if _fnmatch_path(a, pattern)} + return sorted(hits - self.removed) + + def exists(self, rel: str) -> bool: + if rel in self.removed: + return False + if rel in self.added or rel in self.overrides: + return True + return (self.root / rel).is_file() + + def read(self, rel: str) -> str: + if rel in self.overrides: + return self.overrides[rel] + if rel in self.removed: + raise MissingRegistry(rel) + if rel in self.added: + return "" + path = self.root / rel + if not path.is_file(): + raise MissingRegistry(rel) + return path.read_text(encoding="utf-8", errors="ignore") + + +def _fnmatch_path(path: str, pattern: str) -> bool: + """Match a repo-relative path against a `pathlib.glob` pattern. + + `fnmatch` is wrong here: its `*` crosses `/`, so `test-files/*.ts` would + match `test-files/fixtures/a.ts`. Translate segment by segment instead, with + `**` as the only separator-crossing token. + """ + parts = pattern.split("/") + rx = [] + for part in parts: + if part == "**": + rx.append("(?:[^/]+/)*") + continue + seg = "".join( + "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c) for c in part + ) + rx.append(seg + "/") + joined = "".join(rx) + if joined.endswith("/"): + joined = joined[:-1] + return re.fullmatch(joined, path) is not None + + +# --------------------------------------------------------------------------- +# Registry readers. +# --------------------------------------------------------------------------- +def _read_hash_list(tree: Tree, rel: str) -> set[str]: + """One entry per line, `#` starts a comment (gc_repsel_corpus.txt).""" + out = set() + for line in tree.read(rel).splitlines(): + line = line.split("#", 1)[0].strip() + if line: + out.add(line) + return out + + +def _read_toml_paths(tree: Tree, rel: str, key: str) -> set[str]: + """Every ` = "..."` value in a TOML manifest. + + Deliberately a regex and not `tomllib`: the two manifests this reads spell + their entry paths as plain top-level string keys, and a line-oriented reader + keeps the failure mode legible (`grep` finds what the checker found). A + quoted `#` inside the value is not a thing in either file. + """ + return set(re.findall(r'^\s*%s\s*=\s*"([^"]+)"' % re.escape(key), tree.read(rel), re.M)) + + +_MOD_RE = r"^[^\S\n]*(?:pub(?:\([^)]*\))?[^\S\n]+)?mod[^\S\n]+%s[^\S\n]*[;{]" + + +def _rust_module_is_declared(tree: Tree, rel: str) -> bool: + """Is `/.rs` named by a `mod` declaration that can reach it? + + Rust resolves `/.rs` as child module `` of the module + rooted at ``, whose body is either `/mod.rs` (2015 layout) or the + sibling `.rs` (2018 layout). + + But that module may itself be an INLINE `mod { … }` block in an + ancestor file, in which case the declaration lives further up while the file + still sits under `/`. `crates/perry/src/commands/compile/resolve/tests/ + declaration_sidecar_tests/compile_package.rs` is exactly that: its + `mod compile_package;` is inside `mod declaration_sidecar_tests { … }` in + `resolve/tests.rs`, two levels above. A checker that stopped at the + immediate parent would report a live test as dark — and a false positive on + a gate like this one gets the gate disabled, so the walk goes all the way up + to the crate root. It is still only a couple of file reads per candidate. + """ + path = Path(rel) + stem = path.stem + decl = re.compile(_MOD_RE % re.escape(stem), re.M) + # crates//… — stop at the crate directory. + parts = path.parts + floor = 2 if len(parts) > 2 and parts[0] == "crates" else 0 + + parent = path.parent + while len(parent.parts) > floor: + for cand in ((parent / "mod.rs").as_posix(), parent.with_suffix(".rs").as_posix()): + if not tree.exists(cand) or cand == rel: + continue + text = tree.read(cand) + if decl.search(text): + return True + # `#[path = "…"]` can point at this file under any module name. + for target in re.findall(r'#\[path\s*=\s*"([^"]+)"\]', text): + if (Path(cand).parent / target).as_posix() == rel: + return True + parent = parent.parent + return False + + +# --------------------------------------------------------------------------- +# Mechanisms — every place in this repo where a test file must be ENUMERATED +# rather than globbed. A glob-driven suite cannot go dark and is not listed +# here; `--list` names those too, so the reader can see what was considered. +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class Mechanism: + id: str + candidates: tuple[str, ...] + registry: str + runner: str + what: str + key: Callable[[str], str] + registered: Callable[[Tree], set[str]] + # A floor, not a target. Raise it when the corpus grows; NEVER lower it to + # make a run pass — a collapsed candidate set is the finding, not the + # obstacle. (Same discipline as MIN_COMPILED in gc_root_dominance_corpus.sh.) + min_candidates: int + # candidate key -> why it is legitimately not registered. Justified, dated + # to its reason, and checked for staleness. + exclusions: dict[str, str] = field(default_factory=dict) + # Also flag registry entries whose file is gone. Off for the Rust mechanism, + # whose "registry" is a distributed set of `mod` declarations rather than a + # list of paths (an unresolved `mod` is a compile error already). + check_reverse: bool = True + # Turn a registry entry into the repo-relative path it claims exists. + entry_to_path: Callable[[str], str] | None = None + + +def _stem(rel: str) -> str: + return Path(rel).stem + + +MECHANISMS: tuple[Mechanism, ...] = ( + Mechanism( + id="gc-repsel-corpus", + what=( + "GC x representation-selection witnesses. Each file reproduces one " + "stale-root or representation defect and only bites on a moving arm; " + "unregistered, it is compiled by nothing and run by nothing." + ), + candidates=( + "test-files/test_gap_gc_*.ts", + "test-files/test_gap_repsel_*.ts", + "test-files/test_gap_specabi_*.ts", + ), + registry="test-parity/gc_repsel_corpus.txt", + runner="scripts/gc_repsel_matrix.sh (gc-stress, gc-moving-witnesses)", + key=_stem, + registered=lambda t: _read_hash_list(t, "test-parity/gc_repsel_corpus.txt"), + entry_to_path=lambda e: "test-files/%s.ts" % e, + min_candidates=45, + ), + Mechanism( + id="feature-matrix-probes", + what=( + "TypeScript feature probes. The committed feature matrix is " + "generated from this manifest, so an unlisted probe is absent from " + "the matrix as well as from the run." + ), + candidates=("test-features/probes/**/*.ts",), + registry="test-features/feature_matrix.toml", + runner="scripts/gen_feature_matrix.py (feature-matrix.yml)", + key=lambda rel: rel[len("test-features/") :], + registered=lambda t: _read_toml_paths(t, "test-features/feature_matrix.toml", "path"), + entry_to_path=lambda e: "test-features/%s" % e, + min_candidates=20, + exclusions={ + "probes/type_only_imports/model.ts": ( + "helper module, not a probe: imported by probes/type_only_imports/" + "basic.ts, which IS registered. Listing it would run a file with " + "no output of its own." + ), + "probes/modules/support/type-only-values.ts": ( + "helper module, not a probe: imported by probes/modules/" + "type-only-imports.ts, which IS registered." + ), + "probes/dynamic_import/mod.ts": ( + "helper module, not a probe: it is the TARGET of the " + "`import(\"./mod.ts\")` under test in probes/dynamic_import/" + "basic.ts, which IS registered." + ), + }, + ), + Mechanism( + id="compiler-output-workloads", + what=( + "Compiler-output regression fixtures. Each declares IR checks and " + "runtime budgets in the manifest; a fixture with no manifest entry " + "has no assertions attached to it at all." + ), + candidates=("benchmarks/compiler_output/fixtures/**/*.ts",), + registry="benchmarks/compiler_output/workloads.toml", + runner="scripts/compiler_output_regression.py (compiler-output-regression)", + key=lambda rel: rel, + registered=lambda t: _read_toml_paths( + t, "benchmarks/compiler_output/workloads.toml", "source" + ), + entry_to_path=lambda e: e, + min_candidates=18, + exclusions={ + "benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts": ( + "registered in a DIFFERENT registry, not dark: it is the " + "`raw_numeric_layouts` workload spec in " + "scripts/run_memory_stability_tests.sh's " + "run_target_collector_architecture_gates. It has no IR-check or " + "budget entry here because it is driven as a target-collector " + "gate, not as a compiler-output workload." + ), + "benchmarks/compiler_output/fixtures/native_memory_fixture_project/" + "node_modules/@perry-fixtures/native-memory-fixture/index.ts": ( + "vendored package source inside the native_memory fixture " + "PROJECT, not a workload: it exists to be resolved through " + "node_modules by the fixture that imports it." + ), + }, + ), + Mechanism( + id="rust-test-modules", + what=( + "Rust test files that cargo does NOT auto-discover. cargo builds " + "crates//tests/.rs on its own, but a file one level deeper " + "— a suite's module directory, or a #[cfg(test)] submodule under " + "src/ — compiles only if a `mod` declaration names it. Without one " + "it is not dead code, it is not code: rustc never parses it, so no " + "warning fires." + ), + candidates=("crates/*/**/tests/**/*.rs",), + registry="the `mod` declaration in the module's parent (mod.rs or .rs)", + runner="cargo test", + key=lambda rel: rel, + registered=lambda t: set(), # unused; see _rust_module_is_declared + min_candidates=50, + check_reverse=False, + ), +) + +# Suites that are GLOB-driven and therefore cannot have a dark file. Named so a +# reader can tell "considered and safe" from "not looked at" — an unexplained +# absence from MECHANISMS is exactly the silence this gate exists to remove. +GLOB_DRIVEN = ( + ("run_parity_tests.sh", "find test-files -maxdepth 1 -name '*.ts'; " + "find test-parity/node-suite -name '*.ts'"), + ("scripts/gc_root_dominance_corpus.sh", "PATTERNS globs over test-files/; " + "a pattern that matches nothing is already loud, and MIN_COMPILED floors " + "the corpus size"), + ("benchmarks/public_baseline.py", "glob list over benchmarks/**"), + ("cargo test", "crates//tests/*.rs suite roots are auto-discovered " + "targets; the deeper files are mechanism `rust-test-modules` above"), +) + + +# --------------------------------------------------------------------------- +# Checking. +# --------------------------------------------------------------------------- +@dataclass +class Result: + problems: list[str] = field(default_factory=list) + n_candidates: int = 0 + n_registered: int = 0 + n_excluded: int = 0 + + +def evaluate(tree: Tree, m: Mechanism, exclusions: dict[str, str] | None = None) -> Result: + exclusions = m.exclusions if exclusions is None else exclusions + res = Result() + + paths: list[str] = [] + for pattern in m.candidates: + paths.extend(tree.glob(pattern)) + paths = sorted(set(paths)) + if m.id == "rust-test-modules": + # cargo auto-discovers crates//tests/.rs; only deeper files + # need a declaration. `mod.rs` names itself. + paths = [ + p + for p in paths + if not (len(Path(p).parts) == 4 and Path(p).parts[2] == "tests") + and Path(p).name != "mod.rs" + ] + res.n_candidates = len(paths) + + # ★ Liveness. A gate whose subject vanished reports the same "0 problems" as + # a gate whose subject is clean. Refuse to be that gate. + if len(paths) < m.min_candidates: + res.problems.append( + "%s: candidate set COLLAPSED — %d files match %s but the floor is " + "%d. Either the tests moved and these globs are stale (in which " + "case this gate has been checking nothing), or the floor is wrong. " + "Do not lower the floor to make this pass." + % (m.id, len(paths), " ".join(m.candidates), m.min_candidates) + ) + + keys = {m.key(p): p for p in paths} + + if m.id == "rust-test-modules": + 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: + 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)) + res.n_registered = len(set(keys) & registered_keys) + res.n_excluded = len(set(keys) & set(exclusions)) + + for k in dark: + res.problems.append( + "DARK TEST %s\n exists on disk but is not registered in %s, so " + "%s never runs it.\n Register it there, or add it to this " + "script's `%s` exclusions with a reason." + % (keys[k], m.registry, m.runner, m.id) + ) + + # A registry entry whose file is gone is the mirror-image rot: the runner + # either skips it silently or dies on a path that no longer exists. Reuse + # registered_keys rather than re-reading the registry — a second raw call + # to m.registered(tree) would also re-raise MissingRegistry above. + if m.check_reverse and m.entry_to_path is not None: + for entry in sorted(registered_keys): + rel = m.entry_to_path(entry) + if not tree.exists(rel): + res.problems.append( + "ROTTED ENTRY %s lists %r but %s does not exist." + % (m.registry, entry, rel) + ) + + # ★ A stale exclusion is a failure, not a leftover. Otherwise an excuse + # written for one file silently covers whatever takes its name next. + for k in sorted(exclusions): + if k not in keys: + res.problems.append( + "STALE EXCLUSION %s: %r is excluded in this script but matches " + "no file on disk. Delete the entry." % (m.id, k) + ) + + return res + + +def run(tree: Tree, mechanisms: Iterable[Mechanism] = MECHANISMS) -> tuple[list[str], str]: + problems: list[str] = [] + lines: list[str] = [] + total = 0 + mechanisms = tuple(mechanisms) + for m in mechanisms: + res = evaluate(tree, m) + problems.extend(res.problems) + total += res.n_candidates + lines.append( + " %-26s %4d candidates %4d registered %2d excluded" + % (m.id, res.n_candidates, res.n_registered, res.n_excluded) + ) + summary = "checked %d files against %d registries\n%s" % ( + total, + len(mechanisms), + "\n".join(lines), + ) + return problems, summary + + +# --------------------------------------------------------------------------- +# Self-test. Runs the REAL mechanisms over the REAL tree with a virtual overlay, +# so a pass here is evidence about this repo's registries, not about a fixture. +# --------------------------------------------------------------------------- +_PLANT = { + "gc-repsel-corpus": "test-files/test_gap_gc_selftest_planted_witness.ts", + "feature-matrix-probes": "test-features/probes/closures/selftest-planted.ts", + "compiler-output-workloads": "benchmarks/compiler_output/fixtures/selftest_planted.ts", + "rust-test-modules": "crates/perry-codegen/tests/native_proof_regressions/selftest_planted.rs", +} + + +def _self_test(root: Path) -> int: + failures: list[str] = [] + cases = 0 + + def check(name: str, cond: bool, detail: str = "") -> None: + nonlocal cases + cases += 1 + if not cond: + failures.append("%s%s" % (name, (": " + detail) if detail else "")) + + for m in MECHANISMS: + planted = _PLANT[m.id] + if (root / planted).exists(): + failures.append( + "%s: the self-test's plant path %s ALREADY EXISTS in the tree; " + "pick another so the test cannot pass by accident" % (m.id, planted) + ) + continue + + # 1. green on the real tree. + clean = evaluate(Tree(root), m) + check("%s clean" % m.id, not clean.problems, "; ".join(clean.problems)) + + # 2. ★ RED with one unregistered file planted — the whole point. + red = evaluate(Tree(root, added=[planted]), m) + check( + "%s goes red on a planted dark file" % m.id, + any("DARK TEST %s" % planted in p for p in red.problems), + "planted %s, got %r" % (planted, red.problems), + ) + check( + "%s counts the planted file" % m.id, + red.n_candidates == clean.n_candidates + 1, + "%d vs %d" % (red.n_candidates, clean.n_candidates), + ) + + # 2b. excluding the same planted file, with a reason, clears it. Every + # mechanism documents this as the second legitimate way out — the + # rust-test-modules branch once computed `dark` straight from + # `_rust_module_is_declared` and never subtracted `exclusions`, so + # an excluded Rust file stayed reported as dark. + excused = evaluate( + Tree(root, added=[planted]), m, exclusions={m.key(planted): "self-test"} + ) + check( + "%s excluding the planted file clears it" % m.id, + not any("DARK TEST %s" % planted in p for p in excused.problems), + repr(excused.problems), + ) + + # 3. green again once it is gone — proves step 2 was the plant and not + # some ambient breakage. + again = evaluate(Tree(root), m) + check("%s green again" % m.id, not again.problems, "; ".join(again.problems)) + + # 4. ★ an empty/shrunken candidate set FAILS. This is the hazard-4 arm: + # without it, a stale glob makes every future run vacuously green. + all_paths: list[str] = [] + for pattern in m.candidates: + all_paths.extend(Tree(root).glob(pattern)) + empty = evaluate(Tree(root, removed=all_paths), m) + check( + "%s fails on a collapsed candidate set" % m.id, + any("candidate set COLLAPSED" in p for p in empty.problems), + repr(empty.problems), + ) + + # 5. a stale exclusion fails. + stale = evaluate( + Tree(root), m, exclusions={"no/such/file.ts": "deliberately bogus"} + ) + check( + "%s fails on a stale exclusion" % m.id, + any("STALE EXCLUSION" in p for p in stale.problems), + repr(stale.problems), + ) + + # 6. a registry entry pointing at nothing fails. + if m.check_reverse: + body = Tree(root).read(m.registry) + if m.id == "gc-repsel-corpus": + body += "\ntest_gap_gc_selftest_entry_with_no_file\n" + else: + keyname = "source" if m.id == "compiler-output-workloads" else "path" + body += '\n%s = "no/such/registered/file.ts"\n' % keyname + rotted = evaluate(Tree(root, overrides={m.registry: body}), m) + check( + "%s fails on a rotted registry entry" % m.id, + any("ROTTED ENTRY" in p for p in rotted.problems), + repr(rotted.problems), + ) + + # 6b. a missing registry file (deleted or renamed) FAILS by name, + # instead of an unhandled FileNotFoundError crashing the script. + if m.id != "rust-test-modules": + missing = evaluate(Tree(root, removed=[m.registry]), m) + check( + "%s fails on a missing registry file" % m.id, + any("MISSING REGISTRY" in p for p in missing.problems), + repr(missing.problems), + ) + + # 7. ★ FALSE-POSITIVE GUARD. `resolve/tests/declaration_sidecar_tests/ + # compile_package.rs` IS declared — by `mod compile_package;` inside an + # inline `mod declaration_sidecar_tests { … }` block two levels up in + # `resolve/tests.rs`. The first draft of this checker only looked at the + # immediate parent and condemned it. A gate that cries wolf gets deleted, + # so pin the shape. + inline = ( + "crates/perry/src/commands/compile/resolve/tests/" + "declaration_sidecar_tests/compile_package.rs" + ) + if (root / inline).is_file(): + check( + "a `mod` in an inline block two levels up still counts", + _rust_module_is_declared(Tree(root), inline), + ) + else: + failures.append( + "the inline-`mod` false-positive guard's subject %s is gone; " + "re-point it at another live one rather than dropping the case" % inline + ) + + # 8. the path matcher does not let `*` cross a directory separator — the bug + # that would silently widen every mechanism's candidate set. + check( + "glob `*` does not cross /", + not _fnmatch_path("test-files/fixtures/a.ts", "test-files/*.ts"), + ) + check("glob `*` matches in-segment", _fnmatch_path("test-files/a.ts", "test-files/*.ts")) + check( + "glob `**` crosses /", + _fnmatch_path("test-features/probes/x/y.ts", "test-features/probes/**/*.ts"), + ) + check( + "glob `**` matches zero segments", + _fnmatch_path("test-features/probes/y.ts", "test-features/probes/**/*.ts"), + ) + + if failures: + for f in failures: + print("SELF-TEST FAIL: %s" % f, file=sys.stderr) + return 1 + print("check_test_registration self-test: OK (%d cases)" % cases) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--self-test", action="store_true", help="check the checker, then exit") + ap.add_argument("--list", action="store_true", help="describe the scope and exit") + args = ap.parse_args() + + if args.self_test: + return _self_test(REPO_ROOT) + + if args.list: + print("Registry-driven suites (a file here can go dark):\n") + for m in MECHANISMS: + print(" %s" % m.id) + print(" candidates : %s" % " ".join(m.candidates)) + print(" registry : %s" % m.registry) + print(" runner : %s" % m.runner) + print(" floor : %d files" % m.min_candidates) + print(" %s" % m.what) + for k, why in sorted(m.exclusions.items()): + print(" excluded : %s\n %s" % (k, why)) + print() + print("Glob-driven suites (considered; a file here cannot go dark):\n") + for name, how in GLOB_DRIVEN: + print(" %-40s %s" % (name, how)) + print( + "\nNOT covered: tests/*.sh|py|ts. Those have no registry to diff " + "against —\n143 of 171 are referenced by nothing in the tree. That " + "is a separate\narchaeology problem (triage or delete), not an " + "unregistered-file problem." + ) + return 0 + + problems, summary = run(Tree(REPO_ROOT)) + if problems: + print("TEST REGISTRATION: a test file exists that nothing runs.\n", file=sys.stderr) + for p in problems: + print(" - %s" % p, file=sys.stderr) + print("\n%s" % summary, file=sys.stderr) + print( + "\nA new test file must be registered in its suite's registry or it " + "will not run.\nSee docs/src/testing/test-registration.md.", + file=sys.stderr, + ) + return 1 + + print("test registration OK: %s" % summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test-features/feature_matrix.toml b/test-features/feature_matrix.toml index b471ddcab6..0608bb4c76 100644 --- a/test-features/feature_matrix.toml +++ b/test-features/feature_matrix.toml @@ -1,3 +1,13 @@ +# Registry for the TypeScript feature probes under test-features/probes/. +# +# ***A NEW PROBE MUST BE REGISTERED HERE OR IT WILL NOT RUN.*** +# scripts/gen_feature_matrix.py reads this file, not the directory, so a probe +# with no [[probe]] entry is executed by nothing AND absent from the generated +# matrix. scripts/check_test_registration.py fails `lint` on an unregistered +# probe, and on a `path` here that no longer exists. A probe file that is only +# a helper (a module some other probe imports) belongs in that script's +# `feature-matrix-probes` exclusions, with a reason. + [settings] node_args = ["--experimental-strip-types"] diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index c90c305098..4e32372784 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -1,13 +1,27 @@ # Corpus for the GC x representation-selection stress matrix # (scripts/gc_repsel_matrix.sh). # -# ***IF YOU ARE ADDING A REPRESENTATION, ADD ITS GAP FILE HERE.*** -# The matrix script FAILS if a `test_gap_repsel_*` / `test_gap_specabi_*` file -# exists in test-files/ that is not registered below. That is the enforcement -# point behind docs/representation-selection-rfc.md 5.6: a new representation -# must be exercised against every GC arm, not just verified once by hand in its -# own PR. Files outside those two prefixes (typed-array param reads, int-valued -# typed-array locals) are not auto-detected, so register them explicitly. +# ***A NEW TEST FILE MUST BE REGISTERED HERE OR IT WILL NOT RUN.*** +# `test-files/test_gap_{gc,repsel,specabi}_*.ts` is compiled and executed by +# NOTHING unless its basename appears below. An unregistered witness is not a +# failing test, it is no test at all: the PR that adds it goes green having run +# it zero times. That happened four times -- #7192, #7216, #7252 and +# #7270/#7271 -- before it was gated. +# +# Enforcement, cheapest first: +# * scripts/check_test_registration.py -- pure text, ~0.2s, runs in `lint` +# (a REQUIRED context) on every PR. This is the one that will stop you. +# * scripts/gc_repsel_matrix.sh -- auto-detects unregistered +# `test_gap_repsel_*` / `test_gap_specabi_*`, but only behind a full +# compiler build. +# * gc-moving-witnesses.yml -- same, for `test_gap_gc_*`, same cost. +# Deleting a file without deleting its line here also FAILS (a rotted entry). +# +# That is the enforcement point behind docs/representation-selection-rfc.md +# 5.6: a new representation must be exercised against every GC arm, not just +# verified once by hand in its own PR. Files outside those three prefixes +# (typed-array param reads, int-valued typed-array locals) are not +# auto-detected at all, so register them explicitly. # # One basename per line, without the .ts extension. `#` starts a comment.