CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
7.2 KiB
Python
151 lines
7.2 KiB
Python
"""The PostToolUse after-write hook: code written through Bash — sed,
|
|
heredocs, scripts — gets the same prior-art / ledger checks as a Write/Edit.
|
|
|
|
Runs the real shell against a temp git repo and a throwaway HTTP sink, like
|
|
the pre-write hook's end-to-end tests. Skips where the hook's tools are
|
|
missing; asserts on content where they are present."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.helpers import http_sink
|
|
|
|
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
|
HOOK = PLUGIN / "hooks" / "scribe_after_write.sh"
|
|
|
|
|
|
def _env(tmp_path, url="http://127.0.0.1:9"):
|
|
for tool in ("git", "curl", "bash"):
|
|
if shutil.which(tool) is None:
|
|
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
|
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
|
|
"TMPDIR": str(tmp_path), "HOME": str(tmp_path),
|
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
|
|
|
|
|
|
def _repo(tmp_path, env):
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
|
(repo / "b.py").write_text("def one():\n return 1\n")
|
|
(repo / "c.py").write_text("def slug(t):\n return t.lower()\n")
|
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
|
|
subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True, env=env)
|
|
return repo
|
|
|
|
|
|
def _run(repo, env, session="s-after-1", tool="Bash"):
|
|
out = subprocess.run(
|
|
["bash", str(HOOK)],
|
|
input=json.dumps({"session_id": session, "cwd": str(repo), "tool_name": tool,
|
|
"tool_input": {"command": "cat > x"}, "tool_response": {}}),
|
|
capture_output=True, text=True, env=env,
|
|
)
|
|
assert out.returncode == 0, out.stderr
|
|
return out.stdout
|
|
|
|
|
|
SINK_REPLY = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
|
|
|
|
|
|
def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path):
|
|
with http_sink(SINK_REPLY) as (port, seen):
|
|
env = _env(tmp_path, url=f"http://127.0.0.1:{port}")
|
|
repo = _repo(tmp_path, env)
|
|
# "A Bash call" wrote an untracked stylesheet and appended to a tracked file.
|
|
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n")
|
|
(repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n")
|
|
out = _run(repo, env)
|
|
by_path = {q["path"][0]: q for q in seen}
|
|
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
|
|
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
|
|
assert by_path["b.py"]["shapes"] == ["sym:slug"]
|
|
# Added lines only for the tracked file — the existing def is not "just written".
|
|
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
|
|
ctx = json.loads(out)["hookSpecificOutput"]
|
|
assert ctx["hookEventName"] == "PostToolUse"
|
|
assert "> family named" in ctx["additionalContext"]
|
|
# The local by-name arm rides along: `slug` already lives in c.py.
|
|
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
|
|
# Derive keys landed on the SHARED channel the pre-write hook reads.
|
|
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
|
|
assert "dup:483a" in state.read_text().split()
|
|
|
|
# Nothing changed → one git status, no request, no output.
|
|
seen.clear()
|
|
assert _run(repo, env) == ""
|
|
assert seen == []
|
|
|
|
# Another change → only that file, and the dedup channel goes back up.
|
|
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
|
|
_run(repo, env)
|
|
assert [q["path"][0] for q in seen] == ["a.css"]
|
|
assert seen[0]["exclude_derive"] == ["dup:483a"]
|
|
assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
|
|
|
|
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
|
|
env = _env(tmp_path)
|
|
repo = _repo(tmp_path, env)
|
|
# Not a Bash call → nothing (hooks.json matches Bash, the script re-checks).
|
|
(repo / "a.css").write_text(".x {\n color: red;\n}\n")
|
|
assert _run(repo, env, tool="Write") == ""
|
|
# Not a git repo → nothing.
|
|
loose = tmp_path / "loose"
|
|
loose.mkdir()
|
|
(loose / "a.css").write_text(".x {\n color: red;\n}\n")
|
|
assert _run(loose, env, session="s-loose") == ""
|
|
# A change that defines nothing (prose, a call-site edit) → nothing, even
|
|
# with the server unreachable (port 9 refuses): no definitions, no call
|
|
# owed, so not even the outage line. (a.css above is removed first:
|
|
# it DOES define a shape, and an unanswered call for it would rightly speak.)
|
|
(repo / "a.css").unlink()
|
|
(repo / "README.md").write_text("# notes\n")
|
|
(repo / "b.py").write_text("def one():\n return one_more()\n")
|
|
assert _run(repo, env, session="s-quiet") == ""
|
|
|
|
|
|
def test_after_write_local_arm_works_without_a_server_and_says_the_server_did_not_answer(tmp_path):
|
|
"""The local by-name arm needs no instance. A configured instance that
|
|
does not ANSWER (a refused connection stands in for it) is said, once per
|
|
outage — and the record nudge, which claims "nothing recorded",
|
|
is withheld: no answer backs that claim."""
|
|
env = _env(tmp_path)
|
|
repo = _repo(tmp_path, env)
|
|
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
|
|
out = _run(repo, env, session="s-local")
|
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
|
|
assert "Grimoire did not answer the prior-art check for `d.py` within 8s" in ctx
|
|
assert "UNCHECKED" in ctx
|
|
assert "None of those existing copies is recorded" not in ctx
|
|
marker = tmp_path / "scribe-priorart" / "s-local.unreached"
|
|
assert marker.is_file() and marker.read_text().isdigit()
|
|
# Still down a moment later: the local arm speaks, the outage line does not
|
|
# repeat (once per outage, not once per write).
|
|
(repo / "e.py").write_text("def slug(t):\n return t.upper()\n")
|
|
out = _run(repo, env, session="s-local")
|
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
assert "`slug` is already defined in" in ctx
|
|
assert "did not answer" not in ctx
|
|
|
|
|
|
def test_after_write_unconfigured_install_owes_no_call_and_keeps_the_record_nudge(tmp_path):
|
|
"""No URL/token → no call was owed, so nothing is "unreached"; the local
|
|
arm and the record nudge stand on their own."""
|
|
env = {k: v for k, v in _env(tmp_path).items() if k not in ("SCRIBE_URL", "SCRIBE_TOKEN")}
|
|
repo = _repo(tmp_path, env)
|
|
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
|
|
out = _run(repo, env, session="s-unconf")
|
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
|
|
assert "create_snippet" in ctx
|
|
assert "did not answer" not in ctx
|
|
assert not (tmp_path / "scribe-priorart" / "s-unconf.unreached").exists()
|