diff --git a/.claude/bootstrap.sh b/.claude/bootstrap.sh index 021f5270f3fb..078ddd1c22a2 100755 --- a/.claude/bootstrap.sh +++ b/.claude/bootstrap.sh @@ -4,7 +4,9 @@ # `test`. Keeps hook scripts and their tests as a self-contained component. source $(git rev-parse --show-toplevel)/ci3/source_bootstrap -hash=$(cache_content_hash ^.claude) +# Hash everything agents_symlink_test inspects, not just .claude/: the .codex mirrors and the root +# AGENTS.md/CLAUDE.md symlinks live outside .claude/, so a change there must still rerun the test. +hash=$(cache_content_hash "^.*.claude" "^.*.codex" "^AGENTS.md" "^CLAUDE.md") function test_cmds { # source_base cd's us into .claude/, so glob relative-to-here, but emit paths diff --git a/.claude/tests/agents_symlink_test b/.claude/tests/agents_symlink_test index 48a13ff77f16..4c3f2244bcf1 100755 --- a/.claude/tests/agents_symlink_test +++ b/.claude/tests/agents_symlink_test @@ -82,17 +82,43 @@ while IFS= read -r dir; do "$ROOT"/noir/noir-repo/*) continue;; esac codex="$(dirname "$dir")/.codex" - if [[ ! -L "$codex" ]]; then - fail "${codex#$ROOT/} is missing or is not a symlink to .claude" + rel=${codex#$ROOT/} + # A .codex may either be a symlink to its sibling .claude (the repo root keeps this form), or a + # real directory whose immediate children symlink into .claude (the per-package form, required + # because a sandbox cannot bind-mount a path that is itself a symlink). Either way Codex must see + # exactly the same contents as .claude. + if [[ -L "$codex" ]]; then + resolved=$(cd "$(dirname "$codex")" && cd "$(readlink "$codex")" 2>/dev/null && pwd -P) || resolved="" + claude_resolved=$(cd "$dir" && pwd -P) + if [[ "$resolved" != "$claude_resolved" ]]; then + fail "$rel is a symlink to ${resolved:-?}, expected its sibling .claude ($claude_resolved)" + else + pass "$rel -> .claude" + fi continue fi - resolved=$(cd "$(dirname "$codex")" && cd "$(readlink "$codex")" 2>/dev/null && pwd -P) || resolved="" - claude_resolved=$(cd "$dir" && pwd -P) - if [[ "$resolved" != "$claude_resolved" ]]; then - fail "${codex#$ROOT/} resolves to $resolved, expected $claude_resolved" + if [[ ! -d "$codex" ]]; then + fail "$rel is missing (expected a directory mirroring .claude via child symlinks)" continue fi - pass "${codex#$ROOT/}" + codex_ok=1 + # Forward: every entry in .claude must have a matching symlink in .codex. + while IFS= read -r child; do + name=$(basename "$child") + if [[ ! -L "$codex/$name" || ! "$codex/$name" -ef "$child" ]]; then + fail "$rel/$name should be a symlink to ../.claude/$name" + codex_ok=0 + fi + done < <(find "$dir" -mindepth 1 -maxdepth 1) + # Reverse: every entry in .codex must correspond to a .claude entry (no stale or dangling links). + while IFS= read -r entry; do + name=$(basename "$entry") + if [[ ! -e "$dir/$name" && ! -L "$dir/$name" ]]; then + fail "$rel/$name is stale; no matching .claude/$name" + codex_ok=0 + fi + done < <(find "$codex" -mindepth 1 -maxdepth 1) + (( codex_ok )) && pass "$rel" done < <(find "$ROOT" -type d -name .claude -not -path "$ROOT/noir/*" -not -path "$ROOT/**/node_modules/*") echo diff --git a/yarn-project/precommit.sh b/yarn-project/precommit.sh index 1f5bd7c8420a..4407f3045225 100755 --- a/yarn-project/precommit.sh +++ b/yarn-project/precommit.sh @@ -10,50 +10,6 @@ cd $(dirname $0) export FORCE_COLOR=true -# Verify every .codex directory mirrors its sibling .claude via child symlinks, so that adding a -# file or folder to a .claude config does not silently leave the sandboxed .codex path behind. -# Only immediate children are checked: a symlinked folder (e.g. .codex/skills) already covers its -# contents, and a .codex that is itself a symlink (the repo root) mirrors .claude inherently. -check_codex_symlinks() { - local repo_root claude_dirs claude_dir codex_dir path name - local -a errors=() - repo_root=$(git rev-parse --show-toplevel) - claude_dirs=$(cd "$repo_root" && git ls-files -- '.claude/*' '*/.claude/*' | sed -E 's#(.*/)?\.claude/.*#\1.claude#' | sort -u) - - for claude_dir in $claude_dirs; do - codex_dir="${claude_dir%.claude}.codex" - if [ -L "$repo_root/$codex_dir" ]; then - continue - fi - if [ ! -d "$repo_root/$codex_dir" ]; then - errors+=("missing directory $codex_dir (should mirror $claude_dir)") - continue - fi - while IFS= read -r path; do - name=$(basename "$path") - if [ ! -L "$repo_root/$codex_dir/$name" ] || [ ! "$repo_root/$codex_dir/$name" -ef "$path" ]; then - errors+=("$codex_dir/$name should be a symlink to ../.claude/$name") - fi - done < <(find "$repo_root/$claude_dir" -mindepth 1 -maxdepth 1) - while IFS= read -r path; do - name=$(basename "$path") - if [ ! -e "$repo_root/$claude_dir/$name" ] && [ ! -L "$repo_root/$claude_dir/$name" ]; then - errors+=("$codex_dir/$name is stale; no matching $claude_dir/$name") - fi - done < <(find "$repo_root/$codex_dir" -mindepth 1 -maxdepth 1) - done - - if (( ${#errors[@]} > 0 )); then - echo -e "\033[31mError:\033[0m .codex directories are out of sync with their .claude siblings:" - for e in "${errors[@]}"; do echo " - $e"; done - echo "Each entry under a .claude folder needs a sibling symlink in .codex, e.g.:" - echo " (cd /.codex && ln -s ../.claude/ && git add )" - return 1 - fi -} - -check_codex_symlinks - # Get all staged files (excluding deleted), relative to yarn-project staged_files=$(git diff-index --diff-filter=d --relative --cached --name-only HEAD)