From 0f7a728479243e63162d16182d5a051c38cca085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 18 Jun 2026 17:35:48 +0200 Subject: [PATCH] =?UTF-8?q?perf(ci):=20make=20per-PR=20cargo-test=20fast?= =?UTF-8?q?=20(<10=20min)=20=E2=80=94=20unit=20tests=20for=20affected=20cr?= =?UTF-8?q?ates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-test took ~90 min: full workspace, serial debug build (CARGO_BUILD_JOBS=1 + clean-between-packages disk workaround), and 30/40 perry integration tests each auto-optimize-compile via perry (4-6 min each, unparallelizable — concurrent auto-opt builds thrash a shared target). Per-PR cargo-test is now (a) scoped to the diff (scripts/ci_test_scope.py: changed crates + reverse-dep closure, perry edge for runtime-linked stdlib/ffi/ext; infra/unknown -> full; metadata-only -> nothing) and (b) unit/lib/bin tests only (cargo test --lib --bins) — the slow auto-optimize integration tests are NOT run per-PR. Unit binaries are small so builds parallelize (no serialization/clean churn) and no staticlib is needed. Full suite (every integration test) runs on release tags, a new nightly schedule (04:00 UTC), workflow_dispatch, and the run-extended-tests label. test.yml branches the step on github.event_name. --- .github/workflows/test.yml | 139 +++++++++++++++++----------- CHANGELOG.md | 35 ++++++- CLAUDE.md | 2 +- Cargo.lock | 148 +++++++++++++++--------------- Cargo.toml | 2 +- scripts/ci_test_scope.py | 182 +++++++++++++++++++++++++++++++++++++ 6 files changed, 380 insertions(+), 128 deletions(-) create mode 100755 scripts/ci_test_scope.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 072985327..131c210e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,13 @@ on: - '*.md' - '!CLAUDE.md' - '!CHANGELOG.md' + schedule: + # Nightly full-workspace cargo-test safety net (04:00 UTC). The per-PR + # cargo-test gate only exercises crates affected by the diff + # (scripts/ci_test_scope.py); a cross-crate regression that slips a scoped + # PR is caught here within a day. `schedule` is not `pull_request`, so the + # cargo-test job runs the full workspace. + - cron: '0 4 * * *' # Manual escape hatch for the opt-in jobs. Maintainers (write access) # can dispatch the workflow against any ref with `run_extended_tests=true` # to run parity / compile-smoke / package smokes / doc-tests on demand @@ -288,6 +295,8 @@ jobs: # test builds accumulated in target/debug. CARGO_PROFILE_TEST_DEBUG: "0" CARGO_PROFILE_DEV_DEBUG: "0" + # For `gh pr view` (PR changed-file list → affected-crate scope). + GH_TOKEN: ${{ github.token }} run: | ( while sleep 60; do @@ -297,6 +306,26 @@ jobs: cargo_test_heartbeat_pid=$! trap 'kill "$cargo_test_heartbeat_pid" 2>/dev/null || true' EXIT + # Test scope: a per-PR run only exercises the crates the diff can + # affect (changed crates + their reverse-dependency closure, plus a + # `perry` edge for runtime-linked stdlib/ext archives) instead of the + # whole workspace (~90 min). Release tags, the nightly cron, and + # workflow_dispatch run the FULL workspace as the safety net. See + # scripts/ci_test_scope.py for the rules. + if [ "${{ github.event_name }}" = "pull_request" ]; then + changed_files="$(gh pr view "${{ github.event.pull_request.number }}" \ + --json files --jq '.files[].path')" + echo "Changed files in PR:"; printf '%s\n' "$changed_files" + scope="$(printf '%s\n' "$changed_files" | python3 scripts/ci_test_scope.py)" + else + scope="$(python3 scripts/ci_test_scope.py --full ` plus its **reverse-dependency closure** (a foundational-crate + change still fans out). Runtime-linked crates (`perry-stdlib`, `perry-ffi`, + `perry-ext-*`) add a `perry` edge (the driver links those archives at runtime, + not via cargo). Infra changes (`.github/`, `scripts/`, `rust-toolchain*`) or + any unrecognized path → full; metadata-only changes (`CHANGELOG.md`, + `CLAUDE.md`, `*.md`, `docs/`, root `Cargo.toml`/`Cargo.lock`) → nothing (a + version-bump PR is instantly green). +2. **Unit / lib / bin tests only** (`cargo test --lib --bins`): the slow + auto-optimize integration tests are **not** run per-PR. Unit-test binaries are + small, so builds parallelize safely (no serialization / clean churn) and there + is no staticlib to build. This is the part that bounds the per-PR wall-clock. + +The **full** suite — including every integration test — runs on **release tags**, +a new **nightly `schedule`** (04:00 UTC), `workflow_dispatch`, and any PR labeled +`run-extended-tests`. Release tags gate publishing, so nothing ships untested; +the nightly run is the cross-crate / integration regression backstop (main pushes +don't trigger Tests today). `test.yml` branches the cargo-test step on +`github.event_name`: `pull_request` → fast path; everything else → full. + +Trade-off (chosen deliberately, prioritizing a usable per-PR gate): a regression +only an integration test would catch lands on a PR and is caught by the nightly / +release-tag full run rather than at PR time. Three pre-existing CI fragilities on `main` were turning required checks red for PRs. Fixed together since all are "make main's CI green again." diff --git a/CLAUDE.md b/CLAUDE.md index 405341a5a..d23c68ce0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1184 +**Current Version:** 0.5.1185 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 2ef5044b5..999dfaa09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,7 +5283,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "base64", @@ -5340,14 +5340,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "cc", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "log", @@ -5370,7 +5370,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-hir", @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-hir", @@ -5387,7 +5387,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-dispatch", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-hir", @@ -5406,7 +5406,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "base64", @@ -5419,7 +5419,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-hir", @@ -5427,7 +5427,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "async-trait", @@ -5456,14 +5456,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "serde", "serde_json", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1184" +version = "0.5.1185" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "clap", @@ -5497,14 +5497,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "argon2", "perry-ffi", @@ -5512,7 +5512,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "reqwest", @@ -5521,7 +5521,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "bcrypt", "perry-ffi", @@ -5529,7 +5529,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "rusqlite", @@ -5537,7 +5537,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "scraper", @@ -5545,7 +5545,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "perry-runtime", @@ -5553,7 +5553,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "chrono", "cron 0.16.0", @@ -5563,7 +5563,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "chrono", "perry-ffi", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "rust_decimal", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "serde_json", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "perry-runtime", @@ -5603,14 +5603,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "bytes", "http-body-util", @@ -5627,7 +5627,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "lazy_static", "perry-ffi", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5652,7 +5652,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "bytes", "h2", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "lazy_static", "perry-ffi", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "jsonwebtoken", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "lru", "perry-ffi", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "chrono", "perry-ffi", @@ -5712,7 +5712,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "bson", "futures-util", @@ -5724,7 +5724,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "chrono", "perry-ffi", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "nanoid", "perry-ffi", @@ -5743,7 +5743,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "perry-runtime", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "lettre", "perry-ffi", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "printpdf", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "sqlx", @@ -5782,7 +5782,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "governor", "perry-ffi", @@ -5790,7 +5790,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "image", @@ -5799,14 +5799,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "lazy_static", "perry-ffi", @@ -5815,7 +5815,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "uuid", @@ -5823,7 +5823,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ffi", "regex", @@ -5833,7 +5833,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "futures-util", "lazy_static", @@ -5845,7 +5845,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "brotli", "flate2", @@ -5854,7 +5854,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "dashmap", "once_cell", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-api-manifest", @@ -5881,7 +5881,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-diagnostics", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "base64", @@ -5926,7 +5926,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "perry-hir", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6036,14 +6036,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "itoa", @@ -6060,7 +6060,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "rand 0.8.6", "serde", @@ -6070,7 +6070,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "cairo-rs", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "block2", @@ -6109,7 +6109,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "block2", @@ -6124,7 +6124,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1184" +version = "0.5.1185" [[package]] name = "perry-ui-test" @@ -6132,11 +6132,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1184" +version = "0.5.1185" [[package]] name = "perry-ui-tvos" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "block2", @@ -6152,7 +6152,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "block2", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "block2", "libc", @@ -6181,7 +6181,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "libc", @@ -6198,14 +6198,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "base64", "ed25519-dalek", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1184" +version = "0.5.1185" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 007dd0612..02653513f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -215,7 +215,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1184" +version = "0.5.1185" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/scripts/ci_test_scope.py b/scripts/ci_test_scope.py new file mode 100755 index 000000000..a2c0c8865 --- /dev/null +++ b/scripts/ci_test_scope.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Compute which workspace packages a CI `cargo test` run must cover. + +Reads the changed file paths (one per line) on stdin and prints the set of +workspace packages to test, one per line, so the per-PR `cargo-test` gate only +exercises crates the diff can actually affect instead of the whole workspace +(~90 min). The release-tag and nightly runs pass `--full` to test everything. + +Selection rules: + * A file under `crates//...` selects that crate AND every workspace crate + that (transitively) depends on it — a reverse-dependency closure, so a change + to a foundational crate (perry-runtime, perry-hir, …) still fans out to its + dependents. + * Infra changes (`.github/`, `scripts/`, `rust-toolchain*`) or any unrecognized + top-level path force the FULL workspace (conservative — unknown blast radius). + * Metadata-only changes (`CHANGELOG.md`, `CLAUDE.md`, any `*.md`, `docs/`, the + root `Cargo.toml`/`Cargo.lock`) select nothing — a version-bump / changelog + PR runs no tests and is instantly green. + * `--full` (release tags, nightly cron, workflow_dispatch) selects every + testable workspace member. + +Cross-host UI crates and doc fixtures that don't build on the Linux CI image are +always excluded (mirrors the historical exclude list in test.yml). `perry-runtime` +is included when affected; the workflow runs it single-threaded separately. + +Usage: | python3 scripts/ci_test_scope.py [--full] +""" +import json +import subprocess +import sys + +# Excluded from the Linux cargo-test gate (see test.yml): cross-host UI backends +# (objc2 / win32 / NDK / gtk) and the doc fixture crate. +EXCLUDED = { + "perry-ui-macos", + "perry-ui-ios", + "perry-ui-visionos", + "perry-ui-tvos", + "perry-ui-watchos", + "perry-ui-gtk4", + "perry-ui-android", + "perry-ui-windows", + "perry-ui-windows-winui", + "perry-doc-fixture-my-bindings", +} + +INFRA_PREFIXES = (".github/", "scripts/", "rust-toolchain") + +# Top-level files with no effect on which crates to test. +IGNORABLE_EXACT = {"Cargo.toml", "Cargo.lock", "CHANGELOG.md", "CLAUDE.md", "LICENSE"} + + +def _is_ignorable(path: str) -> bool: + if path in IGNORABLE_EXACT: + return True + if path.endswith(".md"): + return True + if path.startswith("docs/"): + return True + return False + + +def _load_metadata(): + raw = subprocess.check_output( + ["cargo", "metadata", "--no-deps", "--format-version", "1"] + ) + return json.loads(raw) + + +def _testable_members(md): + return {p["name"] for p in md["packages"] if p["name"] not in EXCLUDED} + + +def _dir_to_pkg(md): + """Map each `crates/` directory to its cargo package name.""" + out = {} + for p in md["packages"]: + mp = p["manifest_path"] + if "/crates/" in mp: + d = mp.split("/crates/", 1)[1].split("/", 1)[0] + out[d] = p["name"] + return out + + +def _runtime_link_augment(seeds): + """Add `perry` when a runtime-linked crate changes. + + The `perry` compile driver links `libperry_stdlib.a` / `libperry_ffi` and the + per-package `libperry_ext-*.a` archives into the *compiled output* at runtime, + not as cargo dependencies — so the cargo dep graph does NOT capture that + `perry`'s integration tests (which compile + run TS programs against those + archives) depend on them. `perry-runtime` already reaches `perry` through real + cargo edges (via perry-ffi); stdlib / ffi / ext crates need this explicit + edge so a change to them still runs perry's integration suite. + """ + augmented = set(seeds) + for s in seeds: + if s in ("perry-stdlib", "perry-ffi") or s.startswith("perry-ext-"): + augmented.add("perry") + return augmented + + +def _reverse_dep_closure(md, seeds): + """All workspace members that transitively depend on any package in `seeds`.""" + members = {p["name"] for p in md["packages"]} + # revdeps[x] = packages that directly depend on x + revdeps = {} + for p in md["packages"]: + for d in p.get("dependencies", []): + if d["name"] in members: + revdeps.setdefault(d["name"], set()).add(p["name"]) + affected = set(seeds) + stack = list(seeds) + while stack: + cur = stack.pop() + for dependent in revdeps.get(cur, ()): + if dependent not in affected: + affected.add(dependent) + stack.append(dependent) + return affected + + +def _has_lib_mode() -> int: + """Exit 0 if any package named on stdin has a `lib` 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` + crate. The fast per-PR path uses this to choose `--lib --bins` vs `--bins`. + """ + names = set(sys.stdin.read().split()) + md = _load_metadata() + has = any( + any("lib" in t["kind"] for t in p["targets"]) + for p in md["packages"] + if p["name"] in names + ) + return 0 if has else 1 + + +def main() -> int: + if "--has-lib" in sys.argv: + return _has_lib_mode() + + full = "--full" in sys.argv + changed = [line.strip() for line in sys.stdin if line.strip()] + + md = _load_metadata() + testable = _testable_members(md) + + if not full: + dir_to_pkg = _dir_to_pkg(md) + seeds = set() + for f in changed: + if f.startswith(INFRA_PREFIXES): + full = True + elif f.startswith("crates/"): + d = f.split("/", 2)[1] + pkg = dir_to_pkg.get(d) + if pkg is not None: + seeds.add(pkg) + else: + # File under crates/ that isn't a known package dir — be safe. + full = True + elif _is_ignorable(f): + continue + else: + # Unrecognized top-level path (build config, etc.) — be safe. + full = True + + if full: + selected = testable + else: + seeds = _runtime_link_augment(seeds) + selected = _reverse_dep_closure(md, seeds) & testable + + for name in sorted(selected): + print(name) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())