@theglitchking/babel-fish 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,6 +98,10 @@ POST-INSTALL COMMANDS
98
98
  python .claude/project-map/mine-sessions.py --verbose
99
99
  python .claude/project-map/mine-sessions.py --dry-run
100
100
 
101
+ Check auto-loaded context (CLAUDE.md + .claude/rules/) — budget, pointers:
102
+ python .claude/project-map/context-check.py
103
+ python .claude/project-map/context-check.py --budget 80000 --map-style
104
+
101
105
  Re-install git hooks (if you cloned a fresh copy):
102
106
  bash .githooks/install.sh
103
107
 
@@ -121,7 +125,7 @@ KEY FILES AFTER INSTALL
121
125
  .claude/rules/project-vocabulary.md — Auto-loaded every session
122
126
  .claude/rules/operational-runbook.md — Edit manually to grow over time
123
127
  .claude/skills/<slug>-developer-skill/ — Your developer skill
124
- .githooks/pre-commit — Auto-regenerates map on commit
128
+ .githooks/pre-commit — Regenerates map; checks auto-loaded context
125
129
 
126
130
  GRADING CATEGORIES (90% to pass)
127
131
  Section completeness 25% — All 19 sections generated
@@ -378,17 +382,45 @@ if echo "$STAGED_FILES" | grep -qE "$EXTENSIONS_PATTERN" 2>/dev/null; then
378
382
  fi
379
383
  # ── End Codebase Mapper ──────────────────────────────────────────────────────'
380
384
 
381
- if [ -f "$pre_commit" ]; then
382
- if ! grep -q 'Codebase Mapper' "$pre_commit" 2>/dev/null; then
383
- { echo ""; echo "$hook_snippet"; } >> "$pre_commit"
384
- ok "Appended to existing pre-commit hook"
385
- else
386
- ok "pre-commit hook already contains Codebase Mapper snippet"
385
+ # Own marker, checked separately: hooks installed before #20 already carry
386
+ # the Codebase Mapper block, and re-running the installer must still add this.
387
+ local check_snippet
388
+ check_snippet=$(cat <<'SNIPPET'
389
+ # ── Context Check: auto-loaded instructions stay in budget, pointers resolve ──
390
+ if git diff --cached --name-only 2>/dev/null | grep -qE '^(\.claude/)?CLAUDE(\.local)?\.md$|^\.claude/rules/.*\.md$'; then
391
+ CHECK_SCRIPT=".claude/project-map/context-check.py"
392
+ if [ -f "$CHECK_SCRIPT" ]; then
393
+ PYTHON=""
394
+ if [ -f ".venv/bin/python3" ]; then PYTHON=".venv/bin/python3"
395
+ elif command -v python3 &>/dev/null; then PYTHON="python3"
396
+ elif command -v python &>/dev/null; then PYTHON="python"
387
397
  fi
388
- else
389
- printf '#!/bin/bash\n%s\n' "$hook_snippet" > "$pre_commit"
398
+ if [ -n "$PYTHON" ] && ! $PYTHON "$CHECK_SCRIPT"; then
399
+ echo "[context-check] Commit blocked. Fix the FAIL lines above, or add flags (--budget N, --map-style) to this call in .githooks/pre-commit." >&2
400
+ exit 1
401
+ fi
402
+ fi
403
+ fi
404
+ # ── End Context Check ────────────────────────────────────────────────────────
405
+ SNIPPET
406
+ )
407
+
408
+ if [ ! -f "$pre_commit" ]; then
409
+ printf '#!/bin/bash\n' > "$pre_commit"
390
410
  ok "Created pre-commit hook"
391
411
  fi
412
+ if ! grep -q 'Codebase Mapper' "$pre_commit" 2>/dev/null; then
413
+ { echo ""; echo "$hook_snippet"; } >> "$pre_commit"
414
+ ok "Added Codebase Mapper to pre-commit hook"
415
+ else
416
+ ok "pre-commit hook already contains Codebase Mapper snippet"
417
+ fi
418
+ if ! grep -q 'Context Check' "$pre_commit" 2>/dev/null; then
419
+ { echo ""; echo "$check_snippet"; } >> "$pre_commit"
420
+ ok "Added Context Check to pre-commit hook"
421
+ else
422
+ ok "pre-commit hook already contains Context Check snippet"
423
+ fi
392
424
 
393
425
  chmod +x "$pre_commit"
394
426
 
@@ -512,6 +544,7 @@ if [ "$PLUGIN_SOURCE_DIR" != "$CLAUDE_DIR" ]; then
512
544
  cp -r "$PLUGIN_SOURCE_DIR/project-map/generate.py" "$CLAUDE_DIR/project-map/" 2>/dev/null || true
513
545
  cp -r "$PLUGIN_SOURCE_DIR/project-map/grader.py" "$CLAUDE_DIR/project-map/" 2>/dev/null || true
514
546
  cp -r "$PLUGIN_SOURCE_DIR/project-map/mine-sessions.py" "$CLAUDE_DIR/project-map/" 2>/dev/null || true
547
+ cp -r "$PLUGIN_SOURCE_DIR/project-map/context-check.py" "$CLAUDE_DIR/project-map/" 2>/dev/null || true
515
548
  fi
516
549
 
517
550
  # ── Step 1: Ensure Python ─────────────────────────────────────────────────────
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ context-check.py — Babel Fish
4
+ Keeps the files Claude Code auto-loads at session start small and pointing at
5
+ things that exist (#20).
6
+
7
+ `CLAUDE.md` and every `.claude/rules/*.md` load into every session. Past the
8
+ harness limit (150k chars) rules stop binding and nothing reports it; before
9
+ that, growth is one paragraph at a time and nobody sees the total. Three checks:
10
+
11
+ 1. BUDGET — total chars under --budget (default 60k, well under the limit).
12
+ 2. POINTERS — every `→ `path``, backticked `.documentation/…md` path and
13
+ relative markdown link names a file that exists. hewtd link-checks
14
+ only INSIDE `.documentation/`, so archiving or renaming a doc a
15
+ rule points at breaks the pointer silently.
16
+ 3. RULE LINES (--map-style only) — in a rules file every bullet is
17
+ `- ALWAYS|NEVER <rule> — <why> → `doc``; in CLAUDE.md every
18
+ bullet with a pointer is. Opt-in: it is one repo's convention, and
19
+ this runs from the pre-commit hook of every babel-fish install.
20
+
21
+ Deliberately NOT part of generate.py: the hook runs generate.py only when code
22
+ is staged, `.claude/` is outside the watch set, and the hook discards its
23
+ errors — a failure there could never block a commit.
24
+
25
+ Usage:
26
+ python context-check.py [--budget N] [--map-style] [--descriptive a.md,b.md]
27
+ [--warn-only] [--project-root PATH]
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import re
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ PROJECT_ROOT = Path(__file__).parent.parent.parent
37
+
38
+ HARNESS_LIMIT = 150_000
39
+ DEFAULT_BUDGET = 60_000
40
+ # Maps whose lines describe (tools, traps, vocabulary) rather than rule — exempt
41
+ # from the ALWAYS/NEVER form. project-vocabulary.md is babel-fish's own output.
42
+ DEFAULT_DESCRIPTIVE = {"operational-runbook.md", "project-vocabulary.md", "tool-registry.md"}
43
+
44
+ ARROW_RE = re.compile(r"→ `([^`\s*]+?\.md)(?:#[^`]*)?`")
45
+ DOC_PATH_RE = re.compile(r"`(\.documentation/[^`\s*]+?\.md)(?:#[^`]*)?`")
46
+ MD_LINK_RE = re.compile(r"\]\(([^)\s]+)\)")
47
+ FENCE_RE = re.compile(r"^```.*?^```", re.MULTILINE | re.DOTALL)
48
+ RULE_RE = re.compile(r"- (ALWAYS|NEVER)\b")
49
+
50
+
51
+ def configure_paths(project_root: Path) -> None:
52
+ """Read root. Nothing here writes, but match the sibling scripts (#18)."""
53
+ global PROJECT_ROOT
54
+ PROJECT_ROOT = project_root
55
+
56
+
57
+ def loaded() -> dict[str, str]:
58
+ """-> {relpath: text} for every file Claude Code loads at session start."""
59
+ paths = [PROJECT_ROOT / n for n in ("CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md")]
60
+ # ponytail: rules with `paths:` frontmatter load lazily but are counted anyway —
61
+ # over-counting errs toward the budget, not past the limit.
62
+ paths += sorted((PROJECT_ROOT / ".claude" / "rules").rglob("*.md"))
63
+ return {p.relative_to(PROJECT_ROOT).as_posix(): p.read_text(encoding="utf-8", errors="replace")
64
+ for p in paths if p.is_file()}
65
+
66
+
67
+ def bullets(text: str) -> list[str]:
68
+ return [ln for ln in text.split("\n") if ln.startswith("- ")]
69
+
70
+
71
+ def bad_rule_lines(text: str) -> list[str]:
72
+ """-> bullets that are not `- ALWAYS|NEVER … → `path``."""
73
+ return [ln for ln in bullets(text) if not RULE_RE.match(ln) or not ARROW_RE.search(ln)]
74
+
75
+
76
+ def unresolved(text: str, file_dir: Path) -> list[str]:
77
+ """-> every pointer in `text` naming a path that does not exist.
78
+
79
+ `→` pointers and backticked `.documentation/` paths are root-relative (that is
80
+ how the map convention writes them); markdown links resolve relative to the file or
81
+ to the root. Code fences are skipped — examples are not pointers.
82
+ """
83
+ text = FENCE_RE.sub("", text)
84
+ missing = [p for p in set(ARROW_RE.findall(text)) | set(DOC_PATH_RE.findall(text))
85
+ if not (PROJECT_ROOT / p).exists()]
86
+ for link in MD_LINK_RE.findall(text):
87
+ target = link.split("#", 1)[0]
88
+ if not target or re.match(r"[a-z][a-z0-9+.-]*:", target, re.I): # anchor-only, http:, mailto:
89
+ continue
90
+ target = target.lstrip("/")
91
+ # File-relative is markdown; root-relative is how Claude reads a path, and
92
+ # what the installer's own .claude/CLAUDE.md pointer uses. Either counts.
93
+ if not ((file_dir / target).exists() or (PROJECT_ROOT / target).exists()):
94
+ missing.append(link)
95
+ return sorted(set(missing))
96
+
97
+
98
+ def run(budget: int, map_style: bool, descriptive: set[str]) -> int:
99
+ """Print the report; return the number of failed checks."""
100
+ fails = 0
101
+
102
+ def check(name: str, ok: bool, detail: str = "") -> None:
103
+ nonlocal fails
104
+ print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail and not ok else ""))
105
+ fails += not ok
106
+
107
+ files = loaded()
108
+ total = sum(len(t) for t in files.values())
109
+
110
+ print("\nthe budget")
111
+ check(f"auto-loaded context is under {budget:,} chars", total <= budget,
112
+ f"{total:,} -- move detail into .documentation/ and leave one line pointing at it")
113
+ for k, t in sorted(files.items(), key=lambda kv: -len(kv[1])):
114
+ print(f" {len(t):>7,} {k}")
115
+ print(f" {total:>7,} total ({total / HARNESS_LIMIT:.0%} of the {HARNESS_LIMIT:,} harness limit)")
116
+
117
+ print("\nevery pointer resolves")
118
+ for k, t in files.items():
119
+ miss = unresolved(t, (PROJECT_ROOT / k).parent)
120
+ check(f"{k}: all pointers exist", not miss, ", ".join(miss[:3]))
121
+
122
+ if map_style:
123
+ print("\nrule lines are ALWAYS/NEVER, with a pointer")
124
+ for k, t in files.items():
125
+ if Path(k).name in descriptive:
126
+ continue
127
+ if k.startswith(".claude/rules/"):
128
+ bad = bad_rule_lines(t)
129
+ check(f"{k}: every bullet is ALWAYS|NEVER → doc", not bad, bad[0][:80] if bad else "")
130
+ else: # CLAUDE.md: tool lines may go unpointed; a pointed bullet is a rule
131
+ bad = [ln for ln in bad_rule_lines(t) if "→" in ln]
132
+ check(f"{k}: every pointed bullet is ALWAYS|NEVER", not bad, bad[0][:80] if bad else "")
133
+
134
+ print(f"\n{'OK' if not fails else f'{fails} FAILED'}")
135
+ return fails
136
+
137
+
138
+ def main() -> None:
139
+ ap = argparse.ArgumentParser(description="Check the context Claude Code auto-loads (#20).")
140
+ ap.add_argument("--budget", type=int, default=DEFAULT_BUDGET,
141
+ help=f"max total chars (default {DEFAULT_BUDGET:,}; harness limit {HARNESS_LIMIT:,})")
142
+ ap.add_argument("--map-style", action="store_true",
143
+ help="also require rule bullets to be `- ALWAYS|NEVER … → `doc``")
144
+ ap.add_argument("--descriptive", default=",".join(sorted(DEFAULT_DESCRIPTIVE)),
145
+ help="comma-separated filenames exempt from --map-style (default: %(default)s)")
146
+ ap.add_argument("--warn-only", action="store_true", help="report, but always exit 0")
147
+ ap.add_argument("--project-root", type=Path, default=None)
148
+ args = ap.parse_args()
149
+ if args.project_root:
150
+ configure_paths(args.project_root.resolve())
151
+ fails = run(args.budget, args.map_style, {n.strip() for n in args.descriptive.split(",") if n.strip()})
152
+ sys.exit(1 if fails and not args.warn_only else 0)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for context-check.py — run: python3 .claude/project-map/test_context_check.py
3
+
4
+ Stdlib assert + __main__, same shape as the sibling suites. Every check is proven
5
+ to DETECT something (a seeded bad input) as well as to pass a good one: a check
6
+ that only ever prints PASS is indistinguishable from one that checks nothing.
7
+
8
+ Every fixture is a temp project passed via --project-root, so nothing here reads
9
+ or writes the real repo (#18).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ HERE = Path(__file__).parent
21
+ SCRIPT = HERE / "context-check.py"
22
+ REPO = HERE.parent.parent
23
+
24
+ GOOD_RULES = "# Rules\n\n- NEVER do the bad thing — it breaks. → `.documentation/a.md`\n"
25
+
26
+
27
+ def make_project(root: Path, claude_md: str = "# Project\n", rules: dict[str, str] | None = None) -> Path:
28
+ (root / ".documentation").mkdir(parents=True, exist_ok=True)
29
+ (root / ".documentation/a.md").write_text("# A\n")
30
+ (root / "CLAUDE.md").write_text(claude_md)
31
+ (root / ".claude/rules").mkdir(parents=True, exist_ok=True)
32
+ for name, text in (rules or {}).items():
33
+ (root / ".claude/rules" / name).write_text(text)
34
+ return root
35
+
36
+
37
+ def check(root: Path, *flags: str) -> subprocess.CompletedProcess:
38
+ return subprocess.run([sys.executable, str(SCRIPT), "--project-root", str(root), *flags],
39
+ capture_output=True, text=True)
40
+
41
+
42
+ # ── budget ───────────────────────────────────────────────────────────────────
43
+
44
+ def test_under_budget_passes(tmp):
45
+ r = check(make_project(tmp, rules={"r.md": GOOD_RULES}))
46
+ assert r.returncode == 0, r.stdout
47
+
48
+
49
+ def test_over_budget_fails_and_names_the_file(tmp):
50
+ make_project(tmp, rules={"huge.md": "x" * 2_000})
51
+ r = check(tmp, "--budget", "1000")
52
+ assert r.returncode == 1, r.stdout
53
+ assert "FAIL auto-loaded context is under 1,000" in r.stdout
54
+ assert ".claude/rules/huge.md" in r.stdout
55
+
56
+
57
+ def test_budget_counts_nested_rules_and_dot_claude_claude_md(tmp):
58
+ make_project(tmp)
59
+ (tmp / ".claude/rules/sub").mkdir()
60
+ (tmp / ".claude/rules/sub/deep.md").write_text("y" * 600)
61
+ (tmp / ".claude/CLAUDE.md").write_text("z" * 600)
62
+ r = check(tmp, "--budget", "1000")
63
+ assert r.returncode == 1, r.stdout
64
+ assert ".claude/rules/sub/deep.md" in r.stdout and ".claude/CLAUDE.md" in r.stdout
65
+
66
+
67
+ def test_warn_only_exits_zero(tmp):
68
+ r = check(make_project(tmp, rules={"huge.md": "x" * 2_000}), "--budget", "1000", "--warn-only")
69
+ assert r.returncode == 0 and "FAIL" in r.stdout, r.stdout
70
+
71
+
72
+ # ── pointers ─────────────────────────────────────────────────────────────────
73
+
74
+ def test_dangling_arrow_pointer_fails(tmp):
75
+ r = check(make_project(tmp, rules={"r.md": "- NEVER x — y. → `.documentation/gone.md`\n"}))
76
+ assert r.returncode == 1 and ".documentation/gone.md" in r.stdout, r.stdout
77
+
78
+
79
+ def test_dangling_backticked_doc_path_fails(tmp):
80
+ r = check(make_project(tmp, claude_md="See `.documentation/missing/doc.md` for more.\n"))
81
+ assert r.returncode == 1 and "missing/doc.md" in r.stdout, r.stdout
82
+
83
+
84
+ def test_dangling_markdown_link_fails_relative_to_the_file(tmp):
85
+ # This repo's runbook links docs this way — the fork's regexes never saw them.
86
+ make_project(tmp, rules={"r.md": "[doc](../../.documentation/a.md) and [bad](../../.documentation/nope.md)\n"})
87
+ r = check(tmp)
88
+ assert r.returncode == 1, r.stdout
89
+ assert "nope.md" in r.stdout and "a.md" not in r.stdout.split("--")[-1], r.stdout
90
+
91
+
92
+ def test_root_relative_link_in_dot_claude_is_accepted(tmp):
93
+ # The installer writes exactly this into .claude/CLAUDE.md. Strictly file-relative,
94
+ # it blocked the first commit of every fresh install.
95
+ make_project(tmp)
96
+ (tmp / ".claude/project-map").mkdir()
97
+ (tmp / ".claude/project-map/PROJECT_MAP.md").write_text("# Map\n")
98
+ (tmp / ".claude/CLAUDE.md").write_text("[map](.claude/project-map/PROJECT_MAP.md)\n")
99
+ r = check(tmp)
100
+ assert r.returncode == 0, r.stdout
101
+
102
+
103
+ def test_anchor_url_and_code_fence_are_not_false_alarms(tmp):
104
+ make_project(tmp, claude_md=(
105
+ "→ `.documentation/a.md#section`\n"
106
+ "[site](https://example.com/x.md) [mail](mailto:a@b.c) [here](#top)\n"
107
+ "```md\n→ `.documentation/example-only.md`\n[x](nowhere.md)\n```\n"
108
+ ))
109
+ r = check(tmp)
110
+ assert r.returncode == 0, r.stdout
111
+
112
+
113
+ # ── rule lines (--map-style) ─────────────────────────────────────────────────
114
+
115
+ def test_rule_lines_ignored_without_map_style(tmp):
116
+ r = check(make_project(tmp, rules={"r.md": "- just some context\n"}))
117
+ assert r.returncode == 0 and "ALWAYS/NEVER" not in r.stdout, r.stdout
118
+
119
+
120
+ def test_context_line_in_rules_file_fails(tmp):
121
+ r = check(make_project(tmp, rules={"r.md": "- run the gates first → `.documentation/a.md`\n"}), "--map-style")
122
+ assert r.returncode == 1 and "run the gates first" in r.stdout, r.stdout
123
+
124
+
125
+ def test_rule_without_pointer_fails(tmp):
126
+ r = check(make_project(tmp, rules={"r.md": "- NEVER do it — because.\n"}), "--map-style")
127
+ assert r.returncode == 1, r.stdout
128
+
129
+
130
+ def test_good_rule_line_passes(tmp):
131
+ r = check(make_project(tmp, rules={"r.md": GOOD_RULES}), "--map-style")
132
+ assert r.returncode == 0, r.stdout
133
+
134
+
135
+ def test_descriptive_files_are_exempt(tmp):
136
+ make_project(tmp, rules={"operational-runbook.md": "- a trap, described\n"})
137
+ assert check(tmp, "--map-style").returncode == 0
138
+ r = check(tmp, "--map-style", "--descriptive", "other.md")
139
+ assert r.returncode == 1, r.stdout
140
+
141
+
142
+ def test_claude_md_only_pointed_bullets_must_be_rules(tmp):
143
+ make_project(tmp, claude_md="- `run it` with docker\n")
144
+ assert check(tmp, "--map-style").returncode == 0
145
+ (tmp / "CLAUDE.md").write_text("- a tool line → `.documentation/a.md`\n")
146
+ assert check(tmp, "--map-style").returncode == 1
147
+
148
+
149
+ # ── wiring ───────────────────────────────────────────────────────────────────
150
+
151
+ def test_this_repo_passes():
152
+ r = subprocess.run([sys.executable, str(SCRIPT)], capture_output=True, text=True, cwd=REPO)
153
+ assert r.returncode == 0, r.stdout
154
+
155
+
156
+ def _block(text: str) -> str:
157
+ start = text.index("# ── Context Check")
158
+ end = text.index("# ── End Context Check")
159
+ return text[start:end]
160
+
161
+
162
+ def test_installer_writes_the_same_hook_block():
163
+ assert _block((REPO / ".githooks/pre-commit").read_text()) == _block((REPO / ".claude/install.sh").read_text())
164
+
165
+
166
+ def test_hooks_are_committed_executable():
167
+ # Git silently skips a non-executable hook. Until #20 both were committed 100644,
168
+ # so no clone of this repo ever ran its pre-commit hook.
169
+ out = subprocess.run(["git", "ls-files", "-s", ".githooks/"], capture_output=True, text=True,
170
+ cwd=REPO, check=True).stdout
171
+ modes = {line.split("\t")[1]: line.split()[0] for line in out.splitlines()}
172
+ assert modes and all(m == "100755" for m in modes.values()), modes
173
+
174
+
175
+ def test_hook_blocks_a_bad_rules_commit(tmp):
176
+ git = ["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "core.hooksPath=.githooks"]
177
+ subprocess.run(["git", "init", "-q", str(tmp)], check=True)
178
+ make_project(tmp, rules={"r.md": GOOD_RULES})
179
+ (tmp / ".githooks").mkdir()
180
+ shutil.copy(REPO / ".githooks/pre-commit", tmp / ".githooks/pre-commit")
181
+ (tmp / ".githooks/pre-commit").chmod(0o755) # the mode itself is test_hooks_are_committed_executable's job
182
+ (tmp / ".claude/project-map").mkdir(parents=True)
183
+ shutil.copy(SCRIPT, tmp / ".claude/project-map/context-check.py")
184
+ commit = lambda msg: subprocess.run(git + ["commit", "-qm", msg], cwd=tmp, capture_output=True, text=True)
185
+
186
+ subprocess.run(["git", "add", "-A"], cwd=tmp, check=True)
187
+ r = commit("good")
188
+ assert r.returncode == 0, r.stdout + r.stderr
189
+
190
+ (tmp / ".claude/rules/r.md").write_text("- NEVER x — y. → `.documentation/gone.md`\n")
191
+ subprocess.run(["git", "add", "-A"], cwd=tmp, check=True)
192
+ r = commit("bad")
193
+ assert r.returncode != 0, "hook let a dangling pointer through"
194
+ assert "Commit blocked" in r.stderr, r.stderr
195
+
196
+
197
+ def main() -> int:
198
+ tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
199
+ failed = []
200
+ base = Path(tempfile.mkdtemp(prefix="context-check-test-"))
201
+ try:
202
+ for fn in tests:
203
+ d = base / fn.__name__
204
+ d.mkdir(parents=True)
205
+ try:
206
+ if "tmp" in inspect.signature(fn).parameters:
207
+ fn(d)
208
+ else:
209
+ fn()
210
+ print(f" ok {fn.__name__}")
211
+ except Exception as e:
212
+ failed.append(fn.__name__)
213
+ print(f" FAIL {fn.__name__}: {type(e).__name__}: {e}")
214
+ finally:
215
+ shutil.rmtree(base, ignore_errors=True)
216
+ print(f"\n{len(tests) - len(failed)}/{len(tests)} passed")
217
+ return 1 if failed else 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ sys.exit(main())
@@ -30,7 +30,12 @@ def load_generate(project_root: Path):
30
30
  spec.loader.exec_module(mod)
31
31
  finally:
32
32
  sys.argv = argv
33
- mod.PROJECT_ROOT = project_root
33
+ # configure_paths(), not `mod.PROJECT_ROOT = ...`: generate.py binds MAP_DIR,
34
+ # SECTIONS_DIR, CHECKSUMS, LEARNED_VOC and GLOSSARY at import time from
35
+ # __file__, and rebinds them only here. Setting PROJECT_ROOT alone left every
36
+ # output path aimed at the real repo, so the suite overwrote its own
37
+ # glossary.json with fixture data and still passed (#18).
38
+ mod.configure_paths(project_root)
34
39
  return mod
35
40
 
36
41
 
@@ -232,7 +237,11 @@ def test_glossary_survives_foreign_project_root(g):
232
237
  PROJECT_ROOT when --project-root points elsewhere. That combination raised
233
238
  ValueError from relative_to()."""
234
239
  import json
235
- g.write_glossary([], g.load_stack()) # PROJECT_ROOT is the temp fixture here
240
+ # Recreate the mismatch explicitly. It used to arrive for free because the
241
+ # loader left SECTIONS_DIR pointing at the script dir — the same leak that
242
+ # let this test write into the real repo (#18).
243
+ g.SECTIONS_DIR = HERE / "sections"
244
+ g.write_glossary([], g.load_stack())
236
245
  data = json.loads(g.GLOSSARY.read_text())
237
246
  assert data["source"].endswith("01-vocabulary.md"), data["source"]
238
247
 
@@ -28,7 +28,10 @@ def load_miner(project_root: Path, cursor_dir: Path):
28
28
  spec.loader.exec_module(mod)
29
29
  finally:
30
30
  sys.argv = argv
31
- mod.PROJECT_ROOT = project_root
31
+ # configure_paths(), not `mod.PROJECT_ROOT = ...` — see the same note in
32
+ # test_generate.py (#18). LEARNED_VOC is bound at import time from __file__,
33
+ # so assigning PROJECT_ROOT alone leaves it aimed at the real repo.
34
+ mod.configure_paths(project_root)
32
35
  mod.MINE_CURSOR = cursor_dir / ".mine-cursor.json"
33
36
  return mod
34
37
 
@@ -6,13 +6,13 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Official marketplace for babel-fish - Codebase introspection and vocabulary translation for AI coding assistants",
9
- "version": "2.4.2"
9
+ "version": "2.5.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "babel-fish",
14
14
  "description": "Auto-generates a project map, vocabulary translation layer, and developer skill for any codebase. Introspects routes, models, services, features, infrastructure, and session history to give Claude instant full-stack context. Self-updates via pre-commit hook.",
15
- "version": "2.4.2",
15
+ "version": "2.5.0",
16
16
  "author": {
17
17
  "name": "TheGlitchKing"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "babel-fish",
3
3
  "description": "Auto-generates a project map, vocabulary translation layer, and developer skill for any codebase. Introspects routes, models, services, features, infrastructure, and session history to give Claude instant full-stack context. Self-updates via pre-commit hook.",
4
- "version": "2.4.2",
4
+ "version": "2.5.0",
5
5
  "author": {
6
6
  "name": "TheGlitchKing",
7
7
  "email": "theglitchking@users.noreply.github.com"
File without changes
@@ -21,3 +21,20 @@ if echo "$STAGED_FILES" | grep -qE "$EXTENSIONS_PATTERN" 2>/dev/null; then
21
21
  fi
22
22
  fi
23
23
  # ── End Codebase Mapper ──────────────────────────────────────────────────────
24
+
25
+ # ── Context Check: auto-loaded instructions stay in budget, pointers resolve ──
26
+ if git diff --cached --name-only 2>/dev/null | grep -qE '^(\.claude/)?CLAUDE(\.local)?\.md$|^\.claude/rules/.*\.md$'; then
27
+ CHECK_SCRIPT=".claude/project-map/context-check.py"
28
+ if [ -f "$CHECK_SCRIPT" ]; then
29
+ PYTHON=""
30
+ if [ -f ".venv/bin/python3" ]; then PYTHON=".venv/bin/python3"
31
+ elif command -v python3 &>/dev/null; then PYTHON="python3"
32
+ elif command -v python &>/dev/null; then PYTHON="python"
33
+ fi
34
+ if [ -n "$PYTHON" ] && ! $PYTHON "$CHECK_SCRIPT"; then
35
+ echo "[context-check] Commit blocked. Fix the FAIL lines above, or add flags (--budget N, --map-style) to this call in .githooks/pre-commit." >&2
36
+ exit 1
37
+ fi
38
+ fi
39
+ fi
40
+ # ── End Context Check ────────────────────────────────────────────────────────
package/CHANGELOG.md CHANGED
@@ -2,6 +2,76 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.5.0] - 2026-09-25
6
+
7
+ ### Added
8
+
9
+ - **`context-check.py` — keeps auto-loaded context small and its pointers live**
10
+ ([#20](https://github.com/TheGlitchKing/babel-fish/issues/20)). `CLAUDE.md` and
11
+ every `.claude/rules/*.md` load into every session; past Claude Code's 150k-char
12
+ limit rules stop binding, and nothing reports it. glitch-stock-trading-rig had
13
+ reached 225.6k before forking `generate.py` to enforce a budget.
14
+
15
+ The new script checks three things. **Budget:** total chars under `--budget`
16
+ (default 60,000). **Pointers:** every `` → `path` ``, backticked `.documentation/`
17
+ path and relative markdown link resolves. hewtd link-checks only inside
18
+ `.documentation/`, so a renamed doc used to break a rule's pointer silently.
19
+ **Rule lines** (opt-in, `--map-style`): each rule bullet reads
20
+ `` - ALWAYS|NEVER <rule> — <why> → `doc` ``. That form is one repo's convention,
21
+ and the hook ships to every install, so it is off by default.
22
+
23
+ The pre-commit hook runs it when `CLAUDE.md` or `.claude/rules/` is staged, and a
24
+ failure blocks the commit. It is a separate script rather than part of
25
+ `generate.py`: the hook runs `generate.py` only for code changes, `.claude/` is
26
+ outside the watch set, and the hook discards `generate.py`'s errors. The block
27
+ has its own marker, so re-running `bash .claude/install.sh` adds it to hooks
28
+ installed by older versions.
29
+
30
+ Registry drift (item 4 of #20) stays in the downstream fork; babel-fish has no
31
+ tool registry.
32
+
33
+ ### Fixed
34
+
35
+ - **This repo's pre-commit hook never ran.** Both `.githooks/` files were
36
+ committed `100644`, and git skips a non-executable hook without a word, so no
37
+ clone ever regenerated its map on commit. Both are `100755` now, and
38
+ `test_hooks_are_committed_executable` holds them there.
39
+
40
+ 57 tests pass across four suites. Each context check was mutation-tested: breaking
41
+ it fails at least one test. A fresh install and an upgrade over a pre-2.5.0 hook
42
+ were both run end to end.
43
+
44
+ ## [2.4.3] - 2026-09-05
45
+
46
+ ### Fixed
47
+
48
+ - **`npm test` overwrote the map it was testing, and still reported every test
49
+ passing** ([#18](https://github.com/TheGlitchKing/babel-fish/issues/18)). A run
50
+ rewrote the working tree's `.claude/project-map/glossary.json` with fixture
51
+ data — 10 entries down to 1 — and printed a clean pass. Caught only by
52
+ diffing before a commit during the 2.4.2 release.
53
+
54
+ `generate.py` and `mine-sessions.py` bind `MAP_DIR`, `SECTIONS_DIR`,
55
+ `CHECKSUMS`, `LEARNED_VOC` and `GLOSSARY` at import time from `__file__`, and
56
+ rebind them only inside `configure_paths()`. Both test loaders assigned
57
+ `mod.PROJECT_ROOT` and stopped there, so every output path stayed aimed at the
58
+ real repo while the tests believed they were in a temp fixture; whichever test
59
+ called `write_glossary()` last won. This is the [#15](https://github.com/TheGlitchKing/babel-fish/issues/15)
60
+ defect class — writes escaping to the script's own directory — surviving in
61
+ the harness after being fixed in the scripts themselves.
62
+
63
+ Both loaders now call `configure_paths(fixture_root)`.
64
+ `test_glossary_survives_foreign_project_root` depended on the leak (it needs
65
+ `SECTIONS_DIR` outside `PROJECT_ROOT`, which used to arrive for free) and now
66
+ recreates that mismatch explicitly, so it still covers the `ValueError` from
67
+ `relative_to()` without writing outside its fixture — verified by mutation:
68
+ deleting the `try`/`except` in `_relative_source()` fails that test and only
69
+ that one. `test_grader.py` is deliberately untouched; its loader assigns a map
70
+ dir rather than a project root.
71
+
72
+ 38 tests pass across the three suites, and a full run now leaves the tree
73
+ clean.
74
+
5
75
  ## [2.4.2] - 2026-09-05
6
76
 
7
77
  ### Fixed
package/README.md CHANGED
@@ -146,7 +146,7 @@ See [CHANGELOG.md](./CHANGELOG.md) for the full 2.0.0 release notes, breaking-ch
146
146
  2. Detects your stack (language, framework, database, ORM, auth, infra)
147
147
  3. Runs `generate.py` → grades with `grader.py` (iterates up to 3× until 90%+ quality)
148
148
  4. Renders your developer skill and rules files
149
- 5. Installs the pre-commit hook (auto-regenerates map on source file changes)
149
+ 5. Installs the pre-commit hook (auto-regenerates map on source file changes; checks auto-loaded context when `CLAUDE.md` or `.claude/rules/` changes)
150
150
  6. Updates `CLAUDE.md` with a project map pointer
151
151
  7. Prints a full quality report
152
152
 
@@ -227,6 +227,21 @@ Entries follow a simple format: symptom → cause → fix. This is the anti-drif
227
227
 
228
228
  ---
229
229
 
230
+ ## Auto-loaded Context Check *(2.5.0+)*
231
+
232
+ Everything in `CLAUDE.md` and `.claude/rules/` loads into every session, and past Claude Code's 150k-char limit rules stop binding silently. When those files are staged, the pre-commit hook runs `context-check.py` and blocks the commit if:
233
+
234
+ - the total exceeds the budget (default 60,000 chars), or
235
+ - a pointer names a file that doesn't exist. hewtd only link-checks inside `.documentation/`, so a renamed doc breaks a rule's pointer unnoticed.
236
+
237
+ Opt in to `--map-style` to also require every rule bullet to read `` - ALWAYS|NEVER <rule> — <why> → `doc` ``. See [auto-loaded context check](./.documentation/standards/auto-loaded-context-check.md).
238
+
239
+ ```bash
240
+ python .claude/project-map/context-check.py --budget 80000 --map-style
241
+ ```
242
+
243
+ ---
244
+
230
245
  ## File Structure
231
246
 
232
247
  ```
@@ -235,6 +250,7 @@ Entries follow a simple format: symptom → cause → fix. This is the anti-drif
235
250
  │ ├── generate.py # Introspection script
236
251
  │ ├── grader.py # Quality grader
237
252
  │ ├── mine-sessions.py # Session vocabulary miner
253
+ │ ├── context-check.py # Budget + pointer check for auto-loaded files
238
254
  │ ├── PROJECT_MAP.md # TOC + quick routing guide
239
255
  │ ├── sections/ # 19 focused section files
240
256
  │ ├── reports/ # Install and iteration reports
@@ -247,7 +263,7 @@ Entries follow a simple format: symptom → cause → fix. This is the anti-drif
247
263
  └── <project>-developer-skill/
248
264
  └── SKILL.md
249
265
  .githooks/
250
- ├── pre-commit # Auto-regenerates map on commit
266
+ ├── pre-commit # Regenerates map; runs the context check
251
267
  └── install.sh # Register hooks: bash .githooks/install.sh
252
268
  ```
253
269
 
@@ -281,6 +297,7 @@ Entries follow a simple format: symptom → cause → fix. This is the anti-drif
281
297
  | `python .claude/project-map/generate.py --force` | Force-regenerate project map |
282
298
  | `python .claude/project-map/grader.py` | Grade map quality (0–100%) |
283
299
  | `python .claude/project-map/mine-sessions.py` | Mine session vocabulary |
300
+ | `python .claude/project-map/context-check.py` | Check auto-loaded context: budget, pointers |
284
301
  | `bash .githooks/install.sh` | (Re)install git hooks |
285
302
  | `bash .claude/install.sh` | Re-run full plugin installer |
286
303
 
package/checksums.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "install_sh": "565ad35c264c8e683ade82f441ab5a9f8feb3921e0b4180623bb038b23452518",
2
+ "install_sh": "eb3a15ddeaf969b3b0e609fe6cde7792efd7c60ef2305d8593cb761fe812c1aa",
3
3
  "note": "SHA256 of .claude/install.sh \u2014 verified by the remote installer before execution"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theglitchking/babel-fish",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Gives your AI coding assistant instant, accurate knowledge of every route, model, service, feature, and infrastructure element in your codebase.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "postinstall": "node scripts/link-skills.js",
11
- "test": "python3 .claude/project-map/test_generate.py && python3 .claude/project-map/test_mine_sessions.py && python3 .claude/project-map/test_grader.py"
11
+ "test": "python3 .claude/project-map/test_generate.py && python3 .claude/project-map/test_mine_sessions.py && python3 .claude/project-map/test_grader.py && python3 .claude/project-map/test_context_check.py"
12
12
  },
13
13
  "files": [
14
14
  "bin/",
@@ -22,9 +22,11 @@
22
22
  ".claude/project-map/generate.py",
23
23
  ".claude/project-map/grader.py",
24
24
  ".claude/project-map/mine-sessions.py",
25
+ ".claude/project-map/context-check.py",
25
26
  ".claude/project-map/test_generate.py",
26
27
  ".claude/project-map/test_mine_sessions.py",
27
28
  ".claude/project-map/test_grader.py",
29
+ ".claude/project-map/test_context_check.py",
28
30
  ".githooks/",
29
31
  "skills/",
30
32
  "install.sh",