From 7c716d2aacf8ca6f565eb0172cfafdd7a16cb8c5 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 1 Jun 2026 15:55:56 +0800 Subject: [PATCH 1/3] ci: add macOS test matrix and portability fallbacks --- .github/workflows/tests.yml | 54 +++++ scripts/eval/hooks/opencode-stop.sh | 5 +- scripts/eval/hooks/pre-push | 48 ++++- scripts/eval/lib/_yq.py | 305 ++++++++++++++++++++++++++- scripts/eval/lib/manifest.sh | 14 +- scripts/eval/lib/portable.sh | 75 +++++++ scripts/eval/lib/spawn.sh | 3 +- scripts/eval/lib/stability.sh | 4 +- scripts/eval/run.sh | 15 +- scripts/eval/tests/portable_tools.sh | 128 +++++++++++ scripts/eval/tests/yq_shim_stdlib.sh | 83 ++++++++ 11 files changed, 715 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 scripts/eval/lib/portable.sh create mode 100755 scripts/eval/tests/portable_tools.sh create mode 100755 scripts/eval/tests/yq_shim_stdlib.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..71aeba7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + shell-tests: + name: shell tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Ensure jq is available + run: | + if command -v jq >/dev/null 2>&1; then + exit 0 + fi + if [[ "$RUNNER_OS" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y jq + elif [[ "$RUNNER_OS" == "macOS" ]]; then + brew install jq + else + echo "Unsupported runner OS for jq install: $RUNNER_OS" >&2 + exit 1 + fi + + - name: Show tool versions + run: | + bash --version + jq --version + python3 --version + + - name: Run eval-harness shell tests + run: | + set -euo pipefail + for t in scripts/eval/tests/*.sh; do + echo "== $(basename "$t")" + bash "$t" + done diff --git a/scripts/eval/hooks/opencode-stop.sh b/scripts/eval/hooks/opencode-stop.sh index baa3a30..6205859 100755 --- a/scripts/eval/hooks/opencode-stop.sh +++ b/scripts/eval/hooks/opencode-stop.sh @@ -49,7 +49,10 @@ main() { if ! require_opencode_version; then exit 0; fi local changed_skills=() - mapfile -t changed_skills < <(discover_changed_skills) + local changed_skill + while IFS= read -r changed_skill; do + [[ -n "$changed_skill" ]] && changed_skills+=("$changed_skill") + done < <(discover_changed_skills) if [[ ${#changed_skills[@]} -eq 0 ]]; then echo "[opencode-stop] no skill files changed; nothing to evaluate" >&2 exit 0 diff --git a/scripts/eval/hooks/pre-push b/scripts/eval/hooks/pre-push index 98b51d9..45984f2 100755 --- a/scripts/eval/hooks/pre-push +++ b/scripts/eval/hooks/pre-push @@ -6,6 +6,47 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$SCRIPT_DIR/../lib/portable.sh" ]]; then + # shellcheck source=../lib/portable.sh + source "$SCRIPT_DIR/../lib/portable.sh" +else + run_with_timeout() { + local max_seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$max_seconds" "$@" + return $? + fi + if command -v gtimeout >/dev/null 2>&1; then + gtimeout "$max_seconds" "$@" + return $? + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$max_seconds" "$@" <<'PY' +import subprocess +import sys + +timeout = float(sys.argv[1]) +cmd = sys.argv[2:] +if not cmd: + sys.exit(2) +try: + completed = subprocess.run(cmd, timeout=timeout) + sys.exit(completed.returncode) +except subprocess.TimeoutExpired: + sys.exit(124) +except FileNotFoundError: + print(f"[eval-harness] command not found: {cmd[0]}", file=sys.stderr) + sys.exit(127) +PY + return $? + fi + echo "[eval-harness] missing timeout tool: install timeout, gtimeout, or python3" >&2 + return 127 + } +fi + REMOTE="${1:-origin}" URL="${2:-}" @@ -23,7 +64,8 @@ while read -r local_ref local_sha remote_ref remote_sha; do while IFS= read -r f; do case "$f" in .opencode/skills/*/*|*/.opencode/skills/*/*) - skill="$(echo "$f" | grep -oP '\.opencode/skills/\K[^/]+' | head -1 || true)" + skill_path="${f#*.opencode/skills/}" + skill="${skill_path%%/*}" [[ -n "$skill" ]] && affected_skills+=("$skill") ;; esac @@ -41,7 +83,9 @@ echo "$unique_skills" | sed 's/^/ - /' >&2 EVAL_HARNESS_BIN="${EVAL_HARNESS_BIN:-eval-harness}" exit_code=0 for skill in $unique_skills; do - if ! timeout 60 "$EVAL_HARNESS_BIN" --skill="$skill" --trigger=pre-push; then + if run_with_timeout 60 "$EVAL_HARNESS_BIN" --skill="$skill" --trigger=pre-push; then + : + else exit_code=$? fi done diff --git a/scripts/eval/lib/_yq.py b/scripts/eval/lib/_yq.py index 457624a..861db99 100644 --- a/scripts/eval/lib/_yq.py +++ b/scripts/eval/lib/_yq.py @@ -8,8 +8,303 @@ import json import re import argparse +import ast +import os -import yaml +if os.environ.get("EVAL_YQ_FORCE_STDLIB") == "1": + _pyyaml = None +else: + try: + import yaml as _pyyaml + except ModuleNotFoundError: + _pyyaml = None + + +def indent_of(line): + return len(line) - len(line.lstrip(" ")) + + +def next_content(lines, i): + while i < len(lines): + stripped = lines[i].strip() + if stripped and not stripped.startswith("#"): + return i + i += 1 + return i + + +def split_top_level(text, delimiter=","): + parts = [] + buf = "" + quote = None + escape = False + depth = 0 + for ch in text: + if quote: + buf += ch + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + buf += ch + continue + if ch in "[{(": + depth += 1 + elif ch in "]})" and depth > 0: + depth -= 1 + if ch == delimiter and depth == 0: + parts.append(buf.strip()) + buf = "" + else: + buf += ch + if buf.strip() or text.endswith(delimiter): + parts.append(buf.strip()) + return parts + + +def split_key_value(text): + quote = None + escape = False + depth = 0 + for i, ch in enumerate(text): + if quote: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch in "[{(": + depth += 1 + continue + if ch in "]})" and depth > 0: + depth -= 1 + continue + if ch == ":" and depth == 0: + return text[:i].strip(), text[i + 1 :].strip() + return None, None + + +def strip_inline_comment(raw): + quote = None + escape = False + depth = 0 + for i, ch in enumerate(raw): + if quote: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch in "[{(": + depth += 1 + continue + if ch in "]})" and depth > 0: + depth -= 1 + continue + if ch == "#" and depth == 0 and (i == 0 or raw[i - 1].isspace()): + return raw[:i].rstrip() + return raw + + +def parse_scalar(raw): + value = strip_inline_comment(raw.strip()) + if value == "": + return "" + if value in ("[]", "{}"): + return json.loads(value) + if value in ("null", "~"): + return None + if value == "true": + return True + if value == "false": + return False + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + try: + return ast.literal_eval(value) + except (SyntaxError, ValueError): + return value[1:-1] + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + if not inner: + return [] + try: + return json.loads(value) + except json.JSONDecodeError: + return [parse_scalar(part) for part in split_top_level(inner)] + if value.startswith("{") and value.endswith("}"): + inner = value[1:-1].strip() + if not inner: + return {} + try: + return json.loads(value) + except json.JSONDecodeError: + out = {} + for part in split_top_level(inner): + key, val = split_key_value(part) + if key is not None: + out[str(parse_scalar(key))] = parse_scalar(val) + return out + if re.match(r"^-?[0-9]+$", value): + try: + return int(value) + except ValueError: + pass + if re.match(r"^-?[0-9]+\.[0-9]+$", value): + try: + return float(value) + except ValueError: + pass + return value + + +def parse_block_scalar(lines, i, parent_indent, style): + start = i + block_indent = None + while i < len(lines): + raw = lines[i] + if raw.strip(): + ind = indent_of(raw) + if ind <= parent_indent: + break + block_indent = ind if block_indent is None else min(block_indent, ind) + i += 1 + if block_indent is None: + return "", i + + out_lines = [] + for raw in lines[start:i]: + if not raw.strip(): + out_lines.append("") + else: + out_lines.append(raw[block_indent:]) + if style == ">": + return " ".join(line.strip() for line in out_lines).rstrip() + "\n", i + return "\n".join(out_lines).rstrip("\n") + "\n", i + + +def parse_dict(lines, i, indent): + out = {} + while i < len(lines): + i = next_content(lines, i) + if i >= len(lines): + break + ind = indent_of(lines[i]) + if ind < indent: + break + if ind > indent: + break + text = lines[i][ind:] + if text.startswith("- "): + break + key, raw_value = split_key_value(text) + if key is None: + break + key = parse_scalar(key) + if raw_value in ("|", ">"): + out[key], i = parse_block_scalar(lines, i + 1, ind, raw_value) + continue + if raw_value != "": + out[key] = parse_scalar(raw_value) + i += 1 + continue + + j = next_content(lines, i + 1) + if j >= len(lines) or indent_of(lines[j]) <= ind: + out[key] = None + i += 1 + continue + out[key], i = parse_block(lines, j, indent_of(lines[j])) + return out, i + + +def parse_list(lines, i, indent): + out = [] + while i < len(lines): + i = next_content(lines, i) + if i >= len(lines): + break + ind = indent_of(lines[i]) + if ind < indent: + break + if ind != indent: + break + text = lines[i][ind:] + if not text.startswith("- "): + break + item_text = text[2:].strip() + if item_text == "": + j = next_content(lines, i + 1) + if j >= len(lines) or indent_of(lines[j]) <= ind: + out.append(None) + i += 1 + else: + item, i = parse_block(lines, j, indent_of(lines[j])) + out.append(item) + continue + + key, raw_value = split_key_value(item_text) + if key is None: + out.append(parse_scalar(item_text)) + i += 1 + continue + + key = parse_scalar(key) + i += 1 + j = next_content(lines, i) + if raw_value == "": + item = {key: None} + if j < len(lines) and indent_of(lines[j]) > ind: + item[key], i = parse_block(lines, j, indent_of(lines[j])) + else: + item = {key: parse_scalar(raw_value)} + if j < len(lines) and indent_of(lines[j]) > ind: + rest, i = parse_dict(lines, j, indent_of(lines[j])) + item.update(rest) + out.append(item) + return out, i + + +def parse_block(lines, i, indent): + i = next_content(lines, i) + if i >= len(lines): + return None, i + ind = indent_of(lines[i]) + if ind < indent: + return None, i + text = lines[i][ind:] + if text.startswith("- "): + return parse_list(lines, i, ind) + return parse_dict(lines, i, ind) + + +def load_document(text): + if not text.strip(): + return None + try: + return json.loads(text) + except json.JSONDecodeError: + pass + if _pyyaml is not None: + return _pyyaml.safe_load(text) + data, _ = parse_block(text.splitlines(), 0, 0) + return data def navigate(data, expr): @@ -137,8 +432,10 @@ def emit(result, raw, out_format, is_iter): else: if result is None: print("null") + elif _pyyaml is None: + print(json.dumps(result, ensure_ascii=False)) else: - print(yaml.safe_dump(result, default_flow_style=False, sort_keys=False).rstrip()) + print(_pyyaml.safe_dump(result, default_flow_style=False, sort_keys=False).rstrip()) def main(): @@ -170,9 +467,9 @@ def main(): if args.file: with open(args.file) as f: - data = yaml.safe_load(f) + data = load_document(f.read()) else: - data = yaml.safe_load(sys.stdin) + data = load_document(sys.stdin.read()) result = evaluate(data, args.expr) expr_norm = args.expr.strip() diff --git a/scripts/eval/lib/manifest.sh b/scripts/eval/lib/manifest.sh index 83c5f5a..755e7d5 100755 --- a/scripts/eval/lib/manifest.sh +++ b/scripts/eval/lib/manifest.sh @@ -15,6 +15,8 @@ if ! declare -F resolve_skills_root >/dev/null; then source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/skills_root.sh" fi +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/portable.sh" + # Usage: capture_manifest # Writes a JSON manifest to capture_manifest() { @@ -47,8 +49,8 @@ capture_manifest() { if [[ -d "$skills_root" ]]; then skill_bundle_sha="$(cd "$skills_root" && find . -type f \( -name "*.md" -o -name "*.sh" -o -name "*.yaml" -o -name "*.json" \) -print0 \ | sort -z \ - | while IFS= read -r -d $'\0' f; do sha256sum "$f" 2>/dev/null; done \ - | sha256sum \ + | while IFS= read -r -d '' file; do portable_sha256_file "$file"; done \ + | portable_sha256_stdin \ | cut -d' ' -f1)" else skill_bundle_sha="missing" @@ -59,8 +61,8 @@ capture_manifest() { if [[ -d "$skill_dir" ]]; then skill_sha="$(cd "$skill_dir" && find . -type f -print0 \ | sort -z \ - | while IFS= read -r -d $'\0' f; do sha256sum "$f" 2>/dev/null; done \ - | sha256sum \ + | while IFS= read -r -d '' file; do portable_sha256_file "$file"; done \ + | portable_sha256_stdin \ | cut -d' ' -f1)" fi @@ -69,8 +71,8 @@ capture_manifest() { if [[ -n "${EVAL_FIXTURE_DIR:-}" ]] && [[ -d "$EVAL_FIXTURE_DIR" ]]; then fixture_sha="$(cd "$EVAL_FIXTURE_DIR" && find . -type f -print0 \ | sort -z \ - | while IFS= read -r -d $'\0' f; do sha256sum "$f" 2>/dev/null; done \ - | sha256sum \ + | while IFS= read -r -d '' file; do portable_sha256_file "$file"; done \ + | portable_sha256_stdin \ | cut -d' ' -f1)" fi diff --git a/scripts/eval/lib/portable.sh b/scripts/eval/lib/portable.sh new file mode 100644 index 0000000..7685ab4 --- /dev/null +++ b/scripts/eval/lib/portable.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +portable_sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$@" + return $? + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$@" + return $? + fi + echo "[eval-harness] missing sha256 tool: install sha256sum or shasum" >&2 + return 127 +} + +portable_sha256_stdin() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum + return $? + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 + return $? + fi + echo "[eval-harness] missing sha256 tool: install sha256sum or shasum" >&2 + return 127 +} + +resolve_timeout_bin() { + if command -v timeout >/dev/null 2>&1; then + command -v timeout + return 0 + fi + if command -v gtimeout >/dev/null 2>&1; then + command -v gtimeout + return 0 + fi + echo "[eval-harness] missing timeout tool: install timeout or gtimeout" >&2 + return 127 +} + +run_with_timeout() { + local max_seconds="$1" + shift + local timeout_bin + if timeout_bin="$(resolve_timeout_bin 2>/dev/null)"; then + "$timeout_bin" "$max_seconds" "$@" + return $? + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$max_seconds" "$@" <<'PY' +import subprocess +import sys + +timeout = float(sys.argv[1]) +cmd = sys.argv[2:] +if not cmd: + sys.exit(2) +try: + completed = subprocess.run(cmd, timeout=timeout) + sys.exit(completed.returncode) +except subprocess.TimeoutExpired: + sys.exit(124) +except FileNotFoundError: + print(f"[eval-harness] command not found: {cmd[0]}", file=sys.stderr) + sys.exit(127) +PY + return $? + fi + echo "[eval-harness] missing timeout tool: install timeout, gtimeout, or python3" >&2 + return 127 +} + +export -f portable_sha256_file portable_sha256_stdin resolve_timeout_bin run_with_timeout diff --git a/scripts/eval/lib/spawn.sh b/scripts/eval/lib/spawn.sh index df015f1..d2363e8 100755 --- a/scripts/eval/lib/spawn.sh +++ b/scripts/eval/lib/spawn.sh @@ -13,6 +13,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if ! declare -F resolve_skills_root >/dev/null; then source "$SCRIPT_DIR/skills_root.sh" fi +source "$SCRIPT_DIR/portable.sh" if ! declare -F dispatch_runner >/dev/null; then source "$SCRIPT_DIR/runner.sh" @@ -72,7 +73,7 @@ spawn_opencode() { export EVAL_HARNESS_RUNNING=1 export PATH="$workdir:$PATH" cd "$workdir" - timeout "$max_seconds" opencode run \ + run_with_timeout "$max_seconds" opencode run \ --model "$model" \ --format json \ --dir "$workdir" \ diff --git a/scripts/eval/lib/stability.sh b/scripts/eval/lib/stability.sh index fd08f5e..dfe4304 100755 --- a/scripts/eval/lib/stability.sh +++ b/scripts/eval/lib/stability.sh @@ -5,6 +5,8 @@ set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/portable.sh" + # Usage: check_stability # runner_cmd: shell command that emits one results.json per invocation # samples_dir: dir where samples will be written (sample-1.json, sample-2.json, sample-3.json) @@ -21,7 +23,7 @@ check_stability() { bash -c "$runner_cmd" > "$out" 2>"$samples_dir/sample-$i.err" || true # Hash only the "checks" subtree — ignore timestamps and run IDs which are non-deterministic by design local h - h="$(jq -S '.checks // []' "$out" 2>/dev/null | sha256sum | cut -d' ' -f1)" + h="$(jq -S '.checks // []' "$out" 2>/dev/null | portable_sha256_stdin | cut -d' ' -f1)" hashes+=("$h") done diff --git a/scripts/eval/run.sh b/scripts/eval/run.sh index 60277a2..d415156 100755 --- a/scripts/eval/run.sh +++ b/scripts/eval/run.sh @@ -29,6 +29,7 @@ source "$LIB/score.sh" source "$LIB/diff.sh" source "$LIB/stability.sh" source "$LIB/pricing.sh" +source "$LIB/portable.sh" VERSION="0.4.2" @@ -236,7 +237,10 @@ mkdir -p "$RUN_DIR" if [[ -n "$CASE_ID" ]]; then CASE_FILES=("$CASES_DIR/$CASE_ID.yaml") else - mapfile -t CASE_FILES < <(find "$CASES_DIR" -maxdepth 1 -type f -name "*.yaml" | sort) + CASE_FILES=() + while IFS= read -r case_file; do + CASE_FILES+=("$case_file") + done < <(find "$CASES_DIR" -maxdepth 1 -type f -name "*.yaml" | sort) fi if [[ ${#CASE_FILES[@]} -eq 0 ]]; then @@ -274,7 +278,10 @@ for case_file in "${CASE_FILES[@]}"; do cid="$(yq -r '.id' "$case_file")" prompt="$(yq -r '.prompt' "$case_file")" description="$(yq -r '.description // ""' "$case_file")" - mapfile -t skills_loaded < <(yq -r '.skills_loaded[]' "$case_file" 2>/dev/null || true) + skills_loaded=() + while IFS= read -r skill_name; do + [[ -n "$skill_name" ]] && skills_loaded+=("$skill_name") + done < <(yq -r '.skills_loaded[]' "$case_file" 2>/dev/null || true) [[ ${#skills_loaded[@]} -eq 0 ]] && skills_loaded=("$SKILL") case_model="$(yq -r '.model // ""' "$case_file" 2>/dev/null || echo "")" @@ -497,7 +504,7 @@ for case_file in "${CASE_FILES[@]}"; do stability_json='{"samples":1,"byte_identical":true,"hashes":[],"performed":false}' if [[ "$STABILITY_SAMPLES" -gt 1 && "$primary_passed" == "false" ]]; then echo "[eval-harness] case $cid FAILed — running $((STABILITY_SAMPLES - 1)) stability sample(s)" >&2 - hashes=("$(jq -S '.checks // []' "$per_case_dir/checks.json" 2>/dev/null | sha256sum | cut -d' ' -f1)") + hashes=("$(jq -S '.checks // []' "$per_case_dir/checks.json" 2>/dev/null | portable_sha256_stdin | cut -d' ' -f1)") s=2 while [[ "$s" -le "$STABILITY_SAMPLES" ]]; do sample_dir="$per_case_dir/stability/sample-$s" @@ -522,7 +529,7 @@ for case_file in "${CASE_FILES[@]}"; do ;; esac run_all_checks "$case_file" "$sample_workdir" "$sample_transcript" "$sample_dir/checks.json" - hashes+=("$(jq -S '.checks // []' "$sample_dir/checks.json" 2>/dev/null | sha256sum | cut -d' ' -f1)") + hashes+=("$(jq -S '.checks // []' "$sample_dir/checks.json" 2>/dev/null | portable_sha256_stdin | cut -d' ' -f1)") s=$((s+1)) done first="${hashes[0]}" diff --git a/scripts/eval/tests/portable_tools.sh b/scripts/eval/tests/portable_tools.sh new file mode 100755 index 0000000..12b7628 --- /dev/null +++ b/scripts/eval/tests/portable_tools.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK="$(mktemp -d -t eval-harness-portable.XXXXXX)" +trap 'rm -rf "$WORK"' EXIT + +# shellcheck source=../lib/portable.sh +source "$SCRIPT_DIR/../lib/portable.sh" + +cat > "$WORK/shasum" <<'STUB' +#!/bin/sh +if [ "${1:-}" = "-a" ] && [ "${2:-}" = "256" ]; then + shift 2 +fi +if [ "$#" -eq 0 ]; then + while IFS= read -r _line; do :; done + printf 'stdin-fallback -\n' +else + for f in "$@"; do + printf 'file-fallback %s\n' "$f" + done +fi +STUB +chmod +x "$WORK/shasum" + +cat > "$WORK/gtimeout" <<'STUB' +#!/bin/sh +shift +"$@" +STUB +chmod +x "$WORK/gtimeout" + +cat > "$WORK/okcmd" <<'STUB' +#!/bin/sh +printf 'ok\n' +STUB +chmod +x "$WORK/okcmd" + +mkdir -p "$WORK/no-timeout-bin" +cp "$WORK/okcmd" "$WORK/no-timeout-bin/okcmd" + +printf 'data\n' > "$WORK/file.txt" +PATH="$WORK" portable_sha256_file "$WORK/file.txt" > "$WORK/file-hash.txt" +[[ "$(cat "$WORK/file-hash.txt")" == "file-fallback $WORK/file.txt" ]] || { + echo "FAIL: shasum file fallback not used" >&2 + cat "$WORK/file-hash.txt" >&2 + exit 1 +} + +printf 'data\n' | PATH="$WORK" portable_sha256_stdin > "$WORK/stdin-hash.txt" +[[ "$(cat "$WORK/stdin-hash.txt")" == "stdin-fallback -" ]] || { + echo "FAIL: shasum stdin fallback not used" >&2 + cat "$WORK/stdin-hash.txt" >&2 + exit 1 +} + +[[ "$(PATH="$WORK" run_with_timeout 5 okcmd)" == "ok" ]] || { + echo "FAIL: gtimeout fallback not used" >&2 + exit 1 +} + +[[ "$(PATH="$WORK/no-timeout-bin:/usr/bin:/bin:/usr/sbin:/sbin" run_with_timeout 5 okcmd)" == "ok" ]] || { + echo "FAIL: python3 timeout fallback not used" >&2 + exit 1 +} + +if PATH="$WORK/no-timeout-bin:/usr/bin:/bin:/usr/sbin:/sbin" run_with_timeout 1 sleep 2; then + echo "FAIL: python3 timeout fallback did not time out" >&2 + exit 1 +else + rc=$? + [[ "$rc" == "124" ]] || { + echo "FAIL: python3 timeout fallback returned $rc, expected 124" >&2 + exit 1 + } +fi + +cp "$REPO_ROOT/scripts/eval/hooks/pre-push" "$WORK/pre-push-copy" +chmod +x "$WORK/pre-push-copy" +"$WORK/pre-push-copy" origin git@example.invalid "$WORK/hook-bin/eval-harness" <<'STUB' +#!/bin/sh +printf '%s\n' "$*" >> "$EVAL_HARNESS_STUB_LOG" +exit 0 +STUB +chmod +x "$WORK/hook-bin/eval-harness" + +HOOK_REPO="$WORK/hook-repo" +mkdir -p "$HOOK_REPO/.opencode/skills/foo" +git -C "$HOOK_REPO" init -q +git -C "$HOOK_REPO" config user.email test@example.invalid +git -C "$HOOK_REPO" config user.name "Test User" +printf 'old\n' > "$HOOK_REPO/.opencode/skills/foo/SKILL.md" +git -C "$HOOK_REPO" add .opencode/skills/foo/SKILL.md +git -C "$HOOK_REPO" commit -q -m initial +base_sha="$(git -C "$HOOK_REPO" rev-parse HEAD)" +printf 'new\n' >> "$HOOK_REPO/.opencode/skills/foo/SKILL.md" +git -C "$HOOK_REPO" add .opencode/skills/foo/SKILL.md +git -C "$HOOK_REPO" commit -q -m update-skill +head_sha="$(git -C "$HOOK_REPO" rev-parse HEAD)" + +EVAL_HARNESS_STUB_LOG="$WORK/harness.log" \ + PATH="$WORK/hook-bin:$PATH" \ + bash -c "cd '$HOOK_REPO' && '$REPO_ROOT/scripts/eval/hooks/pre-push' origin git@example.invalid" <&2 + cat "$WORK/harness.log" >&2 || true + exit 1 +fi + +if grep -Eq 'grep -[^[:space:]]*P' "$REPO_ROOT/scripts/eval/hooks/pre-push"; then + echo "FAIL: pre-push hook must not require grep -P" >&2 + exit 1 +fi + +if grep -Eq '(^|[[:space:]])timeout[[:space:]]+60' "$REPO_ROOT/scripts/eval/hooks/pre-push"; then + echo "FAIL: pre-push hook must use run_with_timeout" >&2 + exit 1 +fi + +echo "PASS: portable tool fallbacks cover shasum and gtimeout" diff --git a/scripts/eval/tests/yq_shim_stdlib.sh b/scripts/eval/tests/yq_shim_stdlib.sh new file mode 100755 index 0000000..aae458b --- /dev/null +++ b/scripts/eval/tests/yq_shim_stdlib.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK="$(mktemp -d -t eval-harness-yq.XXXXXX)" +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/case.yaml" <<'YAML' +model: anthropic/claude-from-case +budget_usd: 5.00 +unsafe_shell: true # inline comment should not change the boolean +skills_loaded: [omo-session-distiller, pr-code-reviewer] +setup: + fixtures: + "session-input.json": fixtures/session-input.json +prompt: | + Read the fixture. + Write a result. +checks: + - kind: file_exists + path: result.json + - kind: jq_path_contains + file: result.json + path: "[.writes[].tags[]] | unique" + contains: ["decision", "architecture"] + - shell: + cmd: "echo nested" + expect_exact: nested +llm_judge: + model: anthropic/claude-opus-4-7 +YAML + +YQ="$SCRIPT_DIR/../lib/_yq.py" +export EVAL_YQ_FORCE_STDLIB=1 + +[[ "$(python3 "$YQ" -r '.model' "$WORK/case.yaml")" == "anthropic/claude-from-case" ]] || { + echo "FAIL: scalar lookup failed" >&2 + exit 1 +} + +skills="$(python3 "$YQ" -r '.skills_loaded[]' "$WORK/case.yaml" | paste -sd ' ' -)" +[[ "$skills" == "omo-session-distiller pr-code-reviewer" ]] || { + echo "FAIL: inline list iteration failed: $skills" >&2 + exit 1 +} + +[[ "$(python3 "$YQ" -r '.prompt' "$WORK/case.yaml")" == $'Read the fixture.\nWrite a result.' ]] || { + echo "FAIL: block scalar lookup failed" >&2 + exit 1 +} + +[[ "$(python3 "$YQ" -r '.unsafe_shell // false' "$WORK/case.yaml")" == "true" ]] || { + echo "FAIL: inline comment changed scalar value" >&2 + exit 1 +} + +[[ "$(python3 "$YQ" -r '.checks | length' "$WORK/case.yaml")" == "3" ]] || { + echo "FAIL: list length failed" >&2 + exit 1 +} + +python3 "$YQ" -o=json '.checks[1]' "$WORK/case.yaml" > "$WORK/check.json" +[[ "$(python3 "$YQ" -r '.kind' "$WORK/check.json")" == "jq_path_contains" ]] || { + echo "FAIL: indexed object emit/parse failed" >&2 + exit 1 +} + +[[ "$(python3 "$YQ" -o=json '.contains' "$WORK/check.json" | jq -r 'join(",")')" == "decision,architecture" ]] || { + echo "FAIL: nested inline array failed" >&2 + exit 1 +} + +[[ "$(python3 "$YQ" -r '.checks[2].shell.cmd' "$WORK/case.yaml")" == "echo nested" ]] || { + echo "FAIL: nested mapping list item failed" >&2 + exit 1 +} + +[[ "$(python3 "$YQ" -r '.llm_judge.model // ""' "$WORK/case.yaml")" == "anthropic/claude-opus-4-7" ]] || { + echo "FAIL: nested default expression failed" >&2 + exit 1 +} + +echo "PASS: yq shim parses eval-harness YAML subset with python stdlib" From 69d75c4f1ad5f532f6917626b7397f9e9fec5943 Mon Sep 17 00:00:00 2001 From: King Star Date: Fri, 5 Jun 2026 00:44:28 +0800 Subject: [PATCH 2/3] fix: address portability review gaps --- scripts/eval/lib/_yq.py | 4 +++- scripts/eval/lib/manifest.sh | 6 +++--- scripts/eval/lib/portable.sh | 19 ++++++++++++++++++- scripts/eval/tests/portable_tools.sh | 14 ++++++++++++++ scripts/eval/tests/yq_shim_stdlib.sh | 17 ++++++++++++++++- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/scripts/eval/lib/_yq.py b/scripts/eval/lib/_yq.py index 861db99..f3ff9c8 100644 --- a/scripts/eval/lib/_yq.py +++ b/scripts/eval/lib/_yq.py @@ -217,6 +217,7 @@ def parse_dict(lines, i, indent): if key is None: break key = parse_scalar(key) + raw_value = strip_inline_comment(raw_value) if raw_value in ("|", ">"): out[key], i = parse_block_scalar(lines, i + 1, ind, raw_value) continue @@ -248,7 +249,7 @@ def parse_list(lines, i, indent): text = lines[i][ind:] if not text.startswith("- "): break - item_text = text[2:].strip() + item_text = strip_inline_comment(text[2:].strip()) if item_text == "": j = next_content(lines, i + 1) if j >= len(lines) or indent_of(lines[j]) <= ind: @@ -266,6 +267,7 @@ def parse_list(lines, i, indent): continue key = parse_scalar(key) + raw_value = strip_inline_comment(raw_value) i += 1 j = next_content(lines, i) if raw_value == "": diff --git a/scripts/eval/lib/manifest.sh b/scripts/eval/lib/manifest.sh index 755e7d5..533aa73 100755 --- a/scripts/eval/lib/manifest.sh +++ b/scripts/eval/lib/manifest.sh @@ -48,7 +48,7 @@ capture_manifest() { local skill_bundle_sha if [[ -d "$skills_root" ]]; then skill_bundle_sha="$(cd "$skills_root" && find . -type f \( -name "*.md" -o -name "*.sh" -o -name "*.yaml" -o -name "*.json" \) -print0 \ - | sort -z \ + | portable_sort_nul \ | while IFS= read -r -d '' file; do portable_sha256_file "$file"; done \ | portable_sha256_stdin \ | cut -d' ' -f1)" @@ -60,7 +60,7 @@ capture_manifest() { local skill_sha="missing" if [[ -d "$skill_dir" ]]; then skill_sha="$(cd "$skill_dir" && find . -type f -print0 \ - | sort -z \ + | portable_sort_nul \ | while IFS= read -r -d '' file; do portable_sha256_file "$file"; done \ | portable_sha256_stdin \ | cut -d' ' -f1)" @@ -70,7 +70,7 @@ capture_manifest() { local fixture_sha="none" if [[ -n "${EVAL_FIXTURE_DIR:-}" ]] && [[ -d "$EVAL_FIXTURE_DIR" ]]; then fixture_sha="$(cd "$EVAL_FIXTURE_DIR" && find . -type f -print0 \ - | sort -z \ + | portable_sort_nul \ | while IFS= read -r -d '' file; do portable_sha256_file "$file"; done \ | portable_sha256_stdin \ | cut -d' ' -f1)" diff --git a/scripts/eval/lib/portable.sh b/scripts/eval/lib/portable.sh index 7685ab4..27ede0c 100644 --- a/scripts/eval/lib/portable.sh +++ b/scripts/eval/lib/portable.sh @@ -27,6 +27,23 @@ portable_sha256_stdin() { return 127 } +portable_sort_nul() { + if command -v python3 >/dev/null 2>&1; then + python3 -c ' +import sys + +items = sys.stdin.buffer.read().split(b"\0") +if items and items[-1] == b"": + items.pop() +for item in sorted(items): + sys.stdout.buffer.write(item + b"\0") +' + return $? + fi + echo "[eval-harness] missing sort helper: install python3" >&2 + return 127 +} + resolve_timeout_bin() { if command -v timeout >/dev/null 2>&1; then command -v timeout @@ -72,4 +89,4 @@ PY return 127 } -export -f portable_sha256_file portable_sha256_stdin resolve_timeout_bin run_with_timeout +export -f portable_sha256_file portable_sha256_stdin portable_sort_nul resolve_timeout_bin run_with_timeout diff --git a/scripts/eval/tests/portable_tools.sh b/scripts/eval/tests/portable_tools.sh index 12b7628..20db1f3 100755 --- a/scripts/eval/tests/portable_tools.sh +++ b/scripts/eval/tests/portable_tools.sh @@ -56,6 +56,15 @@ printf 'data\n' | PATH="$WORK" portable_sha256_stdin > "$WORK/stdin-hash.txt" exit 1 } +printf 'b\0a\0' | portable_sort_nul | python3 -c ' +import sys + +data = sys.stdin.buffer.read() +if data != b"a\0b\0": + print(f"FAIL: portable_sort_nul returned {data!r}", file=sys.stderr) + sys.exit(1) +' + [[ "$(PATH="$WORK" run_with_timeout 5 okcmd)" == "ok" ]] || { echo "FAIL: gtimeout fallback not used" >&2 exit 1 @@ -125,4 +134,9 @@ if grep -Eq '(^|[[:space:]])timeout[[:space:]]+60' "$REPO_ROOT/scripts/eval/hook exit 1 fi +if grep -Eq 'sort[[:space:]]+-z' "$REPO_ROOT/scripts/eval/lib/manifest.sh"; then + echo "FAIL: manifest.sh must not require GNU sort -z" >&2 + exit 1 +fi + echo "PASS: portable tool fallbacks cover shasum and gtimeout" diff --git a/scripts/eval/tests/yq_shim_stdlib.sh b/scripts/eval/tests/yq_shim_stdlib.sh index aae458b..a5f435f 100755 --- a/scripts/eval/tests/yq_shim_stdlib.sh +++ b/scripts/eval/tests/yq_shim_stdlib.sh @@ -13,6 +13,11 @@ skills_loaded: [omo-session-distiller, pr-code-reviewer] setup: fixtures: "session-input.json": fixtures/session-input.json +commented_setup: # inline comment before nested mapping + enabled: true +optional_items: + - # comment-only empty item + - named prompt: | Read the fixture. Write a result. @@ -23,7 +28,7 @@ checks: file: result.json path: "[.writes[].tags[]] | unique" contains: ["decision", "architecture"] - - shell: + - shell: # inline comment before nested mapping cmd: "echo nested" expect_exact: nested llm_judge: @@ -54,6 +59,16 @@ skills="$(python3 "$YQ" -r '.skills_loaded[]' "$WORK/case.yaml" | paste -sd ' ' exit 1 } +[[ "$(python3 "$YQ" -r '.commented_setup.enabled // false' "$WORK/case.yaml")" == "true" ]] || { + echo "FAIL: inline comment before nested mapping failed" >&2 + exit 1 +} + +[[ "$(python3 "$YQ" -o=json '.optional_items' "$WORK/case.yaml" | jq -c '.')" == '[null,"named"]' ]] || { + echo "FAIL: comment-only list item did not parse as empty item" >&2 + exit 1 +} + [[ "$(python3 "$YQ" -r '.checks | length' "$WORK/case.yaml")" == "3" ]] || { echo "FAIL: list length failed" >&2 exit 1 From cd7beb5aa296010bc9cac43df9d2c46f1a3a75c0 Mon Sep 17 00:00:00 2001 From: King Star Date: Sat, 4 Jul 2026 16:21:14 +0800 Subject: [PATCH 3/3] fix: address portability review follow-up --- scripts/eval/hooks/pre-push | 8 +++--- scripts/eval/lib/_yq.py | 10 +++++++ scripts/eval/lib/preflight.sh | 6 +++-- scripts/eval/run.sh | 35 +++++++++++++++++-------- scripts/eval/tests/portable_tools.sh | 31 ++++++++++++++++++++++ scripts/eval/tests/preflight_yq_deps.sh | 21 ++++++++------- scripts/eval/tests/runner_langgraph.sh | 15 +++++++++-- 7 files changed, 97 insertions(+), 29 deletions(-) diff --git a/scripts/eval/hooks/pre-push b/scripts/eval/hooks/pre-push index 45984f2..3c14606 100755 --- a/scripts/eval/hooks/pre-push +++ b/scripts/eval/hooks/pre-push @@ -83,10 +83,10 @@ echo "$unique_skills" | sed 's/^/ - /' >&2 EVAL_HARNESS_BIN="${EVAL_HARNESS_BIN:-eval-harness}" exit_code=0 for skill in $unique_skills; do - if run_with_timeout 60 "$EVAL_HARNESS_BIN" --skill="$skill" --trigger=pre-push; then - : - else - exit_code=$? + rc=0 + run_with_timeout 60 "$EVAL_HARNESS_BIN" --skill="$skill" --trigger=pre-push || rc=$? + if [[ "$rc" -ne 0 ]]; then + exit_code="$rc" fi done diff --git a/scripts/eval/lib/_yq.py b/scripts/eval/lib/_yq.py index f3ff9c8..e0f8df5 100644 --- a/scripts/eval/lib/_yq.py +++ b/scripts/eval/lib/_yq.py @@ -1,6 +1,16 @@ #!/usr/bin/env python3 """yq-shim helper: a minimal yq-compatible subset for eval-harness. +The stdlib parser exists for locked-down or air-gapped environments where the +project's documented shell + jq + python3 stdlib toolchain is available but +installing PyYAML or a yq binary is not. It is intentionally not a general YAML +implementation. It supports the subset used by eval-harness config and case +files: space-indented mappings and lists, null/bool/number/string scalars, +inline arrays/maps, comments, and literal/folded block scalars. It does not +support anchors, aliases, tags, multi-document streams, or arbitrary YAML 1.2 +features. When PyYAML is available and EVAL_YQ_FORCE_STDLIB is not set, PyYAML +remains the default parser. + Reads YAML from stdin or a file arg, applies a tiny expression language, prints scalars (with -r) or JSON / YAML output. """ diff --git a/scripts/eval/lib/preflight.sh b/scripts/eval/lib/preflight.sh index ad5eb20..777e7a0 100644 --- a/scripts/eval/lib/preflight.sh +++ b/scripts/eval/lib/preflight.sh @@ -11,6 +11,8 @@ set -euo pipefail +_PREFLIGHT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # Usage: preflight_check_langgraph # Runner-aware companion to preflight_check. The langgraph-node runner # does not invoke opencode, so it does not need a provider credential — @@ -37,8 +39,8 @@ preflight_check() { if ! command -v python3 >/dev/null 2>&1; then echo "[eval-harness] preflight FAIL: neither 'yq' binary nor 'python3' (for yq-shim fallback) on PATH" >&2 fail=1 - elif ! python3 -c 'import yaml' 2>/dev/null; then - echo "[eval-harness] preflight FAIL: 'yq' not on PATH and python3 lacks pyyaml. Run: pip install pyyaml" >&2 + elif ! EVAL_YQ_FORCE_STDLIB=1 python3 "$_PREFLIGHT_DIR/_yq.py" --version >/dev/null 2>&1; then + echo "[eval-harness] preflight FAIL: 'yq' not on PATH and python3 yq-shim fallback failed" >&2 fail=1 fi fi diff --git a/scripts/eval/run.sh b/scripts/eval/run.sh index d415156..4031344 100755 --- a/scripts/eval/run.sh +++ b/scripts/eval/run.sh @@ -185,28 +185,38 @@ esac # NOTE: this must happen *before* CASE_FILES is defined below (lines # 218-222). Without this ordering, _NEEDED_RUNNERS is empty and preflight # for every runner (including the default opencode) is silently bypassed. -declare -A _NEEDED_RUNNERS=() +_NEEDED_RUNNERS="" +add_needed_runner() { + local candidate="$1" + case $'\n'"$_NEEDED_RUNNERS"$'\n' in + *$'\n'"$candidate"$'\n'*) return 0 ;; + esac + _NEEDED_RUNNERS="${_NEEDED_RUNNERS}${candidate}"$'\n' +} if [[ -n "${CASE_ID:-}" ]]; then _PRE_CASES_DIR="$(resolve_skills_root)/$SKILL/evals/cases" if [[ -f "$_PRE_CASES_DIR/$CASE_ID.yaml" ]]; then - _PRE_CASE_FILES=("$_PRE_CASES_DIR/$CASE_ID.yaml") + _PRE_CASE_FILES="$_PRE_CASES_DIR/$CASE_ID.yaml" else - _PRE_CASE_FILES=() + _PRE_CASE_FILES="" fi else _PRE_CASES_DIR="$(resolve_skills_root)/$SKILL/evals/cases" + _PRE_CASE_FILES="" if [[ -d "$_PRE_CASES_DIR" ]]; then - mapfile -t _PRE_CASE_FILES < <(find "$_PRE_CASES_DIR" -maxdepth 1 -type f -name "*.yaml" | sort) - else - _PRE_CASE_FILES=() + _PRE_CASE_FILES="$(find "$_PRE_CASES_DIR" -maxdepth 1 -type f -name "*.yaml" | sort)" fi fi -for _cf in "${_PRE_CASE_FILES[@]:-}"; do +while IFS= read -r _cf; do + [[ -n "$_cf" ]] || continue [[ -f "$_cf" ]] || continue _cr="$(yq -r '.runner // "opencode"' "$_cf" 2>/dev/null || echo "opencode")" - _NEEDED_RUNNERS["$_cr"]=1 -done -for _r in "${!_NEEDED_RUNNERS[@]}"; do + add_needed_runner "$_cr" +done < "$WORK/hook-bin/eval-harness" <<'STUB' +#!/bin/sh +printf '%s\n' "$*" >> "$EVAL_HARNESS_STUB_LOG" +exit 12 +STUB +chmod +x "$WORK/hook-bin/eval-harness" + +set +e +EVAL_HARNESS_STUB_LOG="$WORK/harness-fail.log" \ + PATH="$WORK/hook-bin:$PATH" \ + bash -c "cd '$HOOK_REPO' && '$REPO_ROOT/scripts/eval/hooks/pre-push' origin git@example.invalid" <&2 + cat "$WORK/harness-fail.log" >&2 || true + exit 1 +fi + if grep -Eq 'grep -[^[:space:]]*P' "$REPO_ROOT/scripts/eval/hooks/pre-push"; then echo "FAIL: pre-push hook must not require grep -P" >&2 exit 1 @@ -139,4 +160,14 @@ if grep -Eq 'sort[[:space:]]+-z' "$REPO_ROOT/scripts/eval/lib/manifest.sh"; then exit 1 fi +if grep -Eq '(^|[[:space:]])declare[[:space:]]+-A($|[[:space:]])' "$REPO_ROOT/scripts/eval/run.sh"; then + echo "FAIL: run.sh must not require Bash 4 associative arrays" >&2 + exit 1 +fi + +if grep -Eq '(^|[[:space:]])mapfile($|[[:space:]])' "$REPO_ROOT/scripts/eval/run.sh"; then + echo "FAIL: run.sh must not require Bash 4 mapfile" >&2 + exit 1 +fi + echo "PASS: portable tool fallbacks cover shasum and gtimeout" diff --git a/scripts/eval/tests/preflight_yq_deps.sh b/scripts/eval/tests/preflight_yq_deps.sh index 4036c55..724fe40 100755 --- a/scripts/eval/tests/preflight_yq_deps.sh +++ b/scripts/eval/tests/preflight_yq_deps.sh @@ -50,27 +50,28 @@ grep -q "neither 'yq' binary nor 'python3'" "$missing_python" || { make_stub python3 '#!/bin/sh exit 1' -missing_pyyaml="$WORK/missing-pyyaml.log" -if run_preflight "$missing_pyyaml"; then - echo "FAIL: missing PyYAML should fail preflight when yq is absent" >&2 - cat "$missing_pyyaml" >&2 +broken_shim="$WORK/broken-shim.log" +if run_preflight "$broken_shim"; then + echo "FAIL: broken python3 yq-shim fallback should fail preflight when yq is absent" >&2 + cat "$broken_shim" >&2 exit 1 fi -grep -q "python3 lacks pyyaml" "$missing_pyyaml" || { - echo "FAIL: missing-pyyaml diagnostic not found" >&2 - cat "$missing_pyyaml" >&2 +grep -q "python3 yq-shim fallback failed" "$broken_shim" || { + echo "FAIL: broken-shim diagnostic not found" >&2 + cat "$broken_shim" >&2 exit 1 } make_stub python3 '#!/bin/sh -if [ "${1:-}" = "-c" ] && [ "${2:-}" = "import yaml" ]; then +if [ "${2:-}" = "--version" ]; then + echo "python-yq-shim 0.1.0" exit 0 fi exit 1' fallback_ok="$WORK/fallback-ok.log" if ! run_preflight "$fallback_ok"; then - echo "FAIL: python3 with yaml should satisfy yq fallback preflight" >&2 + echo "FAIL: python3 stdlib yq-shim should satisfy yq fallback preflight" >&2 cat "$fallback_ok" >&2 exit 1 fi @@ -86,5 +87,5 @@ if ! run_preflight "$yq_ok"; then exit 1 fi -echo "PASS: preflight reports missing yq fallback deps and accepts yq or python3+pyyaml" +echo "PASS: preflight reports missing yq fallback deps and accepts yq or python3 stdlib shim" exit 0 diff --git a/scripts/eval/tests/runner_langgraph.sh b/scripts/eval/tests/runner_langgraph.sh index 69c9f01..349fa16 100644 --- a/scripts/eval/tests/runner_langgraph.sh +++ b/scripts/eval/tests/runner_langgraph.sh @@ -263,8 +263,19 @@ BASELINE_FILE="$OPENCODE_SKILLS_ROOT/$SKILL_NAME/evals/baselines/$TEST3_CASE.bas || { echo " TEST 3: FAIL (no baseline written)" >&2; ok=0; } cp "$SKILL_DIR/evals/fixtures/graph.py" "$WORK/graph.py.bak" -sed -i 's|base = \["langgraph-docs", "eval-harness"\]|base = ["x", "y", "z"]|' \ - "$SKILL_DIR/evals/fixtures/graph.py" +python3 - "$SKILL_DIR/evals/fixtures/graph.py" <<'PY' +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +old = 'base = ["langgraph-docs", "eval-harness"]' +new = 'base = ["x", "y", "z"]' +text = path.read_text() +if old not in text: + print("FAIL: graph.py mutation target not found", file=sys.stderr) + sys.exit(1) +path.write_text(text.replace(old, new, 1)) +PY set +e bash "$EVAL_BIN" --skill="$SKILL_NAME" --case="$TEST3_CASE" --trigger=manual \