mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* refactor(extensions): colocate packages under crates/extensions/ (WS2) Physical colocation only — no behaviour change. `crates/extensions/` now holds `ironclaw_extension_support/` (renamed from `ironclaw_first_party_extensions`) beside `packages/`, which carries 14 self-contained package directories: the twelve extension packages plus the two `[memory]` providers, each with its manifest, prompts, schemas, committed `wasm/` and the `wasm-src/` guest that produced it. `ironclaw_telegram_v2_adapter` is merged into `ironclaw_telegram_extension`, giving Telegram the one-crate-per-package shape Slack already had and the same four-crate contract-tier dependency set. 512 renames, 489 of them byte-identical; every content change in a moved test file is a path or crate-name repoint. Two silent registration traps this move created, both reproduced live before being fixed: * `classify-test-scope.sh` keys its arms on the crate directory basename, and package directories are named by extension identity, so all four moved package crates classified `has_reborn_tests=false` — the entire Reborn suite would have stopped running on a change to any of them, on a green PR. * A data-only package has no `Cargo.toml`, so five `wasm-src/` guests became the outermost manifest on their path and were promoted to first-class crates, putting ~12k lines of workspace-excluded, never-compiled guest code into the changed-coverage and composition-budget denominators. Both inventories now agree that a manifest declaring its own `[workspace]` table roots a different workspace and is not a crate of this one; `nested_workspace_root()` keeps those paths attributable so the fail-closed unattributable-path guard still means what it says. Adds the committed-`.wasm` freshness gate the tree never had: the rebuild job overwrites artifacts in the working tree before testing them, so a stale artifact shipped silently. It records a digest of each guest's sources rather than of the artifact, because the guest builds are not reproducible. The memory-provider enforcement is re-pointed to PROPOSAL §8.2's amended rule over both providers, with the three surviving dependents held as named shrink-only residue. The layer flip and binary-only linking do not land here: `host_runtime` constructs `NativeMemoryService` itself, so flipping first would create a kernel→products edge. WS3 owns the unblock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ci): make the classifier and the panic delta scan survive a move Three CI-surfaced defects, all the same shape — a gate that assumed the pre-move tree. `classify-test-scope.sh` REFUSED on `packages/github/manifest.toml`: a data-only package carries no `Cargo.toml` by design, so every file in it is attributable to no crate, and the fail-closed arm fired. The refusal was correct behaviour against an incomplete rule. A `package_asset_dir` predicate — anchored on the support crate so `packages/` is found as its sibling rather than by literal path — routes package data to the shared arm, which is exactly where it landed before the move as `first_party_extensions/assets/**`. The self-test had probed a package crate and a `wasm-src` guest but not a data-only asset; all three are probed now. `check_no_panics.py`'s delta scan had no rename detection, so a relocated file had no pre-image on its destination path and every one of its lines read as added: a pure `git mv` of `memory-native/src/repo/filesystem.rs` contributed 1,754 spurious added lines and failed the scan on six `unreachable!()` calls that are in the reviewed baseline. Both git invocations now pass `-M`, the added-line map is computed once for the whole range (pairing a rename needs both sides in one invocation), and the parser is split out and pinned by three tests: pure rename contributes nothing, rename-with-edits reports only the edited lines, creation still reports every line. `test-reborn-coverage.sh`'s M4 case had its fixture paths rewritten with the move while its assertions still named the old crate directories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(checklist): record the rename-aware panic-scan fix * fix(ci): let the changed-coverage gate pair a crate-directory rename `diff_pathspecs()` built one `<crate>/src/**` pair per crate in the CURRENT inventory, which cannot name the SOURCE of a rename whose crate directory moved: `crates/ironclaw_memory_native/` is not in the inventory once the crate lives at `crates/extensions/packages/memory-native/`. `-M` therefore had nothing to pair against and every surviving line of a moved file landed in the denominator — measured on this PR, **33,026 added production lines** across 95 files, none of them an actual edit, against a 90% changed-line floor. Same defect `-M` was added for in #7005, one level up: there a file moved within a crate, here the crate itself moves. It fails in the expensive direction, because the floor then demands coverage for a pure `git mv`. The pathspec is now the crates root. Precision is unchanged — `parse_diff` already classifies each destination path with `is_production()` and drops everything else — so a non-move diff measures exactly what it measured before. On this PR the denominator falls to 2,357, of which 1,187 are test-path files the gate already excludes. Pinned by two tests: the pathspec contract, and an end-to-end regression that `git mv`s a whole crate into `extensions/packages/` and asserts zero added production lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(checklist): generalize the move-breaks-gates finding * fix(ci): teach the coverage gate that crate-root scaffolding is uninstrumentable Two classes of line that LLVM can never emit a coverage region for were not recognized, and each is enough on its own to fail a pure relocation, because one unclassified line defeats the `candidate_lines <= uninstrumentable_lines` escape: * **Inner attributes.** The classifier matched outer `#[...]` but not `#![...]`. Every crate root carries `#![forbid(unsafe_code)]`, so a moved `lib.rs` of pure `mod`/`pub use` declarations landed in "absent from coverage or contain no DA records" on the strength of that single line. Fixing it closes `packages/telegram/src/lib.rs` completely. * **`const`/`static` items.** Their initializers are compile-time and carry no region — `const MANIFEST: &str = include_str!(…)` least of all. Repointing an asset path is the entire Rust content of a package move, so four inventory modules had nothing but const declarations changed and read as "contributed no instrumented lines". Spans to the terminating `;` like `use`, so multi-line initializers are covered too. Both pinned by tests, including a negative assertion that a real function body stays measurable. What is left is exempted with per-site evidence rather than classified: nine `include_bytes!(concat!(…))` lines sitting inside `macro_rules!` BODIES across the same four modules. A macro body is template text — LLVM attributes the expansion to the expansion site, so those line numbers can never carry a DA record however many tests run. The expansions themselves are covered by `ironclaw_extension_support`'s 152 tests, and the changed content is a string literal inside `concat!`: a wrong path fails the build, which is stronger than a coverage hit, and `check-include-str-paths.sh` independently asserts all 119 include targets exist. Classifying macro template text correctly needs a real parser, not a lexer. Whole manifest re-validated through the gate's own loader: 83 exemptions, no stale path, no expired review date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(telegram): cover the payload parser's fail-closed error paths The package move made `payload.rs` unpairable for `git diff -M` — the 2,166-line original split into four files under the 999-line budget and no destination clears the 50% rename threshold — so it reads as a new file with zero pre-existing exclusions, and 88 uncovered instrumented lines surfaced. They are almost all `PayloadParseError` construction sites and defensive branches on an untrusted-input boundary, which is worth testing rather than waiving. 18 tests in a new `src/tests/payload_errors.rs`, each driving a realistic malformed or hostile webhook body through BOTH `parse_telegram_update` and `normalize_telegram_update` so the two entry points cannot drift: missing update_id, the full chat-kind classification table, out-of-range UTF-16 mention windows, anonymous-admin replies, negative media-group message ids, attacker-authored media_group_id and display names, oversize and control-char message bodies, unsliceable bot_command entities, the leading-command rule, and every attachment slot. Changed-line coverage of `payload.rs`: 83.64% -> 95.91% (516/538). Production change is the four-line `#[path]` module declaration; no existing test was weakened or removed (89 -> 107 lib tests). Records one live defect rather than fixing it, since this is a move-only PR: Telegram voice notes and stickers hard-fail the ENTIRE update today. `collect_attachments` pairs voice with `audio/ogg` + `Voice` and sticker with `image/webp` + `Sticker`, but `validate_attachment_kind` (ironclaw_extension_contracts/src/external.rs:370) requires the kind to match the MIME base type unless it is `Other` — so both are rejected and the `?` aborts the parse. A well-formed voice message returns `InvalidExternalRef { kind: "attachment_descriptor" }` instead of being delivered. That is why those blocks had zero coverage. Pinned with an explicit "contract pin, not an endorsement" comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
279 lines
9.4 KiB
Bash
Executable File
279 lines
9.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Build WASM tools and channels that still have local source entries.
|
|
#
|
|
# Verifies local-source registry entries compile against the current WIT
|
|
# definitions. Registry entries that only publish release artifacts are skipped.
|
|
#
|
|
# Prerequisites:
|
|
# rustup target add wasm32-wasip2
|
|
# rustup target add wasm32-wasip1
|
|
# cargo install cargo-component --locked
|
|
#
|
|
# Usage:
|
|
# ./scripts/build-wasm-extensions.sh # build all
|
|
# ./scripts/build-wasm-extensions.sh --tools # tools only
|
|
# ./scripts/build-wasm-extensions.sh --channels # channels only
|
|
# ./scripts/build-wasm-extensions.sh --first-party # first-party extensions only
|
|
#
|
|
# The package root is resolved from the crate inventory
|
|
# (scripts/ci/lib/crate_tree.py) by crate NAME, not from a literal path, so the
|
|
# target-architecture family move (crates/<family>/ironclaw_*, PROPOSAL §5)
|
|
# does not leave this script unable to find the manifests it is supposed to
|
|
# build (docs/reborn/target-architecture/CHECKLIST.md WS10, #6963).
|
|
#
|
|
# WS2 moved the packages out of the support crate: they were
|
|
# `<support-crate>/assets/<ext>/`, they are now `packages/<ext>/` SIBLING to
|
|
# the support crate (PROPOSAL §5 — a package directory is self-contained and
|
|
# is not owned by any crate). So discovery still anchors on the crate name and
|
|
# then steps to its sibling, which keeps both properties: name-keyed (survives
|
|
# a family move) and following the code (survives this one).
|
|
#
|
|
# Override for tests: FIRST_PARTY_ASSETS_ROOT bypasses discovery.
|
|
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
|
|
cd "$(dirname "$0")/.."
|
|
|
|
FIRST_PARTY_CRATE="${FIRST_PARTY_CRATE:-ironclaw_extension_support}"
|
|
|
|
# Resolve the crate that owns the first-party extension assets, at any depth.
|
|
# Failing here is deliberate: "cannot find the crate" must be an actionable
|
|
# repoint, not an empty build that looks like there was nothing to do.
|
|
resolve_first_party_assets_root() {
|
|
if [ -n "${FIRST_PARTY_ASSETS_ROOT:-}" ]; then
|
|
printf '%s\n' "${FIRST_PARTY_ASSETS_ROOT}"
|
|
return 0
|
|
fi
|
|
|
|
local inventory match count discovery_error
|
|
# stdout and stderr are captured separately on purpose: merging them with
|
|
# `2>&1` would fold any Python warning into the inventory itself and turn a
|
|
# benign diagnostic into a bogus crate directory.
|
|
discovery_error="$(mktemp)"
|
|
if ! inventory=$(python3 scripts/ci/lib/crate_tree.py . 2>"${discovery_error}"); then
|
|
echo " FAIL first-party discovery (crate discovery failed: $(cat "${discovery_error}"))" >&2
|
|
rm -f "${discovery_error}"
|
|
return 1
|
|
fi
|
|
rm -f "${discovery_error}"
|
|
match=$(printf '%s\n' "${inventory}" | awk -F/ -v want="${FIRST_PARTY_CRATE}" '$NF == want')
|
|
count=$(printf '%s' "${match}" | grep -c . || true)
|
|
if [ "${count}" -ne 1 ]; then
|
|
echo " FAIL first-party discovery (expected exactly one crate named \
|
|
'${FIRST_PARTY_CRATE}', found ${count} — repoint FIRST_PARTY_CRATE in the same PR that \
|
|
renamed or moved it)" >&2
|
|
return 1
|
|
fi
|
|
# `packages/` sits beside the support crate, not inside it.
|
|
printf '%s/packages\n' "$(dirname "${match}")"
|
|
}
|
|
|
|
# cargo-component may be installed under ~/.cargo while Homebrew rustc wins PATH on
|
|
# developer machines. Force rustup rustc when available so installed WASI targets are
|
|
# visible to component builds.
|
|
RUSTUP_RUSTC=""
|
|
RUSTUP_TOOLCHAIN_NAME=""
|
|
if command -v rustup >/dev/null 2>&1; then
|
|
RUSTUP_RUSTC=$(rustup which rustc 2>/dev/null || true)
|
|
RUSTUP_TOOLCHAIN_NAME=$(rustup show active-toolchain 2>/dev/null | awk '{print $1}' || true)
|
|
if [ -z "${RUSTC:-}" ] && [ -n "$RUSTUP_RUSTC" ]; then
|
|
export RUSTC="$RUSTUP_RUSTC"
|
|
fi
|
|
fi
|
|
|
|
cargo_component_build() {
|
|
if [ -n "$RUSTUP_TOOLCHAIN_NAME" ]; then
|
|
RUSTC="${RUSTC:-$RUSTUP_RUSTC}" rustup run "$RUSTUP_TOOLCHAIN_NAME" cargo component build "$@"
|
|
else
|
|
cargo component build "$@"
|
|
fi
|
|
}
|
|
|
|
BUILD_TOOLS=true
|
|
BUILD_CHANNELS=true
|
|
BUILD_FIRST_PARTY=true
|
|
FAILED=()
|
|
|
|
if [[ "${1:-}" == "--tools" ]]; then
|
|
BUILD_CHANNELS=false
|
|
BUILD_FIRST_PARTY=false
|
|
elif [[ "${1:-}" == "--channels" ]]; then
|
|
BUILD_TOOLS=false
|
|
BUILD_FIRST_PARTY=false
|
|
elif [[ "${1:-}" == "--first-party" ]]; then
|
|
BUILD_TOOLS=false
|
|
BUILD_CHANNELS=false
|
|
fi
|
|
|
|
fail_build() {
|
|
local name="$1"
|
|
local reason="$2"
|
|
|
|
echo " FAIL $name ($reason)"
|
|
FAILED+=("$name")
|
|
}
|
|
|
|
build_manifest_set() {
|
|
local label="$1"
|
|
local builder="$2"
|
|
shift 2
|
|
|
|
echo "Building $label..."
|
|
if [ "$#" -eq 0 ]; then
|
|
fail_build "$label" "no manifests found"
|
|
return 0
|
|
fi
|
|
|
|
local manifest
|
|
for manifest in "$@"; do
|
|
"$builder" "$manifest"
|
|
done
|
|
}
|
|
|
|
build_extension() {
|
|
local manifest_path="$1"
|
|
local hidden
|
|
local source_dir
|
|
local crate_name
|
|
|
|
if ! hidden=$(jq -r '.hidden // false' "$manifest_path"); then
|
|
fail_build "$(basename "$manifest_path" .json)" "could not read hidden flag"
|
|
return 0
|
|
fi
|
|
if [ "$hidden" = "true" ]; then
|
|
echo " SKIP $(basename "$manifest_path" .json) (hidden registry entry)"
|
|
return 0
|
|
fi
|
|
|
|
if ! source_dir=$(jq -r '.source.dir // empty' "$manifest_path"); then
|
|
fail_build "$(basename "$manifest_path" .json)" "could not read source dir"
|
|
return 0
|
|
fi
|
|
if ! crate_name=$(jq -r '.source.crate_name // empty' "$manifest_path"); then
|
|
fail_build "$(basename "$manifest_path" .json)" "could not read crate name"
|
|
return 0
|
|
fi
|
|
local name
|
|
name=$(basename "$manifest_path" .json)
|
|
|
|
if [ -z "$source_dir" ]; then
|
|
echo " SKIP $name (registry entry has no local source)"
|
|
return 0
|
|
fi
|
|
if [ -z "$crate_name" ]; then
|
|
echo " SKIP $name (registry entry has no local crate name)"
|
|
return 0
|
|
fi
|
|
if [ ! -d "$source_dir" ]; then
|
|
echo " SKIP $name (source dir $source_dir not found)"
|
|
return 0
|
|
fi
|
|
|
|
echo " BUILD $name ($crate_name) from $source_dir"
|
|
if ! cargo_component_build --release --target wasm32-wasip2 --manifest-path "$source_dir/Cargo.toml" 2>&1; then
|
|
fail_build "$name" "cargo component build failed"
|
|
return 0
|
|
fi
|
|
echo " OK $name"
|
|
}
|
|
|
|
build_first_party_extension() {
|
|
local manifest_path="$1"
|
|
local extension_root
|
|
local source_dir
|
|
local module_path
|
|
local runtime_kind
|
|
local target_root
|
|
local artifact
|
|
local candidate
|
|
local name
|
|
|
|
extension_root=$(dirname "$manifest_path")
|
|
source_dir="$extension_root/wasm-src"
|
|
name="first-party/$(basename "$extension_root")"
|
|
if ! runtime_kind=$(awk -F'"' '/^[[:space:]]*kind[[:space:]]*=/{ print $2; exit }' "$manifest_path"); then
|
|
fail_build "$name" "could not read manifest"
|
|
return 0
|
|
fi
|
|
if [ "$runtime_kind" != "wasm" ]; then
|
|
echo " SKIP $name (runtime kind $runtime_kind is host-native)"
|
|
return 0
|
|
fi
|
|
if ! module_path=$(awk -F'"' '/^[[:space:]]*module[[:space:]]*=/{ print $2; exit }' "$manifest_path"); then
|
|
fail_build "$name" "could not read manifest"
|
|
return 0
|
|
fi
|
|
|
|
if [ ! -d "$source_dir" ]; then
|
|
fail_build "$name" "source dir $source_dir not found"
|
|
return 0
|
|
fi
|
|
if [ -z "$module_path" ]; then
|
|
fail_build "$name" "manifest missing runtime module"
|
|
return 0
|
|
fi
|
|
|
|
echo " BUILD $name from $source_dir"
|
|
if ! cargo_component_build --release --target wasm32-wasip2 --manifest-path "$source_dir/Cargo.toml" 2>&1; then
|
|
fail_build "$name" "cargo component build failed"
|
|
return 0
|
|
fi
|
|
target_root="${CARGO_TARGET_DIR:-$source_dir/target}"
|
|
artifact=""
|
|
for target_triple in wasm32-wasip2 wasm32-wasip1 wasm32-wasi; do
|
|
candidate="$target_root/$target_triple/release/$(basename "$module_path")"
|
|
if [ -f "$candidate" ]; then
|
|
artifact="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
if [ ! -f "$artifact" ]; then
|
|
fail_build "$name" "expected artifact $(basename "$module_path") not found under $target_root"
|
|
return 0
|
|
fi
|
|
|
|
if ! mkdir -p "$(dirname "$extension_root/$module_path")"; then
|
|
fail_build "$name" "could not create output directory"
|
|
return 0
|
|
fi
|
|
if ! cp "$artifact" "$extension_root/$module_path"; then
|
|
fail_build "$name" "could not copy artifact"
|
|
return 0
|
|
fi
|
|
echo " OK $name"
|
|
}
|
|
|
|
# `${arr[@]+"${arr[@]}"}` rather than `"${arr[@]}"`: under `set -u`, bash before
|
|
# 4.4 (macOS ships 3.2) treats an empty array expansion as an unbound variable
|
|
# and dies with a shell diagnostic instead of reaching build_manifest_set's
|
|
# "no manifests found" guard. Same outcome either way — non-zero — but only this
|
|
# form reports WHICH set was empty, which is the actionable half.
|
|
if $BUILD_TOOLS; then
|
|
tool_manifests=(registry/tools/*.json)
|
|
build_manifest_set "WASM tools" build_extension ${tool_manifests[@]+"${tool_manifests[@]}"}
|
|
fi
|
|
|
|
if $BUILD_FIRST_PARTY; then
|
|
if first_party_assets=$(resolve_first_party_assets_root); then
|
|
first_party_manifests=("${first_party_assets}"/*/manifest.toml)
|
|
else
|
|
first_party_manifests=()
|
|
fi
|
|
build_manifest_set "first-party WASM extensions" build_first_party_extension \
|
|
${first_party_manifests[@]+"${first_party_manifests[@]}"}
|
|
fi
|
|
|
|
if $BUILD_CHANNELS; then
|
|
channel_manifests=(registry/channels/*.json)
|
|
build_manifest_set "WASM channels" build_extension ${channel_manifests[@]+"${channel_manifests[@]}"}
|
|
fi
|
|
|
|
echo ""
|
|
if [ ${#FAILED[@]} -gt 0 ]; then
|
|
echo "FAILED: ${FAILED[*]}"
|
|
exit 1
|
|
else
|
|
echo "All WASM extensions built successfully."
|
|
fi
|