CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 16s
#3127 checklist 19. The step's own deletion list turned out to be largely spent: `check_version_bump()` came out with #3327, and the machinery the step expected to delete alongside it is load-bearing for its replacement. `manifest_version(ref=…)`, `--base`, `--no-version` and the `origin/main` resolve path all STAY. Derivation makes the value right; it does not make the comparison unnecessary. `check_version_is_minted` still has to ask "did the version move when the shipped content did?", and that is a base-branch question no matter who chose the number. The step was planned before #3327 landed, when the assumption was that these died with the guard. What was actually still standing, all of it teaching or asserting the retired scheme: - `plugin/README.md` told the reader to "set a `version` bump per release." A shipped file, instructing the exact act the mint replaced — this is how a deleted control gets re-added by someone following the docs. Now says not to hand-edit the field, names `make mint-plugin`, and says what a forgotten mint costs. (`make` is not installed on every workstation, so the direct script invocation is given too.) - `test_plugin_version_bumped_with_the_hook` asserted `version >= (0,1,31)` as a tuple of ints. Under a minted value it passes vacuously — every date clears a floor of 0.1.31 — and `int("0415")` silently eats the padding the format exists to keep. Superseded by `test_the_shipped_manifest_carries_a_minted_version`, which asserts the canonical shape instead of an ordering the comparator does not perform. Removed whole (rule 22). - The module preamble still ended on "a written rule that depends on being remembered is not a control; this is" — true of the bump guard, and read as a stronger claim than the mint can support. Replaced with what the change did and did not remove: choosing a number is gone, running the mint is not, and the difference is that forgetting is now loud rather than silent. - An orphaned `# --- the version bump ---` section header with nothing under it, and a test docstring still naming `check_version_bump`. `--no-version` keeps its one legitimate case — on `main` the version is measured against itself — and now says so in both the usage block and its `--help`, so it does not read as an escape hatch. `check_session_context_ reports_its_version` stays untouched: a different check with a different job, and the only thing that makes step 6 readable from a transcript (#2220). Version minted 2026.09.02.0415 -> 2026.09.02.0438 for the README change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
716 lines
31 KiB
Python
Executable File
716 lines
31 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.
|
|
|
|
Both were fixed. The second was fixed TWICE — once by bumping the number, and
|
|
then properly, by removing the class it came from: `plugin.json`'s version is
|
|
no longer a value anybody chooses. `scripts/mint_plugin_version.py` derives it
|
|
from the clock (`make mint-plugin`), and `check_version_is_minted` below fails
|
|
the lane when shipped content moved and the version did not.
|
|
|
|
State exactly what that did and did not remove, because a rationale that
|
|
overstates its own control is how the control gets trusted past its limit, and
|
|
because the paragraph this replaces was itself read that way. Gone: having to
|
|
remember which NUMBER to write, and the whole question of whether a chosen
|
|
number was the right one. Not gone: the mint still has to be RUN, and
|
|
forgetting to run it is still possible. What changed is that forgetting is now
|
|
LOUD — a red lane on the batch that forgot, instead of a silent no-op found
|
|
weeks later when somebody says "I don't think it updated" (#2220).
|
|
|
|
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 # on `main` only — see below
|
|
|
|
`--no-version` exists for ONE case. The version is measured against
|
|
`origin/main`, so on `main` itself the comparison is against itself and answers
|
|
nothing; the syntax, pattern and marker checks are the only ones that mean
|
|
anything there. It is NOT a way past a red lane — see
|
|
`check_version_is_minted`, whose whole design is shaped by keeping this flag
|
|
out of anyone's muscle memory.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
# The shape contract is ONE definition, shared with the script that mints it —
|
|
# a checker carrying its own copy of the format would drift from the minter
|
|
# and pass values the minter can no longer produce. Explicit path insert
|
|
# because this file runs both as `python3 scripts/check_plugin.py` (which puts
|
|
# `scripts/` on the path, not the root) and as an import from the test suite.
|
|
sys.path.insert(0, str(ROOT))
|
|
from scripts.mint_plugin_version import VERSION_RE # noqa: E402
|
|
|
|
PLUGIN_DIR = ROOT / "plugin"
|
|
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
|
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
|
|
|
# ── What ships, and what decides what it says about itself ─────────────────
|
|
#
|
|
# ONE definition (#3127 §3, milestone 334 step 2). It has TWO consumers that
|
|
# need different granularities, and conflating them is the bug:
|
|
#
|
|
# the workflow's `paths:` trigger whole paths should CI run at all?
|
|
# the version check paths MINUS should the version
|
|
# the manifest have moved?
|
|
# `version`
|
|
#
|
|
# The second one is why this is not just a tuple of paths. `plugin.json` lives
|
|
# INSIDE `plugin/`, so a version bump is itself a change to the shipped set —
|
|
# and a check that reads the set naively then treats the bump as its own
|
|
# justification. Any bump passes, no bump fails, and it has proved nothing.
|
|
# `shipped_content_changed` below is the exclusion-aware reader.
|
|
#
|
|
# The exclusion is that ONE FIELD, never the whole file: `plugin.json` also
|
|
# carries description, mcpServers and userConfig, all of which reach an
|
|
# install and all of which matter. Excluding the file wholesale would mean a
|
|
# userConfig-only edit computes an unchanged version and never refreshes —
|
|
# #2209 again with a narrower trigger.
|
|
SHIPPED_PATHS = ("plugin", ".claude-plugin")
|
|
|
|
# Files that decide what a published artifact SAYS ABOUT ITSELF — kept as a
|
|
# table so the next artifact is a one-line addition rather than a third
|
|
# bespoke guard (#3127 §3). The membership test is NOT "is this copied into
|
|
# the artifact?" but "can changing this file change the published bytes, or
|
|
# what the artifact says about itself?" — FC learned that twice in four days
|
|
# (#3156, #3202), and a deriver is never in the COPY list.
|
|
#
|
|
# Note what is absent: a CHECKER does not belong here. Whatever validates a
|
|
# version decides whether the lane goes red, not what any artifact reports,
|
|
# so `check_plugin.py` itself is not a deriver, while the script that mints
|
|
# the plugin version is.
|
|
DERIVERS: dict[str, tuple[str, ...]] = {
|
|
# The "Generate image tags and version" step computes the server image's
|
|
# name, ordering key and channel (#3298).
|
|
".forgejo/workflows/ci.yml": ("server-image",),
|
|
# Decides the plugin's version FORMAT, so it decides what every future
|
|
# manifest says about itself (milestone 334 step 3).
|
|
"scripts/mint_plugin_version.py": ("plugin",),
|
|
}
|
|
|
|
|
|
def version_relevant_paths() -> tuple[str, ...]:
|
|
"""Everything a change to which must produce a NEW plugin version.
|
|
|
|
Wider than `SHIPPED_PATHS`, and #3127 §3's asymmetry is why it has to be:
|
|
|
|
A change to how the VERSION is computed is compared against nothing at
|
|
all. Left out, the published artifact goes on reporting the OLD value
|
|
indefinitely.
|
|
|
|
Concretely — change the mint script's format string, change nothing else,
|
|
and a diff over the shipped paths alone reports "no content change, the
|
|
version need not move". The manifest then keeps a value in the old format
|
|
forever and nothing ever says so. The mint script reaches no install and
|
|
belongs here anyway; that is #3156's exact shape.
|
|
|
|
A CHECKER is deliberately not here. Whatever validates the version decides
|
|
whether the lane goes red, not what any artifact reports — so this file is
|
|
absent from its own set, and that is not an oversight.
|
|
"""
|
|
return SHIPPED_PATHS + tuple(
|
|
path for path, artifacts in DERIVERS.items() if "plugin" in artifacts
|
|
)
|
|
|
|
|
|
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")
|
|
|
|
|
|
# --- 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(
|
|
# -x FOLLOWS `# shellcheck source=` directives into the sourced
|
|
# file. Without it the shared helpers in scribe_defs.sh are
|
|
# invisible, so every variable they set reads as unassigned
|
|
# (SC2154) and every bug inside them goes unlinted at the call
|
|
# site — which is the opposite of what sharing them was for.
|
|
[exe, "--severity=warning", "--shell=bash", "-x", 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. Unconfigured, the enrichment hooks are SILENT — no
|
|
# call was owed. 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.
|
|
#
|
|
# UNREACHABLE is different for the two write-path hooks since #2932: a call
|
|
# that was owed and did not come back is SAID, once per outage ("> Scribe did
|
|
# not answer …"), so a session can tell "checked, nothing there" from "never
|
|
# checked". That line — or silence, when the once-per-outage marker in
|
|
# ${TMPDIR:-/tmp}/scribe-priorart/ was set by a run in the last ten minutes —
|
|
# is the only output allowed with no working instance; anything else is a hook
|
|
# speaking on data it cannot have.
|
|
#
|
|
# This is the contract that made #2198 invisible for weeks, so it is worth
|
|
# pinning: the bug and the healthy no-results case looked identical from
|
|
# outside. #2932 is what finally makes the failure visible at the write; this
|
|
# check makes sure the fail-open behaviour stays 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"
|
|
# The hooks that say so when a configured instance does not answer (#2932).
|
|
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
|
|
OUTAGE_LINE = "> Scribe did not answer the prior-art check"
|
|
|
|
|
|
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 and label == "unreachable" and script.name in OUTAGE_SPEAKERS:
|
|
# The only thing allowed here is the outage line itself.
|
|
try:
|
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
except (ValueError, KeyError, TypeError):
|
|
ctx = ""
|
|
if ctx.startswith(OUTAGE_LINE):
|
|
ok(f"{rel} [{label}]: exit 0, says the instance did not answer")
|
|
else:
|
|
fail(f"{rel} [{label}]: emitted output with no working instance "
|
|
f"that is not the outage line:\n {out[:200]}")
|
|
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_text(ref: str | None = None) -> str | None:
|
|
"""The manifest's RAW TEXT at `ref`, or in the working tree when ref is None.
|
|
|
|
Split out from `manifest_version` because the exclusion below needs every
|
|
field except one, not the one field.
|
|
"""
|
|
if ref is None:
|
|
try:
|
|
return MANIFEST.read_text()
|
|
except OSError:
|
|
return None
|
|
rel = MANIFEST.relative_to(ROOT).as_posix()
|
|
code, out = _git("show", f"{ref}:{rel}")
|
|
return out if code == 0 else None
|
|
|
|
|
|
def manifest_version(ref: str | None = None) -> str | None:
|
|
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
|
text = manifest_text(ref)
|
|
if text is None:
|
|
return None
|
|
try:
|
|
return json.loads(text).get("version")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# Distinct from None, which is a legitimate "this manifest does not exist".
|
|
_UNREADABLE = object()
|
|
|
|
|
|
def manifest_differs_beyond_version(a: str | None, b: str | None) -> bool:
|
|
"""Do two `plugin.json` texts differ in anything OTHER than `version`?
|
|
|
|
THE exclusion, and it is kept pure — no git, no filesystem — because this
|
|
is the half worth testing hard and it needs no repository to exercise.
|
|
|
|
Compares PARSED objects rather than text, so reformatting, key reordering
|
|
and whitespace do not read as content changes. `version` is dropped from
|
|
both sides; everything else counts, which is what keeps a userConfig-only
|
|
or mcpServers-only edit demanding a new version.
|
|
|
|
Unreadable input answers True. The conservative direction is "demand a new
|
|
version": a spurious bump costs one cache refresh, while a missed one is
|
|
#2209 — the fix reaches the repo and stops there.
|
|
"""
|
|
def without_version(text: str | None):
|
|
if text is None:
|
|
return None
|
|
try:
|
|
data = json.loads(text)
|
|
except Exception:
|
|
return _UNREADABLE
|
|
if not isinstance(data, dict):
|
|
return _UNREADABLE
|
|
return {k: v for k, v in data.items() if k != "version"}
|
|
|
|
left, right = without_version(a), without_version(b)
|
|
if left is _UNREADABLE or right is _UNREADABLE:
|
|
return True
|
|
return left != right
|
|
|
|
|
|
def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
|
"""Has anything that REACHES AN INSTALL changed against `base`?
|
|
|
|
Returns `(changed, paths)`. `changed` is **None** when the question could
|
|
not be answered — a caller must never read that as "no", which is the
|
|
distinction #2663 cost weeks of zeroed telemetry to learn.
|
|
|
|
The manifest is special-cased, not excluded: if it is the ONLY thing that
|
|
moved and the only difference is `version`, nothing that reaches an
|
|
install has changed. Any other manifest field, or any other file, counts.
|
|
|
|
Reads `version_relevant_paths`, which is the shipped set PLUS the files
|
|
that decide the version — see there for why the deriver has to be in it.
|
|
"""
|
|
code, out = _git("diff", "--name-only", base, "--", *version_relevant_paths())
|
|
if code != 0:
|
|
return None, []
|
|
paths = [p for p in out.splitlines() if p.strip()]
|
|
if not paths:
|
|
return False, []
|
|
|
|
rel_manifest = MANIFEST.relative_to(ROOT).as_posix()
|
|
if paths == [rel_manifest]:
|
|
return manifest_differs_beyond_version(
|
|
manifest_text(), manifest_text(base)
|
|
), paths
|
|
return True, paths
|
|
|
|
|
|
def check_version_is_minted(base: str = "origin/main") -> None:
|
|
"""THE control (#3127 checklist 4), replacing "somebody remembers".
|
|
|
|
The checklist asks, of any hand-set component: *say what happens the
|
|
release somebody forgets it.* This is the answer — the lane goes red,
|
|
deterministically, because CI can compute whether the value should have
|
|
moved. Its predecessor could only ask "did the number move at all", which
|
|
any bump satisfied and which therefore proved nothing.
|
|
|
|
Four verdicts:
|
|
|
|
content changed, version did not FAIL — this is #2209, exactly
|
|
version not in canonical shape FAIL — see below
|
|
version implausibly in the future FAIL — a bad clock or a hand-edit
|
|
version moved, content did not pass, and say so
|
|
|
|
THE LAST ROW IS NOT A FAILURE, DELIBERATELY. A needless re-mint costs one
|
|
cache refresh and nothing else. Failing the lane over a harmless act is how
|
|
a check earns a `--no-version` in somebody's muscle memory and stops
|
|
running at all — which is the failure mode this whole file exists to
|
|
prevent. The implication that matters is one-directional: content changed
|
|
IMPLIES version moved.
|
|
|
|
A malformed version is worth failing on even though the installer would
|
|
accept it. `K4` returns the manifest string verbatim, and `H == "unknown"`
|
|
sets `forceOverwrite`, so a broken value either sorts as a normal string
|
|
or reinstalls the plugin every single session (#3325). Neither is loud.
|
|
|
|
Stated against the BASE BRANCH rather than the last commit, as its
|
|
predecessor was: a per-commit rule would demand a fresh mint from every
|
|
commit in a batch, when what matters is that whatever reaches an install
|
|
differs from what is cached. One mint per batch, which is also how a person
|
|
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 minted-version 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
|
|
|
|
here = manifest_version()
|
|
if here is None:
|
|
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
|
return
|
|
if not VERSION_RE.match(here):
|
|
fail(
|
|
f"the manifest version is {here!r}, which is not YYYY.MM.DD.HHMM.\n"
|
|
f" One shape for every version in the family (#3127 checklist "
|
|
f"10), zero-padded so the midnight case renders 2026.01.05.0000.\n"
|
|
f" Run `make mint-plugin`."
|
|
)
|
|
return
|
|
|
|
minted = datetime.strptime(here, "%Y.%m.%d.%H%M").replace(tzinfo=timezone.utc)
|
|
# A day of slack: the mint happens on a workstation and the lane runs
|
|
# later, so a *small* skew is ordinary. A value further out than that is
|
|
# a wrong clock or a typed year, and it makes the version lie about when
|
|
# it was minted.
|
|
if minted > datetime.now(timezone.utc) + timedelta(days=1):
|
|
fail(
|
|
f"the manifest version {here} is in the future. Either the clock "
|
|
f"that minted it is wrong, or it was typed by hand."
|
|
)
|
|
return
|
|
|
|
changed, paths = shipped_content_changed(base)
|
|
if changed is None:
|
|
fail(f"git diff against {base} failed, so the version check could not run")
|
|
return
|
|
|
|
there = manifest_version(base)
|
|
if there is None:
|
|
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
|
return
|
|
|
|
if changed and here == there:
|
|
files = "\n ".join(paths)
|
|
fail(
|
|
f"plugin content changed but the version is still {here}.\n"
|
|
f" The installer decides whether to refresh its cache by "
|
|
f"comparing this string, so an unchanged version means these edits "
|
|
f"reach the repo and stop there — the marketplace clone updates, the "
|
|
f"cache that actually executes does not (#2209, #1040, #2220).\n"
|
|
f" Run `make mint-plugin`.\n"
|
|
f" Changed:\n {files}"
|
|
)
|
|
elif changed:
|
|
ok(f"plugin content changed and the version was minted {there} -> {here}")
|
|
elif here != there:
|
|
# Not a failure — see the docstring. Named rather than silent, because
|
|
# the uninteresting cause (minted twice) and the interesting one (the
|
|
# version-relevant set is too narrow to see what actually changed)
|
|
# produce the same line, and only a person can tell them apart.
|
|
ok(
|
|
f"the version moved {there} -> {here} with no version-relevant "
|
|
f"change — harmless, unless something DID change that the set "
|
|
f"cannot see"
|
|
)
|
|
else:
|
|
ok(f"nothing version-relevant changed against {base} — no mint required")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--no-version", action="store_true",
|
|
help="skip the minted-version check; for `main`, where "
|
|
"it would be measured against itself")
|
|
parser.add_argument("--base", default="origin/main",
|
|
help="branch the version 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_is_minted(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())
|