Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions hooks/posttool-rename-sweep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
# hook-version: 1.0.0
"""
PostToolUse Hook: Post-Rename Reference Sweep

After a `git mv` command, scans .py/.json/.md/.yaml files for stale
references to the old filename stem and prints a warning listing them.

Design Principles:
- SILENT by default (only speaks when stale references are found)
- Non-blocking (informational only — always exits 0)
- Fast exit path for non-matching commands (<50ms requirement)

ADR: adr/129-post-rename-reference-sweep.md
"""

import json
import os
import re
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "lib"))
from stdin_timeout import read_stdin


def extract_git_mv_paths(command: str):
"""Extract (old_path, new_path) from a git mv command string.

Handles:
- Simple: git mv old new
- With flags: git mv -f old new, git mv --force old new
- Quoted: git mv "old path" "new path"
- Chained: git mv old new && git add .

Returns None if the pattern is not found.
"""
# Skip optional flags (-f, --force, -k, -n, -v, etc.) before positional args
# Then match either quoted or unquoted path arguments
# Unquoted paths stop at whitespace, semicolons, pipes, and ampersands
pattern = r"git\s+mv\s+(?:-\S+\s+)*(?:(['\"])(.+?)\1|([^\s;&|]+))\s+(?:(['\"])(.+?)\4|([^\s;&|]+))"
match = re.search(pattern, command)
if not match:
return None
# Groups: 1=old_quote, 2=old_quoted_path, 3=old_unquoted_path,
# 4=new_quote, 5=new_quoted_path, 6=new_unquoted_path
old_path = match.group(2) or match.group(3)
new_path = match.group(5) or match.group(6)
return old_path, new_path


def grep_for_stem(stem: str, search_root: str) -> list:
"""Run grep for stem across .py/.json/.md/.yaml files.

Returns a list of "file:line: text" strings (may be empty).
Excludes .git/ and __pycache__/.
"""
try:
result = subprocess.run(
[
"grep",
"-rnF",
"--include=*.py",
"--include=*.json",
"--include=*.md",
"--include=*.yaml",
"--include=*.yml",
"--exclude-dir=.git",
"--exclude-dir=__pycache__",
stem,
search_root,
],
capture_output=True,
text=True,
timeout=3,
)
if result.returncode not in (0, 1):
# returncode 1 means no matches (normal), >1 is an error
return []
lines = [l for l in result.stdout.splitlines() if l.strip()]
return lines
except subprocess.TimeoutExpired:
return []
except Exception:
return []


def main():
try:
raw = read_stdin(timeout=5)
if not raw:
return

try:
data = json.loads(raw)
except json.JSONDecodeError:
return

tool_name = data.get("tool_name", "")
if tool_name != "Bash":
return

# Skip failed git mv commands — no rename happened
tool_result = data.get("tool_result", {})
if tool_result.get("is_error", False):
return

tool_input = data.get("tool_input", {})
command = tool_input.get("command", "")

if "git mv" not in command:
return

paths = extract_git_mv_paths(command)
if not paths:
return

old_path, new_path = paths

# Extract stem: strip directory and extension
stem = Path(old_path).stem
if not stem or len(stem) < 3:
return

# Search from the repo root if possible, else cwd
search_root = os.getcwd()

matches = grep_for_stem(stem, search_root)
if not matches:
return

# Filter out matches in the renamed file itself (new_path)
new_path_abs = str(Path(new_path).resolve())
filtered = []
for line in matches:
match_file = line.split(":")[0]
try:
if Path(match_file).resolve() == Path(new_path_abs):
continue
except Exception:
pass
filtered.append(line)

if not filtered:
return

max_shown = 20
print(f'[rename-sweep] Stale references to "{stem}" found after git mv:')
for line in filtered[:max_shown]:
# Make path relative to search_root for readability
try:
rel = os.path.relpath(line.split(":")[0], search_root)
rest = ":".join(line.split(":")[1:])
print(f" {rel}:{rest}")
except Exception:
print(f" {line}")
if len(filtered) > max_shown:
print(f" ... and {len(filtered) - max_shown} more")
print("Consider updating these references before committing.")

except Exception as e:
if os.environ.get("CLAUDE_HOOKS_DEBUG"):
import traceback

print(f"[rename-sweep] HOOK-ERROR: {type(e).__name__}: {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
finally:
sys.exit(0)


if __name__ == "__main__":
main()
200 changes: 200 additions & 0 deletions hooks/tests/test_posttool_rename_sweep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Tests for the posttool-rename-sweep hook.

Run with: python3 -m pytest hooks/tests/test_posttool_rename_sweep.py -v
"""

import importlib.util
import json
import subprocess
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Import the module under test
# ---------------------------------------------------------------------------

HOOK_PATH = Path(__file__).parent.parent / "posttool-rename-sweep.py"

spec = importlib.util.spec_from_file_location("posttool_rename_sweep", HOOK_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def run_hook(event: dict) -> tuple[str, str, int]:
"""Run the hook with given event and return (stdout, stderr, exit_code)."""
result = subprocess.run(
[sys.executable, str(HOOK_PATH)],
input=json.dumps(event),
capture_output=True,
text=True,
timeout=10,
)
return result.stdout, result.stderr, result.returncode


# ---------------------------------------------------------------------------
# extract_git_mv_paths tests
# ---------------------------------------------------------------------------


class TestExtractGitMvPaths:
"""Test regex parsing of git mv commands."""

def test_simple(self):
assert mod.extract_git_mv_paths("git mv old.py new.py") == ("old.py", "new.py")

def test_with_directories(self):
assert mod.extract_git_mv_paths("git mv hooks/old.py hooks/new.py") == (
"hooks/old.py",
"hooks/new.py",
)

def test_chained_with_and(self):
assert mod.extract_git_mv_paths("git mv old.py new.py && git add .") == (
"old.py",
"new.py",
)

def test_chained_with_semicolon(self):
assert mod.extract_git_mv_paths("git mv old.py new.py; echo done") == (
"old.py",
"new.py",
)

def test_chained_with_pipe(self):
assert mod.extract_git_mv_paths("git mv old.py new.py | cat") == (
"old.py",
"new.py",
)

def test_flag_f(self):
assert mod.extract_git_mv_paths("git mv -f old.py new.py") == (
"old.py",
"new.py",
)

def test_flag_force(self):
assert mod.extract_git_mv_paths("git mv --force old.py new.py") == (
"old.py",
"new.py",
)

def test_double_quoted_paths(self):
assert mod.extract_git_mv_paths('git mv "old file.py" "new file.py"') == (
"old file.py",
"new file.py",
)

def test_single_quoted_paths(self):
assert mod.extract_git_mv_paths("git mv 'old file.py' 'new file.py'") == (
"old file.py",
"new file.py",
)

def test_no_git_mv(self):
assert mod.extract_git_mv_paths("echo hello") is None

def test_incomplete_git_mv(self):
assert mod.extract_git_mv_paths("git mv") is None

def test_git_mv_with_only_one_arg(self):
# git mv with only source, no dest — should not match
assert mod.extract_git_mv_paths("git mv old.py") is None


# ---------------------------------------------------------------------------
# Tool name and error filtering
# ---------------------------------------------------------------------------


class TestToolFiltering:
"""Test that the hook correctly filters by tool name and error state."""

def test_non_bash_tool_silent(self):
"""Non-Bash tools produce no output."""
event = {
"tool_name": "Edit",
"tool_input": {"command": "git mv old.py new.py"},
"tool_result": {},
}
stdout, stderr, rc = run_hook(event)
assert rc == 0
assert stdout == ""

def test_failed_git_mv_silent(self):
"""Failed git mv commands (is_error=True) produce no output."""
event = {
"tool_name": "Bash",
"tool_input": {"command": "git mv nonexistent.py dest.py"},
"tool_result": {"is_error": True},
}
stdout, stderr, rc = run_hook(event)
assert rc == 0
assert stdout == ""

def test_no_git_mv_in_command_silent(self):
"""Bash commands without git mv produce no output."""
event = {
"tool_name": "Bash",
"tool_input": {"command": "ls -la"},
"tool_result": {},
}
stdout, stderr, rc = run_hook(event)
assert rc == 0
assert stdout == ""

def test_short_stem_silent(self):
"""Stems under 3 characters are skipped to avoid noisy results."""
event = {
"tool_name": "Bash",
"tool_input": {"command": "git mv a.py b.py"},
"tool_result": {},
}
stdout, stderr, rc = run_hook(event)
assert rc == 0
assert stdout == ""


# ---------------------------------------------------------------------------
# Always exits 0
# ---------------------------------------------------------------------------


class TestExitCode:
"""Hook must always exit 0 (non-blocking)."""

def test_empty_stdin(self):
result = subprocess.run(
[sys.executable, str(HOOK_PATH)],
input="",
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0

def test_invalid_json(self):
result = subprocess.run(
[sys.executable, str(HOOK_PATH)],
input="not json",
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0

def test_valid_bash_no_git_mv(self):
event = {
"tool_name": "Bash",
"tool_input": {"command": "echo hello"},
"tool_result": {},
}
stdout, stderr, rc = run_hook(event)
assert rc == 0
15 changes: 14 additions & 1 deletion pipelines/agent-upgrade/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,20 @@ Proceed with implementation? (or specify which items to include/exclude)
**For peer inconsistencies**:
- Align to the majority pattern observed across peers. If peers themselves are inconsistent, align to the most recent or highest-scoring peer.

**Step 3**: Do NOT change any of the following without explicit user direction because domain logic changes require deliberate user decision, not opportunistic bundling:
**Step 3**: Run positive framing validation on the modified agent or skill.

After applying all approved improvements, invoke `joy-check --mode instruction` on the
target file. This ensures that new or modified content uses positive framing per ADR-127.
Positive framing produces instructions agents act on rather than rules they work around.

If joy-check flags lines in the NEW content (content that was just added/modified):
- Fix those lines as part of this same implementation pass
- Do not flag pre-existing content that was not part of the upgrade scope

If joy-check flags lines in EXISTING content (not modified by this upgrade):
- Note them in the upgrade report but do not fix them (out of scope for this upgrade)

**Step 4**: Do NOT change any of the following without explicit user direction because domain logic changes require deliberate user decision, not opportunistic bundling:
- Routing triggers (`triggers:` frontmatter field)
- Domain coverage statements
- Core methodology or phase structure (for skills)
Expand Down
Loading
Loading