scripts/open_loop_scanner.py
7.8 KB| 1 | #!/usr/bin/env python3 |
| 2 | """open_loop_scanner.py — Inventory the open loops hiding in a directory. |
| 3 | |
| 4 | The GET CLEAR phase of a weekly review starts with evidence, not memory. This script scans a |
| 5 | directory for three kinds of open loop: |
| 6 | |
| 7 | 1. checkbox — unchecked markdown checkboxes: - [ ] / * [ ] |
| 8 | 2. todo_fixme — TODO / FIXME markers in text and source files |
| 9 | 3. stale_file — files not modified in --stale-days days (default 14) |
| 10 | |
| 11 | Output is an inventory grouped by kind, with counts and per-file locations, so the review walks |
| 12 | real loops instead of whatever the user happens to remember. |
| 13 | |
| 14 | Deterministic logic. No LLM calls, no network. Stdlib only. |
| 15 | |
| 16 | Usage: |
| 17 | python open_loop_scanner.py --dir ~/notes |
| 18 | python open_loop_scanner.py --dir . --stale-days 30 --json |
| 19 | python open_loop_scanner.py --sample |
| 20 | |
| 21 | Exit codes: |
| 22 | 0 scan complete (even if zero loops found) — also --sample / --help |
| 23 | 2 directory not found or not a directory |
| 24 | """ |
| 25 | |
| 26 | import argparse |
| 27 | import json |
| 28 | import os |
| 29 | import re |
| 30 | import sys |
| 31 | import time |
| 32 | from typing import Any, Dict, List |
| 33 | |
| 34 | SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", |
| 35 | "dist", "build", ".idea", ".vscode", ".cache"} |
| 36 | CHECKBOX_EXTS = {".md", ".markdown", ".txt"} |
| 37 | TEXT_EXTS = CHECKBOX_EXTS | {".py", ".js", ".ts", ".tsx", ".jsx", ".sh", ".rb", ".go", |
| 38 | ".rs", ".java", ".c", ".h", ".cpp", ".css", ".html", |
| 39 | ".yaml", ".yml", ".toml", ".ini", ".cfg", ".sql"} |
| 40 | |
| 41 | CHECKBOX_RE = re.compile(r"^\s*[-*]\s+\[ \]\s+(.*)$") |
| 42 | TODO_RE = re.compile(r"\b(TODO|FIXME)\b[:\s]*(.*)", re.IGNORECASE) |
| 43 | |
| 44 | |
| 45 | def _snippet(text: str, limit: int = 80) -> str: |
| 46 | text = text.strip() |
| 47 | return text if len(text) <= limit else text[: limit - 1] + "…" |
| 48 | |
| 49 | |
| 50 | def scan(directory: str, stale_days: int) -> Dict[str, Any]: |
| 51 | now = time.time() |
| 52 | stale_cutoff = now - stale_days * 86400 |
| 53 | checkboxes: List[Dict[str, Any]] = [] |
| 54 | todos: List[Dict[str, Any]] = [] |
| 55 | stale: List[Dict[str, Any]] = [] |
| 56 | files_scanned = 0 |
| 57 | |
| 58 | for root, dirs, files in os.walk(directory): |
| 59 | dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")) |
| 60 | for fname in sorted(files): |
| 61 | ext = os.path.splitext(fname)[1].lower() |
| 62 | if ext not in TEXT_EXTS: |
| 63 | continue |
| 64 | path = os.path.join(root, fname) |
| 65 | rel = os.path.relpath(path, directory) |
| 66 | files_scanned += 1 |
| 67 | |
| 68 | try: |
| 69 | mtime = os.path.getmtime(path) |
| 70 | if mtime < stale_cutoff: |
| 71 | stale.append({"file": rel, |
| 72 | "days_since_modified": int((now - mtime) // 86400)}) |
| 73 | with open(path, "r", encoding="utf-8", errors="replace") as f: |
| 74 | for lineno, line in enumerate(f, 1): |
| 75 | if ext in CHECKBOX_EXTS: |
| 76 | m = CHECKBOX_RE.match(line) |
| 77 | if m: |
| 78 | checkboxes.append({"file": rel, "line": lineno, |
| 79 | "text": _snippet(m.group(1))}) |
| 80 | m = TODO_RE.search(line) |
| 81 | if m: |
| 82 | todos.append({"file": rel, "line": lineno, |
| 83 | "marker": m.group(1).upper(), |
| 84 | "text": _snippet(m.group(2))}) |
| 85 | except OSError: |
| 86 | continue |
| 87 | |
| 88 | total = len(checkboxes) + len(todos) + len(stale) |
| 89 | return { |
| 90 | "directory": os.path.abspath(directory), |
| 91 | "stale_days_threshold": stale_days, |
| 92 | "files_scanned": files_scanned, |
| 93 | "total_open_loops": total, |
| 94 | "loops": { |
| 95 | "checkbox": {"count": len(checkboxes), "items": checkboxes}, |
| 96 | "todo_fixme": {"count": len(todos), "items": todos}, |
| 97 | "stale_file": {"count": len(stale), "items": stale}, |
| 98 | }, |
| 99 | } |
| 100 | |
| 101 | |
| 102 | SAMPLE_RESULT: Dict[str, Any] = { |
| 103 | "directory": "/home/sample/notes", |
| 104 | "stale_days_threshold": 14, |
| 105 | "files_scanned": 12, |
| 106 | "total_open_loops": 7, |
| 107 | "loops": { |
| 108 | "checkbox": { |
| 109 | "count": 3, |
| 110 | "items": [ |
| 111 | {"file": "projects/website-relaunch.md", "line": 14, |
| 112 | "text": "email designer about the hero image"}, |
| 113 | {"file": "projects/website-relaunch.md", "line": 15, |
| 114 | "text": "draft the pricing page copy"}, |
| 115 | {"file": "inbox.md", "line": 3, "text": "book dentist appointment"}, |
| 116 | ], |
| 117 | }, |
| 118 | "todo_fixme": { |
| 119 | "count": 2, |
| 120 | "items": [ |
| 121 | {"file": "scripts/backup.sh", "line": 22, "marker": "TODO", |
| 122 | "text": "rotate old archives after 90 days"}, |
| 123 | {"file": "notes/tax-prep.md", "line": 8, "marker": "FIXME", |
| 124 | "text": "the mileage total is wrong, recount Q2"}, |
| 125 | ], |
| 126 | }, |
| 127 | "stale_file": { |
| 128 | "count": 2, |
| 129 | "items": [ |
| 130 | {"file": "projects/learn-spanish.md", "days_since_modified": 41}, |
| 131 | {"file": "someday/write-a-novel.md", "days_since_modified": 63}, |
| 132 | ], |
| 133 | }, |
| 134 | }, |
| 135 | } |
| 136 | |
| 137 | KIND_LABEL = { |
| 138 | "checkbox": "Unchecked checkboxes (- [ ])", |
| 139 | "todo_fixme": "TODO / FIXME markers", |
| 140 | "stale_file": "Stale files (untouched past threshold)", |
| 141 | } |
| 142 | |
| 143 | |
| 144 | def render_human(r: Dict[str, Any]) -> str: |
| 145 | out = ["Open Loop Scanner (evidence first, memory second)", "=" * 64] |
| 146 | out.append(f" Directory: {r['directory']}") |
| 147 | out.append(f" Files scanned: {r['files_scanned']} " |
| 148 | f"Stale threshold: {r['stale_days_threshold']} days") |
| 149 | out.append(f" TOTAL OPEN LOOPS: {r['total_open_loops']}") |
| 150 | for kind in ("checkbox", "todo_fixme", "stale_file"): |
| 151 | bucket = r["loops"][kind] |
| 152 | out.append(f"\n {KIND_LABEL[kind]} — {bucket['count']}") |
| 153 | for item in bucket["items"][:25]: |
| 154 | if kind == "stale_file": |
| 155 | out.append(f" - {item['file']} ({item['days_since_modified']} days untouched)") |
| 156 | elif kind == "todo_fixme": |
| 157 | out.append(f" - {item['file']}:{item['line']} [{item['marker']}] {item['text']}") |
| 158 | else: |
| 159 | out.append(f" - {item['file']}:{item['line']} {item['text']}") |
| 160 | if bucket["count"] > 25: |
| 161 | out.append(f" … and {bucket['count'] - 25} more") |
| 162 | out.append("") |
| 163 | out.append(" Feed these into GET CLEAR: collect, clarify, and route every loop") |
| 164 | out.append(" to a list — next action, waiting-for, someday/maybe, or trash.") |
| 165 | return "\n".join(out) |
| 166 | |
| 167 | |
| 168 | def main(argv: List[str]) -> int: |
| 169 | p = argparse.ArgumentParser( |
| 170 | description="Scan a directory for open loops: unchecked checkboxes, TODO/FIXME, stale files.", |
| 171 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 172 | epilog=__doc__, |
| 173 | ) |
| 174 | p.add_argument("--dir", default=".", help="Directory to scan (default: current directory)") |
| 175 | p.add_argument("--stale-days", type=int, default=14, |
| 176 | help="Flag files not modified in this many days (default: 14)") |
| 177 | p.add_argument("--sample", action="store_true", |
| 178 | help="Print a canned deterministic scan result and exit 0") |
| 179 | p.add_argument("--json", action="store_true", help="Emit JSON instead of text") |
| 180 | args = p.parse_args(argv) |
| 181 | |
| 182 | if args.sample: |
| 183 | result = SAMPLE_RESULT |
| 184 | else: |
| 185 | if not os.path.isdir(args.dir): |
| 186 | print(f"error: not a directory: {args.dir}", file=sys.stderr) |
| 187 | return 2 |
| 188 | result = scan(args.dir, args.stale_days) |
| 189 | |
| 190 | if args.json: |
| 191 | print(json.dumps(result, indent=2)) |
| 192 | else: |
| 193 | print(render_human(result)) |
| 194 | return 0 |
| 195 | |
| 196 | |
| 197 | if __name__ == "__main__": |
| 198 | sys.exit(main(sys.argv[1:])) |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

