mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* docs(ci): plan unified bounded integration execution * ci: unify selected integration tests under nextest * ci: retire duplicate integration runners * docs(ci): remove implementation plan from PR * Address PR review feedback (#7992) - preserve every unique Cargo integration target in execution inventory - cover shared-source aliases through the inventory and lane-runner contracts --------- Co-authored-by: Henry Park <16583448+henrypark133@users.noreply.github.com>
437 lines
17 KiB
Bash
Executable File
437 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Classify a list of changed paths (one per line on stdin) into the test scopes
|
|
# the CI workflows select on: docs_only / has_core_code / has_legacy_tests /
|
|
# has_reborn_tests.
|
|
#
|
|
# Crate paths are normalized to `crates/<crate>/...` BEFORE the case arms run,
|
|
# so the arms below stay keyed to crate identity rather than to how deep the
|
|
# crate sits under `crates/`. Without that, the target-architecture family move
|
|
# (`crates/ironclaw_event_log` -> `crates/substrates/ironclaw_event_log`, PROPOSAL §5)
|
|
# makes every crate-scoped arm stop matching: the paths still look like code, so
|
|
# they fall through to `is_code_path` and get bucketed legacy-only, and the whole
|
|
# Reborn suite is silently skipped on a green PR. See
|
|
# docs/internal/reborn/target-architecture/CHECKLIST.md WS10.
|
|
#
|
|
# Test/override env vars (unset in prod):
|
|
# IRONCLAW_REPO_ROOT repository root whose crate tree defines the inventory
|
|
# (default: this script's ../..)
|
|
#
|
|
# Exit codes: 0 = classified ; 1 = a `crates/` path could not be attributed to
|
|
# a crate, or crate discovery failed. Both are refusals, not classifications.
|
|
|
|
set -euo pipefail
|
|
|
|
has_core_code=false
|
|
docs_only=true
|
|
has_legacy_tests=false
|
|
has_reborn_tests=false
|
|
|
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
repo_root="${IRONCLAW_REPO_ROOT:-$(cd "${script_dir}/../.." && pwd)}"
|
|
|
|
# Fail-closed floor for crate discovery. Mirrors MIN_CRATE_DIRECTORIES in
|
|
# scripts/ci/lib/crate_tree.py, which owns the same rule for the Python-side
|
|
# gates; test-classify-test-scope.sh pins the two inventories equal.
|
|
min_crate_directories=20
|
|
|
|
# Newline-delimited inventory of directories under `crates/` that own a
|
|
# Cargo.toml, at any depth. Nested manifests (ironclaw_safety/fuzz) are kept in
|
|
# the list but never win a lookup: resolution walks path segments outward-in
|
|
# and stops at the first hit, so the *outermost* crate always owns the path —
|
|
# exactly what `crates/<crate>/*` globs did before.
|
|
#
|
|
# Manifests that declare their own `[workspace]` table are skipped entirely:
|
|
# they root a *separate* workspace (the `wasm-src/` guest components), so this
|
|
# workspace never builds them and no test run can cover them. `scripts/ci/lib/crate_tree.py` applies the same rule and
|
|
# `test-classify-test-scope.sh` pins the two inventories equal.
|
|
crate_dirs=""
|
|
workspace_root_dirs=""
|
|
discover_crate_dirs() {
|
|
local crates_root="${repo_root}/crates"
|
|
if [ ! -d "${crates_root}" ]; then
|
|
echo "classify-test-scope: no crates/ directory under ${repo_root}; refusing to" \
|
|
"classify against an empty crate inventory (CHECKLIST WS10)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
local manifest dir relative count
|
|
while IFS= read -r manifest; do
|
|
# `manifest` is relative to crates_root (the find below runs from inside
|
|
# it, see below) — `dir`/`relative` must be rebuilt from it, never from
|
|
# the search root's own absolute prefix. `-not -path '*/.*'` matches
|
|
# against find's PRINTED path: searching from an absolute crates_root
|
|
# would make it also match any dot-component OF THE REPO CHECKOUT PATH
|
|
# itself (e.g. a worktree under `.claude/worktrees/...`), excluding every
|
|
# result. Running `find .` from inside crates_root confines the exclusion
|
|
# to paths actually under crates/, matching what crate_tree.py's
|
|
# `_is_skipped` already does by checking relative parts only.
|
|
relative="${manifest#./}"
|
|
dir="crates/${relative%/Cargo.toml}"
|
|
# `grep -q '^\[workspace\]$'` — line-anchored so `[workspace.dependencies]`
|
|
# in a member manifest is not mistaken for a workspace root.
|
|
if grep -qx '\[workspace\]' "${crates_root}/${relative}" 2>/dev/null; then
|
|
workspace_root_dirs="${workspace_root_dirs}${dir}
|
|
"
|
|
continue
|
|
fi
|
|
crate_dirs="${crate_dirs}${dir}
|
|
"
|
|
done < <(
|
|
(cd "${crates_root}" && find . -type f -name Cargo.toml \
|
|
-not -path '*/target/*' -not -path '*/.*' 2>/dev/null) | sort
|
|
)
|
|
|
|
count="$(printf '%s' "${crate_dirs}" | grep -c . || true)"
|
|
if [ "${count}" -lt "${min_crate_directories}" ]; then
|
|
echo "classify-test-scope: found only ${count} crate director(ies) under" \
|
|
"${crates_root} (floor ${min_crate_directories}). The crate tree moved out from" \
|
|
"under this classifier, or the repo root is wrong. Refusing to bucket every" \
|
|
"crate change as out-of-scope (CHECKLIST WS10)." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
newline='
|
|
'
|
|
|
|
# Sets OWNING_CRATE_DIR to the crate directory that owns "$1"; returns 1 if the
|
|
# path is inside no known crate directory.
|
|
OWNING_CRATE_DIR=""
|
|
owning_crate_dir() {
|
|
local rest="${1#crates/}" acc="crates" segment
|
|
OWNING_CRATE_DIR=""
|
|
while [ -n "${rest}" ]; do
|
|
segment="${rest%%/*}"
|
|
if [ "${segment}" = "${rest}" ]; then
|
|
rest=""
|
|
else
|
|
rest="${rest#*/}"
|
|
fi
|
|
acc="${acc}/${segment}"
|
|
case "${newline}${crate_dirs}" in
|
|
*"${newline}${acc}${newline}"*)
|
|
OWNING_CRATE_DIR="${acc}"
|
|
return 0
|
|
;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# True when "$1" is package data under `extensions/packages/`. Anchored on the
|
|
# support crate rather than a literal path, so a family move keeps it working:
|
|
# `packages/` is that crate's sibling. Sets PACKAGE_ASSET_PACKAGES_DIR to the
|
|
# matched `packages/` directory (whatever depth it actually sits at) so the
|
|
# caller can normalize the path onto it — see normalize_crate_path below.
|
|
PACKAGE_ASSET_PACKAGES_DIR=""
|
|
package_asset_dir() {
|
|
local path="$1" support_dir packages_dir
|
|
PACKAGE_ASSET_PACKAGES_DIR=""
|
|
while IFS= read -r support_dir; do
|
|
[ -n "${support_dir}" ] || continue
|
|
case "${support_dir}" in
|
|
*/ironclaw_extension_support)
|
|
packages_dir="${support_dir%/*}/packages"
|
|
case "${path}" in
|
|
"${packages_dir}"/*)
|
|
PACKAGE_ASSET_PACKAGES_DIR="${packages_dir}"
|
|
return 0
|
|
;;
|
|
esac
|
|
;;
|
|
esac
|
|
done <<EOF
|
|
${crate_dirs}
|
|
EOF
|
|
return 1
|
|
}
|
|
|
|
# True when "$1" lies inside a directory that roots a separate cargo workspace.
|
|
nested_workspace_dir() {
|
|
local path="$1" root
|
|
while IFS= read -r root; do
|
|
[ -n "${root}" ] || continue
|
|
case "${path}" in
|
|
"${root}"/*) return 0 ;;
|
|
esac
|
|
done <<EOF
|
|
${workspace_root_dirs}
|
|
EOF
|
|
return 1
|
|
}
|
|
|
|
# Sets NORMALIZED_PATH: `crates/<...>/<crate>/<rest>` becomes `crates/<crate>/<rest>`,
|
|
# everything else passes through. On today's flat tree every crate directory
|
|
# already is `crates/<crate>`, so this is a no-op for every path in the
|
|
# repository. Exits 1 (not a subshell — the caller must die too) when a
|
|
# `crates/` path cannot be attributed at all.
|
|
NORMALIZED_PATH=""
|
|
normalize_crate_path() {
|
|
local path="$1" tail
|
|
NORMALIZED_PATH="${path}"
|
|
|
|
case "${path}" in
|
|
crates/*) ;;
|
|
*) return 0 ;;
|
|
esac
|
|
|
|
tail="${path#crates/}"
|
|
case "${tail}" in
|
|
*/*) ;;
|
|
# A file sitting directly in crates/ (AGENTS.md, README.md) belongs to no
|
|
# crate and never did.
|
|
*) return 0 ;;
|
|
esac
|
|
|
|
if owning_crate_dir "${path}"; then
|
|
# The crate DIRECTORY basename is the key, not the cargo package name.
|
|
# Usually they are identical; two documented exceptions make the package
|
|
# name the wrong choice here (PROPOSAL §5.1's directory rule):
|
|
# `crates/ironclaw_cli` declares `name = "ironclaw"`, and package
|
|
# directories under `extensions/packages/` are named by extension identity
|
|
# (`packages/slack/` holds `ironclaw_slack_extension`). The arms below are
|
|
# therefore keyed on directory names, and every package directory needs its
|
|
# own arm — a missing one is the silent mis-bucketing this classifier
|
|
# exists to prevent, so `test-classify-test-scope.sh` probes each of them.
|
|
NORMALIZED_PATH="crates/${OWNING_CRATE_DIR##*/}/${path#"${OWNING_CRATE_DIR}/"}"
|
|
return 0
|
|
fi
|
|
|
|
# Two shapes under `crates/` own no crate BY DESIGN, and refusing on them
|
|
# would be wrong — they are attributable, just not to a crate:
|
|
#
|
|
# * a data-only package directory under `extensions/packages/`, which is
|
|
# manifest + prompts + schemas + committed wasm and deliberately carries
|
|
# no `Cargo.toml` (PROPOSAL §5). Its data is embedded by
|
|
# `ironclaw_extension_support`, which is where it used to live, so it
|
|
# lights the same lane that crate does.
|
|
# * a separate cargo workspace (the `wasm-src/` guest components).
|
|
#
|
|
# Checked in this order and NOT symmetrically: a package-asset path is
|
|
# rewritten onto the canonical `crates/extensions/packages/<rest>` identity
|
|
# (the same treatment the crate-owned branch above gives its match), while a
|
|
# non-package workspace root passes through unchanged, matching its
|
|
# literal-path arms below. Without the rewrite, a
|
|
# data-only package's path stays literally wherever `packages/` sits
|
|
# (`crates/<family>/packages/...`) after the family move (PROPOSAL §5), the
|
|
# `crates/extensions/packages/*` arms in is_shared_test_path stop matching,
|
|
# and the file falls through to `is_code_path`'s bare `crates/*` arm —
|
|
# bucketing it has_reborn_tests=false where it was true. A `wasm-src/` guest
|
|
# inside the SAME package would be caught by package_asset_dir first (it is
|
|
# also under `packages/`) and gets the identical rewrite, so its own
|
|
# `nested_workspace_dir` membership never needs to be consulted here.
|
|
if package_asset_dir "${path}"; then
|
|
NORMALIZED_PATH="crates/extensions/packages/${path#"${PACKAGE_ASSET_PACKAGES_DIR}/"}"
|
|
return 0
|
|
fi
|
|
if nested_workspace_dir "${path}"; then
|
|
return 0
|
|
fi
|
|
|
|
# The crate directory is absent from the checkout — the normal case for a
|
|
# path the diff *deletes*. Fall back to the naming convention so a
|
|
# crate-removal PR classifies exactly as it did before this normalization
|
|
# existed.
|
|
case "${tail}" in
|
|
ironclaw_*/*)
|
|
return 0
|
|
;;
|
|
*/ironclaw_*/*)
|
|
# Drop every family segment ahead of the crate, not just the first, so the
|
|
# fallback is as depth-independent as the tree lookup above it. Anchored on
|
|
# the FIRST `ironclaw_` segment (`%%` keeps the shortest prefix): a greedy
|
|
# match on the last one would fold
|
|
# `crates/f/ironclaw_event_log/src/ironclaw_helper.rs` down to
|
|
# `crates/ironclaw_helper.rs`.
|
|
NORMALIZED_PATH="crates/${tail#"${tail%%/ironclaw_*}"/}"
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
# A direct child file of a FAMILY directory (`crates/app/AGENTS.md`,
|
|
# `crates/kernel/AGENTS.md`, ...) is attributable but crate-less, exactly like
|
|
# a file sitting directly in `crates/` — the family dirs exist since WS7 and
|
|
# each carries its own guidance file. Recognized structurally: the parent
|
|
# directory is a prefix of at least one discovered crate dir, and the path
|
|
# has no further directory below it. Falls through to the same buckets as
|
|
# `crates/AGENTS.md` (code-adjacent, legacy suite) — conservative, never
|
|
# silent.
|
|
case "${tail}" in
|
|
*/*/*) ;;
|
|
*/*)
|
|
local family_seg="crates/${tail%%/*}/"
|
|
case "${crate_dirs}" in
|
|
*"${family_seg}"*)
|
|
return 0
|
|
;;
|
|
esac
|
|
;;
|
|
esac
|
|
|
|
# Default arm: a path under crates/ that resolves to no crate, by tree or by
|
|
# convention. Historically this silently fell through to "legacy tests only",
|
|
# which is how a re-shaped crate tree drops the Reborn suite. Refuse instead.
|
|
echo "classify-test-scope: cannot attribute '${path}' to any crate under" \
|
|
"${repo_root}/crates. Add the crate to the tree, or teach this classifier the" \
|
|
"new path shape — silently bucketing it would skip whichever suite owns it" \
|
|
"(CHECKLIST WS10)." >&2
|
|
exit 1
|
|
}
|
|
|
|
is_docs_only_path() {
|
|
local path="$1"
|
|
case "$path" in
|
|
docs/*|.github/ISSUE_TEMPLATE/*|.github/pull_request_template.md)
|
|
return 0
|
|
;;
|
|
*.md)
|
|
case "$path" in
|
|
*/*) return 1 ;;
|
|
*) return 0 ;;
|
|
esac
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
is_shared_test_path() {
|
|
local path="$1"
|
|
case "$path" in
|
|
Cargo.toml|Cargo.lock|crates/ironclaw_llm/assets/providers.json|Dockerfile)
|
|
return 0
|
|
;;
|
|
scripts/ci/classify-test-scope.sh|scripts/ci/test-classify-test-scope.sh|scripts/ci/package-feature-flags.sh)
|
|
return 0
|
|
;;
|
|
.github/workflows/reborn-tests.yml|.github/workflows/reborn-e2e.yml|.github/workflows/nightly-deep-ci.yml)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_common/*|crates/ironclaw_extension_contracts/*|crates/ironclaw_host_api/*|crates/ironclaw_host_runtime/*|crates/ironclaw_loop_contracts/*|crates/ironclaw_loop_host/*|crates/ironclaw_processes/*|crates/ironclaw_product_contracts/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_filesystem/*|crates/ironclaw_memory/*|crates/ironclaw_event_log/*|crates/ironclaw_event_projections/*|crates/ironclaw_event_streams/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_capabilities/*|crates/ironclaw_secrets/*|crates/ironclaw_network/*|crates/ironclaw_runtime_policy/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_authorization/*|crates/ironclaw_approvals/*|crates/ironclaw_resources/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_auth/*|crates/ironclaw_trust/*|crates/ironclaw_turns/*|crates/ironclaw_agent_loop/*|crates/ironclaw_threads/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_prompt_envelope/*|crates/ironclaw_hooks/*|crates/ironclaw_extension_support/*|crates/ironclaw_llm/*|\
|
|
crates/extensions/packages/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_safety/*|crates/ironclaw_skills/*|crates/ironclaw_oauth/*)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
is_reborn_test_path() {
|
|
local path="$1"
|
|
case "$path" in
|
|
docs/internal/reborn/*|scripts/reborn-e2e-rust.sh|scripts/ci/run-reborn-root-partition.sh|scripts/ci/check-reborn-responses-e2e-manifest.py|tests/reborn_*|tests/integration/*|tests/support/reborn_parity_qa/*|tests/fixtures/llm_traces/reborn_qa/*|tests/e2e/reborn_coverage_tests.txt|tests/e2e/reborn_responses_e2e_tests.txt|tests/e2e/scenarios/test_reborn_*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_architecture_tests/*)
|
|
return 0
|
|
;;
|
|
# The WS6 renames dropped the `ironclaw_reborn_` prefix from all seven
|
|
# crates that carried it, so the `crates/ironclaw_reborn_*/*` glob this
|
|
# arm used to rely on now matches NOTHING and every one of these crates
|
|
# silently reclassified as legacy. Enumerated rather than re-globbed:
|
|
# the new names share no prefix, and a glob that matches nothing fails
|
|
# open (quietly) instead of closed.
|
|
crates/ironclaw_turn_runner/*|\
|
|
crates/ironclaw_cli/*|\
|
|
crates/ironclaw_composition/*|\
|
|
crates/ironclaw_config/*|\
|
|
crates/ironclaw_event_store/*|\
|
|
crates/ironclaw_identity/*|\
|
|
crates/ironclaw_openai_compat/*|\
|
|
crates/ironclaw_trace_commons/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_product_*/*|\
|
|
crates/slack/*|crates/telegram/*|crates/memory-native/*|crates/mem0/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_webui/*)
|
|
return 0
|
|
;;
|
|
crates/ironclaw_conversations/*|crates/ironclaw_extension_host/*|crates/ironclaw_extension_manager/*|crates/ironclaw_outbound/*|crates/ironclaw_assistant/*|crates/ironclaw_triggers/*)
|
|
return 0
|
|
;;
|
|
scripts/ci/reborn-coverage-*.sh|scripts/ci/test-reborn-coverage.sh|scripts/ci/test-reborn-coverage-*.sh|scripts/ci/reborn_changed_coverage.py|scripts/ci/test_reborn_changed_coverage.py|scripts/ci/critical_mutation_gate.py|scripts/ci/test-critical-mutation-gate.sh|scripts/ci/check-reborn-branch-coverage-flags.py|scripts/ci/test-check-reborn-branch-coverage-flags.sh|scripts/ci/check-reborn-qa-fixtures.sh|scripts/ci/test-check-reborn-qa-fixtures.sh|scripts/ci/lib/reborn_coverage_lcov.py|scripts/ci/reborn-crate-test-buckets.sh|scripts/ci/test-reborn-crate-test-buckets.sh|scripts/ci/ws12-suite-shards.toml|scripts/ci/ws12_suite_shards.py|scripts/ci/test_ws12_suite_shards.py|scripts/ci/ws12_workflow_contracts.py|scripts/ci/test_ws12_workflow_contracts.py|scripts/ci/lib/rust_toolchain_contracts.py|scripts/ci/lib/workflow_text.py|scripts/ci/lib/integration_test_inventory.py|scripts/ci/test_integration_test_inventory.py|scripts/ci/check-test-suite-boundaries.sh|scripts/ci/classify-test-scope.sh|scripts/ci/test-classify-test-scope.sh)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
is_code_path() {
|
|
local path="$1"
|
|
case "$path" in
|
|
crates/*|tests/*|migrations/*)
|
|
return 0
|
|
;;
|
|
Cargo.toml|Cargo.lock|Dockerfile)
|
|
return 0
|
|
;;
|
|
scripts/check_no_panics.py|scripts/build-wasm-extensions.sh|scripts/check-version-bumps.sh|scripts/reborn-e2e-rust.sh|scripts/ci/*)
|
|
return 0
|
|
;;
|
|
.github/workflows/*.yml|.github/actions/install-cargo-component/*|.github/dependabot.yml|.github/labeler.yml)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
discover_crate_dirs
|
|
|
|
while IFS= read -r raw_path || [ -n "$raw_path" ]; do
|
|
[ -n "$raw_path" ] || continue
|
|
|
|
normalize_crate_path "$raw_path"
|
|
path="${NORMALIZED_PATH}"
|
|
|
|
if ! is_docs_only_path "$path"; then
|
|
docs_only=false
|
|
fi
|
|
|
|
if is_code_path "$path"; then
|
|
has_core_code=true
|
|
fi
|
|
|
|
if is_shared_test_path "$path"; then
|
|
has_legacy_tests=true
|
|
has_reborn_tests=true
|
|
elif is_reborn_test_path "$path"; then
|
|
has_reborn_tests=true
|
|
elif is_code_path "$path"; then
|
|
has_legacy_tests=true
|
|
fi
|
|
done
|
|
|
|
cat <<EOF
|
|
docs_only=${docs_only}
|
|
has_core_code=${has_core_code}
|
|
has_legacy_tests=${has_legacy_tests}
|
|
has_reborn_tests=${has_reborn_tests}
|
|
EOF
|