#!/usr/bin/env python3 """Require a notes file when a file under ``advanced_search_system/`` is deleted. Any commit that deletes a ``.py`` file under ``src/local_deep_research/advanced_search_system/`` — or renames one out of that tree, which removes it from the tracked module — must also add or modify a ``.md`` file under ``docs/strategies/deleted/`` in the same commit. Rationale and template live in ``docs/strategies/deleted/README.md``. Exempt files whose deletion does not by itself remove a component: package ``__init__.py`` aggregators. The exemption list is intentionally narrow; if a new infra file is added (e.g. a base class in a new location), update ``EXEMPT_FILENAMES`` below. ``base_*.py`` files are NOT exempt — deleting a base class is a significant refactor that does warrant a notes file. """ import subprocess import sys from pathlib import PurePosixPath SCOPE_DIR = "src/local_deep_research/advanced_search_system/" DOCS_DIR = "docs/strategies/deleted/" EXEMPT_FILENAMES = {"__init__.py"} # Suggested notes-file name format. PR number isn't known at commit time # so we accept a placeholder the author fills in before pushing. SUGGESTED_FILENAME = "pr--.md" TEMPLATE = """\ # PR # Components deleted in PR #<n> (see that PR for the full pre-deletion code — this file only summarises what was novel). ## Component: `<ClassName>` - File deleted: `<path>` (<N> LOC at deletion). - Reachability: <e.g. "not in factory; only referenced by its own test">. - Closest reachable successor: `<ClassName>` (`<path>`, factory key `"<X>"`). ### Useful ideas from the pre-deletion version - **<short name>** — <1-2 sentences on what it did, why it was distinctive, whether it was validated>. - **<short name>** — <...>. ### Why deletion was safe <2-3 sentences mapping distinctive features to the successor, or flagging at-risk items and why losing them is acceptable.> ### Recovery path <1-2 sentences. Prefer "add a flag on the existing class" over "restore the deleted file".> """ def staged_changes(): """Yield ``(code, old_path, new_path)`` for each staged change. For add/modify/delete, ``old_path == new_path``. For rename (R) and copy (C), they differ; uniform unpacking lets callers use one code path. See ``git diff --name-status`` for the format. """ result = subprocess.run( ["git", "diff", "--cached", "--name-status"], capture_output=True, text=True, check=True, ) for line in result.stdout.splitlines(): if not line.strip(): continue parts = line.split("\t") if len(parts) < 2: continue code = parts[0][ 0 ] # A / M / D / R / C (R and C carry a similarity score suffix) if code in ("R", "C") and len(parts) >= 3: yield code, parts[1], parts[2] else: yield code, parts[1], parts[1] def _is_exempt(path: str) -> bool: # .lower() defends against case-insensitive filesystems (macOS, Windows). return PurePosixPath(path).name.lower() in EXEMPT_FILENAMES def main() -> int: deleted_files = [] doc_changes = [] for code, old_path, new_path in staged_changes(): # A rename out of SCOPE_DIR removes the component from the tracked # tree even though the file survives elsewhere — treat as deletion. # Renames within SCOPE_DIR are legitimate refactors; don't fire. is_deletion_from_scope = ( code == "D" and old_path.startswith(SCOPE_DIR) ) or ( code == "R" and old_path.startswith(SCOPE_DIR) and not new_path.startswith(SCOPE_DIR) ) if ( is_deletion_from_scope and old_path.endswith(".py") and not _is_exempt(old_path) ): deleted_files.append(old_path) if ( code in ("A", "M") and new_path.startswith(DOCS_DIR) and new_path.endswith(".md") and PurePosixPath(new_path).name.lower() != "readme.md" ): doc_changes.append(new_path) if deleted_files and not doc_changes: err = sys.stderr print( "\n \033[31madvanced_search_system deletion without documentation\033[0m", file=err, ) print(" " + "=" * 50, file=err) print( "\n You are deleting (or renaming out of scope) these files:", file=err, ) for path in deleted_files: print(f" - {path}", file=err) print( f"\n To unblock this commit, add a notes file at\n" f" {DOCS_DIR}{SUGGESTED_FILENAME}\n" f" (or extend an existing one under {DOCS_DIR}) with the following shape:\n", file=err, ) for line in TEMPLATE.splitlines(): print(f" {line}" if line else "", file=err) print( '\n Each bullet in "Useful ideas" should answer, in 1-2', file=err, ) print( " sentences:", file=err, ) print( " - what the component did that was different from the successor,", file=err, ) print( " - why that difference was interesting (heuristic, tuning, prompt", file=err, ) print( " trick, interface gap),", file=err, ) print( " - and whether it was validated or exploratory.", file=err, ) print( "\n Do NOT paste verbatim prompts, docstrings, or code blocks —", file=err, ) print( " git already stores them via the deletion PR/commit. Link, don't", file=err, ) print( " duplicate.", file=err, ) print( f"\n Full convention + rationale: {DOCS_DIR}README.md\n", file=err, ) return 1 return 0 if __name__ == "__main__": sys.exit(main())