diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc26019e64d..2595aacf2fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -191,6 +191,9 @@ jobs: if: ${{ !cancelled() }} run: ./scripts/check_changeset_fragment.sh --self-test + - name: Cargo test scope regression tests + run: python3 -m unittest discover -s tests -p 'test_ci_test_scope.py' -v + - name: Docs catalog toolchain tests run: python3 docs/scripts/test_i18n_toolchain.py diff --git a/changelog.d/11424-stdlib-ci-fanout.md b/changelog.d/11424-stdlib-ci-fanout.md new file mode 100644 index 00000000000..6a419ad08e3 --- /dev/null +++ b/changelog.d/11424-stdlib-ci-fanout.md @@ -0,0 +1,8 @@ +Runtime changes now select the `perry-stdlib` unit tests in per-PR CI, covering +the GC, arena, closure and event-loop interactions previously skipped by the +reverse-dependency selector (#11423). Extension shims remain outside dependency +fan-out unless directly changed. Required lint coverage checks the selector’s +runtime, direct-change, unrelated, metadata-only and full-workspace cases. +Library-target detection now recognizes explicit Cargo crate types such as +`rlib`, so the selected stdlib suite actually runs with `--lib --bins` instead +of being skipped by `--bins`. diff --git a/docs/src/testing/ci-tiers.md b/docs/src/testing/ci-tiers.md index 318f20d420f..0aabb32d553 100644 --- a/docs/src/testing/ci-tiers.md +++ b/docs/src/testing/ci-tiers.md @@ -55,6 +55,12 @@ Within the **pr** tier the changed-file list narrows the plan further: - **deps** (a lockfile, manifest, `deny.toml`, `package.json`, `.claude/`, `skills/`, …) → additionally the `security-audit` reusable workflow. +Within `cargo-test`, runtime changes also select `perry-stdlib` unit tests: +those tests exercise runtime GC, arenas, closures, promises and the event loop. +The `perry-ext-*` crates remain excluded from dependency fan-out unless directly +changed. `scripts/ci_test_scope.py` owns this policy, with regression tests in +`lint`. + `e2e-scoped` runs integration suites selected by the diff, but its `SUITE_EXCLUSIONS` check is deliberately not scoped: whenever that list is nonempty, every core PR reruns each excluded exact test and fails if it no diff --git a/scripts/ci_test_scope.py b/scripts/ci_test_scope.py index aaaec90e984..9ba5974567e 100755 --- a/scripts/ci_test_scope.py +++ b/scripts/ci_test_scope.py @@ -96,15 +96,13 @@ def _runtime_link_augment(seeds): def _is_fanout_leaf(name): """Crates we never fan *into* when a dependency changes. - `perry-ext-*` and `perry-stdlib` are runtime FFI shims whose UNIT tests are - self-contained pure-Rust logic — they do not exercise perry-runtime internals, - so a perry-runtime change need not re-run them per-PR (the nightly full run + - perry's integration tests cover that interaction). Excluding them from the - reverse-dep fan-out keeps a foundational change from selecting ~40 crates, and - avoids perry-runtime feature-unification rebuilds. A direct change *to* one of - these crates still selects it (it starts as a seed). + `perry-ext-*` shims stay out of dependency fan-out to bound per-PR test + links and feature-unification rebuilds; direct changes still select them. + `perry-stdlib` MUST participate: its unit tests exercise runtime GC, arena, + closure, promise and event-loop behavior. Excluding it hid runtime-caused + failures until an unrelated stdlib change selected the suite (#11423). """ - return name == "perry-stdlib" or name.startswith("perry-ext-") + return name.startswith("perry-ext-") def _reverse_dep_closure(md, seeds): @@ -133,7 +131,7 @@ def _reverse_dep_closure(md, seeds): def _has_lib_mode() -> int: - """Exit 0 if any package named on stdin has a `lib` target, else exit 1. + """Exit 0 if any package named on stdin has a library target, else exit 1. `cargo test --lib` errors ("no library targets") when *no* selected package has a library — e.g. a perry-only diff selects just the bin-only `perry` @@ -141,8 +139,11 @@ def _has_lib_mode() -> int: """ names = set(sys.stdin.read().split()) md = _load_metadata() + # Cargo reports an explicit crate-type as the kind (e.g. stdlib's rlib), + # rather than always spelling library targets "lib". + library_kinds = {"lib", "rlib", "staticlib", "dylib", "cdylib", "proc-macro"} has = any( - any("lib" in t["kind"] for t in p["targets"]) + any(library_kinds.intersection(t["kind"]) for t in p["targets"]) for p in md["packages"] if p["name"] in names ) diff --git a/tests/test_ci_test_scope.py b/tests/test_ci_test_scope.py new file mode 100644 index 00000000000..998b8f73d28 --- /dev/null +++ b/tests/test_ci_test_scope.py @@ -0,0 +1,91 @@ +"""Regression coverage for the per-PR cargo-test package selection.""" +import contextlib +import io +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) +import ci_test_scope as scope + + +def package(name, *dependencies): + return { + "name": name, + "manifest_path": f"/workspace/crates/{name}/Cargo.toml", + "dependencies": [{"name": dep} for dep in dependencies], + } + + +class CargoTestScopeTests(unittest.TestCase): + def setUp(self): + self.metadata = {"packages": [ + package("perry-runtime"), + package("perry-stdlib", "perry-runtime"), + package("perry-stdlib-static", "perry-stdlib"), + package("perry-ffi", "perry-runtime"), + package("perry", "perry-ffi"), + package("perry-ext-net", "perry-runtime", "perry-stdlib"), + package("perry-parser"), + ]} + + def selected(self, *paths, full=False): + output = io.StringIO() + argv = ["ci_test_scope.py"] + (["--full"] if full else []) + with patch.object(scope, "_load_metadata", return_value=self.metadata), \ + patch.object(sys, "argv", argv), \ + patch.object(sys, "stdin", io.StringIO("\n".join(paths))), \ + contextlib.redirect_stdout(output): + self.assertEqual(scope.main(), 0) + return set(output.getvalue().splitlines()) + + def test_library_target_detection(self): + for kind in ("lib", "rlib", "staticlib", "dylib", "cdylib", "proc-macro", + "bin", "test", "example", "bench", "custom-build"): + with self.subTest(kind=kind): + md = {"packages": [{"name": "subject", "targets": [{"kind": [kind]}]}]} + with patch.object(scope, "_load_metadata", return_value=md), \ + patch.object(sys, "stdin", io.StringIO("subject\n")): + expected = 0 if kind in { + "lib", "rlib", "staticlib", "dylib", "cdylib", "proc-macro" + } else 1 + self.assertEqual(scope._has_lib_mode(), expected) + + def test_runtime_changes_run_stdlib_tests(self): + for path in ("arena/block.rs", "gc/roots.rs", "closure/mod.rs", + "event_pump.rs", "promise.rs"): + with self.subTest(path=path): + selected = self.selected(f"crates/perry-runtime/src/{path}") + self.assertIn("perry-stdlib", selected) + self.assertIn("perry-runtime", selected) + self.assertIn("perry", selected) + self.assertNotIn("perry-ext-net", selected) + self.assertNotIn("perry-parser", selected) + + def test_stdlib_direct_change_selects_stdlib_and_driver(self): + selected = self.selected("crates/perry-stdlib/src/common/async_bridge.rs") + self.assertIn("perry-stdlib", selected) + self.assertIn("perry", selected) + self.assertNotIn("perry-ext-net", selected) + + def test_extension_direct_change_is_still_selected(self): + self.assertEqual(self.selected("crates/perry-ext-net/src/lib.rs"), + {"perry-ext-net", "perry"}) + + def test_unrelated_change_does_not_run_stdlib(self): + self.assertEqual(self.selected("crates/perry-parser/src/lib.rs"), + {"perry-parser"}) + + def test_metadata_only_selects_nothing(self): + self.assertEqual(self.selected("Cargo.toml", "Cargo.lock", "CLAUDE.md"), set()) + + def test_full_and_infra_still_include_extensions(self): + expected = {p["name"] for p in self.metadata["packages"]} - scope.EXCLUDED + self.assertEqual(self.selected(full=True), expected) + self.assertEqual(self.selected("scripts/ci_test_scope.py"), expected) + + +if __name__ == "__main__": + unittest.main()