Files
FabledGrimoire/tests/test_shape_check_hook.py
T
bvandeusenandClaude Opus 5.5 c5eeec471f
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
rename(547): the plugin is grimoire — manifest, MCP key, hooks, env, skills
Step 3 of the Scribe -> Grimoire rename.

- Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key
  `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*.
- hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR
  ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR /
  SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`.
- skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes
  arrive as grimoire-proc-<slug>; moment_actions names them the same way.
- Migration 0124 rewrites stored skill names (rule_moments,
  rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to
  grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing.
- Settings' install commands and MCP-only defaults, the docs and the
  tests follow.
- scripts/check_plugin.py drops the smoke event for a report-check hook
  that has no file in plugin/hooks.

Kept on purpose: the secret-sealing label, and the names that let backups
written as Scribe restore and be counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:52:55 -04:00

172 lines
7.1 KiB
Python

"""The end-of-turn shape check: the write hooks note what each
write defined, and the Stop hook asks the instance which of those nobody has
judged, blocking once in the server's words.
Runs the real shell against the shared HTTP sink. What it pins: the write
hooks' ledger (a Write of a new file adds a `file` line; a skipped path adds
nothing); silence with nothing written; a block only on a reason the instance
returned; the post-block stop recorded and let through; another hook's block
loop left alone; the ledger kept when the instance could not be reached.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers import http_sink
HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
CHECK = HOOKS / "grimoire_shape_check.sh"
PRIOR_ART = HOOKS / "grimoire_prior_art.sh"
REASON = "SERVER REASON: judge what you wrote"
def _env(tmp_path, url="http://127.0.0.1:9"):
for tool in ("curl", "bash", "git"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"], "GRIMOIRE_URL": url, "GRIMOIRE_TOKEN": "t",
"TMPDIR": str(tmp_path), "HOME": str(tmp_path)}
def _ledger(tmp_path, session="s1"):
return tmp_path / "grimoire-priorart" / f"{session}.written.ids"
def _write_ledger(tmp_path, lines, session="s1"):
path = _ledger(tmp_path, session)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("".join(f"{line}\n" for line in lines))
return path
def _stop(env, cwd, active=False, session="s1"):
out = subprocess.run(
["bash", str(CHECK)],
input=json.dumps({"session_id": session, "transcript_path": "/nonexistent",
"cwd": str(cwd), "hook_event_name": "Stop",
"stop_hook_active": active}),
capture_output=True, text=True, env=env, timeout=30,
)
assert out.returncode == 0, out.stderr
return out.stdout.strip()
def _repo(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
subprocess.run(["git", "-C", str(repo), "remote", "add", "origin",
"https://git.example.com/alice/widget.git"], check=True)
return repo
# ── the write hooks keep the ledger ───────────────────────────────────────
def _pre_write(env, repo, rel, content, tool="Write"):
field = "content" if tool == "Write" else "new_string"
out = subprocess.run(
["bash", str(PRIOR_ART)],
input=json.dumps({"session_id": "s1", "cwd": str(repo), "tool_name": tool,
"tool_input": {"file_path": str(repo / rel), field: content}}),
capture_output=True, text=True, env=env, timeout=30,
)
assert out.returncode == 0, out.stderr
def test_a_write_notes_what_it_defined_and_a_new_file_is_a_candidate(tmp_path):
with http_sink(b'{"note_ids":[]}') as (port, _seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
repo = _repo(tmp_path)
(repo / "web").mkdir()
_pre_write(env, repo, "web/StatusChip.svelte",
"<script>\nfunction tone(s) {\n return s;\n}\n</script>\n"
"<style>\n.chip {\n color: red;\n}\n</style>\n")
lines = _ledger(tmp_path).read_text().splitlines()
assert "web/StatusChip.svelte\tfile\tStatusChip" in lines
assert "web/StatusChip.svelte\tsym\ttone" in lines
assert "web/StatusChip.svelte\tcss\tchip" in lines
def test_an_edit_to_an_existing_file_adds_no_file_line(tmp_path):
with http_sink(b'{"note_ids":[]}') as (port, _seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
repo = _repo(tmp_path)
(repo / "lib.py").write_text("x = 1\n")
_pre_write(env, repo, "lib.py", "def helper():\n return 1\n", tool="Edit")
lines = _ledger(tmp_path).read_text().splitlines()
assert lines == ["lib.py\tsym\thelper"]
def test_a_skipped_path_notes_nothing(tmp_path):
with http_sink(b'{"note_ids":[]}') as (port, _seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
repo = _repo(tmp_path)
_pre_write(env, repo, "README.md", "# def helper():\n")
assert not _ledger(tmp_path).exists()
# ── the Stop hook asks ────────────────────────────────────────────────────
def test_nothing_written_is_silent_and_asks_nothing(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
assert _stop(env, _repo(tmp_path)) == ""
assert seen == []
def test_all_judged_passes_silently_and_empties_the_ledger(tmp_path):
with http_sink(b'{"status":"ok","outcome":"passed","unjudged":[]}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
ledger = _write_ledger(tmp_path, ["a.py\tsym\thelper", "a.py\tsym\thelper"])
assert _stop(env, _repo(tmp_path)) == ""
assert seen[0]["phase"] == ["check"]
assert seen[0]["written"] == ["a.py\tsym\thelper"], "duplicates collapse"
assert seen[0]["repo"] == ["https://git.example.com/alice/widget.git"]
assert not ledger.exists()
def test_unjudged_blocks_once_in_the_servers_words_then_records_the_answer(tmp_path):
reply = json.dumps({"status": "ok", "outcome": "blocked", "reason": REASON}).encode()
with http_sink(reply) as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
repo = _repo(tmp_path)
ledger = _write_ledger(tmp_path, ["web/A.svelte\tfile\tA"])
assert json.loads(_stop(env, repo)) == {"decision": "block", "reason": REASON}
assert ledger.exists(), "kept, so the stop after the block asks about the same set"
# The stop after the block: recorded, never blocked, ledger consumed.
assert _stop(env, repo, active=True) == ""
assert [q["phase"] for q in seen] == [["check"], ["after"]]
assert not ledger.exists()
assert _stop(env, repo, active=True) == ""
assert len(seen) == 2
def test_no_block_without_a_reason_from_the_instance(tmp_path):
with http_sink(b'{"status":"ok"}') as (port, _seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
_write_ledger(tmp_path, ["a.py\tsym\thelper"])
assert _stop(env, _repo(tmp_path)) == ""
def test_another_hooks_block_loop_is_left_alone(tmp_path):
with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen):
env = _env(tmp_path, f"http://127.0.0.1:{port}")
ledger = _write_ledger(tmp_path, ["a.py\tsym\thelper"])
assert _stop(env, _repo(tmp_path), active=True) == ""
assert seen == []
assert ledger.exists(), "this hook's own question is still owed"
def test_an_unreachable_instance_keeps_the_question_for_later(tmp_path):
env = _env(tmp_path) # port 9: nothing listens
ledger = _write_ledger(tmp_path, ["a.py\tsym\thelper"])
assert _stop(env, _repo(tmp_path)) == ""
assert ledger.exists()