Files
FabledScribe/scripts/check_plugin.py
T
bvandeusenandClaude Fable 5 5925335ca0
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 40s
CI & Build / Build & push image (push) Skipped
feat(plugin): after-write hook — PostToolUse on Bash diffs the working tree and runs the prior-art + ledger arms on what was just written; shared scribe_defs.sh; plugin 0.1.39 (#2901, milestone 299 step 3)
Edits made through sed/heredocs/scripts never reached the PreToolUse
Write|Edit hook, so a whole class of writes got no prior-art hint, no
ledger feed and no duplicate-family warning. scribe_after_write.sh asks git
what changed since it last looked (per-session path+blob snapshot; first
call = files touched in the last minute), extracts the definitions in the
added lines and calls /api/plugin/prior-art with the same three dedup
channels the pre hook keeps. Never blocks; silent on any failure. The
extractor, the prose/data skip list and the local by-name arm move to
scribe_defs.sh, sourced by both hooks. Version bump covers the step-2 hook
change too (run 4239 failed only on the bump check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:35:45 -04:00

465 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
"""Guards for `plugin/` — the one part of this repo that ships straight to users.
WHY THIS EXISTS. `plugin/` is not built into the Docker image. Installs fetch it
from this git repo via `.claude-plugin/marketplace.json`, so a push IS the
release for plugin content: no build, no gate, immediately fetchable. Two
separate defects have reached a live install through that path:
- #2198 — all four hook scripts were inert (wrong env-var case, line-oriented
`jq -rR`, line-oriented `cut -c`). No CI ran, because `plugin/**` wasn't in
the workflow's `paths:` filter at all.
- #2209 — the fix for #2198 shipped to `main` and still couldn't reach an
install, because `plugin.json`'s version wasn't bumped and the installer
compares versions to decide whether to refresh its cache.
The rule for the second one was already written down and was still missed. A
written rule that depends on being remembered during a long session is not a
control; this is.
shellcheck and jq are NOT in `ci-python` (verified against CI-runner's Dockerfile
and scripts/install-common.sh, not from memory — rule #37). CI installs both
per-job, which is what CI-runner's own docs/process.md prescribes for a dep with
a single consumer: "If only one project needs the dep, prefer that project
installing it per-job in their workflow — at least until a second consumer
arrives." Promotion into the image is filed as an issue there rather than
assumed here.
Both are optional at runtime: without shellcheck the lint step is SKIPPED and
says so, and without jq the smoke test is skipped. A skipped check announces
itself loudly, because a check that quietly no-ops is the failure mode this
whole file exists to prevent.
Usage:
python3 scripts/check_plugin.py # all checks
python3 scripts/check_plugin.py --no-version # skip the bump check
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PLUGIN_DIR = ROOT / "plugin"
HOOKS_DIR = PLUGIN_DIR / "hooks"
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
# Paths whose contents reach an install. Keep in step with the workflow's
# `paths:` filter — a path that ships but isn't checked here is the gap again.
SHIPPED = ("plugin", ".claude-plugin")
failures: list[str] = []
def fail(msg: str) -> None:
failures.append(msg)
print(f"FAIL {msg}")
def ok(msg: str) -> None:
print(f"ok {msg}")
def skip(msg: str) -> None:
# Loud on purpose. A check that quietly does nothing is indistinguishable
# from a check that passed — the exact confusion that let #2198 survive.
print(f"SKIP {msg}")
def hook_scripts() -> list[Path]:
return sorted(HOOKS_DIR.glob("*.sh"))
# --- syntax ----------------------------------------------------------------
def check_syntax() -> None:
"""`bash -n` every hook. Catches nothing subtle, costs nothing, and a syntax
error here means a hook that silently never runs."""
for script in hook_scripts():
proc = subprocess.run(
["bash", "-n", str(script)], capture_output=True, text=True
)
if proc.returncode != 0:
fail(f"{script.relative_to(ROOT)}: bash -n — {proc.stderr.strip()}")
else:
ok(f"{script.relative_to(ROOT)}: syntax")
# --- known-bad patterns ----------------------------------------------------
# Each entry: (compiled pattern, short label, why it's wrong).
# These are the exact classes from #2198. They are deliberately specific — a
# broad shell linter belongs in the image, not hand-rolled here.
PATTERNS: list[tuple[re.Pattern, str, str]] = [
(
re.compile(r"CLAUDE_PLUGIN_OPTION_[a-z]"),
"lowercase userConfig env var",
"Claude Code exports userConfig to hooks as CLAUDE_PLUGIN_OPTION_<KEY> "
"with the key UPPERCASED. The lowercase spelling reads as empty and the "
"hook then does nothing, silently.",
),
(
# -R without -s: reads input line by line, so a multi-line payload is
# encoded per line and joined with raw newlines. The class is a-r + t-z
# (i.e. every letter EXCEPT `s`) so `-rR` is caught and `-sRr` is not —
# an earlier a-q spelling silently excluded `r` and missed the real
# defect, which is exactly the flag combination that shipped.
re.compile(r"jq\s+-(?:[a-rt-zA-Z]*R[a-rt-zA-Z]*)\s"),
"line-oriented jq -R",
"jq -R reads input LINE BY LINE. Encoding a multi-line payload that way "
"produces separate encoded lines joined by raw newlines — an invalid "
"URL. Use -s (slurp) as well, e.g. `jq -sRr '@uri'`.",
),
(
re.compile(r"\|\s*cut\s+-c"),
"line-oriented cut for a payload cap",
"cut -c truncates EACH LINE, so it does not cap total size. Use "
"`head -c N` to bound a payload.",
),
]
def check_patterns() -> None:
for script in hook_scripts():
text = script.read_text(encoding="utf-8", errors="replace")
rel = script.relative_to(ROOT)
hits = 0
for line_no, line in enumerate(text.splitlines(), 1):
# A line that only *documents* the trap is fine — several hooks now
# carry a comment naming the wrong form so the next reader knows.
if line.lstrip().startswith("#"):
continue
for pattern, label, why in PATTERNS:
if pattern.search(line):
hits += 1
fail(f"{rel}:{line_no}: {label}\n {line.strip()}\n {why}")
if not hits:
ok(f"{rel}: no known-bad patterns")
# --- the version bump ------------------------------------------------------
# --- shellcheck ------------------------------------------------------------
def check_shellcheck() -> None:
"""Real shell linting, where available.
The hand-rolled patterns above only know the bugs that already happened.
This is what catches the next one.
"""
exe = shutil.which("shellcheck")
if not exe:
skip("shellcheck not installed — install it to lint the hooks properly")
return
for script in hook_scripts():
proc = subprocess.run(
[exe, "--severity=warning", "--shell=bash", str(script)],
capture_output=True, text=True,
)
rel = script.relative_to(ROOT)
if proc.returncode != 0:
fail(f"{rel}: shellcheck\n{proc.stdout.strip()}")
else:
ok(f"{rel}: shellcheck")
# --- the fail-open contract ------------------------------------------------
# Every hook promises never to break the operator's session: unconfigured or
# unreachable, it exits 0. Three of them additionally promise SILENCE, because
# they are pure enrichment. scribe_session_context.sh is the exception by
# design — it always emits a static behavioural floor that needs no credentials
# and no network, so "silent" would be the wrong assertion for it.
#
# This is the contract that made #2198 invisible for weeks, so it is worth
# pinning: the bug and the healthy no-results case look identical from outside.
# Pinning it does NOT make the failure visible; it makes sure the fail-open
# behaviour is deliberate rather than accidental.
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
# The prior-art hook's local arm (#2280) fires with no credentials, so the
# silence assertion below needs a name the repo genuinely lacks. Two traps,
# both hit while writing this:
# - `def f` matched real code, so the hook spoke and "silent" was asserting
# the wrong thing;
# - spelling the replacement out in full put `def <name>(` INTO this file,
# so the smoke event defined the very symbol it claimed was absent.
# Concatenating keeps the contiguous string out of the source.
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
SMOKE_EVENTS: dict[str, str] = {
"scribe_autoinject.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
),
"scribe_prior_art.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
"tool_input": {"file_path": "src/x.py",
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
# The after-write hook (#2901) diffs the working tree; on CI's clean
# checkout there is nothing to report, so silence is the right assertion.
# (On a dirty local tree with a definition just written it may speak —
# that is the hook working, not a failure of the contract.)
"scribe_after_write.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "true"}, "tool_response": {}}
),
# The shared library is sourced, never run; executed bare it defines
# functions and exits — silent by construction.
"scribe_defs.sh": "",
}
# The one hook that legitimately produces output with no credentials.
STATIC_FLOOR = "scribe_session_context.sh"
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items()
if not k.startswith(("SCRIBE_", "CLAUDE_PLUGIN_OPTION_"))}
env.update(env_extra)
return subprocess.run(
["bash", str(script)], input=event, capture_output=True, text=True,
env=env, timeout=30,
)
def check_fail_open() -> None:
if not shutil.which("jq"):
# Without jq every hook bails at its first line, so this would pass
# while exercising nothing. Say so rather than bank a green tick.
skip("jq not installed — the hooks would exit at line 1, so this "
"check would pass without testing anything")
return
scenarios = [
("unconfigured", {}),
# Connection refused immediately — exercises the unreachable-instance
# path without waiting on a real network timeout.
("unreachable", {"SCRIBE_URL": "http://127.0.0.1:1", "SCRIBE_TOKEN": "x"}),
]
for script in hook_scripts():
rel = script.relative_to(ROOT)
event = SMOKE_EVENTS.get(script.name)
if event is None:
skip(f"{rel}: no smoke event defined")
continue
for label, env_extra in scenarios:
try:
proc = _run_hook(script, event, env_extra)
except subprocess.TimeoutExpired:
fail(f"{rel} [{label}]: hung — a hook must never block a session")
continue
if proc.returncode != 0:
fail(f"{rel} [{label}]: exited {proc.returncode}, must be 0 — "
f"a recall aid may never fail the operator's action")
continue
out = proc.stdout.strip()
if script.name == STATIC_FLOOR:
# Emits its bundled static tier regardless; that floor is the
# whole point of the two-tier design.
if not out:
fail(f"{rel} [{label}]: emitted nothing — the static "
f"behavioural floor must survive having no credentials")
else:
ok(f"{rel} [{label}]: exit 0, static floor present")
elif out:
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
f" {out[:200]}")
else:
ok(f"{rel} [{label}]: exit 0, silent")
def check_local_prior_art_needs_no_instance() -> None:
"""The prior-art hook's local arm must answer with no credentials (#2280).
The other arms ask Scribe what was RECORDED. This one asks the repo what
EXISTS, which needs no instance — and that is the whole reason it catches
the case the recorded arms structurally cannot: a helper nobody thought to
record. If it ever silently starts depending on configuration, it stops
covering that case and nothing else would notice.
Paired with the silence assertion in check_fail_open, which uses a symbol
that cannot exist. Together they pin both halves: silent when there is
nothing to say, and speaking when there is — both with no instance at all.
"""
script = HOOKS_DIR / "scribe_prior_art.sh"
if not script.is_file() or not shutil.which("jq"):
skip("prior-art local arm: hook or jq missing")
return
# A definition this repo really does contain, written into a DIFFERENT file
# so the self-match exclusion doesn't suppress it.
event = json.dumps({
"session_id": "smoke", "cwd": ".", "tool_name": "Write",
"tool_input": {
"file_path": "scripts/_probe_not_real.py",
"content": "def check_local_prior_art_needs_no_instance():\n pass\n",
},
})
try:
proc = _run_hook(script, event, {}) # NO credentials, on purpose
except subprocess.TimeoutExpired:
fail("prior-art local arm: hung")
return
if proc.returncode != 0:
fail(f"prior-art local arm: exited {proc.returncode}, must be 0")
elif "already defined" not in proc.stdout:
fail("prior-art local arm: found nothing for a symbol this repo "
"defines, with no credentials — the arm that needs no instance "
"has stopped working, and the recorded arms cannot cover for it")
else:
ok("prior-art local arm: answers with no instance configured")
def check_session_context_reports_its_version() -> None:
"""The SessionStart context must name the plugin version it is running.
An install has two halves and only one self-updates: the marketplace clone
pulls on its own, while the CACHE is what executes and refreshes only when
the manifest version changes. So a shipped fix can sit unreached while
inspecting the clone shows it present — the obvious debugging move
misleads, and twice the only detector was a human saying "I don't think it
updated" (#2209, #2220).
Asserted WITHOUT credentials on purpose. The state most needing diagnosis
is the one where the token never arrives, and a marker that vanished there
would be missing exactly when it is wanted.
"""
script = HOOKS_DIR / "scribe_session_context.sh"
if not script.is_file() or not shutil.which("jq"):
skip("version marker: hook or jq missing")
return
manifest_v = manifest_version()
if manifest_v is None:
fail("version marker: could not read the manifest version")
return
try:
proc = _run_hook(script, json.dumps({"source": "startup"}), {})
except subprocess.TimeoutExpired:
fail("version marker: hook hung")
return
if proc.returncode != 0:
fail(f"version marker: hook exited {proc.returncode}")
elif manifest_v not in proc.stdout:
fail(f"version marker: session context never names v{manifest_v} — "
f"a stale install would be undetectable from the transcript")
else:
ok(f"version marker: session context reports v{manifest_v}, no credentials needed")
def _git(*args: str) -> tuple[int, str]:
proc = subprocess.run(
["git", *args], capture_output=True, text=True, cwd=ROOT
)
return proc.returncode, (proc.stdout or proc.stderr).strip()
def manifest_version(ref: str | None = None) -> str | None:
"""The manifest version at `ref`, or in the working tree when ref is None."""
if ref is None:
try:
return json.loads(MANIFEST.read_text()).get("version")
except Exception:
return None
rel = MANIFEST.relative_to(ROOT).as_posix()
code, out = _git("show", f"{ref}:{rel}")
if code != 0:
return None
try:
return json.loads(out).get("version")
except Exception:
return None
def check_version_bump(base: str = "origin/main") -> None:
"""If shipped plugin content differs from `base`, the version must too.
Stated against the BASE BRANCH rather than the last commit on purpose. A
per-commit rule would demand a bump from every commit in a batch; what
actually matters is that whatever reaches an install carries a version the
installer can tell apart from the one already cached. One bump per batch,
which is also how a human would do it.
"""
code, _ = _git("rev-parse", "--verify", base)
if code != 0:
# Do NOT pass silently — a check that quietly no-ops is how this class
# of bug survives in the first place.
fail(
f"cannot resolve {base}, so the version-bump check could not run. "
f"Fetch it first — `git fetch --depth=1 origin main:refs/remotes/"
f"origin/main` is enough, since this diffs two trees and needs no "
f"common ancestor — or pass --no-version deliberately."
)
return
code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
if code != 0:
fail(f"git diff against {base} failed: {changed}")
return
if not changed.strip():
ok(f"no shipped plugin changes against {base} — version bump not required")
return
here, there = manifest_version(), manifest_version(base)
if here is None:
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
return
if there is None:
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
return
if here == there:
files = "\n ".join(changed.splitlines())
fail(
f"plugin content changed but the manifest version is still {here}.\n"
f" The installer compares versions to decide whether to refresh "
f"its cache, so an unchanged version means these edits reach the repo "
f"and stop there — the marketplace clone updates, the cache that "
f"actually executes does not (issue #2209).\n"
f" Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
f" Changed:\n {files}"
)
else:
ok(f"plugin content changed and version moved {there} -> {here}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--no-version", action="store_true",
help="skip the manifest version-bump check")
parser.add_argument("--base", default="origin/main",
help="branch the version bump is measured against")
args = parser.parse_args()
if not HOOKS_DIR.is_dir():
print(f"FAIL no hooks directory at {HOOKS_DIR}")
return 1
check_syntax()
check_patterns()
check_shellcheck()
check_fail_open()
check_local_prior_art_needs_no_instance()
check_session_context_reports_its_version()
if not args.no_version:
check_version_bump(args.base)
print()
if failures:
print(f"{len(failures)} problem(s) found.")
return 1
print("All plugin checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())