Files
FabledScribe/tests/test_plugin_shipped_set.py
T
bvandeusenandClaude Opus 5 f5a3643da8
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
refactor(plugin): retire what the hand-bump scheme left behind — the README that taught it, the floor test, the stale rationale (#3328)
#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
2026-09-02 00:38:50 -04:00

251 lines
10 KiB
Python

"""One definition of what SHIPS in the plugin, and the drift guards on it.
WHAT THIS IS ABOUT (#3127 §3, milestone 334 step 2). Scribe publishes two
artifacts. `plugin/` is not in the Docker image — installs fetch it from this
repo through `.claude-plugin/marketplace.json`, so **a push IS the release**,
with no build step in between. That makes "which files reach an install?" a
question with real consequences, and it has been answered wrong twice:
- #2198 — `plugin/**` was in no `paths:` filter, so four broken hooks
reached live installs having triggered no CI at all.
- #2209 — the fix for that shipped and still could not reach an install,
because the manifest version had not moved.
The set lives in `scripts/check_plugin.py`. Its second consumer is the
workflow's `paths:` trigger, which is YAML and cannot import Python — so the
"one definition" is held together by the drift tests here rather than by an
import. That is the honest shape, and it is why these tests exist at all.
The exclusion tests are the load-bearing half. Without the manifest-`version`
exclusion the version check is CIRCULAR: bumping the version edits a file
inside `plugin/`, which then reads as the content change that justifies the
bump. Every bump passes, no bump ever fails, and the check has proved nothing
while looking green.
"""
import json
import pathlib
import re
import pytest
from scripts.check_plugin import (
DERIVERS,
SHIPPED_PATHS,
manifest_differs_beyond_version,
)
ROOT = pathlib.Path(__file__).resolve().parents[1]
CI = ROOT / ".forgejo/workflows/ci.yml"
def trigger_paths() -> list[str]:
"""The `paths:` list under the workflow's push trigger.
Parsed with a regex rather than a YAML library, matching what
test_version_endpoint.py already does with this file — the alternative is
adding PyYAML as a dependency for one assertion. Raises rather than
returning empty: a silent no-op here would defeat the point of the file.
"""
text = CI.read_text()
block = re.search(r"^ paths:\n((?:(?: [-#].*)?\n)+)", text, re.M)
if block is None:
raise AssertionError("could not find the push trigger's `paths:` block")
found = re.findall(r'^ - "([^"]+)"', block.group(1), re.M)
if not found:
raise AssertionError("the `paths:` block parsed to zero entries")
return found
# ── The set itself ─────────────────────────────────────────────────────────
def test_every_shipped_path_exists():
"""A set naming something that isn't there is not a definition of anything."""
for path in SHIPPED_PATHS:
assert (ROOT / path).exists(), f"SHIPPED_PATHS names {path}, which does not exist"
def test_every_shipped_path_triggers_ci():
"""#2198's exact hole, stated as an assertion.
Directional on purpose: the trigger is a superset (it also fires on
`src/**`, `tests/**` and friends). What must never happen is a path that
reaches an install and fires no lane.
"""
triggers = trigger_paths()
for path in SHIPPED_PATHS:
covered = any(t == path or t.startswith(f"{path}/") for t in triggers)
assert covered, (
f"{path} ships to installs but no `paths:` entry covers it — "
f"changes there would reach a live install having run no CI (#2198)"
)
def test_the_checker_itself_triggers_ci():
"""Changing the checks must re-run them.
Not a member of the shipped set — a checker decides whether the lane goes
red, not what any artifact reports — but a change to it that runs no lane
is the same silence by a different route.
"""
assert "scripts/check_plugin.py" in trigger_paths()
def test_no_trigger_path_names_something_that_does_not_exist():
"""The guard that catches scaffolding outliving its subsystem.
`fable-mcp/**` sat in this list for three months after the directory was
deleted (commit 91bafb6, 2026-05-27), and `assets/**` named a path that
never existed at all. Neither ever failed anything — a `paths:` entry
matching nothing simply never fires — which is precisely why a list kept
by hand drifts and nobody finds out.
"""
missing = [
entry for entry in trigger_paths()
if not (ROOT / re.sub(r"/\*\*$", "", entry)).exists()
]
assert not missing, (
f"`paths:` names {missing}, which do not exist in the repo. A trigger "
f"that matches nothing is silent, so it survives every review."
)
def test_every_deriver_exists():
"""§3's table, kept honest.
The point of the table is that the next artifact is a one-line addition
(milestone 334 step 3 adds the plugin's mint script). A row pointing at a
file that has moved would make the table read as complete when it is not.
"""
for path, artifacts in DERIVERS.items():
assert (ROOT / path).exists(), f"DERIVERS names {path}, which does not exist"
assert artifacts, f"DERIVERS[{path}] names no artifact"
# ── The exclusion — the half that makes the version check mean anything ────
def manifest(**fields) -> str:
base = {
"name": "scribe",
"description": "d",
"version": "0.1.48",
"mcpServers": {"scribe": {"type": "http", "url": "${user_config.api_endpoint}/mcp"}},
"userConfig": {"api_endpoint": {"type": "string"}},
}
base.update(fields)
return json.dumps(base)
def test_a_version_only_change_is_NOT_a_content_change():
"""THE assertion. Without it the version check is self-satisfying: the
bump edits `plugin.json`, which lives inside `plugin/`, so the bump is its
own justification and every bump passes."""
assert manifest_differs_beyond_version(
manifest(version="2026.09.01.0512"), manifest(version="0.1.48")
) is False
def test_an_identical_manifest_is_not_a_change():
assert manifest_differs_beyond_version(manifest(), manifest()) is False
@pytest.mark.parametrize("field,value", [
("userConfig", {"api_endpoint": {"type": "string", "title": "changed"}}),
("mcpServers", {"scribe": {"type": "http", "url": "elsewhere"}}),
("description", "a different description"),
("name", "renamed"),
])
def test_every_OTHER_manifest_field_still_demands_a_new_version(field, value):
"""Why the exclusion is one FIELD and never the whole file.
`plugin.json` carries description, mcpServers and userConfig alongside the
version, and all of them reach an install. Excluding the file wholesale
would mean a userConfig-only edit computes an unchanged version and never
refreshes — #2209 again, with a narrower trigger and the same silence.
"""
assert manifest_differs_beyond_version(manifest(**{field: value}), manifest()) is True
def test_reformatting_is_not_a_content_change():
"""Parsed objects, not text. Whitespace and key order are not content, and
a check that treated them as such would demand a version for a re-indent."""
data = json.loads(manifest())
reordered = {k: data[k] for k in reversed(list(data))}
assert manifest_differs_beyond_version(
json.dumps(reordered, indent=4), json.dumps(data, separators=(",", ":"))
) is False
@pytest.mark.parametrize("bad", ["", "{not json", "[]", '"a string"', "null"])
def test_unreadable_input_demands_a_new_version(bad):
"""The conservative direction, chosen deliberately.
A spurious bump costs one cache refresh. A missed one is #2209 — the fix
reaches the repo and stops there, and the only detector is a human saying
"I don't think it updated."
"""
assert manifest_differs_beyond_version(bad, manifest()) is True
assert manifest_differs_beyond_version(manifest(), bad) is True
def test_a_manifest_appearing_or_vanishing_is_a_change():
"""None means the file is absent at that ref — a real difference, and not
the same thing as unreadable."""
assert manifest_differs_beyond_version(None, manifest()) is True
assert manifest_differs_beyond_version(manifest(), None) is True
# ── The reader that joins the exclusion to git ─────────────────────────────
def test_shipped_content_changed_reports_a_version_only_commit_as_unchanged(monkeypatch):
"""End to end through the git seam, with git stubbed.
The unit above proves the comparison; this proves it is actually WIRED to
the path that `check_version_is_minted` reads. A correct helper nobody calls
would leave the circular check exactly as it was.
"""
from scripts import check_plugin
monkeypatch.setattr(
check_plugin, "_git",
lambda *a: (0, "plugin/.claude-plugin/plugin.json"),
)
monkeypatch.setattr(
check_plugin, "manifest_text",
lambda ref=None: manifest(version="2026.09.01.0512" if ref is None else "0.1.48"),
)
changed, paths = check_plugin.shipped_content_changed("origin/main")
assert changed is False
assert paths == ["plugin/.claude-plugin/plugin.json"]
def test_shipped_content_changed_reports_a_hook_edit_as_changed(monkeypatch):
"""The guard against an exclusion that swallowed everything — a check that
can never fire is indistinguishable from one that is broken."""
from scripts import check_plugin
monkeypatch.setattr(
check_plugin, "_git",
lambda *a: (0, "plugin/hooks/scribe_session_context.sh"),
)
changed, paths = check_plugin.shipped_content_changed("origin/main")
assert changed is True
assert paths == ["plugin/hooks/scribe_session_context.sh"]
def test_a_failed_diff_is_None_and_never_False(monkeypatch):
"""Could-not-tell and nothing-changed must not collapse into one value.
#2663 is the precedent: a read that failed inside a broad except reported
the same zero as a genuinely empty window, and every counter read zero for
weeks with nothing to distinguish the two.
"""
from scripts import check_plugin
monkeypatch.setattr(check_plugin, "_git", lambda *a: (128, "fatal: bad revision"))
changed, paths = check_plugin.shipped_content_changed("origin/main")
assert changed is None
assert paths == []