From 6cbe1048191a2821184cb6358eb2cd08b550baab Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Apr 2026 14:36:42 +0900 Subject: [PATCH 1/3] refactor: centralize git hook scripts --- .githooks/pre-commit | 31 +++++---------------------- .githooks/pre-push | 18 ++++++---------- scripts/git-hooks/pre-commit.sh | 38 +++++++++++++++++++++++++++++++++ scripts/git-hooks/pre-push.sh | 23 ++++++++++++++++++++ tests/test_git_hook_scripts.py | 23 ++++++++++++++++++++ 5 files changed, 95 insertions(+), 38 deletions(-) create mode 100644 scripts/git-hooks/pre-commit.sh create mode 100644 scripts/git-hooks/pre-push.sh create mode 100644 tests/test_git_hook_scripts.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1e66aa6..a797b1f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,31 +2,10 @@ set -euo pipefail -cd "$(git rev-parse --show-toplevel)" - -staged_python_files=$(git diff --cached --name-only --diff-filter=ACMR -- "*.py" "*.pyi") - -if [ -z "$staged_python_files" ]; then - exit 0 +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + script_dir="$(cd "$(dirname "$0")" && pwd)" + repo_root="$(cd "$script_dir/.." && pwd)" fi -set -- -while IFS= read -r file; do - [ -n "$file" ] || continue - [ -f "$file" ] || continue - set -- "$@" "$file" -done < None: + pre_commit = (ROOT / ".githooks" / "pre-commit").read_text(encoding="utf-8") + pre_push = (ROOT / ".githooks" / "pre-push").read_text(encoding="utf-8") + shared_pre_commit = (ROOT / "scripts" / "git-hooks" / "pre-commit.sh").read_text( + encoding="utf-8" + ) + shared_pre_push = (ROOT / "scripts" / "git-hooks" / "pre-push.sh").read_text(encoding="utf-8") + + assert "scripts/git-hooks/pre-commit.sh" in pre_commit + assert "scripts/git-hooks/pre-push.sh" in pre_push + assert "git rev-parse --show-toplevel 2>/dev/null || true" in pre_commit + assert "git rev-parse --show-toplevel 2>/dev/null || true" in pre_push + assert 'cd "$repo_root"' in shared_pre_commit + assert 'cd "$repo_root"' in shared_pre_push + assert "uv run ruff check --fix" in shared_pre_commit + assert "uv run pytest --ignore=tests/test_mlx_runtime.py" in shared_pre_push From 23a58ce518f40e8bb7ba006e40cc7e8cb9577e28 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Apr 2026 14:57:54 +0900 Subject: [PATCH 2/3] refactor: centralize repository hook implementations Move canonical git and Claude hook logic into scripts/hooks while keeping .githooks, scripts/git-hooks, and .harness/hooks as stable wrapper entrypoints. Add focused regression coverage for the new hook layout and wrapper delegation. --- .githooks/pre-commit | 2 +- .githooks/pre-push | 2 +- .harness/README.md | 5 +- .harness/hooks/check-secrets.sh | 39 +-- .harness/hooks/claude-stop-checks.sh | 18 +- .harness/hooks/graphify-auto-refresh.sh | 180 +------------- .harness/hooks/graphify-mode-note.sh | 41 +--- .harness/hooks/graphify-pretool.sh | 13 +- .harness/hooks/session-start.sh | 35 +-- .harness/hooks/simplify-ignore-test.sh | 250 +------------------- .harness/hooks/simplify-ignore.sh | 301 +---------------------- scripts/git-hooks/pre-commit.sh | 29 +-- scripts/git-hooks/pre-push.sh | 14 +- scripts/hooks/check-secrets.sh | 45 ++++ scripts/hooks/claude-stop-checks.sh | 22 ++ scripts/hooks/common.sh | 27 +++ scripts/hooks/graphify-auto-refresh.sh | 193 +++++++++++++++ scripts/hooks/graphify-mode-note.sh | 42 ++++ scripts/hooks/graphify-pretool.sh | 14 ++ scripts/hooks/pre-commit.sh | 37 +++ scripts/hooks/pre-push.sh | 22 ++ scripts/hooks/session-start.sh | 32 +++ scripts/hooks/simplify-ignore-test.sh | 249 +++++++++++++++++++ scripts/hooks/simplify-ignore.sh | 302 ++++++++++++++++++++++++ tests/test_git_hook_scripts.py | 20 +- tests/test_graphify_auto_refresh.py | 2 + tests/test_hook_runtime_layout.py | 42 ++++ 27 files changed, 1090 insertions(+), 888 deletions(-) mode change 100644 => 100755 .harness/hooks/simplify-ignore-test.sh mode change 100644 => 100755 scripts/git-hooks/pre-commit.sh mode change 100644 => 100755 scripts/git-hooks/pre-push.sh create mode 100755 scripts/hooks/check-secrets.sh create mode 100755 scripts/hooks/claude-stop-checks.sh create mode 100755 scripts/hooks/common.sh create mode 100755 scripts/hooks/graphify-auto-refresh.sh create mode 100755 scripts/hooks/graphify-mode-note.sh create mode 100755 scripts/hooks/graphify-pretool.sh create mode 100755 scripts/hooks/pre-commit.sh create mode 100755 scripts/hooks/pre-push.sh create mode 100755 scripts/hooks/session-start.sh create mode 100755 scripts/hooks/simplify-ignore-test.sh create mode 100755 scripts/hooks/simplify-ignore.sh create mode 100644 tests/test_hook_runtime_layout.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit index a797b1f..5ea51e6 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -8,4 +8,4 @@ if [ -z "$repo_root" ]; then repo_root="$(cd "$script_dir/.." && pwd)" fi -bash "$repo_root/scripts/git-hooks/pre-commit.sh" +bash "$repo_root/scripts/hooks/pre-commit.sh" diff --git a/.githooks/pre-push b/.githooks/pre-push index a179e09..be10f02 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -8,4 +8,4 @@ if [ -z "$repo_root" ]; then repo_root="$(cd "$script_dir/.." && pwd)" fi -bash "$repo_root/scripts/git-hooks/pre-push.sh" +bash "$repo_root/scripts/hooks/pre-push.sh" diff --git a/.harness/README.md b/.harness/README.md index 3d1ffad..335b4b5 100644 --- a/.harness/README.md +++ b/.harness/README.md @@ -10,10 +10,11 @@ - `.github/` — Copilot CLI / Chat 진입 규칙과 agent persona - `.agents/skills/` — repo-local skill 본체 - `AGENTS.md` — 최상위 규칙 / 스키마 정본 + - `scripts/hooks/` — canonical hook implementation - **harness support assets** - `.harness/reference/` — imported agent-skills 문서 스냅샷, 로컬 적응 규칙, command draft, persona 스냅샷 - - `.harness/hooks/` — Claude Code safety hook 스크립트 및 support hook 자산 + - `.harness/hooks/` — Claude/Codex hook entrypoint wrapper와 support hook 자산 ## 왜 루트 진입점은 그대로 두는가 @@ -31,4 +32,4 @@ Copilot과 skill discovery는 `.github/`, `.agents/skills/`, `AGENTS.md` 같은 1. 새 support 문서나 스냅샷을 추가할 때는 우선 `.harness/` 아래에 둔다. 2. 외부 툴이 직접 읽는 파일은 `.claude/settings.json`, `.github/`, `.agents/skills/`, `AGENTS.md`에 유지한다. -3. `.harness/hooks/`는 Claude Code active safety hooks의 실제 스크립트 위치로 사용하며, 그 외 hook 참고 자산도 함께 둔다. +3. 실제 hook 구현은 `scripts/hooks/`에 두고, `.harness/hooks/`는 Claude/Codex가 직접 참조하는 wrapper와 support hook 자산을 유지한다. diff --git a/.harness/hooks/check-secrets.sh b/.harness/hooks/check-secrets.sh index 8e7a596..896d0fd 100755 --- a/.harness/hooks/check-secrets.sh +++ b/.harness/hooks/check-secrets.sh @@ -2,39 +2,10 @@ set -euo pipefail -if ! git rev-parse --show-toplevel >/dev/null 2>&1; then - exit 0 +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + script_dir="$(cd "$(dirname "$0")" && pwd)" + repo_root="$(cd "$script_dir/../.." && pwd)" fi -cd "$(git rev-parse --show-toplevel)" - -staged_files="$(git diff --cached --name-only)" -if [ -z "$staged_files" ]; then - exit 0 -fi - -warned=0 - -while IFS= read -r file; do - [ -n "$file" ] || continue - case "$file" in - *.example) - continue - ;; - .env|.env.*|*/.env|*/.env.*|*credentials.json|*secret.json|*secrets.json|*secret.yaml|*secrets.yaml|*secret.yml|*secrets.yml|*secret.toml|*secrets.toml|*.pem|*.key|*.p12|*.pfx) - echo "WARN: staged file looks like secret material: $file" >&2 - warned=1 - ;; - esac -done <&2 - warned=1 -fi - -if [ "$warned" -eq 1 ]; then - echo "WARN: review staged changes for accidental secret exposure before pushing." >&2 -fi +bash "$repo_root/scripts/hooks/check-secrets.sh" diff --git a/.harness/hooks/claude-stop-checks.sh b/.harness/hooks/claude-stop-checks.sh index af1feea..5d1b796 100755 --- a/.harness/hooks/claude-stop-checks.sh +++ b/.harness/hooks/claude-stop-checks.sh @@ -2,16 +2,10 @@ set -euo pipefail -cd "$(git rev-parse --show-toplevel)" +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + script_dir="$(cd "$(dirname "$0")" && pwd)" + repo_root="$(cd "$script_dir/../.." && pwd)" +fi -echo "claude stop: uv run ruff check ." -uv run ruff check . - -echo "claude stop: uv run ruff format --check ." -uv run ruff format --check . - -echo "claude stop: uv run mypy src" -uv run mypy src - -echo "claude stop: uv run pytest --ignore=tests/test_mlx_runtime.py --ignore=tests/test_cli_smoke_mlx.py" -uv run pytest --ignore=tests/test_mlx_runtime.py --ignore=tests/test_cli_smoke_mlx.py +bash "$repo_root/scripts/hooks/claude-stop-checks.sh" diff --git a/.harness/hooks/graphify-auto-refresh.sh b/.harness/hooks/graphify-auto-refresh.sh index 246d542..23f5885 100755 --- a/.harness/hooks/graphify-auto-refresh.sh +++ b/.harness/hooks/graphify-auto-refresh.sh @@ -8,182 +8,4 @@ if [ -z "$repo_root" ]; then repo_root="$(cd "$script_dir/../.." && pwd)" fi -cd "$repo_root" - -mkdir -p .graphify-work - -STATE_FILE=".graphify-work/auto_refresh_state.json" - -python3 - <<'PY' -from __future__ import annotations - -import hashlib -import json -import subprocess -from pathlib import Path - -ROOT = Path(".").resolve() -STATE_FILE = ROOT / ".graphify-work" / "auto_refresh_state.json" -BUILD_INFO_FILE = ROOT / "graphify-out" / "BUILD_INFO.json" - - -def _iter_files(paths: list[Path]) -> list[Path]: - files: set[Path] = set() - for path in paths: - if path.is_file(): - files.add(path) - elif path.is_dir(): - files.update(candidate for candidate in path.rglob("*") if candidate.is_file()) - return sorted(files) - - -def _digest(paths: list[Path]) -> str: - digest = hashlib.sha256() - for path in _iter_files(paths): - digest.update(path.relative_to(ROOT).as_posix().encode("utf-8")) - digest.update(b"\0") - digest.update(path.read_bytes()) - digest.update(b"\0") - return digest.hexdigest() - - -def _load_state() -> dict[str, str]: - if not STATE_FILE.exists(): - return {} - return json.loads(STATE_FILE.read_text(encoding="utf-8")) - - -def _write_state(payload: dict[str, str]) -> None: - payload = {"schema_version": "raw-source-v1", **payload} - STATE_FILE.write_text( - json.dumps(payload, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - - -def _run(command: list[str], *, label: str) -> bool: - result = subprocess.run(command, capture_output=True, text=True) - if result.returncode == 0: - print(f"graphify auto-refresh: {label} completed") - return True - - print(f"graphify auto-refresh: {label} failed", flush=True) - if result.stdout.strip(): - print(result.stdout.strip(), flush=True) - if result.stderr.strip(): - print(result.stderr.strip(), flush=True) - return False - - -def _detect_bootstrap_changes() -> tuple[bool, bool]: - result = subprocess.run( - [ - "git", - "status", - "--short", - "--untracked-files=all", - "--", - "src", - "tests", - "raw", - "scripts/graphify_code_refresh.sh", - "scripts/graphify_prepare_corpus.sh", - "scripts/graphify_full_refresh.py", - "scripts/graphify_semantic_adapter.py", - "scripts/graphify_verify_full_refresh.py", - "scripts/graphify_sync_staged.sh", - "scripts/graphify_ci_candidate.sh", - ], - capture_output=True, - text=True, - check=True, - ) - changed_paths = [line[3:] for line in result.stdout.splitlines() if len(line) >= 4] - has_full_refresh_changes = any( - path.startswith("raw/") - or path - in { - "scripts/graphify_prepare_corpus.sh", - "scripts/graphify_full_refresh.py", - "scripts/graphify_semantic_adapter.py", - "scripts/graphify_verify_full_refresh.py", - "scripts/graphify_sync_staged.sh", - "scripts/graphify_ci_candidate.sh", - } - for path in changed_paths - ) - has_code_changes = any( - path.startswith("src/") - or path.startswith("tests/") - or path == "scripts/graphify_code_refresh.sh" - for path in changed_paths - ) - return has_code_changes, has_full_refresh_changes - - -code_inputs = _digest([ROOT / "src", ROOT / "tests"]) -full_inputs = _digest( - [ - ROOT / "raw", - ROOT / "scripts" / "graphify_full_refresh.py", - ROOT / "scripts" / "graphify_semantic_adapter.py", - ROOT / "scripts" / "graphify_verify_full_refresh.py", - ] -) - -state = _load_state() -if state.get("schema_version") != "raw-source-v1": - state = {} -last_code_inputs = state.get("code_inputs") -last_full_inputs = state.get("full_inputs") - -if not state and BUILD_INFO_FILE.exists(): - existing_build_info = json.loads(BUILD_INFO_FILE.read_text(encoding="utf-8")) - existing_mode = existing_build_info.get("mode") - has_code_changes, has_full_refresh_changes = _detect_bootstrap_changes() - bootstrap_code_inputs = code_inputs - bootstrap_full_inputs = full_inputs - if has_full_refresh_changes: - bootstrap_full_inputs = "" - elif has_code_changes or existing_mode != "full_refresh": - bootstrap_code_inputs = "" - _write_state({"code_inputs": bootstrap_code_inputs, "full_inputs": bootstrap_full_inputs}) - last_code_inputs = bootstrap_code_inputs - last_full_inputs = bootstrap_full_inputs - print("graphify auto-refresh: initialized state from existing graph") - -if last_full_inputs != full_inputs: - ok = _run(["bash", "scripts/graphify_prepare_corpus.sh"], label="prepare corpus") - ok = ok and _run( - [ - "uv", - "run", - "--with", - "graphifyy==0.4.23", - "python", - "scripts/graphify_full_refresh.py", - ".graphify-work/corpus", - ], - label="full refresh producer", - ) - ok = ok and _run( - [ - "python3", - "scripts/graphify_verify_full_refresh.py", - ".graphify-work/corpus/graphify-out", - ], - label="verify full refresh", - ) - ok = ok and _run(["bash", "scripts/graphify_sync_staged.sh"], label="sync full refresh") - if ok: - _write_state({"code_inputs": code_inputs, "full_inputs": full_inputs}) - raise SystemExit(0) - -if last_code_inputs != code_inputs: - ok = _run(["bash", "scripts/graphify_code_refresh.sh"], label="code refresh") - if ok: - _write_state({"code_inputs": code_inputs, "full_inputs": full_inputs}) - raise SystemExit(0) - -print("graphify auto-refresh: inputs unchanged") -PY +bash "$repo_root/scripts/hooks/graphify-auto-refresh.sh" diff --git a/.harness/hooks/graphify-mode-note.sh b/.harness/hooks/graphify-mode-note.sh index ccb0e53..a496ed7 100755 --- a/.harness/hooks/graphify-mode-note.sh +++ b/.harness/hooks/graphify-mode-note.sh @@ -2,41 +2,10 @@ set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -REPORT="$REPO_ROOT/graphify-out/GRAPH_REPORT.md" -GRAPH_JSON="$REPO_ROOT/graphify-out/graph.json" -BUILD_INFO="$REPO_ROOT/graphify-out/BUILD_INFO.json" - -if [ ! -f "$REPORT" ] || [ ! -f "$GRAPH_JSON" ]; then - exit 0 +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + script_dir="$(cd "$(dirname "$0")" && pwd)" + repo_root="$(cd "$script_dir/../.." && pwd)" fi -python3 - "$BUILD_INFO" <<'PY' -import json -import sys -from pathlib import Path - -build_info = Path(sys.argv[1]) -if not build_info.exists(): - print( - "graphify: Primary graph exists at graphify-out/. Read GRAPH_REPORT.md first, then graph.json. " - "BUILD_INFO is missing, so inspect raw/ directly if the graph lacks needed design or external source context." - ) - raise SystemExit - -data = json.loads(build_info.read_text(encoding="utf-8")) -mode = data.get("mode") -verified = bool(data.get("verified")) - -if mode == "full_refresh" and verified: - print( - "graphify: Verified full_refresh graph with raw source coverage exists at graphify-out/. Read GRAPH_REPORT.md first, then graph.json and BUILD_INFO.json. " - "Inspect raw/ only if the graph still lacks the needed design or external source context." - ) -else: - print( - "graphify: Code-only or unverified graph exists at graphify-out/. Read GRAPH_REPORT.md first, then graph.json and BUILD_INFO.json, " - "and inspect raw/ directly for design or external source context when needed." - ) -PY +bash "$repo_root/scripts/hooks/graphify-mode-note.sh" diff --git a/.harness/hooks/graphify-pretool.sh b/.harness/hooks/graphify-pretool.sh index 1db3af8..b74d9fb 100755 --- a/.harness/hooks/graphify-pretool.sh +++ b/.harness/hooks/graphify-pretool.sh @@ -2,13 +2,10 @@ set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -NOTE="$("$SCRIPT_DIR/graphify-mode-note.sh" || true)" - -if [ -z "${NOTE:-}" ]; then - exit 0 +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + script_dir="$(cd "$(dirname "$0")" && pwd)" + repo_root="$(cd "$script_dir/../.." && pwd)" fi -cat </dev/null 2>&1; then shasum - elif command -v sha1sum >/dev/null 2>&1; then sha1sum - else printf '%s\n' "error: missing shasum or sha1sum" >&2; exit 1; fi -} -file_id() { printf '%s' "$1" | hash_cmd | cut -c1-16; } -block_hash() { printf '%s' "$1" | hash_cmd | cut -c1-8; } -escape_glob() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\*/\\*}" - s="${s//\?/\\?}" - s="${s//\[/\\[}" - printf '%s' "$s" -} - -# Extract filter_file from the hook script (line 59 "filter_file()" to line 142 closing brace) -eval "$(sed -n '/^filter_file()/,/^}/p' "$SCRIPT_DIR/simplify-ignore.sh")" - -assert_eq() { - local label="$1" expected="$2" actual="$3" - if [ "$expected" = "$actual" ]; then - PASS=$((PASS + 1)) - printf ' PASS: %s\n' "$label" - else - FAIL=$((FAIL + 1)) - printf ' FAIL: %s\n' "$label" >&2 - printf ' expected: %s\n' "$(printf '%s' "$expected" | cat -v)" >&2 - printf ' actual: %s\n' "$(printf '%s' "$actual" | cat -v)" >&2 - fi -} - -# ── Test 1: Single-line block produces exactly one placeholder ──────────── -printf 'Test 1: Single-line block (start+end on same line)\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/single-line.js" -DEST="$TMPDIR/single-line-filtered.js" -cat > "$SRC" <<'EOF' -const a = 1; -/* simplify-ignore-start */ const secret = 42; /* simplify-ignore-end */ -const b = 2; -EOF - -FID="test_single" -filter_file "$SRC" "$DEST" "$FID" - -placeholder_count=$(grep -c 'BLOCK_' "$DEST") -assert_eq "exactly one placeholder line" "1" "$placeholder_count" -assert_eq "line before block preserved" "1" "$(grep -c 'const a = 1' "$DEST")" -assert_eq "line after block preserved" "1" "$(grep -c 'const b = 2' "$DEST")" - -block_files=$(ls "$CACHE/${FID}".block.* 2>/dev/null | wc -l | tr -d ' ') -assert_eq "one block file in cache" "1" "$block_files" - -block_content=$(cat "$CACHE/${FID}".block.*) -assert_eq "block content matches" \ - "/* simplify-ignore-start */ const secret = 42; /* simplify-ignore-end */" \ - "$block_content" - -# ── Test 2: Multi-line block ───────────────────────────────────────────── -printf '\nTest 2: Multi-line block\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/multi-line.js" -DEST="$TMPDIR/multi-line-filtered.js" -cat > "$SRC" <<'EOF' -const a = 1; -// simplify-ignore-start -const secret1 = 42; -const secret2 = 99; -// simplify-ignore-end -const b = 2; -EOF - -FID="test_multi" -filter_file "$SRC" "$DEST" "$FID" - -placeholder_count=$(grep -c 'BLOCK_' "$DEST") -assert_eq "exactly one placeholder for multi-line block" "1" "$placeholder_count" - -output_lines=$(wc -l < "$DEST" | tr -d ' ') -assert_eq "output has 3 lines (before + placeholder + after)" "3" "$output_lines" - -# ── Test 3: Multiple blocks in one file ────────────────────────────────── -printf '\nTest 3: Multiple blocks in one file\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/multi-block.js" -DEST="$TMPDIR/multi-block-filtered.js" -cat > "$SRC" <<'EOF' -line1 -// simplify-ignore-start -blockA -// simplify-ignore-end -line2 -// simplify-ignore-start -blockB -// simplify-ignore-end -line3 -EOF - -FID="test_multiblock" -filter_file "$SRC" "$DEST" "$FID" - -placeholder_count=$(grep -c 'BLOCK_' "$DEST") -assert_eq "two placeholders for two blocks" "2" "$placeholder_count" - -block_files=$(ls "$CACHE/${FID}".block.* 2>/dev/null | wc -l | tr -d ' ') -assert_eq "two block files in cache" "2" "$block_files" - -# ── Test 4: Reason string preserved ────────────────────────────────────── -printf '\nTest 4: Reason string in placeholder\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/reason.js" -DEST="$TMPDIR/reason-filtered.js" -cat > "$SRC" <<'EOF' -// simplify-ignore-start: perf-critical -hot_loop(); -// simplify-ignore-end -EOF - -FID="test_reason" -filter_file "$SRC" "$DEST" "$FID" - -assert_eq "placeholder includes reason" "1" "$(grep -c 'perf-critical' "$DEST")" - -reason_files=$(ls "$CACHE/${FID}".reason.* 2>/dev/null | wc -l | tr -d ' ') -assert_eq "reason file saved" "1" "$reason_files" -assert_eq "reason content" "perf-critical" "$(cat "$CACHE/${FID}".reason.*)" - -# ── Test 5: Trailing newline preservation ──────────────────────────────── -printf '\nTest 5: Trailing newline preservation\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/no-trailing-nl.js" -DEST="$TMPDIR/no-trailing-nl-filtered.js" -printf 'line1\n// simplify-ignore-start\nsecret\n// simplify-ignore-end' > "$SRC" - -FID="test_trail" -filter_file "$SRC" "$DEST" "$FID" - -# Source has no trailing newline; dest should also have no trailing newline -src_has_nl=$(tail -c 1 "$SRC" | wc -l | tr -d ' ') -dest_has_nl=$(tail -c 1 "$DEST" | wc -l | tr -d ' ') -assert_eq "dest preserves no-trailing-newline from source" "$src_has_nl" "$dest_has_nl" - -# ── Test 6: No blocks → return 1 ──────────────────────────────────────── -printf '\nTest 6: No blocks returns 1\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/no-blocks.js" -DEST="$TMPDIR/no-blocks-filtered.js" -cat > "$SRC" <<'EOF' -const a = 1; -const b = 2; -EOF - -FID="test_noblocks" -rc=0 -filter_file "$SRC" "$DEST" "$FID" || rc=$? -assert_eq "returns 1 when no blocks found" "1" "$rc" - -# ── Test 7: Unclosed block emits warning and flushes ───────────────────── -printf '\nTest 7: Unclosed block\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/unclosed.js" -DEST="$TMPDIR/unclosed-filtered.js" -cat > "$SRC" <<'EOF' -line1 -// simplify-ignore-start -orphan code -EOF - -FID="test_unclosed" -stderr_out=$(filter_file "$SRC" "$DEST" "$FID" 2>&1) || true -assert_eq "warning emitted for unclosed block" "1" "$(printf '%s' "$stderr_out" | grep -c 'unclosed')" -assert_eq "orphan code flushed to output" "1" "$(grep -c 'orphan code' "$DEST")" - -# ── Test 8: Single-line block with reason ──────────────────────────────── -printf '\nTest 8: Single-line block with reason\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/single-reason.js" -DEST="$TMPDIR/single-reason-filtered.js" -cat > "$SRC" <<'EOF' -before -/* simplify-ignore-start: hot-path */ x = compute(); /* simplify-ignore-end */ -after -EOF - -FID="test_single_reason" -filter_file "$SRC" "$DEST" "$FID" - -placeholder_count=$(grep -c 'BLOCK_' "$DEST") -assert_eq "exactly one placeholder for single-line+reason" "1" "$placeholder_count" -assert_eq "reason in placeholder" "1" "$(grep -c 'hot-path' "$DEST")" - -# ── Test 9: HTML comment syntax ────────────────────────────────────────── -printf '\nTest 9: HTML comment syntax\n' -rm -f "$CACHE"/* - -SRC="$TMPDIR/html.html" -DEST="$TMPDIR/html-filtered.html" -cat > "$SRC" <<'EOF' -
- - - -
-EOF - -FID="test_html" -filter_file "$SRC" "$DEST" "$FID" - -placeholder_count=$(grep -c 'BLOCK_' "$DEST") -assert_eq "HTML block replaced" "1" "$placeholder_count" -assert_eq "HTML suffix preserved" "1" "$(grep -c '\-\->' "$DEST")" - -# ── Test 10: JSON parsing error warning ────────────────────────────────── -printf '\nTest 10: Malformed JSON input produces warning\n' - -warning_out=$(echo 'NOT_JSON{{{' | bash "$SCRIPT_DIR/simplify-ignore.sh" 2>&1) || true -assert_eq "warning on bad JSON" "1" "$(printf '%s' "$warning_out" | grep -c 'Warning.*failed to parse')" - -# ── Summary ────────────────────────────────────────────────────────────── -printf '\n══════════════════════════════════════════\n' -printf 'Results: %d passed, %d failed\n' "$PASS" "$FAIL" -[ "$FAIL" -eq 0 ] && exit 0 || exit 1 +bash "$repo_root/scripts/hooks/simplify-ignore-test.sh" diff --git a/.harness/hooks/simplify-ignore.sh b/.harness/hooks/simplify-ignore.sh index a93c467..4fd06e2 100755 --- a/.harness/hooks/simplify-ignore.sh +++ b/.harness/hooks/simplify-ignore.sh @@ -1,302 +1,11 @@ #!/bin/bash -# simplify-ignore.sh — Hook for Read (PreToolUse), Edit|Write (PostToolUse), Stop -# -# PreToolUse Read → backs up file, replaces blocks with BLOCK_ in-place -# PostToolUse Edit → expands placeholders, re-filters so file stays hidden -# PostToolUse Write → expands placeholders, re-filters so file stays hidden -# Stop → restores real file content from backup -# -# The file on disk ALWAYS has placeholders while the session is active. -# The real content (with model's changes applied) lives in the backup. -# -# Dependencies: jq, shasum or sha1sum (auto-detected) set -euo pipefail -if ! command -v jq >/dev/null 2>&1; then - printf '%s\n' "error: missing jq" >&2; exit 1 +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + script_dir="$(cd "$(dirname "$0")" && pwd)" + repo_root="$(cd "$script_dir/../.." && pwd)" fi -CACHE="${CLAUDE_PROJECT_DIR:-.}/.claude/.simplify-ignore-cache" -if [ -t 0 ]; then INPUT="{}"; else INPUT=$(cat); fi - -# Parse hook input — trap errors explicitly so set -e doesn't cause -# a silent exit on malformed JSON, and surface a useful diagnostic. -parse_error="" -TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || { - parse_error="failed to parse .tool_name from hook input" - TOOL_NAME="" -} -FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || { - parse_error="failed to parse .tool_input.file_path from hook input" - FILE_PATH="" -} -if [ -n "$parse_error" ]; then - printf 'Warning: %s (input: %.120s)\n' "$parse_error" "$INPUT" >&2 -fi - -hash_cmd() { - if command -v shasum >/dev/null 2>&1; then shasum - elif command -v sha1sum >/dev/null 2>&1; then sha1sum - else printf '%s\n' "error: missing shasum or sha1sum" >&2; exit 1; fi -} -file_id() { printf '%s' "$1" | hash_cmd | cut -c1-16; } -block_hash() { printf '%s' "$1" | hash_cmd | cut -c1-8; } -# Escape glob metacharacters so ${var/pattern/repl} treats pattern as literal. -# Needed for Bash 3.2 (macOS) where quotes don't suppress globbing in PE patterns. -escape_glob() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\*/\\*}" - s="${s//\?/\\?}" - s="${s//\[/\\[}" - printf '%s' "$s" -} - -# ── filter_file: replace simplify-ignore blocks with BLOCK_ placeholders ─ -# Reads $1 (source), writes filtered version to $2 (dest), saves blocks to cache. -# Returns 0 if blocks were found, 1 if none. -filter_file() { - local src="$1" dest="$2" fid="$3" - : > "$dest" - rm -f "$CACHE/${fid}".block.* "$CACHE/${fid}".reason.* "$CACHE/${fid}".prefix.* "$CACHE/${fid}".suffix.* - - local count=0 in_block=0 buf="" reason="" prefix="" suffix="" - - while IFS= read -r line || [ -n "$line" ]; do - # Check for start marker (no fork — uses bash case) - if [ $in_block -eq 0 ]; then - case "$line" in *simplify-ignore-start*) - in_block=1 - buf="$line" - # Extract comment prefix/suffix to preserve language-appropriate syntax - prefix="${line%%simplify-ignore-start*}" - suffix="" - case "$line" in *'*/'*) suffix=" */" ;; *'-->'*) suffix=" -->" ;; esac - reason=$(printf '%s' "$line" | sed -n 's/.*simplify-ignore-start:[[:space:]]*//p' \ - | sed 's/[[:space:]]*\*\/.*$//' | sed 's/[[:space:]]*-->.*$//' | sed 's/[[:space:]]*$//') - # Handle single-line block (start + end on same line) - case "$line" in *simplify-ignore-end*) - in_block=0 - # Write single-line block immediately and skip to next line - # to avoid the end-marker check below firing again - local h; h=$(block_hash "$buf") - count=$((count + 1)) - printf '%s' "$buf" > "$CACHE/${fid}.block.${h}" - [ -n "$reason" ] && printf '%s' "$reason" > "$CACHE/${fid}.reason.${h}" - printf '%s' "$prefix" > "$CACHE/${fid}.prefix.${h}" - printf '%s' "$suffix" > "$CACHE/${fid}.suffix.${h}" - if [ -n "$reason" ]; then - printf '%s\n' "${prefix}BLOCK_${h}: ${reason}${suffix}" >> "$dest" - else - printf '%s\n' "${prefix}BLOCK_${h}${suffix}" >> "$dest" - fi - buf=""; reason=""; prefix=""; suffix="" - continue - ;; *) - continue - ;; - esac - ;; esac - fi - # Accumulate block content - if [ $in_block -eq 1 ]; then - buf="${buf} -${line}" - fi - # Check for end marker - case "$line" in *simplify-ignore-end*) - if [ $in_block -eq 1 ]; then - local h; h=$(block_hash "$buf") - count=$((count + 1)) - printf '%s' "$buf" > "$CACHE/${fid}.block.${h}" - [ -n "$reason" ] && printf '%s' "$reason" > "$CACHE/${fid}.reason.${h}" - printf '%s' "$prefix" > "$CACHE/${fid}.prefix.${h}" - printf '%s' "$suffix" > "$CACHE/${fid}.suffix.${h}" - if [ -n "$reason" ]; then - printf '%s\n' "${prefix}BLOCK_${h}: ${reason}${suffix}" >> "$dest" - else - printf '%s\n' "${prefix}BLOCK_${h}${suffix}" >> "$dest" - fi - in_block=0; buf=""; reason=""; prefix=""; suffix="" - continue - fi - ;; - esac - [ $in_block -eq 0 ] && printf '%s\n' "$line" >> "$dest" - done < "$src" - - # Unclosed block → flush as-is - if [ $in_block -eq 1 ] && [ -n "$buf" ]; then - printf 'Warning: unclosed simplify-ignore-start in %s (block not hidden)\n' "$src" >&2 - printf '%s\n' "$buf" >> "$dest" - fi - - # Preserve trailing newline status of source - if [ -s "$dest" ] && [ -s "$src" ] && [ -n "$(tail -c 1 "$src")" ]; then - perl -pe 'chomp if eof' "$dest" > "${dest}.nnl" && \ - cat "${dest}.nnl" > "$dest" && rm -f "${dest}.nnl" - fi - - [ $count -gt 0 ] && return 0 || return 1 -} - -# ── Stop: restore all files from backup ─────────────────────────────────────── -if [ -z "$TOOL_NAME" ]; then - [ -d "$CACHE" ] || exit 0 - for bak in "$CACHE"/*.bak; do - [ -f "$bak" ] || continue - fid="${bak##*/}"; fid="${fid%.bak}" - pathfile="$CACHE/${fid}.path" - [ -f "$pathfile" ] || { rm -f "$bak"; continue; } - orig=$(cat "$pathfile") - if [ -f "$orig" ]; then - cat "$bak" > "$orig" - rm -f "$bak" "$pathfile" "$CACHE/${fid}".block.* "$CACHE/${fid}".reason.* "$CACHE/${fid}".prefix.* "$CACHE/${fid}".suffix.* - rmdir "$CACHE/${fid}.lock" 2>/dev/null - else - # File was moved/deleted — save backup as .recovered, don't destroy it - mkdir -p "$(dirname "${orig}.recovered")" - mv "$bak" "${orig}.recovered" - rm -f "$pathfile" "$CACHE/${fid}".block.* "$CACHE/${fid}".reason.* "$CACHE/${fid}".prefix.* "$CACHE/${fid}".suffix.* - rmdir "$CACHE/${fid}.lock" 2>/dev/null - printf 'Warning: %s was moved/deleted. Recovered original to %s.recovered\n' "$orig" "$orig" >&2 - fi - done - # Clean orphan locks (created but crash before backup) - for lockdir in "$CACHE"/*.lock; do - [ -d "$lockdir" ] || continue - rmdir "$lockdir" 2>/dev/null - done - exit 0 -fi - -[ -z "$FILE_PATH" ] && exit 0 - -# ── PreToolUse Read: filter in-place ────────────────────────────────────────── -if [ "$TOOL_NAME" = "Read" ]; then - [ -f "$FILE_PATH" ] || exit 0 - case "$(basename "$FILE_PATH")" in simplify-ignore*|SIMPLIFY-IGNORE*) exit 0 ;; esac - - mkdir -p "$CACHE" - ID=$(file_id "$FILE_PATH") - - # If backup exists, file is already filtered — skip - [ -f "$CACHE/${ID}.bak" ] && exit 0 - - grep -q 'simplify-ignore-start' -- "$FILE_PATH" || exit 0 - - # Atomic lock: mkdir fails if another session races us - if ! mkdir "$CACHE/${ID}.lock" 2>/dev/null; then - # Lock exists — reclaim only if stale (>60s old, no backup = crash leftover) - if [ ! -f "$CACHE/${ID}.bak" ] && \ - [ -n "$(find "$CACHE/${ID}.lock" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then - rmdir "$CACHE/${ID}.lock" 2>/dev/null || true - mkdir "$CACHE/${ID}.lock" 2>/dev/null || exit 0 - else - exit 0 - fi - fi - - # Back up the original (preserve trailing newline status) - cp -p "$FILE_PATH" "$CACHE/${ID}.bak" 2>/dev/null || cp "$FILE_PATH" "$CACHE/${ID}.bak" - printf '%s' "$FILE_PATH" > "$CACHE/${ID}.path" - - # Filter in-place (cat > preserves inode and permissions) - FILTERED="$CACHE/${ID}.$$.tmp" - rm -f "$FILTERED" - if filter_file "$FILE_PATH" "$FILTERED" "$ID"; then - cat "$FILTERED" > "$FILE_PATH" - rm -f "$FILTERED" - else - rm -f "$FILTERED" "$CACHE/${ID}.bak" "$CACHE/${ID}.path" - rmdir "$CACHE/${ID}.lock" 2>/dev/null - fi - exit 0 -fi - -# ── PostToolUse Edit|Write: expand, then re-filter ──────────────────────────── -if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then - ID=$(file_id "$FILE_PATH") - [ -f "$CACHE/${ID}.bak" ] || exit 0 - ls "$CACHE/${ID}".block.* >/dev/null 2>&1 || exit 0 - - # Expand placeholders, preserving any inline code the model added around them - EXPANDED="$CACHE/${ID}.$$.expanded" - rm -f "$EXPANDED" - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in *BLOCK_*) - # Expand all placeholders on this line (supports multiple per line) - for bf in "$CACHE/${ID}".block.*; do - [ -f "$bf" ] || continue - h="${bf##*.}" - case "$line" in *"BLOCK_${h}"*) - # Reconstruct the exact placeholder pattern - bp=""; bs=""; br="" - [ -f "$CACHE/${ID}.prefix.${h}" ] && bp=$(cat "$CACHE/${ID}.prefix.${h}") - [ -f "$CACHE/${ID}.suffix.${h}" ] && bs=$(cat "$CACHE/${ID}.suffix.${h}") - [ -f "$CACHE/${ID}.reason.${h}" ] && br=$(cat "$CACHE/${ID}.reason.${h}") - if [ -n "$br" ]; then - placeholder="${bp}BLOCK_${h}: ${br}${bs}" - else - placeholder="${bp}BLOCK_${h}${bs}" - fi - block_content=$(cat "$bf"; printf x); block_content="${block_content%x}" - # Escape glob metacharacters (* ? [ \) in the pattern - esc_placeholder=$(escape_glob "$placeholder") - # Bash native substitution (// = global replace): replace placeholder, keep surrounding code - line="${line//$esc_placeholder/$block_content}" - # Fallback: if model altered the reason text, try without reason - # (only trigger if BLOCK_hash is still present AND wasn't in the original block content) - case "$block_content" in *"BLOCK_${h}"*) ;; *) - case "$line" in *"BLOCK_${h}"*) - printf 'Warning: placeholder BLOCK_%s was modified by model, using fuzzy match\n' "$h" >&2 - esc_fuzzy=$(escape_glob "${bp}BLOCK_${h}${bs}") - line="${line//$esc_fuzzy/$block_content}" - # Last resort: match just the hash token - case "$line" in *"BLOCK_${h}"*) - line="${line//BLOCK_${h}/$block_content}" - ;; esac - ;; esac - ;; esac - ;; esac - done - ;; esac - printf '%s\n' "$line" >> "$EXPANDED" - done < "$FILE_PATH" - # Preserve trailing newline status - if [ -s "$EXPANDED" ] && [ -s "$FILE_PATH" ] && [ -n "$(tail -c 1 "$FILE_PATH")" ]; then - perl -pe 'chomp if eof' "$EXPANDED" > "${EXPANDED}.nnl" && \ - cat "${EXPANDED}.nnl" > "$EXPANDED" && rm -f "${EXPANDED}.nnl" - fi - # Warn if model deleted a protected block entirely - for bf in "$CACHE/${ID}".block.*; do - [ -f "$bf" ] || continue - bh="${bf##*.}" - # After expansion, blocks appear as original code (simplify-ignore-start). - # If neither the expanded code nor the placeholder is in EXPANDED, it was deleted. - if ! grep -qF "BLOCK_${bh}" "$EXPANDED" 2>/dev/null; then - # Get first line of block to check if it was expanded back - first_line=$(head -1 "$bf") - if ! grep -qF "$first_line" "$EXPANDED" 2>/dev/null; then - printf 'Warning: protected block BLOCK_%s was deleted by model\n' "$bh" >&2 - fi - fi - done - # Preserve inode and permissions - cat "$EXPANDED" > "$FILE_PATH" - rm -f "$EXPANDED" - - # Save expanded version as new backup (this is the "real" file with model's changes) - cp "$FILE_PATH" "$CACHE/${ID}.bak" - - # Re-filter in-place so the file on disk stays with placeholders - FILTERED="$CACHE/${ID}.$$.tmp" - rm -f "$FILTERED" - if filter_file "$FILE_PATH" "$FILTERED" "$ID"; then - cat "$FILTERED" > "$FILE_PATH" - rm -f "$FILTERED" - fi - - exit 0 -fi +bash "$repo_root/scripts/hooks/simplify-ignore.sh" diff --git a/scripts/git-hooks/pre-commit.sh b/scripts/git-hooks/pre-commit.sh old mode 100644 new mode 100755 index 4e43be4..43323c0 --- a/scripts/git-hooks/pre-commit.sh +++ b/scripts/git-hooks/pre-commit.sh @@ -8,31 +8,4 @@ if [ -z "$repo_root" ]; then repo_root="$(cd "$script_dir/../.." && pwd)" fi -cd "$repo_root" - -staged_python_files=$(git diff --cached --name-only --diff-filter=ACMR -- "*.py" "*.pyi") - -if [ -z "$staged_python_files" ]; then - exit 0 -fi - -set -- -while IFS= read -r file; do - [ -n "$file" ] || continue - [ -f "$file" ] || continue - set -- "$@" "$file" -done </dev/null 2>&1; then + exit 0 +fi + +cd "$REPO_ROOT" + +staged_files="$(git_in_repo diff --cached --name-only)" +if [ -z "$staged_files" ]; then + exit 0 +fi + +warned=0 + +while IFS= read -r file; do + [ -n "$file" ] || continue + case "$file" in + *.example) + continue + ;; + .env|.env.*|*/.env|*/.env.*|*credentials.json|*secret.json|*secrets.json|*secret.yaml|*secrets.yaml|*secret.yml|*secrets.yml|*secret.toml|*secrets.toml|*.pem|*.key|*.p12|*.pfx) + echo "WARN: staged file looks like secret material: $file" >&2 + warned=1 + ;; + esac +done <&2 + warned=1 +fi + +if [ "$warned" -eq 1 ]; then + echo "WARN: review staged changes for accidental secret exposure before pushing." >&2 +fi diff --git a/scripts/hooks/claude-stop-checks.sh b/scripts/hooks/claude-stop-checks.sh new file mode 100755 index 0000000..f747912 --- /dev/null +++ b/scripts/hooks/claude-stop-checks.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" +init_repo_context "$0" + +cd "$REPO_ROOT" + +echo "claude stop: uv run ruff check ." +uv run ruff check . + +echo "claude stop: uv run ruff format --check ." +uv run ruff format --check . + +echo "claude stop: uv run mypy src" +uv run mypy src + +echo "claude stop: uv run pytest --ignore=tests/test_mlx_runtime.py --ignore=tests/test_cli_smoke_mlx.py" +uv run pytest --ignore=tests/test_mlx_runtime.py --ignore=tests/test_cli_smoke_mlx.py diff --git a/scripts/hooks/common.sh b/scripts/hooks/common.sh new file mode 100755 index 0000000..e8728d0 --- /dev/null +++ b/scripts/hooks/common.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -euo pipefail + +init_repo_context() { + local script_path="${1:-$0}" + local script_dir + + REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -z "$REPO_ROOT" ]; then + script_dir="$(cd "$(dirname "$script_path")" && pwd)" + REPO_ROOT="$(cd "$script_dir/../.." && pwd)" + fi + + GIT_ARGS=() + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + return 0 + fi + + if [ -d "$REPO_ROOT/.git" ] && git --git-dir="$REPO_ROOT/.git" --work-tree="$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + GIT_ARGS=(--git-dir="$REPO_ROOT/.git" --work-tree="$REPO_ROOT") + fi +} + +git_in_repo() { + git "${GIT_ARGS[@]}" "$@" +} diff --git a/scripts/hooks/graphify-auto-refresh.sh b/scripts/hooks/graphify-auto-refresh.sh new file mode 100755 index 0000000..ca343dd --- /dev/null +++ b/scripts/hooks/graphify-auto-refresh.sh @@ -0,0 +1,193 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" +init_repo_context "$0" + +cd "$REPO_ROOT" + +mkdir -p .graphify-work + +STATE_FILE=".graphify-work/auto_refresh_state.json" + +if [ "${#GIT_ARGS[@]}" -eq 2 ]; then + export GIT_DIR="${GIT_ARGS[0]#--git-dir=}" + export GIT_WORK_TREE="${GIT_ARGS[1]#--work-tree=}" +fi + +python3 - <<'PY' +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +ROOT = Path(".").resolve() +STATE_FILE = ROOT / ".graphify-work" / "auto_refresh_state.json" +BUILD_INFO_FILE = ROOT / "graphify-out" / "BUILD_INFO.json" + + +def _iter_files(paths: list[Path]) -> list[Path]: + files: set[Path] = set() + for path in paths: + if path.is_file(): + files.add(path) + elif path.is_dir(): + files.update(candidate for candidate in path.rglob("*") if candidate.is_file()) + return sorted(files) + + +def _digest(paths: list[Path]) -> str: + digest = hashlib.sha256() + for path in _iter_files(paths): + digest.update(path.relative_to(ROOT).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def _load_state() -> dict[str, str]: + if not STATE_FILE.exists(): + return {} + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + + +def _write_state(payload: dict[str, str]) -> None: + payload = {"schema_version": "raw-source-v1", **payload} + STATE_FILE.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + +def _run(command: list[str], *, label: str) -> bool: + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode == 0: + print(f"graphify auto-refresh: {label} completed") + return True + + print(f"graphify auto-refresh: {label} failed", flush=True) + if result.stdout.strip(): + print(result.stdout.strip(), flush=True) + if result.stderr.strip(): + print(result.stderr.strip(), flush=True) + return False + + +def _detect_bootstrap_changes() -> tuple[bool, bool]: + result = subprocess.run( + [ + "git", + "status", + "--short", + "--untracked-files=all", + "--", + "src", + "tests", + "raw", + "scripts/graphify_code_refresh.sh", + "scripts/graphify_prepare_corpus.sh", + "scripts/graphify_full_refresh.py", + "scripts/graphify_semantic_adapter.py", + "scripts/graphify_verify_full_refresh.py", + "scripts/graphify_sync_staged.sh", + "scripts/graphify_ci_candidate.sh", + ], + capture_output=True, + text=True, + check=True, + ) + changed_paths = [line[3:] for line in result.stdout.splitlines() if len(line) >= 4] + has_full_refresh_changes = any( + path.startswith("raw/") + or path + in { + "scripts/graphify_prepare_corpus.sh", + "scripts/graphify_full_refresh.py", + "scripts/graphify_semantic_adapter.py", + "scripts/graphify_verify_full_refresh.py", + "scripts/graphify_sync_staged.sh", + "scripts/graphify_ci_candidate.sh", + } + for path in changed_paths + ) + has_code_changes = any( + path.startswith("src/") + or path.startswith("tests/") + or path == "scripts/graphify_code_refresh.sh" + for path in changed_paths + ) + return has_code_changes, has_full_refresh_changes + + +code_inputs = _digest([ROOT / "src", ROOT / "tests"]) +full_inputs = _digest( + [ + ROOT / "raw", + ROOT / "scripts" / "graphify_full_refresh.py", + ROOT / "scripts" / "graphify_semantic_adapter.py", + ROOT / "scripts" / "graphify_verify_full_refresh.py", + ] +) + +state = _load_state() +if state.get("schema_version") != "raw-source-v1": + state = {} +last_code_inputs = state.get("code_inputs") +last_full_inputs = state.get("full_inputs") + +if not state and BUILD_INFO_FILE.exists(): + existing_build_info = json.loads(BUILD_INFO_FILE.read_text(encoding="utf-8")) + existing_mode = existing_build_info.get("mode") + has_code_changes, has_full_refresh_changes = _detect_bootstrap_changes() + bootstrap_code_inputs = code_inputs + bootstrap_full_inputs = full_inputs + if has_full_refresh_changes: + bootstrap_full_inputs = "" + elif has_code_changes or existing_mode != "full_refresh": + bootstrap_code_inputs = "" + _write_state({"code_inputs": bootstrap_code_inputs, "full_inputs": bootstrap_full_inputs}) + last_code_inputs = bootstrap_code_inputs + last_full_inputs = bootstrap_full_inputs + print("graphify auto-refresh: initialized state from existing graph") + +if last_full_inputs != full_inputs: + ok = _run(["bash", "scripts/graphify_prepare_corpus.sh"], label="prepare corpus") + ok = ok and _run( + [ + "uv", + "run", + "--with", + "graphifyy==0.4.23", + "python", + "scripts/graphify_full_refresh.py", + ".graphify-work/corpus", + ], + label="full refresh producer", + ) + ok = ok and _run( + [ + "python3", + "scripts/graphify_verify_full_refresh.py", + ".graphify-work/corpus/graphify-out", + ], + label="verify full refresh", + ) + ok = ok and _run(["bash", "scripts/graphify_sync_staged.sh"], label="sync full refresh") + if ok: + _write_state({"code_inputs": code_inputs, "full_inputs": full_inputs}) + raise SystemExit(0) + +if last_code_inputs != code_inputs: + ok = _run(["bash", "scripts/graphify_code_refresh.sh"], label="code refresh") + if ok: + _write_state({"code_inputs": code_inputs, "full_inputs": full_inputs}) + raise SystemExit(0) + +print("graphify auto-refresh: inputs unchanged") +PY diff --git a/scripts/hooks/graphify-mode-note.sh b/scripts/hooks/graphify-mode-note.sh new file mode 100755 index 0000000..ccb0e53 --- /dev/null +++ b/scripts/hooks/graphify-mode-note.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +REPORT="$REPO_ROOT/graphify-out/GRAPH_REPORT.md" +GRAPH_JSON="$REPO_ROOT/graphify-out/graph.json" +BUILD_INFO="$REPO_ROOT/graphify-out/BUILD_INFO.json" + +if [ ! -f "$REPORT" ] || [ ! -f "$GRAPH_JSON" ]; then + exit 0 +fi + +python3 - "$BUILD_INFO" <<'PY' +import json +import sys +from pathlib import Path + +build_info = Path(sys.argv[1]) +if not build_info.exists(): + print( + "graphify: Primary graph exists at graphify-out/. Read GRAPH_REPORT.md first, then graph.json. " + "BUILD_INFO is missing, so inspect raw/ directly if the graph lacks needed design or external source context." + ) + raise SystemExit + +data = json.loads(build_info.read_text(encoding="utf-8")) +mode = data.get("mode") +verified = bool(data.get("verified")) + +if mode == "full_refresh" and verified: + print( + "graphify: Verified full_refresh graph with raw source coverage exists at graphify-out/. Read GRAPH_REPORT.md first, then graph.json and BUILD_INFO.json. " + "Inspect raw/ only if the graph still lacks the needed design or external source context." + ) +else: + print( + "graphify: Code-only or unverified graph exists at graphify-out/. Read GRAPH_REPORT.md first, then graph.json and BUILD_INFO.json, " + "and inspect raw/ directly for design or external source context when needed." + ) +PY diff --git a/scripts/hooks/graphify-pretool.sh b/scripts/hooks/graphify-pretool.sh new file mode 100755 index 0000000..1db3af8 --- /dev/null +++ b/scripts/hooks/graphify-pretool.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +NOTE="$("$SCRIPT_DIR/graphify-mode-note.sh" || true)" + +if [ -z "${NOTE:-}" ]; then + exit 0 +fi + +cat </dev/null 2>&1; then shasum + elif command -v sha1sum >/dev/null 2>&1; then sha1sum + else printf '%s\n' "error: missing shasum or sha1sum" >&2; exit 1; fi +} +file_id() { printf '%s' "$1" | hash_cmd | cut -c1-16; } +block_hash() { printf '%s' "$1" | hash_cmd | cut -c1-8; } +escape_glob() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\*/\\*}" + s="${s//\?/\\?}" + s="${s//\[/\\[}" + printf '%s' "$s" +} + +# Extract filter_file from the hook script (line 59 "filter_file()" to line 142 closing brace) +eval "$(sed -n '/^filter_file()/,/^}/p' "$SCRIPT_DIR/simplify-ignore.sh")" + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + PASS=$((PASS + 1)) + printf ' PASS: %s\n' "$label" + else + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$label" >&2 + printf ' expected: %s\n' "$(printf '%s' "$expected" | cat -v)" >&2 + printf ' actual: %s\n' "$(printf '%s' "$actual" | cat -v)" >&2 + fi +} + +# ── Test 1: Single-line block produces exactly one placeholder ──────────── +printf 'Test 1: Single-line block (start+end on same line)\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/single-line.js" +DEST="$TMPDIR/single-line-filtered.js" +cat > "$SRC" <<'EOF' +const a = 1; +/* simplify-ignore-start */ const secret = 42; /* simplify-ignore-end */ +const b = 2; +EOF + +FID="test_single" +filter_file "$SRC" "$DEST" "$FID" + +placeholder_count=$(grep -c 'BLOCK_' "$DEST") +assert_eq "exactly one placeholder line" "1" "$placeholder_count" +assert_eq "line before block preserved" "1" "$(grep -c 'const a = 1' "$DEST")" +assert_eq "line after block preserved" "1" "$(grep -c 'const b = 2' "$DEST")" + +block_files=$(ls "$CACHE/${FID}".block.* 2>/dev/null | wc -l | tr -d ' ') +assert_eq "one block file in cache" "1" "$block_files" + +block_content=$(cat "$CACHE/${FID}".block.*) +assert_eq "block content matches" \ + "/* simplify-ignore-start */ const secret = 42; /* simplify-ignore-end */" \ + "$block_content" + +# ── Test 2: Multi-line block ───────────────────────────────────────────── +printf '\nTest 2: Multi-line block\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/multi-line.js" +DEST="$TMPDIR/multi-line-filtered.js" +cat > "$SRC" <<'EOF' +const a = 1; +// simplify-ignore-start +const secret1 = 42; +const secret2 = 99; +// simplify-ignore-end +const b = 2; +EOF + +FID="test_multi" +filter_file "$SRC" "$DEST" "$FID" + +placeholder_count=$(grep -c 'BLOCK_' "$DEST") +assert_eq "exactly one placeholder for multi-line block" "1" "$placeholder_count" + +output_lines=$(wc -l < "$DEST" | tr -d ' ') +assert_eq "output has 3 lines (before + placeholder + after)" "3" "$output_lines" + +# ── Test 3: Multiple blocks in one file ────────────────────────────────── +printf '\nTest 3: Multiple blocks in one file\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/multi-block.js" +DEST="$TMPDIR/multi-block-filtered.js" +cat > "$SRC" <<'EOF' +line1 +// simplify-ignore-start +blockA +// simplify-ignore-end +line2 +// simplify-ignore-start +blockB +// simplify-ignore-end +line3 +EOF + +FID="test_multiblock" +filter_file "$SRC" "$DEST" "$FID" + +placeholder_count=$(grep -c 'BLOCK_' "$DEST") +assert_eq "two placeholders for two blocks" "2" "$placeholder_count" + +block_files=$(ls "$CACHE/${FID}".block.* 2>/dev/null | wc -l | tr -d ' ') +assert_eq "two block files in cache" "2" "$block_files" + +# ── Test 4: Reason string preserved ────────────────────────────────────── +printf '\nTest 4: Reason string in placeholder\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/reason.js" +DEST="$TMPDIR/reason-filtered.js" +cat > "$SRC" <<'EOF' +// simplify-ignore-start: perf-critical +hot_loop(); +// simplify-ignore-end +EOF + +FID="test_reason" +filter_file "$SRC" "$DEST" "$FID" + +assert_eq "placeholder includes reason" "1" "$(grep -c 'perf-critical' "$DEST")" + +reason_files=$(ls "$CACHE/${FID}".reason.* 2>/dev/null | wc -l | tr -d ' ') +assert_eq "reason file saved" "1" "$reason_files" +assert_eq "reason content" "perf-critical" "$(cat "$CACHE/${FID}".reason.*)" + +# ── Test 5: Trailing newline preservation ──────────────────────────────── +printf '\nTest 5: Trailing newline preservation\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/no-trailing-nl.js" +DEST="$TMPDIR/no-trailing-nl-filtered.js" +printf 'line1\n// simplify-ignore-start\nsecret\n// simplify-ignore-end' > "$SRC" + +FID="test_trail" +filter_file "$SRC" "$DEST" "$FID" + +# Source has no trailing newline; dest should also have no trailing newline +src_has_nl=$(tail -c 1 "$SRC" | wc -l | tr -d ' ') +dest_has_nl=$(tail -c 1 "$DEST" | wc -l | tr -d ' ') +assert_eq "dest preserves no-trailing-newline from source" "$src_has_nl" "$dest_has_nl" + +# ── Test 6: No blocks → return 1 ──────────────────────────────────────── +printf '\nTest 6: No blocks returns 1\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/no-blocks.js" +DEST="$TMPDIR/no-blocks-filtered.js" +cat > "$SRC" <<'EOF' +const a = 1; +const b = 2; +EOF + +FID="test_noblocks" +rc=0 +filter_file "$SRC" "$DEST" "$FID" || rc=$? +assert_eq "returns 1 when no blocks found" "1" "$rc" + +# ── Test 7: Unclosed block emits warning and flushes ───────────────────── +printf '\nTest 7: Unclosed block\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/unclosed.js" +DEST="$TMPDIR/unclosed-filtered.js" +cat > "$SRC" <<'EOF' +line1 +// simplify-ignore-start +orphan code +EOF + +FID="test_unclosed" +stderr_out=$(filter_file "$SRC" "$DEST" "$FID" 2>&1) || true +assert_eq "warning emitted for unclosed block" "1" "$(printf '%s' "$stderr_out" | grep -c 'unclosed')" +assert_eq "orphan code flushed to output" "1" "$(grep -c 'orphan code' "$DEST")" + +# ── Test 8: Single-line block with reason ──────────────────────────────── +printf '\nTest 8: Single-line block with reason\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/single-reason.js" +DEST="$TMPDIR/single-reason-filtered.js" +cat > "$SRC" <<'EOF' +before +/* simplify-ignore-start: hot-path */ x = compute(); /* simplify-ignore-end */ +after +EOF + +FID="test_single_reason" +filter_file "$SRC" "$DEST" "$FID" + +placeholder_count=$(grep -c 'BLOCK_' "$DEST") +assert_eq "exactly one placeholder for single-line+reason" "1" "$placeholder_count" +assert_eq "reason in placeholder" "1" "$(grep -c 'hot-path' "$DEST")" + +# ── Test 9: HTML comment syntax ────────────────────────────────────────── +printf '\nTest 9: HTML comment syntax\n' +rm -f "$CACHE"/* + +SRC="$TMPDIR/html.html" +DEST="$TMPDIR/html-filtered.html" +cat > "$SRC" <<'EOF' +
+ + + +
+EOF + +FID="test_html" +filter_file "$SRC" "$DEST" "$FID" + +placeholder_count=$(grep -c 'BLOCK_' "$DEST") +assert_eq "HTML block replaced" "1" "$placeholder_count" +assert_eq "HTML suffix preserved" "1" "$(grep -c '\-\->' "$DEST")" + +# ── Test 10: JSON parsing error warning ────────────────────────────────── +printf '\nTest 10: Malformed JSON input produces warning\n' + +warning_out=$(echo 'NOT_JSON{{{' | bash "$SCRIPT_DIR/simplify-ignore.sh" 2>&1) || true +assert_eq "warning on bad JSON" "1" "$(printf '%s' "$warning_out" | grep -c 'Warning.*failed to parse')" + +# ── Summary ────────────────────────────────────────────────────────────── +printf '\n══════════════════════════════════════════\n' +printf 'Results: %d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/scripts/hooks/simplify-ignore.sh b/scripts/hooks/simplify-ignore.sh new file mode 100755 index 0000000..a93c467 --- /dev/null +++ b/scripts/hooks/simplify-ignore.sh @@ -0,0 +1,302 @@ +#!/bin/bash +# simplify-ignore.sh — Hook for Read (PreToolUse), Edit|Write (PostToolUse), Stop +# +# PreToolUse Read → backs up file, replaces blocks with BLOCK_ in-place +# PostToolUse Edit → expands placeholders, re-filters so file stays hidden +# PostToolUse Write → expands placeholders, re-filters so file stays hidden +# Stop → restores real file content from backup +# +# The file on disk ALWAYS has placeholders while the session is active. +# The real content (with model's changes applied) lives in the backup. +# +# Dependencies: jq, shasum or sha1sum (auto-detected) + +set -euo pipefail + +if ! command -v jq >/dev/null 2>&1; then + printf '%s\n' "error: missing jq" >&2; exit 1 +fi + +CACHE="${CLAUDE_PROJECT_DIR:-.}/.claude/.simplify-ignore-cache" +if [ -t 0 ]; then INPUT="{}"; else INPUT=$(cat); fi + +# Parse hook input — trap errors explicitly so set -e doesn't cause +# a silent exit on malformed JSON, and surface a useful diagnostic. +parse_error="" +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) || { + parse_error="failed to parse .tool_name from hook input" + TOOL_NAME="" +} +FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || { + parse_error="failed to parse .tool_input.file_path from hook input" + FILE_PATH="" +} +if [ -n "$parse_error" ]; then + printf 'Warning: %s (input: %.120s)\n' "$parse_error" "$INPUT" >&2 +fi + +hash_cmd() { + if command -v shasum >/dev/null 2>&1; then shasum + elif command -v sha1sum >/dev/null 2>&1; then sha1sum + else printf '%s\n' "error: missing shasum or sha1sum" >&2; exit 1; fi +} +file_id() { printf '%s' "$1" | hash_cmd | cut -c1-16; } +block_hash() { printf '%s' "$1" | hash_cmd | cut -c1-8; } +# Escape glob metacharacters so ${var/pattern/repl} treats pattern as literal. +# Needed for Bash 3.2 (macOS) where quotes don't suppress globbing in PE patterns. +escape_glob() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\*/\\*}" + s="${s//\?/\\?}" + s="${s//\[/\\[}" + printf '%s' "$s" +} + +# ── filter_file: replace simplify-ignore blocks with BLOCK_ placeholders ─ +# Reads $1 (source), writes filtered version to $2 (dest), saves blocks to cache. +# Returns 0 if blocks were found, 1 if none. +filter_file() { + local src="$1" dest="$2" fid="$3" + : > "$dest" + rm -f "$CACHE/${fid}".block.* "$CACHE/${fid}".reason.* "$CACHE/${fid}".prefix.* "$CACHE/${fid}".suffix.* + + local count=0 in_block=0 buf="" reason="" prefix="" suffix="" + + while IFS= read -r line || [ -n "$line" ]; do + # Check for start marker (no fork — uses bash case) + if [ $in_block -eq 0 ]; then + case "$line" in *simplify-ignore-start*) + in_block=1 + buf="$line" + # Extract comment prefix/suffix to preserve language-appropriate syntax + prefix="${line%%simplify-ignore-start*}" + suffix="" + case "$line" in *'*/'*) suffix=" */" ;; *'-->'*) suffix=" -->" ;; esac + reason=$(printf '%s' "$line" | sed -n 's/.*simplify-ignore-start:[[:space:]]*//p' \ + | sed 's/[[:space:]]*\*\/.*$//' | sed 's/[[:space:]]*-->.*$//' | sed 's/[[:space:]]*$//') + # Handle single-line block (start + end on same line) + case "$line" in *simplify-ignore-end*) + in_block=0 + # Write single-line block immediately and skip to next line + # to avoid the end-marker check below firing again + local h; h=$(block_hash "$buf") + count=$((count + 1)) + printf '%s' "$buf" > "$CACHE/${fid}.block.${h}" + [ -n "$reason" ] && printf '%s' "$reason" > "$CACHE/${fid}.reason.${h}" + printf '%s' "$prefix" > "$CACHE/${fid}.prefix.${h}" + printf '%s' "$suffix" > "$CACHE/${fid}.suffix.${h}" + if [ -n "$reason" ]; then + printf '%s\n' "${prefix}BLOCK_${h}: ${reason}${suffix}" >> "$dest" + else + printf '%s\n' "${prefix}BLOCK_${h}${suffix}" >> "$dest" + fi + buf=""; reason=""; prefix=""; suffix="" + continue + ;; *) + continue + ;; + esac + ;; esac + fi + # Accumulate block content + if [ $in_block -eq 1 ]; then + buf="${buf} +${line}" + fi + # Check for end marker + case "$line" in *simplify-ignore-end*) + if [ $in_block -eq 1 ]; then + local h; h=$(block_hash "$buf") + count=$((count + 1)) + printf '%s' "$buf" > "$CACHE/${fid}.block.${h}" + [ -n "$reason" ] && printf '%s' "$reason" > "$CACHE/${fid}.reason.${h}" + printf '%s' "$prefix" > "$CACHE/${fid}.prefix.${h}" + printf '%s' "$suffix" > "$CACHE/${fid}.suffix.${h}" + if [ -n "$reason" ]; then + printf '%s\n' "${prefix}BLOCK_${h}: ${reason}${suffix}" >> "$dest" + else + printf '%s\n' "${prefix}BLOCK_${h}${suffix}" >> "$dest" + fi + in_block=0; buf=""; reason=""; prefix=""; suffix="" + continue + fi + ;; + esac + [ $in_block -eq 0 ] && printf '%s\n' "$line" >> "$dest" + done < "$src" + + # Unclosed block → flush as-is + if [ $in_block -eq 1 ] && [ -n "$buf" ]; then + printf 'Warning: unclosed simplify-ignore-start in %s (block not hidden)\n' "$src" >&2 + printf '%s\n' "$buf" >> "$dest" + fi + + # Preserve trailing newline status of source + if [ -s "$dest" ] && [ -s "$src" ] && [ -n "$(tail -c 1 "$src")" ]; then + perl -pe 'chomp if eof' "$dest" > "${dest}.nnl" && \ + cat "${dest}.nnl" > "$dest" && rm -f "${dest}.nnl" + fi + + [ $count -gt 0 ] && return 0 || return 1 +} + +# ── Stop: restore all files from backup ─────────────────────────────────────── +if [ -z "$TOOL_NAME" ]; then + [ -d "$CACHE" ] || exit 0 + for bak in "$CACHE"/*.bak; do + [ -f "$bak" ] || continue + fid="${bak##*/}"; fid="${fid%.bak}" + pathfile="$CACHE/${fid}.path" + [ -f "$pathfile" ] || { rm -f "$bak"; continue; } + orig=$(cat "$pathfile") + if [ -f "$orig" ]; then + cat "$bak" > "$orig" + rm -f "$bak" "$pathfile" "$CACHE/${fid}".block.* "$CACHE/${fid}".reason.* "$CACHE/${fid}".prefix.* "$CACHE/${fid}".suffix.* + rmdir "$CACHE/${fid}.lock" 2>/dev/null + else + # File was moved/deleted — save backup as .recovered, don't destroy it + mkdir -p "$(dirname "${orig}.recovered")" + mv "$bak" "${orig}.recovered" + rm -f "$pathfile" "$CACHE/${fid}".block.* "$CACHE/${fid}".reason.* "$CACHE/${fid}".prefix.* "$CACHE/${fid}".suffix.* + rmdir "$CACHE/${fid}.lock" 2>/dev/null + printf 'Warning: %s was moved/deleted. Recovered original to %s.recovered\n' "$orig" "$orig" >&2 + fi + done + # Clean orphan locks (created but crash before backup) + for lockdir in "$CACHE"/*.lock; do + [ -d "$lockdir" ] || continue + rmdir "$lockdir" 2>/dev/null + done + exit 0 +fi + +[ -z "$FILE_PATH" ] && exit 0 + +# ── PreToolUse Read: filter in-place ────────────────────────────────────────── +if [ "$TOOL_NAME" = "Read" ]; then + [ -f "$FILE_PATH" ] || exit 0 + case "$(basename "$FILE_PATH")" in simplify-ignore*|SIMPLIFY-IGNORE*) exit 0 ;; esac + + mkdir -p "$CACHE" + ID=$(file_id "$FILE_PATH") + + # If backup exists, file is already filtered — skip + [ -f "$CACHE/${ID}.bak" ] && exit 0 + + grep -q 'simplify-ignore-start' -- "$FILE_PATH" || exit 0 + + # Atomic lock: mkdir fails if another session races us + if ! mkdir "$CACHE/${ID}.lock" 2>/dev/null; then + # Lock exists — reclaim only if stale (>60s old, no backup = crash leftover) + if [ ! -f "$CACHE/${ID}.bak" ] && \ + [ -n "$(find "$CACHE/${ID}.lock" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then + rmdir "$CACHE/${ID}.lock" 2>/dev/null || true + mkdir "$CACHE/${ID}.lock" 2>/dev/null || exit 0 + else + exit 0 + fi + fi + + # Back up the original (preserve trailing newline status) + cp -p "$FILE_PATH" "$CACHE/${ID}.bak" 2>/dev/null || cp "$FILE_PATH" "$CACHE/${ID}.bak" + printf '%s' "$FILE_PATH" > "$CACHE/${ID}.path" + + # Filter in-place (cat > preserves inode and permissions) + FILTERED="$CACHE/${ID}.$$.tmp" + rm -f "$FILTERED" + if filter_file "$FILE_PATH" "$FILTERED" "$ID"; then + cat "$FILTERED" > "$FILE_PATH" + rm -f "$FILTERED" + else + rm -f "$FILTERED" "$CACHE/${ID}.bak" "$CACHE/${ID}.path" + rmdir "$CACHE/${ID}.lock" 2>/dev/null + fi + exit 0 +fi + +# ── PostToolUse Edit|Write: expand, then re-filter ──────────────────────────── +if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then + ID=$(file_id "$FILE_PATH") + [ -f "$CACHE/${ID}.bak" ] || exit 0 + ls "$CACHE/${ID}".block.* >/dev/null 2>&1 || exit 0 + + # Expand placeholders, preserving any inline code the model added around them + EXPANDED="$CACHE/${ID}.$$.expanded" + rm -f "$EXPANDED" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in *BLOCK_*) + # Expand all placeholders on this line (supports multiple per line) + for bf in "$CACHE/${ID}".block.*; do + [ -f "$bf" ] || continue + h="${bf##*.}" + case "$line" in *"BLOCK_${h}"*) + # Reconstruct the exact placeholder pattern + bp=""; bs=""; br="" + [ -f "$CACHE/${ID}.prefix.${h}" ] && bp=$(cat "$CACHE/${ID}.prefix.${h}") + [ -f "$CACHE/${ID}.suffix.${h}" ] && bs=$(cat "$CACHE/${ID}.suffix.${h}") + [ -f "$CACHE/${ID}.reason.${h}" ] && br=$(cat "$CACHE/${ID}.reason.${h}") + if [ -n "$br" ]; then + placeholder="${bp}BLOCK_${h}: ${br}${bs}" + else + placeholder="${bp}BLOCK_${h}${bs}" + fi + block_content=$(cat "$bf"; printf x); block_content="${block_content%x}" + # Escape glob metacharacters (* ? [ \) in the pattern + esc_placeholder=$(escape_glob "$placeholder") + # Bash native substitution (// = global replace): replace placeholder, keep surrounding code + line="${line//$esc_placeholder/$block_content}" + # Fallback: if model altered the reason text, try without reason + # (only trigger if BLOCK_hash is still present AND wasn't in the original block content) + case "$block_content" in *"BLOCK_${h}"*) ;; *) + case "$line" in *"BLOCK_${h}"*) + printf 'Warning: placeholder BLOCK_%s was modified by model, using fuzzy match\n' "$h" >&2 + esc_fuzzy=$(escape_glob "${bp}BLOCK_${h}${bs}") + line="${line//$esc_fuzzy/$block_content}" + # Last resort: match just the hash token + case "$line" in *"BLOCK_${h}"*) + line="${line//BLOCK_${h}/$block_content}" + ;; esac + ;; esac + ;; esac + ;; esac + done + ;; esac + printf '%s\n' "$line" >> "$EXPANDED" + done < "$FILE_PATH" + # Preserve trailing newline status + if [ -s "$EXPANDED" ] && [ -s "$FILE_PATH" ] && [ -n "$(tail -c 1 "$FILE_PATH")" ]; then + perl -pe 'chomp if eof' "$EXPANDED" > "${EXPANDED}.nnl" && \ + cat "${EXPANDED}.nnl" > "$EXPANDED" && rm -f "${EXPANDED}.nnl" + fi + # Warn if model deleted a protected block entirely + for bf in "$CACHE/${ID}".block.*; do + [ -f "$bf" ] || continue + bh="${bf##*.}" + # After expansion, blocks appear as original code (simplify-ignore-start). + # If neither the expanded code nor the placeholder is in EXPANDED, it was deleted. + if ! grep -qF "BLOCK_${bh}" "$EXPANDED" 2>/dev/null; then + # Get first line of block to check if it was expanded back + first_line=$(head -1 "$bf") + if ! grep -qF "$first_line" "$EXPANDED" 2>/dev/null; then + printf 'Warning: protected block BLOCK_%s was deleted by model\n' "$bh" >&2 + fi + fi + done + # Preserve inode and permissions + cat "$EXPANDED" > "$FILE_PATH" + rm -f "$EXPANDED" + + # Save expanded version as new backup (this is the "real" file with model's changes) + cp "$FILE_PATH" "$CACHE/${ID}.bak" + + # Re-filter in-place so the file on disk stays with placeholders + FILTERED="$CACHE/${ID}.$$.tmp" + rm -f "$FILTERED" + if filter_file "$FILE_PATH" "$FILTERED" "$ID"; then + cat "$FILTERED" > "$FILE_PATH" + rm -f "$FILTERED" + fi + + exit 0 +fi diff --git a/tests/test_git_hook_scripts.py b/tests/test_git_hook_scripts.py index f08dedb..74e3b55 100644 --- a/tests/test_git_hook_scripts.py +++ b/tests/test_git_hook_scripts.py @@ -8,16 +8,24 @@ def test_git_hooks_delegate_to_shared_scripts_with_repo_root_fallback() -> None: pre_commit = (ROOT / ".githooks" / "pre-commit").read_text(encoding="utf-8") pre_push = (ROOT / ".githooks" / "pre-push").read_text(encoding="utf-8") - shared_pre_commit = (ROOT / "scripts" / "git-hooks" / "pre-commit.sh").read_text( + compatibility_pre_commit = (ROOT / "scripts" / "git-hooks" / "pre-commit.sh").read_text( encoding="utf-8" ) - shared_pre_push = (ROOT / "scripts" / "git-hooks" / "pre-push.sh").read_text(encoding="utf-8") + compatibility_pre_push = (ROOT / "scripts" / "git-hooks" / "pre-push.sh").read_text( + encoding="utf-8" + ) + shared_pre_commit = (ROOT / "scripts" / "hooks" / "pre-commit.sh").read_text(encoding="utf-8") + shared_pre_push = (ROOT / "scripts" / "hooks" / "pre-push.sh").read_text(encoding="utf-8") - assert "scripts/git-hooks/pre-commit.sh" in pre_commit - assert "scripts/git-hooks/pre-push.sh" in pre_push + assert "scripts/hooks/pre-commit.sh" in pre_commit + assert "scripts/hooks/pre-push.sh" in pre_push + assert "scripts/hooks/pre-commit.sh" in compatibility_pre_commit + assert "scripts/hooks/pre-push.sh" in compatibility_pre_push assert "git rev-parse --show-toplevel 2>/dev/null || true" in pre_commit assert "git rev-parse --show-toplevel 2>/dev/null || true" in pre_push - assert 'cd "$repo_root"' in shared_pre_commit - assert 'cd "$repo_root"' in shared_pre_push + assert "common.sh" in shared_pre_commit + assert "common.sh" in shared_pre_push + assert 'cd "$REPO_ROOT"' in shared_pre_commit + assert 'cd "$REPO_ROOT"' in shared_pre_push assert "uv run ruff check --fix" in shared_pre_commit assert "uv run pytest --ignore=tests/test_mlx_runtime.py" in shared_pre_push diff --git a/tests/test_graphify_auto_refresh.py b/tests/test_graphify_auto_refresh.py index b2a5be4..5ee1c18 100644 --- a/tests/test_graphify_auto_refresh.py +++ b/tests/test_graphify_auto_refresh.py @@ -62,6 +62,8 @@ def _commit_all(repo: Path, message: str = "seed") -> None: def _copy_graphify_runtime(repo: Path) -> None: for relative in ( ".harness/hooks/graphify-auto-refresh.sh", + "scripts/hooks/common.sh", + "scripts/hooks/graphify-auto-refresh.sh", "scripts/graphify_code_refresh.sh", "scripts/graphify_prepare_corpus.sh", "scripts/graphify_full_refresh.py", diff --git a/tests/test_hook_runtime_layout.py b/tests/test_hook_runtime_layout.py new file mode 100644 index 0000000..41d53de --- /dev/null +++ b/tests/test_hook_runtime_layout.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_session_start_wrapper_delegates_to_canonical_script() -> None: + wrapper = (ROOT / ".harness" / "hooks" / "session-start.sh").read_text(encoding="utf-8") + canonical = (ROOT / "scripts" / "hooks" / "session-start.sh").read_text(encoding="utf-8") + + assert "scripts/hooks/session-start.sh" in wrapper + assert "graphify-mode-note.sh" in canonical + assert "BUILD_INFO.json" in canonical + + +def test_graphify_mode_wrapper_delegates_to_canonical_script() -> None: + wrapper = (ROOT / ".harness" / "hooks" / "graphify-mode-note.sh").read_text(encoding="utf-8") + canonical = (ROOT / "scripts" / "hooks" / "graphify-mode-note.sh").read_text(encoding="utf-8") + + assert "scripts/hooks/graphify-mode-note.sh" in wrapper + assert "BUILD_INFO.json" in canonical + assert "full_refresh" in canonical + assert "raw/" in canonical + + +def test_claude_safety_wrappers_delegate_to_canonical_scripts() -> None: + check_secrets_wrapper = (ROOT / ".harness" / "hooks" / "check-secrets.sh").read_text( + encoding="utf-8" + ) + stop_checks_wrapper = (ROOT / ".harness" / "hooks" / "claude-stop-checks.sh").read_text( + encoding="utf-8" + ) + check_secrets = (ROOT / "scripts" / "hooks" / "check-secrets.sh").read_text(encoding="utf-8") + stop_checks = (ROOT / "scripts" / "hooks" / "claude-stop-checks.sh").read_text(encoding="utf-8") + + assert "scripts/hooks/check-secrets.sh" in check_secrets_wrapper + assert "scripts/hooks/claude-stop-checks.sh" in stop_checks_wrapper + assert "common.sh" in check_secrets + assert "common.sh" in stop_checks + assert "WARN: staged diff may contain credentials" in check_secrets + assert "uv run mypy src" in stop_checks From 41ebaeeed745e6ab75f77e1fc67786afd774b6cb Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Apr 2026 15:03:53 +0900 Subject: [PATCH 3/3] test: align hook wrapper expectations Update existing harness and execute tests to read canonical scripts for implementation assertions while keeping wrapper entrypoints under .harness/hooks. --- tests/test_execute_script.py | 8 ++++---- tests/test_graphify_harness.py | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_execute_script.py b/tests/test_execute_script.py index 0fd6f3a..9f6dd99 100644 --- a/tests/test_execute_script.py +++ b/tests/test_execute_script.py @@ -211,11 +211,11 @@ def test_claude_settings_enable_repo_specific_safety_hooks() -> None: def test_claude_stop_script_uses_repo_validation_chain() -> None: - script_path = ( - Path(__file__).resolve().parents[1] / ".harness" / "hooks" / "claude-stop-checks.sh" - ) - content = script_path.read_text(encoding="utf-8") + root = Path(__file__).resolve().parents[1] + wrapper = (root / ".harness" / "hooks" / "claude-stop-checks.sh").read_text(encoding="utf-8") + content = (root / "scripts" / "hooks" / "claude-stop-checks.sh").read_text(encoding="utf-8") + assert "scripts/hooks/claude-stop-checks.sh" in wrapper assert "uv run ruff check ." in content assert "uv run ruff format --check ." in content assert "uv run mypy src" in content diff --git a/tests/test_graphify_harness.py b/tests/test_graphify_harness.py index e1e49f9..e45ed9a 100644 --- a/tests/test_graphify_harness.py +++ b/tests/test_graphify_harness.py @@ -52,8 +52,10 @@ def test_claude_settings_prefer_graphify_before_raw_search() -> None: def test_session_start_mentions_graphify_priority() -> None: - content = (ROOT / ".harness" / "hooks" / "session-start.sh").read_text(encoding="utf-8") + wrapper = (ROOT / ".harness" / "hooks" / "session-start.sh").read_text(encoding="utf-8") + content = (ROOT / "scripts" / "hooks" / "session-start.sh").read_text(encoding="utf-8") + assert "scripts/hooks/session-start.sh" in wrapper assert "graphify-mode-note.sh" in content assert "BUILD_INFO.json" in content @@ -162,8 +164,10 @@ def test_graphify_full_wrapper_skill_exists() -> None: def test_graphify_mode_helper_mentions_full_refresh_policy() -> None: - content = (ROOT / ".harness" / "hooks" / "graphify-mode-note.sh").read_text(encoding="utf-8") + wrapper = (ROOT / ".harness" / "hooks" / "graphify-mode-note.sh").read_text(encoding="utf-8") + content = (ROOT / "scripts" / "hooks" / "graphify-mode-note.sh").read_text(encoding="utf-8") + assert "scripts/hooks/graphify-mode-note.sh" in wrapper assert "BUILD_INFO.json" in content assert "full_refresh" in content assert "raw/" in content