Files
ironclaw/scripts/ci/lib/crate_tree.py
Josh Ford 318a6e6748 docs: consolidate docs/reborn/ into docs/internal/reborn/ (#7559)
* docs: consolidate docs/reborn/ into docs/internal/reborn/

Move-only migration; no content changes beyond path references. Executes
the follow-up that PR #7259 left open: docs/.mintignore's reborn/ entry
was kept only because the path was load-bearing, and its comment
documented that it moves under internal/ once its consumers move with it.

- git mv docs/reborn docs/internal/reborn (115 files, history preserved)
- rewrite docs/reborn -> docs/internal/reborn across every consumer
  (crate AGENTS/READMEs and doc-comments, .claude/ skills and rules,
  AGENTS.md, CI scripts, reborn-e2e.yml path filters, Dockerfile, tests,
  docs/internal plans)
- fix six relative internal/adr/ links inside the moved tree for the
  added directory level
- drop reborn/ from docs/.mintignore and FROZEN_MINTIGNORE_PATTERNS in
  scripts/ci/docs_publication_boundary.py (the frozen list only ever
  shrinks); internal/ already fences the new location

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: classify tests/dockerfile_runtime_home.rs and shrink boundary self-test fixture

Two CI gates failed on the docs/reborn consolidation and forced decisions
this commit records:

- The Reborn PR test planner failed closed on tests/dockerfile_runtime_home.rs
  (its path-rewrite edit is functional: the test reads the moved deploy doc).
  The file was deliberately unmapped because no lane inventoried it. Decide it
  now: _root_test_partitions() and run-reborn-root-partition.sh both inventory
  it alongside support_unit_tests.rs, so the hermetic root-partition lanes run
  it (they previously ran it nowhere) and a change to it selects its partition.
  With the reader laned, map the two config.hosted-single-tenant*.toml readers
  it owns in DOCKER_RUNTIME_CONFIG_OWNERS — root-test owners select their root
  partition, completing the per-file decision set the planner comments left
  open. docker/process-sandbox-entrypoint.sh stays fail-closed.
- test_docs_publication_boundary.py's subset fixture still listed reborn/ in
  the frozen mintignore list; use the surviving entries.

Verified: both self-test suites pass (77 planner + boundary), the planner
emits a valid selected plan for this PR's full 342-path diff, shell and
Python inventories agree on partition assignment (index 0), and
dockerfile_runtime_home passes (19 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:39:06 +00:00

362 lines
15 KiB
Python

#!/usr/bin/env python3
"""Tree-shape-agnostic discovery of the workspace's crate directories.
CI gates that key off the literal `crates/ironclaw_*` shape all share one
failure mode: when the target-architecture restructure moves crates into
family directories (`crates/<family>/ironclaw_*`, see
docs/internal/reborn/target-architecture/PROPOSAL.md §5), the pattern stops matching,
the gate scans nothing, and it reports success. Coverage goes dark, a scope
classifier buckets everything as out-of-scope, a metric prints 0% — all green.
This module is the one definition of "which directories under `crates/` are
crates", derived from where `Cargo.toml` files actually are rather than from
how deep they sit. It is used by the Python-side gates; the pure-bash
`scripts/ci/classify-test-scope.sh` reimplements the same rule inline (it runs
in a lightweight checkout-only job) and `scripts/ci/test-classify-test-scope.sh`
pins the two inventories equal so they cannot drift.
Deliberately filesystem-based, not `cargo metadata`-based: the consumers run in
jobs that have a checkout but no Rust toolchain (the coverage-report job in
.github/workflows/reborn-tests.yml installs Python only). Gates that *do* have
cargo — `scripts/check_no_panics.py` — use `cargo metadata` directly, which is
strictly better where it is available.
Outermost wins: `crates/ironclaw_safety/fuzz/` is nested inside a crate that
already owns its path, so files under it are attributed to the enclosing crate.
That is exactly what the flat-shaped patterns did (`crates/ironclaw_safety/*`
matched the fuzz subtree too).
Separate workspace roots are not crates of this workspace. A `Cargo.toml` that
declares its own `[workspace]` table is by construction the root of a
*different* workspace: `cargo build` here never compiles it, `cargo metadata`
never lists it, and no test run of this workspace can cover a line inside it.
Those manifests are pruned from the inventory; paths under them are reported by
`nested_workspace_root()` instead, so a caller can tell "excluded by
construction" apart from "the tree and the inventory disagree".
Why the rule had to be stated rather than inherited (WS2 package colocation).
The six `wasm-src/` guest components used to sit under
`crates/ironclaw_first_party_extensions/`, so outermost-wins attributed them to
the enclosing crate and they never surfaced. Once packages moved to
`crates/extensions/packages/<ext>/` — where a data-only package deliberately has
no `Cargo.toml` of its own — five of them became the outermost manifest on their
path and were silently promoted to first-class crates, which put ~12k lines of
workspace-excluded, uncoverable guest code into the changed-coverage and
composition-budget denominators. The `[workspace]` marker is exactly what those
directories had in common with the silk decoder (also `exclude`d from the root
workspace, also never built here), so one rule covered both; the silk-decoder
half was a latent-bug fix that arrived with it. WS7 then moved that helper out
of `crates/` entirely (`tools/ironclaw_silk_decoder`, PROPOSAL §5 / §12.13
D-O), so it is now excluded by *scope* rather than by the `[workspace]` rule
and the guest components are the rule's remaining members. The rule stays: it
is what keeps a future `[workspace]`-rooted directory under `crates/` out of
the denominators.
Usage:
python3 scripts/ci/lib/crate_tree.py [repo_root]
Print every crate directory, one per line (unchanged, load-bearing
contract: scripts/build-wasm-extensions.sh and others parse this).
python3 scripts/ci/lib/crate_tree.py --directory <name> [repo_root]
Print the single crate directory whose basename is <name>. Exits 1
with the CrateTreeError message on stderr when the name is absent or
ambiguous — never falls back to a guessed literal path. The
`scripts/ci/crate-dir.sh` wrapper shells to this for bash callers.
"""
from __future__ import annotations
import pathlib
import sys
CRATES_ROOT_NAME = "crates"
# Fail-closed floor. Discovery either finds the workspace's crates or it is
# broken (wrong root, truncated checkout, a `crates/` that moved wholesale);
# a handful of results is never a legitimate answer for this repository, and
# silently returning them is the exact class of bug this module exists to kill.
# It is a sanity floor, not a ratchet — it is far below the real count (65 at
# the WS0 baseline, 64 after WS7 moved the silk decoder to `tools/`) and is
# never expected to bind.
MIN_CRATE_DIRECTORIES = 20
# Build outputs and dotted directories can contain vendored manifests that are
# not workspace crates. `crates/<x>/target/` appears whenever someone builds
# inside a crate directory (cargo-fuzz does this by default).
_SKIPPED_DIRECTORY_NAMES = ("target",)
# A `[workspace]` table at the start of a line. Cargo only recognizes the
# table at the top level of a manifest, so a line-anchored match is exact;
# `[workspace.dependencies]` and friends are deliberately not matched, since a
# member manifest may inherit from them without being a workspace root itself.
_WORKSPACE_TABLE_HEADER = "[workspace]"
class CrateTreeError(RuntimeError):
"""Discovery could not produce a usable crate inventory."""
def _declares_own_workspace(manifest: pathlib.Path) -> bool:
"""True when ``manifest`` is the root of a separate cargo workspace.
Read fail-closed: an unreadable manifest is treated as a normal crate so a
permissions problem shows up as a build error rather than as a directory
quietly vanishing from every path-keyed gate.
"""
try:
text = manifest.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return False
return any(
line.strip() == _WORKSPACE_TABLE_HEADER for line in text.splitlines()
)
def _is_skipped(relative: pathlib.PurePosixPath) -> bool:
return any(
part in _SKIPPED_DIRECTORY_NAMES or part.startswith(".")
for part in relative.parts
)
def crate_directories(repo_root: str | pathlib.Path = ".") -> list[str]:
"""Return every crate directory under `crates/`, repo-relative and sorted.
A crate directory is the *outermost* directory under `crates/` that owns a
`Cargo.toml`. Results are POSIX-style relative paths (`crates/ironclaw_llm`,
or `crates/substrates/ironclaw_llm` once the family move lands).
"""
root = pathlib.Path(repo_root)
crates_root = root / CRATES_ROOT_NAME
if not crates_root.is_dir():
raise CrateTreeError(
f"no {CRATES_ROOT_NAME}/ directory under {root!s} — crate discovery cannot "
"run. Every path-keyed CI gate resolves its scope through this "
"inventory; refusing rather than scanning nothing is deliberate "
"(docs/internal/reborn/target-architecture/CHECKLIST.md WS10)."
)
manifests = []
for manifest in crates_root.rglob("Cargo.toml"):
relative = pathlib.PurePosixPath(manifest.parent.relative_to(root).as_posix())
if _is_skipped(relative):
continue
# A separate workspace root is not a crate of this workspace. Skipping
# it here (rather than after the outermost-wins pass) is deliberate: a
# guest that is not shadowed by an enclosing crate must not become one.
if _declares_own_workspace(manifest):
continue
manifests.append(relative)
# Shallowest first so the outermost owner of a path is always seen before
# any manifest nested inside it.
manifests.sort(key=lambda relative: (len(relative.parts), relative.as_posix()))
crate_dirs: list[str] = []
for relative in manifests:
text = relative.as_posix()
if any(text == kept or text.startswith(f"{kept}/") for kept in crate_dirs):
continue
crate_dirs.append(text)
if len(crate_dirs) < MIN_CRATE_DIRECTORIES:
raise CrateTreeError(
f"crate discovery found only {len(crate_dirs)} crate director(ies) under "
f"{crates_root!s} (floor is {MIN_CRATE_DIRECTORIES}). Either the crate tree "
"moved out from under this gate or the repository root is wrong. Failing "
"closed: a gate that scans nothing must never report success "
"(docs/internal/reborn/target-architecture/CHECKLIST.md WS10)."
)
return sorted(crate_dirs)
def crate_directory(name: str, repo_root: str | pathlib.Path = ".") -> str:
"""Return the crate directory whose basename is ``name``.
Raises when the crate is absent — a named crate that a gate measures cannot
silently resolve to "nothing" just because it moved or was renamed.
"""
matches = [
directory
for directory in _crate_directories_cached(repo_root)
if directory.rsplit("/", 1)[-1] == name
]
if len(matches) != 1:
raise CrateTreeError(
f"expected exactly one crate directory named {name!r} under "
f"{CRATES_ROOT_NAME}/, found {len(matches)}: {matches}. If the crate was "
"renamed or moved, repoint the gate that names it rather than letting it "
"measure an empty tree."
)
return matches[0]
def owning_crate_directory(
path: str, repo_root: str | pathlib.Path = "."
) -> str | None:
"""Return the crate directory that owns repo-relative ``path``, else ``None``.
Outermost wins, matching `crate_directories()` and the inline rule in
`scripts/ci/classify-test-scope.sh`: `crates/ironclaw_safety/fuzz/src/main.rs`
is owned by `crates/ironclaw_safety`, not by the nested fuzz manifest.
``None`` means "under `crates/` but attributable to no crate" (or not under
`crates/` at all). Callers that classify production sources must treat the
first case as an error rather than as "not production" — that fall-through
is the WS10 silent-dark failure mode
(docs/internal/reborn/target-architecture/CHECKLIST.md) — *unless*
`nested_workspace_root()` claims the path, which is the sanctioned
"excluded by construction" answer.
"""
normalized = pathlib.PurePosixPath(path).as_posix()
for directory in _crate_directories_cached(repo_root):
if normalized.startswith(f"{directory}/"):
return directory
return None
def workspace_root_directories(repo_root: str | pathlib.Path = ".") -> list[str]:
"""Directories under `crates/` that are roots of a *separate* workspace.
These are excluded from `crate_directories()` on purpose (see the module
docstring). Today: the six `crates/extensions/packages/*/wasm-src` guest
components. (The silk decoder was the seventh until WS7 moved it to
`tools/`, where it is out of this walk's scope entirely.)
"""
root = pathlib.Path(repo_root)
crates_root = root / CRATES_ROOT_NAME
if not crates_root.is_dir():
raise CrateTreeError(
f"no {CRATES_ROOT_NAME}/ directory under {root!s} — cannot enumerate "
"separate workspace roots."
)
roots: list[str] = []
for manifest in crates_root.rglob("Cargo.toml"):
relative = pathlib.PurePosixPath(manifest.parent.relative_to(root).as_posix())
if _is_skipped(relative):
continue
if _declares_own_workspace(manifest):
roots.append(relative.as_posix())
return sorted(roots)
def nested_workspace_root(
path: str, repo_root: str | pathlib.Path = "."
) -> str | None:
"""Return the separate-workspace root containing ``path``, else ``None``.
A path this claims is excluded from the workspace *by construction* — it is
never compiled here and no line in it can be covered — which is a different
answer from "the inventory and the tree disagree". Callers that fail closed
on unattributable `crates/` paths consult this first.
"""
normalized = pathlib.PurePosixPath(path).as_posix()
for directory in _workspace_roots_cached(repo_root):
if normalized.startswith(f"{directory}/"):
return directory
return None
def crate_source_relative(
path: str, repo_root: str | pathlib.Path = "."
) -> tuple[str, str] | None:
"""Split ``path`` into ``(crate_directory, in-crate remainder)``, else ``None``."""
owner = owning_crate_directory(path, repo_root)
if owner is None:
return None
return owner, pathlib.PurePosixPath(path).as_posix()[len(owner) + 1 :]
_INVENTORY_CACHE: dict[str, list[str]] = {}
_WORKSPACE_ROOT_CACHE: dict[str, list[str]] = {}
def _workspace_roots_cached(repo_root: str | pathlib.Path = ".") -> list[str]:
"""`workspace_root_directories()` memoized per resolved root."""
key = str(pathlib.Path(repo_root).resolve())
cached = _WORKSPACE_ROOT_CACHE.get(key)
if cached is None:
cached = sorted(workspace_root_directories(repo_root), key=len)
_WORKSPACE_ROOT_CACHE[key] = cached
return cached
def _crate_directories_cached(repo_root: str | pathlib.Path = ".") -> list[str]:
"""`crate_directories()` memoized per resolved root.
Path classification is a per-file question asked thousands of times per gate
run; re-walking `crates/` for each one turns an O(tree) gate into O(tree x
files). The cache is process-local and the tree does not change under a
running gate. `crate_directories()` already prunes manifests nested inside a
crate, so no entry is a prefix of another and lookup order cannot change the
answer; shortest-first is kept anyway so that if that pruning is ever
relaxed, the first match is still the *outermost* owner — the rule
`owning_crate_directory` documents.
"""
key = str(pathlib.Path(repo_root).resolve())
cached = _INVENTORY_CACHE.get(key)
if cached is None:
cached = sorted(crate_directories(repo_root), key=len)
_INVENTORY_CACHE[key] = cached
return cached
def reset_inventory_cache() -> None:
"""Drop the memoized inventories.
Only in-process callers that mutate a crate tree between queries need this
— self-tests that build a fixture root, assert, then sabotage the same root.
Gates run once per process and never call it.
"""
_INVENTORY_CACHE.clear()
_WORKSPACE_ROOT_CACHE.clear()
def main() -> int:
args = sys.argv[1:]
# Manual parsing (not argparse) so the pre-existing positional-root
# contract above is untouched byte-for-byte: every current caller passes
# at most one bare positional argument, and that must keep working
# exactly as before regardless of how `--directory` is implemented.
directory_name: str | None = None
if "--directory" in args:
flag_index = args.index("--directory")
try:
directory_name = args[flag_index + 1]
except IndexError:
print(
"crate discovery failed: --directory requires a crate name",
file=sys.stderr,
)
return 1
del args[flag_index : flag_index + 2]
root = args[0] if args else "."
try:
if directory_name is not None:
print(crate_directory(directory_name, root))
else:
for directory in crate_directories(root):
print(directory)
except CrateTreeError as error:
print(f"crate discovery failed: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())