Просмотр исходного кода

test(skills): container-orchestration/portless-ops/auto-skill suites + build-push protocol backfill

Add offline, deterministic test suites for the three skills that ship scripts
with real side effects, and backfill container-orchestration's build-push.sh to
the Skill Resource Protocol.

build-push.sh (protocol backfill, behavior-preserving):
- contract header (Usage/Input/Output/Stderr/Exit + Examples), --help/-h exit 0,
  semantic exit codes (0 ok / 2 usage / 5 missing-dep / 1 build|push failed),
  stream separation (data on stdout, banners on stderr).
- new --dry-run path resolves and prints the plan WITHOUT invoking docker; a
  `command -v docker` guard means --help/arg-parsing/dry-run never need a daemon.
- happy-path DATA output (Image/Dockerfile/Context) is byte-identical pre/post;
  only the chatter banners and --help were added/moved.

Suites (all offline; never run a real docker build/push, never mutate real
portless state, never touch real ~/.claude/auto-skill or the repo skills/ tree):
- container-orchestration: --help/EXAMPLES, exit-2 usage, --dry-run resolution,
  stream separation, pre/post data diff vs the old git blob, and a sentinel
  `docker` proving the safe paths invoke no docker.
- portless-ops: STATIC/structural only (no pwsh) — synopsis blocks, portless.json
  templates parse, reset-state.ps1 guards Remove-Item via Test-Path + PreserveCa
  default-true (strict -WhatIf/ShouldProcess absence surfaced as INFO).
- auto-skill: sandboxed HOME; track-tools.sh writer tagging; evaluate.sh
  classification (qualify / harness-whitelist fire vs too-few-writes /
  too-few-types / non-harness-skill / cooldown silent); reset path deletes only
  the intended tracking file, siblings survive.

New run.sh files and build-push.sh tracked as 100755.

Co-Authored-By: Claude <noreply@anthropic.com>
0xDarkMatter 1 неделя назад
Родитель
Сommit
faab9f9086

+ 184 - 0
skills/auto-skill/tests/run.sh

@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# Self-test for auto-skill — sandboxed, offline, never touches real state.
+#
+# evaluate.sh ships real side effects: it reads a per-session tracking file
+# (/tmp/claude_autoskill_<8>), classifies the session, then `rm -f`s the tracking
+# file and appends to ~/.claude/auto-skill/pending.log. track-tools.sh is the
+# PostToolUse writer that populates that tracking file. Testing them must NEVER
+# touch the real ~/.claude/auto-skill/ or the repo's skills/ tree.
+#
+# Isolation strategy:
+#   - HOME is redirected to a throwaway sandbox for EVERY evaluate.sh call, so
+#     pending.log + the disable-toggle check land in the sandbox, not real ~.
+#   - Each scenario gets a UNIQUE 8-char session-id prefix (the first 8 chars of
+#     the session id select the tracking file). The prefix starts with 'g', which
+#     never appears in a real (hex-UUID) Claude session id, so our tracking files
+#     can never collide with a real running session. gen_sid is called WITHOUT
+#     command substitution so its counter survives (a `$(...)` subshell would
+#     discard the increment).
+#   - The trap removes our 'g'-prefixed tracking + suggested files (and only
+#     those) on exit.
+#
+# Coverage:
+#   - track-tools.sh records bare tool names and tags Skill:<name> (sanitised).
+#   - evaluate.sh classification: fires on qualifying sessions and when only a
+#     harness skill was loaded; stays silent on too-few-writes / too-few-types /
+#     non-harness-skill / cooldown.
+#   - evaluate.sh reset/safety: the reset path deletes ONLY the intended
+#     tracking file (siblings survive); the happy path cleans the tracking file
+#     and writes pending.log into the SANDBOX, not real HOME.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+EVAL="$SKILL/scripts/evaluate.sh"
+TRACK_TOOL="$SKILL/scripts/track-tools.sh"
+
+SB="$(mktemp -d)"; mkdir -p "$SB/home"
+cleanup() {
+    rm -rf "$SB" 2>/dev/null
+    # 'g' prefix is never a real (hex-UUID) session id → only our files match.
+    rm -f /tmp/claude_autoskill_g* /tmp/claude_autoskill_suggested_g* 2>/dev/null
+}
+trap cleanup EXIT
+
+# Unique per-run, per-scenario 8-char session-id prefixes. SID_BASE from the PID
+# keeps parallel runs apart; SEQ increments per call (in the parent shell, NOT a
+# subshell). The leading 'g' avoids any real Claude session id (hex UUID).
+SID_BASE=$(( $$ % 10000 ))
+SEQ=0
+GEN_SID=""
+gen_sid() { SEQ=$((SEQ+1)); GEN_SID=$(printf 'g%04d%03d' "$SID_BASE" "$SEQ"); }
+track_file()    { printf '/tmp/claude_autoskill_%s'            "$1"; }
+suggested_file(){ printf '/tmp/claude_autoskill_suggested_%s'  "$1"; }
+
+# Write tool names (one per line) into a tracking file.
+write_track() { local f="$1"; shift; : > "$f"; for t in "$@"; do printf '%s\n' "$t" >> "$f"; done; }
+
+# Run evaluate.sh with a session id: HOME=sandbox, CWD=sandbox (no project
+# disable file). Captures stdout only (evaluate always exits 0, stderr muted).
+run_eval() { ( cd "$SB" && printf '{"session_id":"%sxxxxxxxxxxxx"}' "$1" \
+                    | HOME="$SB/home" bash "$EVAL" ); }
+# Run track-tools.sh with a tool/session payload, HOME=sandbox.
+run_track() { # $1=session_id  $2=tool_name  $3=skill_name(or '')
+    local payload
+    if [[ -n "$3" ]]; then
+        payload=$(printf '{"tool_name":"%s","session_id":"%sxxxxxxxxxxxx","tool_input":{"skill":"%s"}}' "$2" "$1" "$3")
+    else
+        payload=$(printf '{"tool_name":"%s","session_id":"%sxxxxxxxxxxxx"}' "$2" "$1")
+    fi
+    ( cd "$SB" && printf '%s' "$payload" | HOME="$SB/home" bash "$TRACK_TOOL" )
+}
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+echo "=== auto-skill self-test (sandboxed) ==="
+
+# ── syntax ───────────────────────────────────────────────────────────────────
+echo "-- syntax --"
+bash -n "$EVAL"      2>/dev/null && ok "bash -n evaluate.sh"      || no "bash -n evaluate.sh"
+bash -n "$TRACK_TOOL" 2>/dev/null && ok "bash -n track-tools.sh"  || no "bash -n track-tools.sh"
+
+# ── track-tools.sh: records bare names + tags Skill:<name> ───────────────────
+echo "-- track-tools.sh writer --"
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+run_track "$SID" "Edit" ""
+run_track "$SID" "Bash" ""
+run_track "$SID" "Skill" "sync"
+run_track "$SID" "Skill" "deep-research"
+run_track "$SID" "Skill" "my:weird skill"
+contents="$(cat "$TF" 2>/dev/null)"
+expect_has "records bare tool name"  $'Edit'                "$contents"
+expect_has "records bare Bash"       $'Bash'                "$contents"
+expect_has "tags harness skill"      "Skill:sync"           "$contents"
+expect_has "tags non-harness skill"  "Skill:deep-research"  "$contents"
+expect_has "sanitises separators"    "Skill:my_weird_skill" "$contents"
+# written to the per-session file derived from the first 8 chars of session_id
+[[ -f "$TF" ]] && ok "track file path derives from session-id prefix" \
+                || no "track file path derives from session-id prefix"
+
+# ── evaluate.sh classification ───────────────────────────────────────────────
+echo "-- evaluate.sh classification (fires) --"
+# Qualifying session: 9 mutating ops, 5 distinct types, no skill -> fires.
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+write_track "$TF" Edit Edit Edit Write Write Bash Bash NotebookEdit MultiEdit
+out="$(run_eval "$SID")"
+expect_has "qualify -> systemMessage" "systemMessage"   "$out"
+expect_has "qualify reports mutating count" "9 mutating ops" "$out"
+expect_has "qualify reports type count"     "5 tool types"   "$out"
+expect_has "qualify reports total"          "(9 total)"      "$out"
+
+# Harness skill loaded (sync) must NOT disqualify -> still fires.
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+write_track "$TF" Skill:sync Edit Edit Edit Write Write Bash Bash NotebookEdit MultiEdit
+out="$(run_eval "$SID")"
+expect_has "harness skill does not disqualify" "systemMessage" "$out"
+expect_has "harness-fire counts skill in total" "(10 total)"   "$out"
+
+echo "-- evaluate.sh classification (silent) --"
+# Too few mutating ops (7 < 8).
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+write_track "$TF" Edit Edit Edit Write Write Write Bash
+out="$(run_eval "$SID")"
+[[ -z "$out" ]] && ok "too few writes (7) -> silent" || no "too few writes should be silent: [$out]"
+
+# Enough writes but too few distinct types (3 < 4).
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+write_track "$TF" Edit Edit Edit Edit Write Write Bash Bash
+out="$(run_eval "$SID")"
+[[ -z "$out" ]] && ok "too few distinct types (3) -> silent" || no "too few types should be silent: [$out]"
+
+# Non-harness skill loaded -> Gate 1 disqualifies.
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+write_track "$TF" Skill:deep-research Edit Edit Edit Write Write Bash Bash NotebookEdit MultiEdit
+out="$(run_eval "$SID")"
+[[ -z "$out" ]] && ok "non-harness skill -> silent" || no "non-harness skill should be silent: [$out]"
+
+# ── evaluate.sh reset / safety ───────────────────────────────────────────────
+echo "-- evaluate.sh reset/safety --"
+# Happy path cleans the tracking file and writes pending.log into the SANDBOX.
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+write_track "$TF" Edit Edit Edit Write Write Bash Bash NotebookEdit MultiEdit
+run_eval "$SID" >/dev/null
+[[ ! -f "$TF" ]]                       && ok "happy path removes tracking file" || no "happy path should remove tracking file"
+[[ -f "$SB/home/.claude/auto-skill/pending.log" ]] \
+    && ok "pending.log lands in sandbox HOME (not real ~)" \
+    || no "pending.log should land in sandbox HOME"
+
+# Reset path (cooldown) deletes ONLY the intended tracking file: the per-session
+# SUGGESTED marker and an unrelated sibling tracking file must survive.
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID"); SG=$(suggested_file "$SID")
+gen_sid; OTHER="$GEN_SID"; DEC=$(track_file "$OTHER")
+write_track "$TF" Edit Edit Edit Write Write Bash Bash NotebookEdit MultiEdit
+printf 'cooldown-marker\n' > "$SG"
+printf 'decoy\n'            > "$DEC"
+out="$(run_eval "$SID")"
+[[ -z "$out" ]]  && ok "cooldown -> silent"             || no "cooldown should be silent"
+[[ ! -f "$TF" ]] && ok "cooldown removes tracking file" || no "cooldown should remove tracking file"
+[[  -f "$SG" ]]  && ok "cooldown keeps SUGGESTED marker" || no "cooldown must not delete SUGGESTED marker"
+[[  -f "$DEC" ]] && ok "cooldown keeps sibling file"     || no "cooldown must not delete sibling tracking file"
+
+# Reset path (global disable): removes tracking file, no output, no pending.log.
+gen_sid; SID="$GEN_SID"; TF=$(track_file "$SID")
+write_track "$TF" Edit Edit Edit Write Write Bash Bash NotebookEdit MultiEdit
+mkdir -p "$SB/home/.claude"; touch "$SB/home/.claude/auto-skill.disable"
+LOG_BEFORE="$(wc -l < "$SB/home/.claude/auto-skill/pending.log" 2>/dev/null || echo 0)"
+out="$(run_eval "$SID")"
+[[ -z "$out" ]]  && ok "disabled -> silent"             || no "disabled should be silent"
+[[ ! -f "$TF" ]] && ok "disabled removes tracking file" || no "disabled should remove tracking file"
+LOG_AFTER="$(wc -l < "$SB/home/.claude/auto-skill/pending.log" 2>/dev/null || echo 0)"
+[[ "$LOG_AFTER" == "$LOG_BEFORE" ]] && ok "disabled writes no pending.log line" \
+                                    || no "disabled must not append pending.log"
+rm -f "$SB/home/.claude/auto-skill.disable"
+
+echo ""
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1
+exit 0

+ 129 - 57
skills/container-orchestration/scripts/build-push.sh

@@ -1,8 +1,56 @@
-#!/bin/bash
-# Build and push Docker image
-# Usage: ./build-push.sh [--tag TAG] [--registry REGISTRY] [--push]
+#!/usr/bin/env bash
+# Build and (optionally) push a Docker image with a predictable, agent-safe CLI.
+#
+# Usage:   build-push.sh [--tag TAG] [--registry REG] [--push]
+#                        [--dockerfile FILE] [--context DIR] [--dry-run] [--help]
+# Input:   argv only. Env overrides: IMAGE_NAME, IMAGE_TAG, DOCKER_REGISTRY.
+# Output:  stdout carries the resolved plan as plain "Key: Value" data lines
+#          (Image / Dockerfile / Context, [+ Pushed] after a push) — identical
+#          data to the pre-backfill behaviour, so downstream parsers are
+#          unaffected. Under --dry-run the planned `docker build` command is
+#          appended and nothing is executed.
+# Stderr:  progress banners ("=== Building ... ===") and diagnostics.
+# Exit:    0 ok, 2 usage (unknown flag or missing value), 5 missing-dep (docker
+#          not on PATH), 1 build or push failed.
+#
+# Examples:
+#   build-push.sh --tag v1.2.3 --registry ghcr.io/acme --push
+#   IMAGE_NAME=svc build-push.sh --dry-run --tag dev
+#   build-push.sh --dockerfile Dockerfile.prod --context ./app
+set -uo pipefail
 
-set -e
+usage() {
+    cat <<'EOF'
+Usage: build-push.sh [OPTIONS]
+
+Build and optionally push a Docker image.
+
+Options:
+  -t, --tag TAG          Image tag (default: $IMAGE_TAG or "latest").
+  -r, --registry REG     Registry prefix, e.g. ghcr.io/acme (default: $DOCKER_REGISTRY).
+  -p, --push             Push the image after a successful build.
+  -f, --dockerfile FILE  Dockerfile path (default: Dockerfile).
+  -c, --context DIR      Build context directory (default: .).
+  -n, --dry-run          Resolve and print the plan WITHOUT invoking docker.
+  -h, --help             Show this help and exit.
+
+Environment:
+  IMAGE_NAME        Image name (default: current directory basename).
+  IMAGE_TAG         Default tag.
+  DOCKER_REGISTRY   Default registry prefix.
+
+Exit codes:
+  0  success
+  2  usage error (unknown flag or missing value)
+  5  docker is not installed (missing dependency)
+  1  build or push failed
+
+Examples:
+  build-push.sh --tag v1.2.3 --registry ghcr.io/acme --push
+  IMAGE_NAME=svc build-push.sh --dry-run --tag dev
+  build-push.sh --dockerfile Dockerfile.prod --context ./app
+EOF
+}
 
 # Defaults
 REGISTRY="${DOCKER_REGISTRY:-}"
@@ -10,76 +58,100 @@ TAG="${IMAGE_TAG:-latest}"
 PUSH=false
 DOCKERFILE="Dockerfile"
 CONTEXT="."
+DRY_RUN=false
 
-# Parse arguments
+# Parse arguments — runs with NO docker dependency so --help / validation never
+# require a running daemon.
+need_value() {
+    echo "build-push.sh: $1 requires a value" >&2
+    exit 2
+}
 while [[ $# -gt 0 ]]; do
     case $1 in
         --tag|-t)
-            TAG="$2"
-            shift 2
-            ;;
+            [[ $# -ge 2 ]] || need_value "$1"; TAG="$2"; shift 2 ;;
         --registry|-r)
-            REGISTRY="$2"
-            shift 2
-            ;;
+            [[ $# -ge 2 ]] || need_value "$1"; REGISTRY="$2"; shift 2 ;;
         --push|-p)
-            PUSH=true
-            shift
-            ;;
+            PUSH=true; shift ;;
         --dockerfile|-f)
-            DOCKERFILE="$2"
-            shift 2
-            ;;
+            [[ $# -ge 2 ]] || need_value "$1"; DOCKERFILE="$2"; shift 2 ;;
         --context|-c)
-            CONTEXT="$2"
-            shift 2
-            ;;
+            [[ $# -ge 2 ]] || need_value "$1"; CONTEXT="$2"; shift 2 ;;
+        --dry-run|-n)
+            DRY_RUN=true; shift ;;
+        --help|-h)
+            usage; exit 0 ;;
         *)
-            echo "Unknown option: $1"
-            exit 1
-            ;;
+            echo "build-push.sh: unknown option: $1" >&2
+            echo "Run 'build-push.sh --help' for usage." >&2
+            exit 2 ;;
     esac
 done
 
-# Get image name from directory or git
-if [ -z "$IMAGE_NAME" ]; then
-    IMAGE_NAME=$(basename "$(pwd)")
-fi
-
-# Build full image name
-if [ -n "$REGISTRY" ]; then
+# Resolve image name (env override, else current directory) and full reference.
+IMAGE_NAME="${IMAGE_NAME:-$(basename "$(pwd)")}"
+if [[ -n "$REGISTRY" ]]; then
     FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
 else
     FULL_IMAGE="${IMAGE_NAME}:${TAG}"
 fi
 
-echo "=== Building Docker Image ==="
-echo "Image: $FULL_IMAGE"
-echo "Dockerfile: $DOCKERFILE"
-echo "Context: $CONTEXT"
-echo ""
-
-# Build
-docker build \
-    -t "$FULL_IMAGE" \
-    -f "$DOCKERFILE" \
-    --build-arg BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
-    --build-arg VCS_REF="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" \
-    "$CONTEXT"
-
-echo ""
-echo "=== Build Complete ==="
-echo "Image: $FULL_IMAGE"
-
-# Push if requested
-if [ "$PUSH" = true ]; then
-    echo ""
-    echo "=== Pushing Image ==="
-    docker push "$FULL_IMAGE"
-    echo "Pushed: $FULL_IMAGE"
+# Progress banners are chatter -> stderr; the resolved Image/Dockerfile/Context
+# are the data product -> stdout (unchanged from pre-backfill so anything that
+# keyed off "Image: ..." keeps working).
+banner() { printf '=== %s ===\n' "$*" >&2; }
+
+# Dry-run: validate args and print the plan, never touching docker.
+if [[ "$DRY_RUN" = true ]]; then
+    banner "Dry run (no docker invoked)"
+    printf 'Image: %s\n' "$FULL_IMAGE"
+    printf 'Dockerfile: %s\n' "$DOCKERFILE"
+    printf 'Context: %s\n' "$CONTEXT"
+    printf 'Push: %s\n' "$PUSH"
+    printf 'Plan: docker build -t %s -f %s %s' "$FULL_IMAGE" "$DOCKERFILE" "$CONTEXT"
+    [[ "$PUSH" = true ]] && printf ' && docker push %s' "$FULL_IMAGE"
+    printf '\n'
+    exit 0
+fi
+
+# Real path: docker is required from here on.
+if ! command -v docker >/dev/null 2>&1; then
+    echo "build-push.sh: docker is not installed (or not on PATH)." >&2
+    echo "  Install Docker: https://docs.docker.com/get-docker/" >&2
+    exit 5
+fi
+
+banner "Building Docker Image"
+printf 'Image: %s\n' "$FULL_IMAGE"
+printf 'Dockerfile: %s\n' "$DOCKERFILE"
+printf 'Context: %s\n' "$CONTEXT"
+printf '\n'
+
+# Build — a failed build is a runtime error (exit 1), distinct from usage (2).
+if ! docker build \
+        -t "$FULL_IMAGE" \
+        -f "$DOCKERFILE" \
+        --build-arg BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
+        --build-arg VCS_REF="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" \
+        "$CONTEXT"; then
+    echo "build-push.sh: docker build failed" >&2
+    exit 1
+fi
+
+banner "Build Complete"
+printf 'Image: %s\n' "$FULL_IMAGE"
+
+# Push if requested.
+if [[ "$PUSH" = true ]]; then
+    banner "Pushing Image"
+    if ! docker push "$FULL_IMAGE"; then
+        echo "build-push.sh: docker push failed" >&2
+        exit 1
+    fi
+    printf 'Pushed: %s\n' "$FULL_IMAGE"
 fi
 
-# Show image info
-echo ""
-echo "=== Image Info ==="
+# Show image info.
+banner "Image Info"
 docker images "$FULL_IMAGE" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}"

+ 147 - 0
skills/container-orchestration/tests/run.sh

@@ -0,0 +1,147 @@
+#!/usr/bin/env bash
+# Self-test for container-orchestration — fully offline: never runs a real
+# `docker build`/`docker push` and never calls a real registry.
+#
+# build-push.sh ships real side effects, so this suite exercises ONLY its safe
+# surfaces: the protocol contract (--help / EXAMPLES / exit codes), arg parsing
+# through the no-docker `--dry-run` path, stream separation (data on stdout,
+# chatter on stderr), and a pre/post proof that the happy-path DATA output is
+# unchanged by the protocol backfill (only the chatter banners and --help were
+# added/moved). A sentinel `docker` on PATH proves the help/parse/dry-run paths
+# never invoke docker at all.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+V="$SKILL/scripts/build-push.sh"
+
+# Pre-backfill git blob of build-push.sh — used for a live pre/post data diff
+# when the object is still reachable in the repo's history.
+OLD_BLOB="0d6b10cdef2901784b3360a2c61ba2ea144b6bc2"
+
+SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
+expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+
+# Sentinel `docker`: records any invocation into $DOCKER_SENTINEL_LOG, then
+# exits nonzero WITHOUT performing a real build/push. Prepended to PATH so we
+# can prove the safe paths never touch docker (and the baseline run of the old
+# script likewise performs no real build).
+mkdir -p "$SB/bin"
+cat > "$SB/bin/docker" <<'EOF'
+#!/usr/bin/env bash
+# Test sentinel — never builds/pushes; just records the call.
+printf 'docker %s\n' "$*" >> "${DOCKER_SENTINEL_LOG:-/dev/null}"
+exit 1
+EOF
+chmod +x "$SB/bin/docker"
+NO_DOCKER_PATH="$SB/bin:$PATH"
+no_docker_calls() { [[ ! -s "$SB/docker.log" ]]; }   # true == nothing was logged
+
+echo "=== container-orchestration self-test ==="
+
+# ── syntax + contract header ─────────────────────────────────────────────────
+echo "-- contract --"
+bash -n "$V" 2>/dev/null && ok "bash -n build-push.sh" || no "bash -n build-push.sh"
+hdr="$(head -25 "$V")"
+expect_has "header has Usage"     "Usage:"   "$hdr"
+expect_has "header has Output"    "Output:"  "$hdr"
+expect_has "header has Stderr"    "Stderr:"  "$hdr"
+expect_has "header has Exit"      "Exit:"    "$hdr"
+expect_has "header has Examples"  "xamples"  "$hdr"
+
+# ── --help / -h ──────────────────────────────────────────────────────────────
+echo "-- help --"
+PATH="$NO_DOCKER_PATH" bash "$V" --help >/dev/null 2>&1; expect_exit "--help exits 0" 0 $?
+PATH="$NO_DOCKER_PATH" bash "$V" -h     >/dev/null 2>&1; expect_exit "-h exits 0"     0 $?
+out="$(PATH="$NO_DOCKER_PATH" bash "$V" --help 2>/dev/null)"
+expect_has "--help lists --tag"      "--tag"      "$out"
+expect_has "--help lists --registry" "--registry" "$out"
+expect_has "--help lists --push"     "--push"     "$out"
+expect_has "--help lists --dry-run"  "--dry-run"  "$out"
+expect_has "--help documents exit 2" "2"          "$out"
+expect_has "--help documents exit 5" "5"          "$out"
+rm -f "$SB/docker.log"
+PATH="$NO_DOCKER_PATH" DOCKER_SENTINEL_LOG="$SB/docker.log" bash "$V" --help >/dev/null 2>&1
+no_docker_calls && ok "--help invokes no docker" || no "--help invoked docker"
+
+# ── usage errors (exit 2) ────────────────────────────────────────────────────
+echo "-- usage errors --"
+PATH="$NO_DOCKER_PATH" bash "$V" --bogus >/dev/null 2>&1; expect_exit "unknown flag -> 2" 2 $?
+PATH="$NO_DOCKER_PATH" bash "$V" --tag   >/dev/null 2>&1; expect_exit "missing --tag value -> 2" 2 $?
+PATH="$NO_DOCKER_PATH" bash "$V" --registry >/dev/null 2>&1; expect_exit "missing --registry value -> 2" 2 $?
+rm -f "$SB/docker.log"
+PATH="$NO_DOCKER_PATH" DOCKER_SENTINEL_LOG="$SB/docker.log" bash "$V" --bogus >/dev/null 2>&1
+no_docker_calls && ok "arg-parse error invokes no docker" || no "arg-parse error invoked docker"
+
+# ── arg parsing + resolution via --dry-run (no docker) ───────────────────────
+echo "-- dry-run resolution --"
+rm -f "$SB/docker.log"
+out="$(PATH="$NO_DOCKER_PATH" DOCKER_SENTINEL_LOG="$SB/docker.log" \
+       IMAGE_NAME=myapp bash "$V" --dry-run --tag v1 --registry ghcr.io/acme 2>/dev/null)"; rc=$?
+expect_exit "dry-run exits 0" 0 "$rc"
+expect_has "resolves full image"    "Image: ghcr.io/acme/myapp:v1" "$out"
+expect_has "default dockerfile"     "Dockerfile: Dockerfile"      "$out"
+expect_has "default context"        "Context: ."                  "$out"
+expect_has "push flag shown false"  "Push: false"                 "$out"
+no_docker_calls && ok "dry-run invokes no docker" || no "dry-run invoked docker"
+
+out="$(PATH="$NO_DOCKER_PATH" IMAGE_NAME=svc bash "$V" --dry-run --push --tag dev 2>/dev/null)"
+expect_has "no registry -> bare image" "Image: svc:dev"      "$out"
+expect_has "push flag shown true"      "Push: true"          "$out"
+expect_has "plan includes build"       "docker build -t svc:dev -f Dockerfile ." "$out"
+expect_has "plan includes push"        "docker push svc:dev" "$out"
+
+out="$(PATH="$NO_DOCKER_PATH" IMAGE_NAME=app bash "$V" --dry-run \
+       --dockerfile Dockerfile.prod --context ./builds/app 2>/dev/null)"
+expect_has "--dockerfile honoured" "Dockerfile: Dockerfile.prod" "$out"
+expect_has "--context honoured"    "Context: ./builds/app"       "$out"
+
+# ── stream separation: banners on stderr, data on stdout ─────────────────────
+echo "-- stream separation --"
+err="$(PATH="$NO_DOCKER_PATH" IMAGE_NAME=x bash "$V" --dry-run 2>&1 1>/dev/null)"
+expect_has "banner on stderr" "===" "$err"
+dat="$(PATH="$NO_DOCKER_PATH" IMAGE_NAME=x bash "$V" --dry-run 2>/dev/null)"
+case "$dat" in
+    *"==="*) no "stdout leaked a banner";;
+    *)       ok "stdout carries only data lines";;
+esac
+
+# ── happy-path DATA preserved pre/post (only chatter/help moved) ─────────────
+echo "-- data preservation (pre/post) --"
+# Captured baseline = the pre-backfill happy-path stdout data lines. These must
+# stay byte-identical so downstream parsers are unaffected.
+BASELINE=$'Image: ghcr.io/acme/myapp:v1\nDockerfile: Dockerfile\nContext: .'
+new_data="$(PATH="$NO_DOCKER_PATH" IMAGE_NAME=myapp bash "$V" --dry-run \
+            --tag v1 --registry ghcr.io/acme 2>/dev/null \
+            | grep -E '^(Image|Dockerfile|Context): ')"
+[[ "$new_data" == "$BASELINE" ]] \
+    && ok "data lines match captured baseline" \
+    || { no "data lines match captured baseline"; printf '  got:\n%s\n' "$new_data" >&2; }
+
+# Live pre/post diff against the actual pre-backfill blob, when reachable.
+if OLD_SRC="$(git -C "$SKILL/../../.." cat-file -p "$OLD_BLOB" 2>/dev/null)"; then
+    printf '%s' "$OLD_SRC" > "$SB/old-build-push.sh"
+    old_data="$(cd "$SB" && PATH="$NO_DOCKER_PATH" DOCKER_SENTINEL_LOG="$SB/old-docker.log" \
+                IMAGE_NAME=myapp bash "$SB/old-build-push.sh" --tag v1 --registry ghcr.io/acme 2>/dev/null \
+                | grep -E '^(Image|Dockerfile|Context): ')"
+    if [[ -n "$old_data" && "$old_data" == "$new_data" ]]; then
+        ok "pre/post data identical (live diff vs old blob)"
+    else
+        no "pre/post data identical (live diff vs old blob)"
+    fi
+else
+    echo "  SKIP  live pre/post diff (old blob $OLD_BLOB not reachable)"
+fi
+
+echo ""
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1
+exit 0

+ 151 - 0
skills/portless-ops/tests/run.sh

@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+# Self-test for portless-ops — STATIC / STRUCTURAL ONLY.
+#
+# portless-ops ships PowerShell (.ps1) scripts that mutate real local state
+# (stop/restart a proxy, wipe routes.json, re-register aliases). On Linux CI you
+# cannot run pwsh reliably, and even on Windows you must not let a test suite
+# touch a developer's real .portless state. So this suite never executes the
+# scripts: it asserts their STATIC contract instead —
+#   1. each script carries a synopsis/usage (.SYNOPSIS + .EXAMPLE) block,
+#   2. the shipped portless.json asset templates parse as valid JSON, and
+#   3. reset-state.ps1 guards its destructive Remove-Item (Test-Path existence
+#      check + the nuclear `portless clean` opt-in via -PreserveCa, which
+#      DEFAULTS to $true = safe-by-default).
+#
+# NOTE (see final reply): reset-state.ps1 does not use -WhatIf / -Confirm /
+# ShouldProcess; its guard is the Test-Path existence check plus the safe-by-
+# default PreserveCa flag. That gap is surfaced below as INFO, not a failure —
+# it is documented rather than hidden.
+#
+# Usage:   bash tests/run.sh
+# Exit:    0 all pass, 1 one or more failures
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SKILL="$(dirname "$HERE")"
+SCRIPTS="$SKILL/scripts"
+ASSETS="$SKILL/assets"
+
+# JSON parser: prefer jq, fall back to a working python (skip if neither).
+JSON_TOOL=""
+if command -v jq >/dev/null 2>&1; then
+    JSON_TOOL="jq"
+else
+    for c in python python3; do
+        if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then JSON_TOOL="$c"; break; fi
+    done
+fi
+json_ok() { # $1 = file -> 0 if parses
+    case "$JSON_TOOL" in
+        jq)     jq empty "$1" >/dev/null 2>&1 ;;
+        python|python3) "$JSON_TOOL" -c "import json,sys; json.load(open(sys.argv[1],encoding='utf-8'))" "$1" >/dev/null 2>&1 ;;
+        *)      return 2 ;;  # no parser available
+    esac
+}
+json_get() { # $1 = file, $2 = key -> prints value (jq: .key; python best-effort)
+    case "$JSON_TOOL" in
+        jq)     jq -r "$2" "$1" 2>/dev/null ;;
+        *)      "$JSON_TOOL" -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8'));
+import builtins as b
+k=sys.argv[2].lstrip('.')
+print(d.get(k) if k in d else '')" "$1" "$2" 2>/dev/null ;;
+    esac
+}
+
+PASS=0; FAIL=0
+ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
+no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
+info() { printf '  INFO  %s\n' "$1"; }
+
+echo "=== portless-ops self-test (static/structural) ==="
+
+# ── every script has a synopsis/usage block ──────────────────────────────────
+echo "-- synopsis/usage blocks --"
+for s in "$SCRIPTS"/*.ps1; do
+    [[ -f "$s" ]] || continue
+    b="$(basename "$s")"
+    if grep -q '\.SYNOPSIS' "$s" && grep -q '\.EXAMPLE' "$s"; then
+        ok "$b has .SYNOPSIS + .EXAMPLE"
+    else
+        no "$b missing .SYNOPSIS/.EXAMPLE"
+    fi
+    grep -q '\[CmdletBinding()\]' "$s" \
+        && ok "$b uses [CmdletBinding()] (param discipline)" \
+        || no "$b missing [CmdletBinding()]"
+done
+
+# ── reset-state.ps1: destructive ops are guarded ────────────────────────────
+echo "-- reset-state.ps1 guards --"
+R="$SCRIPTS/reset-state.ps1"
+# 3a. Remove-Item is preceded by a Test-Path existence guard (no unconditional
+#     wipe) — the real, present guard before the destructive call.
+if grep -q 'Test-Path' "$R" && grep -q 'Remove-Item' "$R"; then
+    tp_line=$(grep -n 'Test-Path' "$R" | head -1 | cut -d: -f1)
+    ri_line=$(grep -n 'Remove-Item' "$R" | head -1 | cut -d: -f1)
+    if [[ "${tp_line:-0}" -gt 0 && "${ri_line:-0}" -gt "${tp_line:-0}" ]]; then
+        ok "Remove-Item is guarded by a Test-Path check (line $tp_line < $ri_line)"
+    else
+        no "Remove-Item not preceded by Test-Path guard"
+    fi
+else
+    no "reset-state.ps1 missing Test-Path/Remove-Item"
+fi
+# 3b. The nuclear `portless clean` is opt-in and DEFAULTS to safe ($true).
+if grep -qE '\$PreserveCa\s*=\s*\$true' "$R"; then
+    ok "nuclear 'portless clean' opt-in via PreserveCa (default \$true)"
+else
+    no "PreserveCa does not default to \$true"
+fi
+# 3c. Transparently surface the absence of a strict per-op confirmation flag.
+if grep -qE -- '-WhatIf|-Confirm|ShouldProcess|ShouldContinue' "$R"; then
+    ok "strict confirmation/dry-run guard (-WhatIf/-Confirm) present"
+else
+    info "no -WhatIf/-Confirm/ShouldProcess — guard is Test-Path + PreserveCa default"
+fi
+
+# ── asset JSON templates parse ───────────────────────────────────────────────
+echo "-- asset JSON parse --"
+if [[ -z "$JSON_TOOL" ]]; then
+    info "no jq/python available — JSON parse skipped (still green)"
+else
+    for a in "$ASSETS"/*.json; do
+        [[ -f "$a" ]] || continue
+        b="$(basename "$a")"
+        if json_ok "$a"; then ok "asset parses: $b"; else no "asset parses: $b"; fi
+    done
+    # light semantic checks: each template is on-purpose (carries its key shape)
+    [[ "$(json_get "$ASSETS/portless.json.simple.json" '.name')" == "myapp" ]] \
+        && ok "simple template has .name" || no "simple template .name"
+    [[ -n "$(json_get "$ASSETS/portless.json.monorepo.json" '.apps')" ]] \
+        && ok "monorepo template has .apps" || no "monorepo template .apps"
+    [[ -n "$(json_get "$ASSETS/portless.json.with-custom-tld.json" '._tld_choice')" ]] \
+        && ok "custom-tld template records _tld_choice" || no "custom-tld template _tld_choice"
+    [[ -n "$(json_get "$ASSETS/package.json-portless-key.json" '.portless')" ]] \
+        && ok "package.json template has .portless key" || no "package.json template .portless"
+fi
+
+# ── install-portless.ps1: supply-chain audit posture (static) ────────────────
+echo "-- install-portless.ps1 audit posture --"
+I="$SCRIPTS/install-portless.ps1"
+grep -qi 'SHA512\|SHA-512' "$I" && ok "verifies tarball SHA-512" || no "verifies tarball SHA-512"
+grep -qi 'IOC' "$I" && ok "scans package for IOC strings" || no "scans package for IOC strings"
+# integrity check must run BEFORE the install step
+ic_line=$(grep -ni 'integrity\|SHA-512\|SHA512' "$I" | head -1 | cut -d: -f1)
+in_line=$(grep -ni 'npm install -g' "$I" | head -1 | cut -d: -f1)
+if [[ "${ic_line:-0}" -gt 0 && "${in_line:-0}" -gt "${ic_line:-0}" ]]; then
+    ok "integrity check precedes npm install"
+else
+    no "integrity check must precede npm install"
+fi
+
+# ── sync-aliases-from-yaml.ps1: declares its yq dependency ───────────────────
+echo "-- sync-aliases-from-yaml.ps1 --"
+S="$SCRIPTS/sync-aliases-from-yaml.ps1"
+grep -q 'yq' "$S" && ok "declares yq dependency" || no "declares yq dependency"
+grep -q -- '--force' "$S" && ok "registers aliases idempotently (--force)" || no "idempotent --force"
+
+echo ""
+echo "=== $PASS passed, $FAIL failed ==="
+[[ "$FAIL" -eq 0 ]] || exit 1
+exit 0