Files
ironclaw/scripts/ci/reborn_pr_test_plan.py
Henry Park 5b4a86690c ci: parallelize affected crate tests with nextest (#8013)
* ci: parallelize affected crate tests with nextest

* ci: preserve process-local test contracts under nextest

- serialize composition runtime and CLI trace-policy tests\n- preserve zero-test package buckets\n- restore fail-closed metadata routing

* fix(ci): validate cargo runner bucket subsets

* Address PR review feedback (#8013)

- Skip nextest installation for exact-target and Cargo-only buckets.
- Validate serialized nextest groups against compiled test inventory.

* fix(ci): parse nextest inventory specifications

Replace the YAML-sensitive heredoc with a Bash array and syntax-check the rendered crate runner in the workflow contract suite.

---------

Co-authored-by: Henry Park <16583448+henrypark133@users.noreply.github.com>
2026-09-01 21:54:43 +00:00

1589 lines
71 KiB
Python

#!/usr/bin/env python3
"""Select focused Reborn test lanes for pull requests and merge groups.
Diff events run direct evidence for changed packages and test surfaces.
Global or unattributable merge-group changes widen to exhaustive coverage;
main and manual runs remain exhaustive.
"""
from __future__ import annotations
import argparse
import functools
import json
import subprocess
import sys
import tomllib
from collections import defaultdict
from pathlib import Path, PurePosixPath
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
# The WebUI frontend path prefix is resolved by crate NAME through the shared
# inventory (scripts/ci/lib/crate_tree.py), not a literal `crates/ironclaw_webui`
# prefix. Under the target-architecture family move
# (crates/<family>/ironclaw_*, PROPOSAL §5) a literal prefix stops matching,
# frontend diffs stop routing to the Code Style lane, and the planner reports
# "no Reborn test surface changed" for a WebUI change — silently, since
# nothing else covers that lane. See
# docs/internal/reborn/target-architecture/CHECKLIST.md WS10.
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from crate_tree import CrateTreeError, crate_directory # noqa: E402
from integration_test_inventory import ( # noqa: E402
INTEGRATION_PARTITION_COUNT,
planner_test_lanes,
)
# Owns the publication fence (.mintignore parsing and matching).
import docs_publication_boundary # noqa: E402
@functools.lru_cache(maxsize=None)
def _webui_frontend_prefix() -> str:
"""`<ironclaw_webui crate dir>/frontend/`, resolved once per process."""
try:
directory = crate_directory("ironclaw_webui", ROOT)
except CrateTreeError as error:
raise RuntimeError(
"reborn_pr_test_plan: cannot resolve the ironclaw_webui crate, so "
f"the frontend path prefix used to route Code Style is unknown: {error}"
) from error
return f"{directory}/frontend/"
@functools.lru_cache(maxsize=None)
def _sandbox_crate_directory() -> str:
"""`<ironclaw_sandbox crate dir>`, resolved once per process."""
try:
return crate_directory("ironclaw_sandbox", ROOT)
except CrateTreeError as error:
raise RuntimeError(
"reborn_pr_test_plan: cannot resolve the ironclaw_sandbox crate, so "
f"the source paths used to route the Docker lane are unknown: {error}"
) from error
def _sandbox_docker_prefixes() -> tuple[str, ...]:
"""Sandbox source prefixes whose changes require the Docker lane."""
directory = _sandbox_crate_directory()
return (
f"{directory}/src/sandbox_process",
f"{directory}/tests/user_sandbox_docker_live/",
)
MAX_PR_CRATE_BUCKETS = 3
DIFF_EVENTS = {"pull_request", "merge_group"}
FULL_EVENTS = {"push", "workflow_call", "workflow_dispatch", "schedule"}
ALL_ROOT_PARTITIONS = (0, 1, 2, 3)
ALL_INTEGRATION_LANES = (*range(INTEGRATION_PARTITION_COUNT), "groups")
NEXTEST_LIBTEST_TARGET_KINDS = {
"lib",
"bin",
"test",
"bench",
"example",
"proc-macro",
}
# Doc-fact contract tests (#7378) read `docs/` pages from inside owning
# crates, so a published-page edit can fail a cargo test. Route those edits
# to exactly the doc-fact test binaries (no reverse-dependency widening —
# prose can only change the assertions that read it); otherwise docs-only
# PRs merge green and the failure lands on an unrelated change later.
DOC_FACT_PAGE_TESTS = {
"docs/using/cli.mdx": ("ironclaw", "docs_cli_reference"),
"docs/api/responses.mdx": ("ironclaw_openai_compat", "docs_responses_contract"),
}
DOC_FACT_PUBLISHED_SWEEP = (
"ironclaw_extension_registry",
"docs_manifest_schema_version",
)
DOCS_PREFIX = "docs/"
DOCS_MINTIGNORE = "docs/.mintignore"
@functools.cache
def _publication_fence() -> list[str]:
"""The `.mintignore` patterns, parsed from the authoritative file so a
removed fence entry widens the routing with the sweep it selects. A
missing file means no fence (everything published), matching
docs_publication_boundary.find_violations()."""
path = Path(__file__).resolve().parents[2] / DOCS_MINTIGNORE
if not path.exists():
return []
return docs_publication_boundary.parse_mintignore(path.read_text(encoding="utf-8"))
def _doc_fact_selections(path: str) -> list[tuple[str, str]]:
"""(package, test target) pairs whose doc-fact tests read this path."""
if not path.startswith(DOCS_PREFIX):
return []
if path == DOCS_MINTIGNORE:
# A fence edit changes the sweep's scope; run it.
return [DOC_FACT_PUBLISHED_SWEEP]
selections = []
page = DOC_FACT_PAGE_TESTS.get(path)
if page is not None:
selections.append(page)
if Path(path).suffix in {".md", ".mdx"} and not docs_publication_boundary.is_ignored(
PurePosixPath(path[len(DOCS_PREFIX) :]), _publication_fence()
):
selections.append(DOC_FACT_PUBLISHED_SWEEP)
return selections
# Path classes with no Rust or E2E surface any Reborn lane can exercise.
# `.claude/` is agent guidance (skills, commands, rules) — prose in the same
# class as `docs/` (whose published Markdown is escalated by the doc-fact arm
# above before this class applies; only fenced trees and non-page files reach
# it). It was unclassified until 2026-08-03, which meant the
# planner's fail-closed arm rejected every PR that touched agent guidance:
# the only satisfiable behaviour for that class was "never edit it", which is
# not a policy anyone chose. Classifying it is the fix; loosening the
# fail-closed arm is not.
# `openwiki/` is the auto-generated prose wiki, regenerated by
# `.github/workflows/openwiki-update.yml` and explicitly not hand-edited (see
# the root CLAUDE.md). It has no build or test surface — exactly `docs/`'s
# footing — and was unclassified until 2026-08-05, so the fail-closed arm
# rejected every PR that touched it. A crate rename touches it by construction
# (its prose names crate directories), which is how the gap surfaced. Same fix
# as the `.claude/` and `.env.example` gaps above: classify it, rather than
# loosen the arm that catches genuinely unknown paths.
IGNORED_PREFIXES = (
"docs/",
"openwiki/",
".claude/",
# IronLoop reads this repository configuration and optional role guidance;
# no Reborn crate or test lane consumes it.
".ironloop/",
".github/ISSUE_TEMPLATE/",
# `ISSUE_TEMPLATE/`'s exact sibling: a GitHub UI template that changes no
# crate, test, or runtime surface (`classify-test-scope.sh` already pairs
# the two in its docs-only arm). The rest of `.github/` stays undecided.
".github/pull_request_template.md",
# Agent bootstrap data only. The compressed graph and its attributes do
# not change an Ironclaw crate, test, or runtime surface. (#7215 commits
# the artifact; ported from main's planner at the #7152 refresh.)
".codebase-memory/",
)
IGNORED_GUIDANCE_PATHS = {
"tests/AGENTS.md",
"tests/CLAUDE.md",
"tests/integration/AGENTS.md",
"tests/integration/CLAUDE.md",
# `check-guidance.py`'s alias rule also requires this pair (`crates/` or
# `TESTS_PREFIX`); without an explicit entry here it falls into the
# `tests/support/reborn_parity_qa/` arm below and over-selects the root
# partition for a pure guidance edit.
"tests/support/reborn_parity_qa/AGENTS.md",
"tests/support/reborn_parity_qa/CLAUDE.md",
}
# Repo-root prose/example files with no build or test surface. Root `*.md` is
# already handled inline below; this covers the non-`.md` siblings.
#
# `.env.example` is documentation of environment variables, not an input to
# anything: no crate, test, or workflow reads the file (only doc comments
# mention it by name). It was unclassified until 2026-08-04, so the
# fail-closed arm rejected every PR that corrected an env-var comment — the
# same shape as the `.claude/` gap above, and the same fix: classify it,
# rather than loosen the arm that catches genuinely unknown paths.
IGNORED_ROOT_FILES = (".env.example",)
DEDICATED_WORKFLOW_PREFIXES = (
"tools/ironclaw_stress/",
# `harness/latency/runner` is a standalone cargo project with its own
# Cargo.lock, excluded from the workspace. No Reborn test lane builds
# it, so it selects nothing rather than failing the planner closed.
# (Same *class* as WORKSPACE_EXCLUDED_PREFIXES below; kept here because
# renaming its reason string would churn nothing useful.)
"harness/",
)
# Cargo projects this workspace deliberately does not build, with no workflow
# of their own either. No lane can be selected for them, so they select nothing
# — the honest answer, and not the same claim as "a dedicated workflow owns it".
#
# `tools/ironclaw_silk_decoder` is `[workspace]`-rooted and named in the root
# `exclude`: `cargo build --workspace` never sees it and `cargo metadata` never
# lists it. It was reachable only through the `crates/**` arm until WS7 moved
# it to `tools/` (PROPOSAL §12.13 D-O), and that arm answered **badly** — a
# one-line edit to the excluded helper's `main.rs` selected the ENTIRE
# workspace, because the crate-attribution fall-through treats a path under
# `crates/` with no owning member as broad risk. So this entry does not merely
# restore the old behaviour; it replaces a silent over-selection with an
# explicit "nothing builds this".
WORKSPACE_EXCLUDED_PREFIXES = ("tools/ironclaw_silk_decoder/",)
DEDICATED_E2E_PREFIX = "tests/e2e/"
QA_HARNESS_PREFIXES = (
# ⚠ `scripts/live-canary/` and `scripts/live_canary/` are BOTH real
# directories that differ only by hyphen-vs-underscore, and classifying one
# does not classify the other. The hyphen tree is the canary's fixtures and
# reporters; the underscore tree is its importable Python package
# (`__init__.py`, `common.py`, `auth_runtime.py`). Both are read only by
# `live-canary.yml` and `reborn-e2e.yml` — dedicated workflows with their
# own scope detectors — and by no `Tests (Reborn)` lane.
#
# The underscore tree was unclassified until 2026-08-05: a crate rename
# reaches it (`common.py` builds a `RUST_LOG` value naming a crate), so the
# fail-closed arm rejected the whole of WS6 on one string. Same class as
# `scripts/reborn_qa_matrix/` below, and the same fix.
"scripts/live-canary/",
"scripts/live_canary/",
"scripts/reborn_webui_v2_live_qa/",
# The QA surface-inventory auditor and its self-test. Same footing as the
# two above: an offline reporting tool over the WebUI/OpenAI-compat route
# descriptors, run by hand and by the QA matrix, never by a `Tests (Reborn)`
# lane. Added 2026-08-04 (WS10) — before this, editing it raised
# `unmapped test or CI path` and failed `Detect Reborn test scope`, skipping
# every downstream Reborn lane. Same class as the `.claude/` gap this row
# already records.
"scripts/reborn_qa_matrix/",
# The tool-discovery benchmark is a manual live-model harness. It records
# QA evidence across disclosure modes and catalog sizes; no Reborn Rust
# lane invokes it.
"scripts/tool_discovery_benchmark/",
# The live Telegram release smoke harness (`run_smoke.py` + config +
# README): run by hand against a real bot, referenced by no workflow, never
# by a `Tests (Reborn)` lane. Unclassified until 2026-08-06, when PR
# #7264's stale-command fix in its README hit the fail-closed arm.
"scripts/telegram_smoke/",
)
CHANGED_COVERAGE_MANIFEST = "tests/integration/changed-coverage-exemptions.toml"
SANDBOX_DOCKER_PREFIXES = ("docker/sandbox/",)
SANDBOX_DOCKER_EXACT_PATHS = {
"Dockerfile.sandbox-worker",
"crates/app/ironclaw_cli/src/runtime/mod.rs",
"crates/app/ironclaw_composition/src/sandbox.rs",
"crates/app/ironclaw_composition/src/builtin_capability_policy.rs",
"crates/app/ironclaw_composition/src/deployment.rs",
"crates/app/ironclaw_composition/src/factory/production_backend_assembly.rs",
"crates/app/ironclaw_composition/src/factory/runtime_lane_assembly.rs",
"crates/app/ironclaw_composition/src/input.rs",
"crates/app/ironclaw_config/src/profile.rs",
"crates/kernel/ironclaw_host_runtime/src/first_party_tools/mod.rs",
"crates/kernel/ironclaw_host_runtime/src/invocation_services.rs",
"crates/kernel/ironclaw_host_runtime/src/process_port.rs",
"crates/kernel/ironclaw_host_runtime/src/services.rs",
"crates/kernel/ironclaw_host_runtime/src/services/builder.rs",
"crates/kernel/ironclaw_runtime_policy/src/planner.rs",
"crates/kernel/ironclaw_runtime_policy/src/resolver.rs",
"crates/lanes/ironclaw_sandbox/tests/support/docker_gate.rs",
"crates/lanes/ironclaw_sandbox/tests/user_sandbox_docker_live.rs",
"tests/integration/reborn_sandbox_shell_turn.rs",
"tests/e2e_trace_runtime_policy_serde.rs",
"tests/fixtures/llm_traces/runtime_policy/hosted_dev_no_shell.json",
"tests/integration/support/builder.rs",
"tests/integration/support/capability_backend.rs",
"tests/integration/support/docker_gate.rs",
"tests/integration/support/harness/mod.rs",
"tests/integration/support/harness/options.rs",
"tests/integration/support/harness/profiles/sandbox_shell.rs",
}
# Asset trees that live outside every crate root but are compiled *into* a
# workspace crate through a relative `include_bytes!` / `include_str!` that
# escapes its own crate (the §11.2.7 reach-ins inventoried by
# `crates/app/ironclaw_architecture_tests/tests/reborn_cross_crate_include_scan.rs`).
# Cargo's package directories cannot see them, so the `crates/` arm below
# resolves no package and the planner used to fail closed on every PR that
# touched a first-party extension package.
#
# They must NOT be classified as ignored. A `wasm/*.wasm` under
# `crates/extensions/packages/` is a *shipped artifact* that
# `ironclaw_extension_support` embeds byte-for-byte; calling it prose would
# turn today's loud failure into a silent under-schedule of a change to
# production output. Route each tree to the crate that compiles it instead, so
# a change still schedules that crate's tests and everything downstream of it.
#
# `scripts/ci/test_reborn_pr_test_plan.py` pins both halves: that the mapping
# routes (not ignores), and that every prefix and owner below still exists.
BUNDLED_SKILLS_PREFIX = "skills/"
EMBEDDED_ASSET_OWNERS: tuple[tuple[str, str], ...] = (
# manifests, prompts, schemas and built `wasm/*.wasm` for the first-party
# extension packages -> `ironclaw_extension_support`
# (`src/packages/*.rs`). Packages that are themselves workspace crates
# (slack, telegram, mem0, memory-native) resolve to their own package
# first and never reach this table.
("crates/extensions/packages/", "ironclaw_extension_support"),
# the uploadable WASM fixture bundles, whose `manifest.toml` files are
# `include_str!`d by `ironclaw_extension_host`
# (`src/available_extension_import.rs`). The guest sources beside them are
# standalone cargo workspaces that no Reborn lane compiles; routing the
# whole tree to the embedding crate over-schedules those rather than
# letting a fixture change select nothing at all.
("test-tools/", "ironclaw_extension_host"),
# the bundled Reborn product skills. `ironclaw_extension_host`'s build
# script (`build.rs`, `embed_reborn_skills`) walks this repo-root tree,
# parses every `skills/<name>/SKILL.md`, and embeds each skill's complete
# file set byte-for-byte into the binary (`src/bundled_skills.rs`), with
# `cargo:rerun-if-changed` on every file. Unlike the two trees above,
# *every* file here is shipped product output — Markdown included, which
# is why `_is_shipped_asset_markdown` special-cases this prefix instead of
# keying on a `prompts/` segment. Unclassified until 2026-08-05, when the
# fail-closed arm rejected #7157 on `skills/delegation/SKILL.md`.
(BUNDLED_SKILLS_PREFIX, "ironclaw_extension_host"),
)
EMBEDDED_ASSET_PREFIXES: tuple[str, ...] = tuple(
prefix for prefix, _ in EMBEDDED_ASSET_OWNERS
)
# Everything the crate/asset arm owns: crate roots plus the asset trees above.
CRATE_OR_ASSET_PREFIXES = ("crates/",) + tuple(
prefix for prefix, _ in EMBEDDED_ASSET_OWNERS
)
def _is_shipped_asset_markdown(path: str) -> bool:
"""True for Markdown that is itself a shipped asset in a table-owned tree.
Two Markdown *asset* kinds exist, and everything else ending in `.md`
under the asset trees is documentation — `test-tools/README.md`, a package
`AGENTS.md` — and stays prose:
* **Prompts** in the package and fixture trees: the table owns "manifests,
prompts, schemas and built `wasm/*.wasm`", and of those kinds only a
prompt is Markdown (the other three are `.toml`, `.json` and `.wasm`).
Keyed on the directory segment so it holds at any depth
(`<pkg>/prompts/<tool>/<name>.md` today, deeper tomorrow).
* **Bundled skills**: under `skills/` every file is the shipped artifact —
`build.rs` parses each `SKILL.md` and embeds each skill's complete file
set — so all Markdown there is product output, keyed on the tree itself.
"""
if not path.startswith(EMBEDDED_ASSET_PREFIXES):
return False
if path.startswith(BUNDLED_SKILLS_PREFIX):
return True
return "prompts" in Path(path).parts[:-1]
INTEGRATION_SUPPORT_OWNERS = {
"tests/fixtures/extensions/acme-messenger/manifest.toml": (
"tests/integration/extension_runtime.rs"
),
"tests/support/hosted_mcp_registration_server.rs": (
"tests/integration/hosted_mcp_registration.rs"
),
}
INTEGRATION_SNAPSHOT_PREFIX_OWNERS = {
"tests/snapshots/golden_payload__": "tests/integration/golden_payload.rs",
}
# Production files whose changes alter the model-visible prompt or tool
# surface that `golden_payload` snapshot-pins — the surface digest, the
# instruction bundle, the communication-context renderer, and the shipped
# prompt assets of the crates the golden harness composes. A change here still
# classifies as a production-package change below (crate buckets, reverse
# dependents); this mapping ADDITIONALLY schedules the golden integration lane
# so a prompt-surface PR cannot land green and then bounce the merge queue on
# stale goldens (#7361's queue failure, 2026-08-07: `surface.rs` changed the
# surface digest, the PR lane never ran the golden bucket, the exhaustive
# queue gate caught it first).
#
# Curated, not derived — a new prompt-composition site must be added here by
# hand, and until it is, the merge queue remains the backstop exactly as it
# was for every path before this mapping existed. Self-tested by
# `test_reborn_pr_test_plan.py` (positive per entry + a negative control).
PROMPT_SURFACE_GOLDEN_OWNER = "tests/integration/golden_payload.rs"
PROMPT_SURFACE_PATHS = (
"crates/kernel/ironclaw_host_runtime/src/surface.rs",
"crates/contracts/ironclaw_loop_contracts/src/instruction_bundle.rs",
"crates/contracts/ironclaw_loop_contracts/src/runtime_context.rs",
)
PROMPT_SURFACE_PREFIXES = (
"crates/contracts/ironclaw_loop_contracts/prompts/",
"crates/contracts/ironclaw_host_api/prompts/",
"crates/loop/ironclaw_agent_loop/prompts/",
"crates/loop/ironclaw_loop_host/prompts/",
# The host-managed ports assemble the exact request the goldens pin:
# `prompt.rs` drives the InstructionBundleBuilder into the message list
# and `model.rs` shapes the model request around it.
"crates/kernel/ironclaw_turns/src/host_managed_ports/",
)
PR_STATIC_CONTROL_PATHS = {
"Cargo.toml",
"rust-toolchain",
"rust-toolchain.toml",
".cargo/config",
".cargo/config.toml",
"tests/integration/coverage-exemptions.toml",
"tests/integration/coverage-floor.toml",
# Release-smoke Python coverage is executed by Code Style's dedicated
# `Release smoke script tests` step. It drives the real
# `scripts/ci/smoke-release-binary.py`; no Tests (Reborn) Rust lane owns
# this unittest module.
"tests/test_smoke_release_binary.py",
# Repo-root `scripts/` is deliberately NOT prefix-classified — the
# `unmapped test or CI path` arm below exists to force a per-file decision.
# These are decided:
# * the panic baseline is enforced by Code Style
# (`check_no_panics.py --reborn-baseline`), which runs on every PR and
# owns the whole check; no Reborn lane reads it.
# * `reborn-e2e-rust.sh` is driven by the `Reborn E2E` workflow, which has
# its own scope detector (`Detect Reborn E2E scope`). This planner
# selects lanes for `Tests (Reborn)` only, and that workflow does not
# invoke the script.
# * `check-version-bumps.sh` is the WIT/package version-parity gate, run
# only by `platform-and-compat.yml` (`run: ./scripts/check-version-bumps.sh`,
# behind that workflow's own `has_direct_wasm_abi_risk` filter, which
# already names the script). No `Tests (Reborn)` lane invokes it.
# Decided 2026-08-04 (WS10): editing it previously raised
# `unmapped test or CI path`, failing `Detect Reborn test scope` and
# skipping every downstream Reborn lane.
# * `run-reborn-webui.sh` is a local developer launcher for the WebUI dev
# server. It is referenced by no workflow at all (a search over
# `.github/` finds nothing), so no lane can be selected for it.
# * `build-wasm-extensions.sh` is named in the same
# `has_direct_wasm_abi_risk` classifier, which both scopes and runs it,
# and additionally has a Code Style self-test
# (`scripts/ci/test-build-wasm-extensions.sh`). No Reborn Rust lane
# executes it.
# * `e2e-skill-self-creation.sh` drives the skill self-creation e2e
# against a live model, selected by `E2E_PROFILE`. Like
# `run-reborn-webui.sh` it is referenced by no workflow (a search over
# `.github/` finds nothing) and needs credentials no lane has, so no
# lane can be selected for it; it is run by hand per
# `docs/internal/skills/multi_tenant_enablement.md`.
"scripts/no_panics_reborn_baseline.txt",
"scripts/reborn-e2e-rust.sh",
"scripts/build-wasm-extensions.sh",
"scripts/check-version-bumps.sh",
"scripts/run-reborn-webui.sh",
"scripts/e2e-skill-self-creation.sh",
# `codebase-graph.sh` inspects agent-only graph metadata. It does not
# execute or select a Reborn product test surface. (Arrived with #7215.)
"scripts/codebase-graph.sh",
# Container build inputs. `platform-and-compat.yml` keys `has_docker_risk`
# off exactly this pair and owns the image build; Code Style additionally
# proves every `include_str!` target is inside each build context
# (`scripts/ci/check-include-str-paths.sh`, the #5603 outage class). A
# Reborn Rust lane never builds an image.
"Dockerfile",
".dockerignore",
# `.gitignore` is decided the same way, and belongs here rather than with
# the repo-root prose above precisely because something *does* read it:
# Code Style's `Reject tracked files that match .gitignore` guard
# (`git ls-files -ci --exclude-standard`) owns that check outright. It runs
# whenever `has_code` is true, and `has_code` names `.gitignore` itself, so
# it runs on every PR that edits one. No Reborn lane reads the file, so no
# lane here can exercise a change to it. It was unclassified until
# 2026-08-04 (#6965), so the fail-closed arm failed the whole
# `Tests (Reborn)` roll-up on any PR that added an ignore rule.
".gitignore",
# * `check_no_panics.py` is the enforcer of the baseline listed above and
# runs under Code Style on every PR; same owner, same reasoning.
"scripts/check_no_panics.py",
# * `dev_metrics.py` is a reporting tool; nothing gates on it.
"scripts/dev_metrics.py",
# * `check-type-duplicates.py` is a local dev analysis tool (type-dedup
# backlog reporting); no workflow or hook invokes it, so no lane can
# exercise a change to it. Surfaced 2026-08-05 (#7259) when the docs
# path sweep touched its docstring.
"scripts/check-type-duplicates.py",
# * Its self-test, `python3 scripts/test-check-type-duplicates.py`, runs
# under Code Style's "Static-check self-tests" step
# (`.github/workflows/code_style.yml`) — the same lane that runs
# `scripts/ci/test-check-guidance.py` and the other `scripts/ci/`
# self-tests covered by the `PR_STATIC_CONTROL_PREFIXES` rule below.
# Neither this file nor `scripts/check-type-duplicates.py` sits under
# `scripts/ci/`, so unlike its sibling neither tripped the workflow's
# `has_code` path filter on its own — a PR touching only this file had
# `fast-checks` (and thus the self-test step) skipped entirely (review
# on #7797). `has_code`'s scope list now names both scripts
# explicitly, pinned by the `has_code` filter's `in_scope` probes in
# `scripts/ci/ws12_workflow_contracts.py`. Static control here means
# "no Tests (Reborn) lane reads it", not "no workflow runs it": Code
# Style owns this self-test, not a Reborn Rust test lane, so it stays
# classified as static control.
"scripts/test-check-type-duplicates.py",
# * `render-architecture-video.sh` is a local one-command Remotion
# render for docs/internal/architecture-video; never invoked by a
# workflow (the `architecture-video` Claude skill that used to
# reference it was deleted as stale v1-architecture content — see
# `.claude/skills/`). No lane can exercise a change to it.
"scripts/render-architecture-video.sh",
# * `pre-commit-safety.sh` is a local git hook, not a CI lane.
"scripts/pre-commit-safety.sh",
# * `preflight-gates.sh` is the local pre-push gate gauntlet proposed by
# the 2026-08 gate audit (docs/internal/gate-audit-2026-08.md §4.3);
# referenced by no workflow, so no lane can be selected for it.
"scripts/preflight-gates.sh",
# * `check-boundaries.sh` was DELETED by the same audit (measured broken
# on a clean tree, run by nothing). The entry stays so the deletion
# diff — and any revert — classifies instead of tripping the
# fail-closed arm; the audit's own PR was the first to hit it.
"scripts/check-boundaries.sh",
# * `test-mutation-audit.sh` is the self-test for the mutation audit,
# driven by its own lane rather than by a crate/integration selection.
"scripts/test-mutation-audit.sh",
# * `mutation-audit.sh` is the audit those guardrails self-test, run by
# the same dedicated lane (`nightly-deep-ci.yml`'s mutation-frontier
# job) and by hand; no Reborn PR lane invokes it.
"scripts/mutation-audit.sh",
# * the rest of the repo-root metadata class, classified 2026-08-04 as a
# class rather than one file per red run. Every entry above this block
# was added the other way — a rename-shaped diff touches root files no
# feature PR normally touches, the planner fails closed on the first
# one, and the next only surfaces after that one is fixed (`Dockerfile`,
# then `clippy.toml`, then six more). The whole remaining class is
# enumerated here so the sequence stops.
#
# Membership rule: a file belongs here only if NO Reborn test lane
# reads it. Verified per file against `crates/**/*.rs` and `tests/**`
# before listing. `.dockerignore` and `.env.example` are absent from
# this block because they are decided elsewhere above — the former in
# the container-build pair, the latter in `IGNORED_ROOT_FILES`.
#
# Workspace lint/format/dependency/release policy, owned by the lanes
# that read them — `clippy.toml` by Code Style, `deny.toml` by
# cargo-deny, `release-plz.toml` by the release workflow:
"clippy.toml",
"deny.toml",
"release-plz.toml",
# VCS/repo metadata — read by git and by review tooling, never a lane:
".gitattributes",
".coderabbit.yaml",
".mcp.json",
# Toolchain pins and lint config for non-Rust lanes:
".node-version",
".nvmrc",
".sqlfluff",
# Container/deploy descriptors owned by their own workflows:
"Dockerfile.process-sandbox",
"docker-compose.yml",
"railway.toml",
"codecov.yml",
# Shipped artifacts with no test coverage of their own:
"ironclaw.bash",
"ironclaw.fish",
"ironclaw.zsh",
"ironclaw.png",
"LICENSE-APACHE",
"LICENSE-MIT",
# The Reborn container startup scripts are shell, not Rust: no Reborn test
# lane executes them. Code Style owns their fast self-tests, while
# `platform-and-compat.yml` classifies both as Docker risk and exercises
# them through the built runtime image. `docker/` stays per-file, never a
# prefix: it mixes classes, and the shipped runtime
# configs beside this script belong to a Rust lane instead — see
# `DOCKER_RUNTIME_CONFIG_OWNERS` below. `docker/process-sandbox-entrypoint.sh`
# has no owning lane and must keep refusing.)
"docker/reborn/entrypoint.sh",
"docker/reborn/start-sshd.sh",
}
# Shipped container configs a Reborn Rust test parses and asserts on, mapped to
# the test source that owns them. They are NOT static control: the membership
# rule for that set is "no Reborn test lane reads the file", and
# `ironclaw_cli`'s `smoke` test reads both of these — it parses each through
# `ironclaw_config::RebornConfigFile::parse_text` and pins the resulting
# profile, storage backend and policy (`docker_reborn_production_config_uses_postgres_storage`
# and its local-config sibling). Calling them prose would silently under-select
# the one lane that can catch a broken production config.
#
# Both were unclassified until 2026-08-11, when #7471's Postgres pool change
# edited `config.production.toml` and the fail-closed arm failed
# `Detect Reborn test scope`, cascading into the whole `Tests (Reborn)`
# roll-up. Classified as the pair they are, rather than one per red run —
# the same lesson the repo-root metadata block above records.
#
# The two `config.hosted-single-tenant*.toml` siblings were undecided until
# the docs/internal/reborn consolidation (2026-08-12) touched their reader,
# `tests/dockerfile_runtime_home.rs`, and hit this planner's fail-closed arm.
# That PR gave the reader a lane — `_root_test_partitions()` and
# `run-reborn-root-partition.sh` both inventory it alongside
# `support_unit_tests.rs` — so the configs now map to it: a root-test owner
# selects its root partition, a crate-test owner selects its exact crate
# target (both arms below).
DOCKER_RUNTIME_CONFIG_OWNERS = {
"docker/reborn/config.toml": "crates/app/ironclaw_cli/tests/smoke.rs",
"docker/reborn/config.production.toml": "crates/app/ironclaw_cli/tests/smoke.rs",
"docker/reborn/config.hosted-single-tenant.toml": "tests/dockerfile_runtime_home.rs",
"docker/reborn/config.hosted-single-tenant-volume.toml": "tests/dockerfile_runtime_home.rs",
}
# Repository configuration that a Reborn crate test reads as an asserted input.
# These paths are not static CI control: changing one must schedule the test that
# defines its product/security contract. The linked-device supply-chain test
# parses Dependabot's Cargo ignore rules so the exact grammers pin cannot be
# silently reopened by an automated dependency update.
REPO_CONFIG_TEST_OWNERS = {
".github/dependabot.yml": (
"crates/app/ironclaw_architecture_tests/tests/"
"reborn_linked_device_supply_chain_pin.rs"
),
}
# `.githooks/` is developer-local git hook plumbing: no Reborn lane executes a
# hook, while Code Style both triggers on the tree and lints its contents
# (`scripts/ci/test-ci-comm-locale-pin.sh` follows the symlinks and scans them).
PR_STATIC_CONTROL_PREFIXES = (".github/workflows/", "scripts/ci/", ".githooks/")
# cargo-dist's build-setup fragment. cargo-dist re-inlines it into
# `.github/workflows/ironclaw-release.yml` on every `dist generate`, so it is
# workflow source that happens to live outside `.github/workflows/` — the same
# static control as the file it is inlined into, and read by no Reborn lane.
# The planner fails closed on unclassified paths, so a `.github/` file with no
# rule takes the whole PR's plan step down rather than mis-scheduling it.
PR_STATIC_CONTROL_FRAGMENTS = (".github/dist-build-setup.yml",)
SHARED_REBORN_ACTION_PREFIXES = (
".github/actions/setup-sccache-dist/",
".github/actions/setup-rust/",
# The deliberate mapping the arm below asks for. `install-cargo-component`
# is consumed by `coverage.yml` and `platform-and-compat.yml`; a change to
# it can move what those lanes build, and no narrow lane exercises it
# safely, so it takes the exhaustive plan like its two siblings. Until this
# entry existed it hit the fail-closed arm, so a PR editing only that file
# took the whole plan step down instead of scheduling anything.
".github/actions/install-cargo-component/",
)
BUCKET_WEIGHTS = {
"reborn-core": 12,
"auth-security": 9,
"extension-operator": 8,
"product-workflow": 8,
"webui-ingress": 8,
"composition-core": 8,
"wasm-sandbox": 8,
"agent-runtime": 7,
"llm-mcp": 7,
"events-conversations": 7,
"host-runtime": 6,
"channel-adapters": 6,
"architecture-misc": 5,
"memory-skills": 5,
}
def _run(*argv: str) -> str:
return subprocess.run(
argv,
cwd=ROOT,
check=True,
capture_output=True,
text=True,
).stdout.strip()
def _metadata() -> dict[str, Any]:
return json.loads(_run("cargo", "metadata", "--format-version", "1"))
def _lockfile_change_is_manifest_owned(
*,
current: dict[str, Any],
base: dict[str, Any],
changed_paths: set[str],
metadata: dict[str, Any],
) -> bool:
"""Return true only for lockfile edits confined to changed workspace manifests."""
if {key: value for key, value in current.items() if key != "package"} != {
key: value for key, value in base.items() if key != "package"
}:
return False
workspace_members = set(metadata["workspace_members"])
workspace_packages = {
package["name"]: str(
Path(package["manifest_path"]).resolve().relative_to(ROOT)
)
for package in metadata["packages"]
if package["id"] in workspace_members
}
changed_workspace_packages = {
name for name, manifest in workspace_packages.items() if manifest in changed_paths
}
if not changed_workspace_packages:
return False
def indexed(lockfile: dict[str, Any]) -> dict[tuple[str, str, str], dict[str, Any]]:
packages = lockfile.get("package", [])
if not isinstance(packages, list):
return {}
result = {
(
str(package.get("name", "")),
str(package.get("version", "")),
str(package.get("source", "")),
): package
for package in packages
if isinstance(package, dict)
}
return result if len(result) == len(packages) else {}
current_packages = indexed(current)
base_packages = indexed(base)
if not current_packages or current_packages.keys() != base_packages.keys():
return False
for key, current_package in current_packages.items():
base_package = base_packages[key]
if current_package == base_package:
continue
name, _version, source = key
if source or name not in changed_workspace_packages:
return False
current_without_dependencies = {
field: value
for field, value in current_package.items()
if field != "dependencies"
}
base_without_dependencies = {
field: value for field, value in base_package.items() if field != "dependencies"
}
if current_without_dependencies != base_without_dependencies:
return False
return True
def _manifest_owned_lockfile_change(
*, base_sha: str, changed_paths: set[str], metadata: dict[str, Any]
) -> bool:
if not base_sha:
return False
return _lockfile_change_is_manifest_owned(
current=tomllib.loads((ROOT / "Cargo.lock").read_text(encoding="utf-8")),
base=tomllib.loads(_run("git", "show", f"{base_sha}:Cargo.lock")),
changed_paths=changed_paths,
metadata=metadata,
)
def _canonical_packages() -> list[str]:
return json.loads(_run("scripts/ci/discover-reborn-package-crates.sh"))
def _bucket_packages(packages: list[str]) -> list[dict[str, Any]]:
return json.loads(
_run("scripts/ci/reborn-crate-test-buckets.sh", json.dumps(packages))
)
def _bound_pr_buckets(
buckets: list[dict[str, Any]], max_buckets: int = MAX_PR_CRATE_BUCKETS
) -> list[dict[str, Any]]:
"""Pack canonical buckets into bounded PR jobs without splitting them."""
if len(buckets) <= max_buckets:
return buckets
bounded = [
{"name": f"affected-{index + 1}", "packages": []}
for index in range(min(max_buckets, len(buckets)))
]
weights = [0] * len(bounded)
ordered = sorted(
buckets,
key=lambda bucket: (
-BUCKET_WEIGHTS.get(
str(bucket.get("name")), len(bucket.get("packages", []))
),
str(bucket.get("name")),
),
)
for bucket in ordered:
target = min(range(len(bounded)), key=lambda index: (weights[index], index))
bounded[target]["packages"].extend(bucket["packages"])
weights[target] += BUCKET_WEIGHTS.get(
str(bucket.get("name")), len(bucket.get("packages", []))
)
return bounded
def _root_test_partitions() -> dict[str, int]:
extra_tests = [
name
for name in ("dockerfile_runtime_home", "support_unit_tests")
if (ROOT / f"tests/{name}.rs").is_file()
]
names = sorted(
[
path.stem
for path in (ROOT / "tests").glob("reborn_*.rs")
if path.is_file()
]
+ extra_tests
)
return {f"tests/{name}.rs": index % 4 for index, name in enumerate(names)}
def _integration_test_lanes() -> dict[str, str | int]:
return planner_test_lanes(ROOT)
def _workspace_packages(metadata: dict[str, Any]) -> tuple[dict[str, str], dict[str, set[str]]]:
members = set(metadata["workspace_members"])
packages_by_id = {
package["id"]: package
for package in metadata["packages"]
if package["id"] in members
}
directories = {
str(Path(package["manifest_path"]).resolve().parent.relative_to(ROOT)): package[
"name"
]
for package in packages_by_id.values()
if Path(package["manifest_path"]).resolve().parent != ROOT
}
reverse: dict[str, set[str]] = defaultdict(set)
for node in metadata["resolve"]["nodes"]:
if node["id"] not in packages_by_id:
continue
dependent = packages_by_id[node["id"]]["name"]
for dependency in node["deps"]:
if dependency["pkg"] in packages_by_id:
reverse[packages_by_id[dependency["pkg"]]["name"]].add(dependent)
return directories, reverse
def _affected_packages(changed: set[str], reverse: dict[str, set[str]]) -> set[str]:
affected = set(changed)
pending = list(changed)
while pending:
package = pending.pop()
for dependent in reverse.get(package, set()):
if dependent not in affected:
affected.add(dependent)
pending.append(dependent)
return affected
def _manifest_requires_cargo(package: dict[str, Any]) -> bool:
"""Whether a whole package needs Cargo's in-process test semantics."""
name = str(package.get("name", "<unknown>"))
manifest_path = Path(str(package.get("manifest_path", "")))
try:
manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as error:
raise ValueError(
f"cannot classify test runner for {name} at {manifest_path}: {error}"
) from error
manifest_targets: list[dict[str, Any]] = []
for table in ("lib", "bin", "test", "bench", "example"):
value = manifest.get(table, [])
if isinstance(value, dict):
manifest_targets.append(value)
elif isinstance(value, list):
manifest_targets.extend(
target for target in value if isinstance(target, dict)
)
if any(target.get("harness") is False for target in manifest_targets):
return True
for target in package.get("targets", []):
if not target.get("test", False):
continue
kinds = set(target.get("kind", []))
if not kinds or not kinds <= NEXTEST_LIBTEST_TARGET_KINDS:
return True
return False
def _cargo_required_packages(
metadata: dict[str, Any], packages: set[str]
) -> set[str]:
by_name = {str(package["name"]): package for package in metadata["packages"]}
missing = sorted(packages - by_name.keys())
if missing:
raise ValueError(
"selected packages are missing from Cargo metadata: " + ", ".join(missing)
)
return {
package
for package in packages
if _manifest_requires_cargo(by_name[package])
}
def _annotate_cargo_packages(
buckets: list[dict[str, Any]], metadata: dict[str, Any]
) -> list[dict[str, Any]]:
"""Add the Cargo-only subset after every bucket's package list is final."""
full_package_names = {
str(package)
for bucket in buckets
if not bucket.get("exact_targets")
for package in bucket.get("packages", [])
}
cargo_required = _cargo_required_packages(metadata, full_package_names)
annotated: list[dict[str, Any]] = []
for bucket in buckets:
candidate = dict(bucket)
if not candidate.get("exact_targets"):
cargo_packages = sorted(set(candidate["packages"]) & cargo_required)
if cargo_packages:
candidate["cargo_packages"] = cargo_packages
annotated.append(candidate)
return annotated
def _full_plan(
reason: str,
canonical_packages: list[str],
metadata: dict[str, Any],
) -> dict[str, Any]:
return {
"mode": "full",
"reasons": [reason],
"changed_packages": [],
"affected_packages": canonical_packages,
"crate_buckets": _annotate_cargo_packages(
_bucket_packages(canonical_packages), metadata
),
"root_partitions": list(ALL_ROOT_PARTITIONS),
"integration_lanes": list(ALL_INTEGRATION_LANES),
"run_qa_replay": True,
"run_sandbox_docker": True,
"coverage_mode": "full",
}
def _merge_group_global_risk(paths: set[str]) -> str | None:
"""Return the first path whose queue impact cannot be narrowed safely."""
for path in sorted(paths):
if (
path in {"Cargo.lock", ".config/nextest.toml"}
or path in PR_STATIC_CONTROL_PATHS
or path in PR_STATIC_CONTROL_FRAGMENTS
or path.startswith(PR_STATIC_CONTROL_PREFIXES)
or path.startswith(SHARED_REBORN_ACTION_PREFIXES)
):
return path
return None
def _unclassified_path_plan(
*,
event: str,
reason: str,
canonical_packages: list[str],
metadata: dict[str, Any],
) -> dict[str, Any]:
"""Fail PR feedback loudly; widen an otherwise valid queue diff."""
if event == "merge_group":
return _full_plan(
f"merge-group scope could not classify {reason}; running the "
"exhaustive plan",
canonical_packages,
metadata,
)
raise ValueError(reason)
def build_plan(
*,
event: str,
changed_paths: list[str],
metadata: dict[str, Any],
canonical_packages: list[str],
lockfile_manifest_owned: bool = False,
) -> dict[str, Any]:
"""Build a deterministic test plan, rejecting or widening unknown inputs."""
if event in FULL_EVENTS:
return _full_plan(
f"{event} requires exhaustive coverage", canonical_packages, metadata
)
if event not in DIFF_EVENTS:
return _full_plan(f"unknown event {event!r}", canonical_packages, metadata)
paths = {path.strip().replace("\\", "/") for path in changed_paths if path.strip()}
if not paths:
raise ValueError(
f"empty {event.replace('_', '-')} diff cannot be classified; "
"refusing to launch "
"an unbounded test matrix"
)
if event == "merge_group":
global_risk = _merge_group_global_risk(paths)
if global_risk is not None:
return _full_plan(
f"merge-group global or topology input changed: {global_risk}; "
"running the exhaustive plan",
canonical_packages,
metadata,
)
package_directories, reverse = _workspace_packages(metadata)
if event == "merge_group":
changed_manifest = next(
(
f"{directory}/Cargo.toml"
for directory in sorted(package_directories)
if f"{directory}/Cargo.toml" in paths
),
None,
)
if changed_manifest is not None:
return _full_plan(
"merge-group workspace topology input changed: "
f"{changed_manifest}; running the exhaustive plan",
canonical_packages,
metadata,
)
production_packages: set[str] = set()
direct_test_packages: set[str] = set()
exact_test_targets: dict[str, set[tuple[str, str]]] = defaultdict(set)
packages_requiring_all_targets: set[str] = set()
root_partitions: set[int] = set()
integration_lanes: set[str | int] = set()
# Recorded replay is a repository-wide ordering and integration sentinel,
# not affected-area coverage. Keep it on for every pull request even when
# no changed path maps to another Reborn lane.
run_qa_replay = True
run_sandbox_docker = False
qa_evidence_changed = False
nextest_config_changed = False
shared_reborn_action_changed = False
reasons: list[str] = []
root_inventory = _root_test_partitions()
integration_inventory = _integration_test_lanes()
group_integration_prefixes = {
f"{Path(owner).parent.as_posix()}/"
for owner, lane in integration_inventory.items()
if lane == "groups"
}
sandbox_crate_prefixes = _sandbox_docker_prefixes()
sandbox_docker_prefixes = SANDBOX_DOCKER_PREFIXES + sandbox_crate_prefixes
sandbox_docker_exact_paths = set(SANDBOX_DOCKER_EXACT_PATHS)
if sandbox_crate_prefixes:
sandbox_crate_directory = _sandbox_crate_directory()
sandbox_docker_exact_paths.update(
{
f"{sandbox_crate_directory}/Cargo.toml",
f"{sandbox_crate_directory}/src/lib.rs",
f"{sandbox_crate_directory}/tests/support/user_sandbox_live.rs",
f"{sandbox_crate_directory}/tests/user_sandbox_docker_live.rs",
}
)
for path in sorted(paths):
path_parts = PurePosixPath(path).parts
if path == "Cargo.lock":
if lockfile_manifest_owned:
reasons.append(
"Cargo.lock change is owned by a changed crate manifest"
)
else:
reasons.append(
"workspace lockfile breadth is deferred to the exhaustive merge-queue gate"
)
continue
if path == ".config/nextest.toml":
# Test-runner config: every `Tests (Reborn)` lane executes cargo
# nextest with these profiles, so a change to it cannot be
# exercised by any narrow lane. It is deliberately NOT static
# control — the membership rule for that set is "no Reborn test
# lane reads the file", and these lanes read it. Widening to the
# exhaustive plan is the safe resolution (a superset can never
# under-select). Unclassified until 2026-08-10, when deleting the
# dead `live_tests::zizmor_scan*` overrides failed the whole
# `Tests (Reborn)` roll-up on the provider-matrix retirement PR.
nextest_config_changed = True
continue
if path.startswith(SHARED_REBORN_ACTION_PREFIXES):
# Every `Tests (Reborn)` job installs the compiler cache
# (setup-sccache-dist) or the Rust toolchain itself
# (setup-rust) through one of these local actions. No narrow
# lane can exercise a change to either safely, so use the
# exhaustive plan just as we do for shared nextest
# configuration. Keep other `.github/actions/**` paths
# fail-closed until their consumers are mapped deliberately.
shared_reborn_action_changed = True
continue
if path in REPO_CONFIG_TEST_OWNERS:
owner = REPO_CONFIG_TEST_OWNERS[path]
package = next(
(
name
for directory, name in package_directories.items()
if owner.startswith(f"{directory}/")
),
None,
)
if package is None:
raise ValueError(
f"repository config owner is in no workspace package: {owner}"
)
direct_test_packages.add(package)
exact_test_targets[package].add(("test", Path(owner).stem))
reasons.append(f"repository config parsed by {owner}: {path}")
continue
if path in PR_STATIC_CONTROL_FRAGMENTS or path in PR_STATIC_CONTROL_PATHS or path.startswith(
PR_STATIC_CONTROL_PREFIXES
):
reasons.append(f"static CI or workspace-policy checks own: {path}")
continue
if path in DOCKER_RUNTIME_CONFIG_OWNERS:
owner = DOCKER_RUNTIME_CONFIG_OWNERS[path]
if owner in root_inventory:
root_partitions.add(root_inventory[owner])
reasons.append(f"shipped container config parsed by {owner}: {path}")
continue
package = next(
(
name
for directory, name in package_directories.items()
if owner.startswith(f"{directory}/")
),
None,
)
if package is None:
raise ValueError(
f"container config owner is in no workspace package: {owner}"
)
direct_test_packages.add(package)
exact_test_targets[package].add(("test", Path(owner).stem))
reasons.append(f"shipped container config parsed by {owner}: {path}")
continue
if path.startswith(DEDICATED_WORKFLOW_PREFIXES):
reasons.append(f"dedicated workflow owns: {path}")
continue
if path.startswith(WORKSPACE_EXCLUDED_PREFIXES):
reasons.append(f"workspace-excluded project, built by no lane: {path}")
continue
if path.startswith(DEDICATED_E2E_PREFIX):
reasons.append(f"dedicated Reborn E2E workflow owns: {path}")
continue
if path.startswith(QA_HARNESS_PREFIXES):
qa_evidence_changed = True
reasons.append(f"live QA harness changed: {path}")
continue
if path == CHANGED_COVERAGE_MANIFEST:
reasons.append("changed-coverage policy is statically validated")
continue
doc_fact = _doc_fact_selections(path)
if doc_fact:
for package, target in doc_fact:
direct_test_packages.add(package)
exact_test_targets[package].add(("test", target))
reasons.append(f"doc-fact contract tests read: {path}")
continue
if (
path in IGNORED_GUIDANCE_PATHS
or path.startswith(IGNORED_PREFIXES)
or path in IGNORED_ROOT_FILES
or (path.endswith(".md") and "/" not in path)
):
continue
if path in sandbox_docker_exact_paths or path.startswith(
sandbox_docker_prefixes
):
run_sandbox_docker = True
reasons.append(f"sandbox Docker surface changed: {path}")
if (
not path.startswith("crates/")
and path not in root_inventory
and path not in integration_inventory
):
continue
if path.startswith(_webui_frontend_prefix()):
reasons.append("Code Style owns WebUI lint, tests, and production build")
continue
if path in root_inventory:
root_partitions.add(root_inventory[path])
reasons.append(f"root test changed: {path}")
continue
if (
path.startswith("tests/support/reborn_parity_qa/")
or path == "tests/support_unit_tests.rs"
):
if event == "merge_group":
root_partitions.update(ALL_ROOT_PARTITIONS)
reasons.append(
"shared root-test support changed; merge group runs every root partition"
)
else:
root_partitions.add(0)
reasons.append(
"shared root-test support changed; PR runs a representative partition"
)
continue
if path.startswith("tests/support/") and path not in INTEGRATION_SUPPORT_OWNERS:
# Direct shared root-test support (tests/support/mod.rs and the
# modules it declares). The integration group targets also compile
# this tree via `#[path = "../../support/mod.rs"]`, so schedule a
# representative lane of each tier.
if event == "merge_group":
root_partitions.update(ALL_ROOT_PARTITIONS)
integration_lanes.update(ALL_INTEGRATION_LANES)
reasons.append(
"shared root-test support changed; merge group runs every "
"root and integration consumer lane"
)
else:
root_partitions.add(0)
integration_lanes.add(0)
reasons.append(
"shared root-test support changed; PR runs a representative "
"partition and integration lane"
)
continue
if path in integration_inventory:
integration_lanes.add(integration_inventory[path])
reasons.append(f"integration test changed: {path}")
continue
if any(path.startswith(prefix) for prefix in group_integration_prefixes):
integration_lanes.add("groups")
reasons.append(f"integration group scenario changed: {path}")
continue
if (
len(path_parts) >= 4
and path_parts[:2] == ("tests", "integration")
and path_parts[2].startswith("group_")
):
integration_lanes.add("groups")
reasons.append(f"unregistered integration group path changed: {path}")
continue
if path in INTEGRATION_SUPPORT_OWNERS:
owner = INTEGRATION_SUPPORT_OWNERS[path]
integration_lanes.add(integration_inventory[owner])
reasons.append(f"integration test support changed: {path}")
continue
snapshot_owner = next(
(
owner
for prefix, owner in INTEGRATION_SNAPSHOT_PREFIX_OWNERS.items()
if path.startswith(prefix)
),
None,
)
if snapshot_owner is not None:
integration_lanes.add(integration_inventory[snapshot_owner])
reasons.append(f"integration test snapshot changed: {path}")
continue
if path.startswith("tests/integration/support/"):
if event == "merge_group":
# Root parity/QA binaries and every flat/group integration
# target compile this support tree, so the queue must run all
# of those consumers together.
root_partitions.update(ALL_ROOT_PARTITIONS)
integration_lanes.update(ALL_INTEGRATION_LANES)
reasons.append(
"shared integration support changed; merge group runs every "
"root and integration consumer lane"
)
else:
integration_lanes.add(0)
reasons.append(
"shared integration support changed; PR runs a representative lane"
)
continue
if path.startswith("tests/integration/"):
integration_lanes.add(0)
reasons.append(
"shared integration support changed; PR runs a representative lane"
)
continue
if path.startswith("tests/fixtures/llm_traces/reborn_qa/") or path in {
"scripts/ci/check-reborn-qa-fixtures.sh",
"scripts/ci/test-check-reborn-qa-fixtures.sh",
"scripts/ci/test-check-regression-promotions.py",
}:
qa_evidence_changed = True
reasons.append("recorded QA evidence changed")
continue
if path.startswith("tests/fixtures/") and not path.startswith(
"tests/fixtures/llm_traces/"
):
# Document/binary fixtures (docx, xlsx, pptx, pdf) are consumed by
# integration tests through `include_bytes!`, so changing one
# changes what those tests assert. Recorded LLM traces under
# `reborn_qa` are handled by the QA-evidence arm above;
# other trace families require an explicit owner rather than
# silently becoming generic integration fixtures.
integration_lanes.add(0)
reasons.append(f"integration fixture changed: {path}")
continue
if path.startswith(("tests/reborn_", "tests/e2e/reborn_", "scripts/ci/reborn-")):
return _unclassified_path_plan(
event=event,
reason=f"unmapped Reborn test path: {path}",
canonical_packages=canonical_packages,
metadata=metadata,
)
if path.startswith("tests/e2e/"):
# E2E scenarios and support live in the dedicated
# `reborn-e2e.yml` workflow, which runs its own changed-path
# filter and provider/shard matrix. They are not part of the
# crate-bucket / root-partition / integration-lane plan emitted
# here, so skip them instead of failing closed on an unmapped
# path.
reasons.append(f"Reborn E2E workflow owns: {path}")
continue
if path in PROMPT_SURFACE_PATHS or path.startswith(PROMPT_SURFACE_PREFIXES):
# Deliberately no `continue`: the path still classifies as a
# production-package change below. This arm only ADDS the golden
# lane so the prompt-surface snapshots run on the PR instead of
# first failing in the merge queue.
integration_lanes.add(
integration_inventory[PROMPT_SURFACE_GOLDEN_OWNER]
)
reasons.append(f"model-visible prompt surface changed: {path}")
if path.startswith(CRATE_OR_ASSET_PREFIXES):
# Family-level prose that belongs to no package: `crates/AGENTS.md`,
# `crates/README.md`, `crates/Architecture.md`, and the family
# `AGENTS.md` files the target-architecture tree move adds. Matched
# by "Markdown that no package directory owns", so a genuinely
# unmapped *crate* path still reaches the arms checked below.
# Depth-independent by construction, so it keeps holding for
# `crates/AGENTS.md` and for a future `crates/<family>/AGENTS.md`
# after the WS7 family move. A crate-resident doc still resolves to
# its package below and keeps selecting that package's lane.
#
# The carve-out yields to shipped Markdown *assets*, and must.
# Prompts: the asset table declares it owns "manifests, prompts,
# schemas and built `wasm/*.wasm`"; of those kinds only a prompt is
# Markdown (manifests are `.toml`, schemas `.json`, wasm `.wasm`),
# so `.md` under a `prompts/` directory is an asset. Bundled
# skills: under `skills/` every file is embedded product output,
# `SKILL.md` first among them, so all its Markdown is an asset.
# Every other `.md` is prose. Without this clause the prose arm
# fired first and a change to a shipped prompt — production output
# that `ironclaw_extension_support` compiles in — planned
# `mode=none`, selecting no lane at all, while its sibling
# `manifest.toml` in the same package correctly selected two. That
# is exactly the "silent under-schedule of a change to production
# output" the comment above `EMBEDDED_ASSET_OWNERS` forbids.
#
# Keyed on the `prompts/` segment (or the `skills/` tree) rather
# than on the asset prefixes themselves, because the package and
# fixture prefixes also cover genuine prose: `test-tools/README.md`
# is documentation of the fixture bundles and stays prose, as its
# own test pins.
if Path(path).suffix == ".md" and not _is_shipped_asset_markdown(path):
if not any(
path.startswith(f"{directory}/")
for directory in package_directories
):
reasons.append(f"crate-tree guidance changed: {path}")
continue
package = next(
(
name
for directory, name in package_directories.items()
if path == directory or path.startswith(f"{directory}/")
),
None,
)
if package is None:
# A shipped asset tree that no package *directory* owns but a
# package *compiles*: `crates/extensions/packages/*/wasm/*.wasm`
# is `include_bytes!`d by `ironclaw_extension_support` and
# `test-tools/*/manifest.toml` is `include_str!`d by
# `ironclaw_extension_host`. This arm is checked before the
# widening below so the change schedules the crate that
# compiles it, with that crate named in the reason — calling
# either tree prose would turn a change to production output
# into a silent under-schedule.
owner = next(
(
owner
for prefix, owner in EMBEDDED_ASSET_OWNERS
if path.startswith(prefix)
),
None,
)
if owner is not None:
production_packages.add(owner)
reasons.append(f"asset compiled into {owner} changed: {path}")
continue
# The path is under `crates/` but belongs to no workspace
# package. The normal cause is a crate this PR **deletes or
# renames** — `git diff` reports its old paths, and the merge
# or rename is exactly the change shape the target-architecture
# restructure ships (PROPOSAL §2 plans six crate deletions).
#
# Refusing to plan is the wrong resolution: it blocks the PR
# outright. Widening is the safe one — the exhaustive plan is a
# superset of any narrowing, so an unattributable path can never
# cause under-selection. Malformed input is still rejected: a
# path outside every classified prefix raises below.
return _full_plan(
"a crate path maps to no workspace package (deletion or "
f"rename): {path}; this PR runs the exhaustive plan",
canonical_packages,
metadata,
)
directory = next(
directory
for directory, name in package_directories.items()
if name == package
)
relative = path.removeprefix(f"{directory}/")
if relative.startswith(("tests/", "benches/", "examples/")):
direct_test_packages.add(package)
reasons.append(f"package-owned test surface changed: {package}")
parts = Path(relative).parts
target_kinds = {
"tests": "test",
"benches": "bench",
"examples": "example",
}
if len(parts) == 2 and Path(parts[1]).suffix == ".rs":
exact_test_targets[package].add(
(target_kinds[parts[0]], Path(parts[1]).stem)
)
else:
packages_requiring_all_targets.add(package)
else:
production_packages.add(package)
reasons.append(f"production package changed: {package}")
continue
if path.startswith(("tests/reborn_", "tests/e2e/reborn_", "scripts/ci/reborn-")):
return _unclassified_path_plan(
event=event,
reason=f"unmapped Reborn test path: {path}",
canonical_packages=canonical_packages,
metadata=metadata,
)
if path.startswith("tests/e2e/"):
# The browser/E2E suite has its own workflow (`reborn-e2e.yml`,
# `paths: tests/e2e/**`) with its own scope detection, so this
# planner must not also schedule Rust lanes for it. The
# `tests/e2e/reborn_*` harnesses above stay a deliberate hard
# error — they are shared fixtures, not one scenario.
reasons.append(f"dedicated Reborn E2E workflow owns: {path}")
continue
if path.startswith(("scripts/", "tests/", ".github/actions/")):
return _unclassified_path_plan(
event=event,
reason=f"unmapped test or CI path: {path}",
canonical_packages=canonical_packages,
metadata=metadata,
)
return _unclassified_path_plan(
event=event,
reason=f"unclassified pull-request path: {path}",
canonical_packages=canonical_packages,
metadata=metadata,
)
if nextest_config_changed:
return _full_plan(
"nextest runner config changed; this PR runs the exhaustive plan",
canonical_packages,
metadata,
)
if shared_reborn_action_changed:
return _full_plan(
"shared reborn action changed; this PR runs the exhaustive plan",
canonical_packages,
metadata,
)
canonical_set = set(canonical_packages)
changed_packages = production_packages | direct_test_packages
affected = (
_affected_packages(production_packages, reverse) | direct_test_packages
) & canonical_set
if changed_packages and not affected:
raise ValueError(
"changed packages are outside the canonical Reborn package set: "
f"{', '.join(sorted(changed_packages))}"
)
buckets = _bucket_packages(sorted(affected)) if affected else []
full_target_packages = (
_affected_packages(production_packages, reverse)
| packages_requiring_all_targets
) & canonical_set
for bucket in buckets:
bucket_packages = set(bucket["packages"])
if bucket_packages & full_target_packages:
continue
if not all(package in exact_test_targets for package in bucket_packages):
continue
bucket["exact_targets"] = [
{"package": package, "kind": kind, "name": name}
for package in sorted(bucket_packages)
for kind, name in sorted(exact_test_targets[package])
]
# PR feedback is latency-bounded; merge-group coverage stays exhaustive
# and preserves every canonical bucket boundary.
if event == "pull_request" and len(buckets) > MAX_PR_CRATE_BUCKETS:
original_bucket_count = len(buckets)
buckets = _bound_pr_buckets(buckets)
reasons.append(
f"coalesced {original_bucket_count} affected crate buckets into "
f"{len(buckets)} PR jobs without omitting packages"
)
buckets = _annotate_cargo_packages(buckets, metadata)
active = bool(
buckets
or root_partitions
or integration_lanes
or qa_evidence_changed
or run_sandbox_docker
)
return {
"mode": "selected" if active else "none",
"reasons": reasons or ["no Reborn test surface changed"],
"changed_packages": sorted(changed_packages),
"affected_packages": sorted(affected),
"crate_buckets": buckets,
"root_partitions": sorted(root_partitions),
"integration_lanes": sorted(
integration_lanes, key=lambda value: (isinstance(value, str), str(value))
),
"run_qa_replay": run_qa_replay,
"run_sandbox_docker": run_sandbox_docker,
"coverage_mode": "none",
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--event", required=True)
parser.add_argument(
"--changed-files",
type=Path,
help="newline-delimited changed paths; required for diff events",
)
parser.add_argument(
"--canonical-packages",
type=Path,
help="JSON package array produced by discover-reborn-package-crates.sh",
)
parser.add_argument(
"--base-sha",
help="pull-request base commit used to verify manifest-owned lockfile edits",
)
args = parser.parse_args()
try:
changed_paths = (
args.changed_files.read_text(encoding="utf-8").splitlines()
if args.changed_files
else []
)
canonical_packages = (
json.loads(args.canonical_packages.read_text(encoding="utf-8"))
if args.canonical_packages
else _canonical_packages()
)
normalized_paths = {
path.strip().replace("\\", "/") for path in changed_paths if path.strip()
}
metadata = _metadata()
lockfile_manifest_owned = (
_manifest_owned_lockfile_change(
base_sha=args.base_sha or "",
changed_paths=normalized_paths,
metadata=metadata,
)
if "Cargo.lock" in normalized_paths
else False
)
plan = build_plan(
event=args.event,
changed_paths=changed_paths,
metadata=metadata,
canonical_packages=canonical_packages,
lockfile_manifest_owned=lockfile_manifest_owned,
)
except (
OSError,
KeyError,
ValueError,
RuntimeError,
subprocess.CalledProcessError,
) as error:
print(f"Reborn PR test planner failed: {error}", file=sys.stderr)
return 1
print(json.dumps(plan, separators=(",", ":"), sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())