dev → main: rule usage telemetry, the plugin's derived version, and the backlog since b267037
#136
@@ -1,4 +1,4 @@
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
|
||||
|
||||
# --- Docker ---
|
||||
|
||||
@@ -36,3 +36,12 @@ test:
|
||||
|
||||
# Run all checks in one shot (mirrors what CI does)
|
||||
check: lint typecheck test
|
||||
|
||||
# --- Plugin ---
|
||||
|
||||
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
|
||||
# committing. The plugin ships straight from git with no build step, so its
|
||||
# version is minted here rather than stamped by CI; the lane fails if you
|
||||
# forget, but this is what makes remembering cheap.
|
||||
mint-plugin:
|
||||
python3 scripts/mint_plugin_version.py
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.48",
|
||||
"version": "2026.09.01.2252",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
+126
-33
@@ -32,7 +32,7 @@ whole file exists to prevent.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_plugin.py # all checks
|
||||
python3 scripts/check_plugin.py --no-version # skip the bump check
|
||||
python3 scripts/check_plugin.py --no-version # skip the version check
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -43,9 +43,19 @@ 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"
|
||||
@@ -82,14 +92,42 @@ SHIPPED_PATHS = ("plugin", ".claude-plugin")
|
||||
#
|
||||
# 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 — the plugin's mint script
|
||||
# (milestone 334 step 3) will be, and adds its own row.
|
||||
# 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] = []
|
||||
|
||||
|
||||
@@ -500,8 +538,11 @@ def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
||||
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, "--", *SHIPPED_PATHS)
|
||||
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()]
|
||||
@@ -516,19 +557,39 @@ def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
||||
return True, paths
|
||||
|
||||
|
||||
def check_version_bump(base: str = "origin/main") -> None:
|
||||
"""If shipped plugin content differs from `base`, the version must too.
|
||||
def check_version_is_minted(base: str = "origin/main") -> None:
|
||||
"""THE control (#3127 checklist 4), replacing "somebody remembers".
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Reads the set through `shipped_content_changed`, so a commit whose ONLY
|
||||
change is the version field does not count as content moving. Without that
|
||||
the check is circular — the bump edits a file inside `plugin/`, which then
|
||||
reads as the change that justifies the bump.
|
||||
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:
|
||||
@@ -542,42 +603,74 @@ def check_version_bump(base: str = "origin/main") -> None:
|
||||
)
|
||||
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
|
||||
if not changed:
|
||||
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
|
||||
there = manifest_version(base)
|
||||
if there is None:
|
||||
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
||||
return
|
||||
if here == there:
|
||||
|
||||
if changed and here == there:
|
||||
files = "\n ".join(paths)
|
||||
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"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"plugin content changed and version moved {there} -> {here}")
|
||||
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 manifest version-bump check")
|
||||
help="skip the minted-version check")
|
||||
parser.add_argument("--base", default="origin/main",
|
||||
help="branch the version bump is measured against")
|
||||
help="branch the version is measured against")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not HOOKS_DIR.is_dir():
|
||||
@@ -591,7 +684,7 @@ def main() -> int:
|
||||
check_local_prior_art_needs_no_instance()
|
||||
check_session_context_reports_its_version()
|
||||
if not args.no_version:
|
||||
check_version_bump(args.base)
|
||||
check_version_is_minted(args.base)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mint the plugin's version — `YYYY.MM.DD.HHMM`, UTC, zero-padded.
|
||||
|
||||
Run this whenever you change something under `plugin/` or `.claude-plugin/`,
|
||||
before you commit:
|
||||
|
||||
make mint-plugin # or: python3 scripts/mint_plugin_version.py
|
||||
|
||||
WHY A SCRIPT AND NOT A BUILD STEP. `plugin/` is not in the Docker image.
|
||||
Installs fetch it straight from this git repo via `.claude-plugin/
|
||||
marketplace.json`, so **a push IS the release** — there is no build between
|
||||
you committing and a user fetching, and therefore no moment at which CI could
|
||||
stamp a version in. Every other artifact in the family derives its version
|
||||
during a build (note #3127 §2). This one has no build to derive during.
|
||||
|
||||
WHICH CLOCK, AND WHY IT DIFFERS FROM THE SERVER IMAGE — the divergence is
|
||||
deliberate, and it lives one directory away from its opposite, so it is
|
||||
exactly what a later "let's make these consistent" change would collapse:
|
||||
|
||||
server image name from COMMIT time, ordering key from BUILD time
|
||||
(two lanes building one source must report one string;
|
||||
a rebuild of an older commit must not go backwards)
|
||||
plugin one value, from MINT time
|
||||
|
||||
§2's reason for commit time is that two lanes build one source. The plugin has
|
||||
one lane and no build, so that reason does not reach it and paying its cost
|
||||
buys nothing. What is given up is reproducibility-from-history: you cannot
|
||||
recompute this value later, only verify that it moved when it had to.
|
||||
|
||||
That trade is acceptable ONLY because of what #3325 established by reading the
|
||||
installer's code: the refresh test is `P.version === H`, plain string
|
||||
equality, with no ordering comparison anywhere. Where a comparator ORDERS, an
|
||||
unreproducible version is dangerous — nothing can check it is right. Where it
|
||||
only tests equality, "did it change when it should have" is the entire
|
||||
specification, and `check_plugin.py` checks that completely.
|
||||
|
||||
The manifest is rewritten with a surgical replacement of the `version` line
|
||||
rather than `json.dump`, because its formatting and key order are not this
|
||||
script's to decide and a whole-file reformat would make every mint an
|
||||
unreadable diff.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
|
||||
|
||||
# Four dot-separated numeric fields, zero-padded, and nothing else — one shape
|
||||
# for every human-readable version in the family (#3127 checklist 10). The
|
||||
# padding is load-bearing for the midnight case the checklist names by hand:
|
||||
# 2026.01.05.0000, which an unpadded `%-H%M` would render as `0` and silently
|
||||
# shorten. Harmless while nothing orders these, wrong the moment anything does.
|
||||
VERSION_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
||||
VERSION_FORMAT = "%Y.%m.%d.%H%M"
|
||||
|
||||
# The `version` line, captured so its surroundings survive byte-for-byte.
|
||||
VERSION_LINE_RE = re.compile(r'^(\s*"version"\s*:\s*")([^"]*)(".*)$', re.M)
|
||||
|
||||
|
||||
def mint(now: datetime | None = None) -> str:
|
||||
"""The version for this moment. UTC, always — a local-time mint would make
|
||||
the value depend on who ran it."""
|
||||
return (now or datetime.now(timezone.utc)).strftime(VERSION_FORMAT)
|
||||
|
||||
|
||||
def rewrite(text: str, version: str) -> str:
|
||||
"""`text` with its `version` value replaced, and everything else untouched.
|
||||
|
||||
Raises rather than falling back to a JSON round-trip: a manifest this
|
||||
cannot match is one whose shape changed, and quietly reformatting the file
|
||||
to cope would be a much larger edit than the caller asked for.
|
||||
"""
|
||||
# Counted BEFORE substituting, not via subn's return: a capped `subn`
|
||||
# reports the replacements it made, so a manifest with two `version` lines
|
||||
# would look like a clean single match while the second one — the real one,
|
||||
# perhaps — kept its old value.
|
||||
matches = VERSION_LINE_RE.findall(text)
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"expected exactly one `version` line in the manifest, found {len(matches)}"
|
||||
)
|
||||
return VERSION_LINE_RE.sub(
|
||||
lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Mint the plugin's version.")
|
||||
parser.add_argument(
|
||||
"--check", action="store_true",
|
||||
help="print the version that WOULD be minted and change nothing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
version = mint()
|
||||
if args.check:
|
||||
print(version)
|
||||
return 0
|
||||
|
||||
try:
|
||||
text = MANIFEST.read_text()
|
||||
except OSError as exc:
|
||||
print(f"cannot read {MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
previous = json.loads(text).get("version")
|
||||
except Exception:
|
||||
previous = None
|
||||
|
||||
if previous == version:
|
||||
# Same minute. Not an error — the value is already correct for now, and
|
||||
# failing here would turn "I ran it twice" into a problem to solve.
|
||||
print(f"plugin version already {version} (same minute) — unchanged")
|
||||
return 0
|
||||
|
||||
try:
|
||||
MANIFEST.write_text(rewrite(text, version))
|
||||
except ValueError as exc:
|
||||
print(f"{MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"plugin version {previous} -> {version}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,279 @@
|
||||
"""The plugin's version is MINTED, and CI is the control that it moved.
|
||||
|
||||
WHAT THIS IS ABOUT (milestone 334 step 3). `plugin/` ships straight from this
|
||||
git repo — no build step, so no moment at which CI could stamp a version in.
|
||||
The value is therefore minted by a script before the commit, and CI's job is
|
||||
not to produce it but to prove it moved when it had to.
|
||||
|
||||
THE DIVERGENCE THESE GUARD. Two artifacts in one repo derive their versions
|
||||
from different clocks, on purpose:
|
||||
|
||||
server image name from COMMIT time, ordering key from BUILD time
|
||||
plugin one value, from MINT time
|
||||
|
||||
#3127 §2 prescribes commit time so two lanes building one source report one
|
||||
string. The plugin has one lane and no build, so that reason does not reach
|
||||
it. "Let's make these consistent" is the obvious tidy-up and it breaks
|
||||
whichever artifact loses — which is why the difference is pinned here rather
|
||||
than only explained in a comment.
|
||||
|
||||
The trade mint time makes — you cannot recompute the value from history, only
|
||||
verify it moved — is acceptable ONLY because #3325 established that the
|
||||
installer's refresh test is `===` with no ordering anywhere. Where a
|
||||
comparator orders, an unreproducible version would be unverifiable too.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import check_plugin
|
||||
from scripts.mint_plugin_version import VERSION_RE, mint, rewrite
|
||||
|
||||
MINT_SRC = pathlib.Path(check_plugin.ROOT) / "scripts" / "mint_plugin_version.py"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_failures():
|
||||
"""`check_plugin.fail` appends to a module global; without this a failing
|
||||
assertion in one test would be visible from the next."""
|
||||
check_plugin.failures.clear()
|
||||
yield
|
||||
check_plugin.failures.clear()
|
||||
|
||||
|
||||
def fake_manifest(version: str = "2026.09.01.2252") -> str:
|
||||
return json.dumps(
|
||||
{"name": "scribe", "description": "d", "version": version,
|
||||
"userConfig": {"api_endpoint": {"type": "string"}}},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
# ── The mint ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("when,expected", [
|
||||
# THE midnight case, which #3127 checklist 10 names by hand. An unpadded
|
||||
# `%-H%M` renders this hour as `0` and silently shortens the string.
|
||||
(datetime(2026, 1, 5, 0, 0, tzinfo=timezone.utc), "2026.01.05.0000"),
|
||||
(datetime(2026, 1, 5, 0, 9, tzinfo=timezone.utc), "2026.01.05.0009"),
|
||||
(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc), "2026.12.31.2359"),
|
||||
(datetime(2026, 9, 1, 22, 52, tzinfo=timezone.utc), "2026.09.01.2252"),
|
||||
])
|
||||
def test_the_mint_zero_pads_every_field(when, expected):
|
||||
assert mint(when) == expected
|
||||
assert VERSION_RE.match(mint(when))
|
||||
|
||||
|
||||
def test_the_mint_is_UTC_not_local():
|
||||
"""A local-time mint would make the value depend on who ran it — two people
|
||||
minting the same minute would disagree, and the string is the artifact's
|
||||
identity."""
|
||||
utc = datetime(2026, 9, 1, 22, 52, tzinfo=timezone.utc)
|
||||
east = utc.astimezone(timezone(timedelta(hours=9)))
|
||||
assert mint(east) == mint(utc) == "2026.09.01.2252"
|
||||
|
||||
|
||||
def test_the_mint_reads_a_CLOCK_and_never_git():
|
||||
"""The clock divergence from the server image, asserted structurally.
|
||||
|
||||
Mint time is only meaningful if nothing consults history — the moment this
|
||||
script shells out to git it has quietly become a commit-time deriver, and
|
||||
the two artifacts' clocks have been "made consistent" without anyone
|
||||
deciding to. That change would pass every other test in this file.
|
||||
|
||||
Asserted over the AST rather than the text, because the module docstring
|
||||
discusses git at length explaining why it is absent. This looks for USE,
|
||||
not mention.
|
||||
"""
|
||||
tree = ast.parse(MINT_SRC.read_text())
|
||||
|
||||
imported = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imported |= {a.name.split(".")[0] for a in node.names}
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imported.add(node.module.split(".")[0])
|
||||
assert "subprocess" not in imported, (
|
||||
"the mint script imports subprocess — a mint that can read history is "
|
||||
"a commit-time deriver wearing the wrong name"
|
||||
)
|
||||
|
||||
called = {ast.unparse(n.func) for n in ast.walk(tree) if isinstance(n, ast.Call)}
|
||||
assert "datetime.now" in called, "the mint script no longer reads a clock"
|
||||
|
||||
|
||||
# ── The rewrite ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_rewrite_touches_exactly_one_line():
|
||||
"""Surgical, not a JSON round-trip. The manifest's formatting and key order
|
||||
are not this script's to decide, and a whole-file reformat would make every
|
||||
mint an unreadable diff."""
|
||||
before = fake_manifest("0.1.48")
|
||||
after = rewrite(before, "2026.09.01.2252")
|
||||
|
||||
b, a = before.splitlines(), after.splitlines()
|
||||
assert len(b) == len(a)
|
||||
differing = [i for i, (x, y) in enumerate(zip(b, a)) if x != y]
|
||||
assert len(differing) == 1
|
||||
assert '"version": "2026.09.01.2252"' in a[differing[0]]
|
||||
|
||||
|
||||
def test_the_rewrite_preserves_indentation_and_key_order():
|
||||
weird = '{\n\t"name": "scribe",\n\t"version": "0.1.48",\n\t"z": 1\n}\n'
|
||||
out = rewrite(weird, "2026.09.01.2252")
|
||||
assert out == '{\n\t"name": "scribe",\n\t"version": "2026.09.01.2252",\n\t"z": 1\n}\n'
|
||||
|
||||
|
||||
def test_the_rewrite_refuses_a_manifest_it_cannot_match():
|
||||
"""Raises rather than falling back to a JSON round-trip: a manifest this
|
||||
cannot match is one whose shape changed, and quietly reformatting the file
|
||||
to cope would be a far larger edit than the caller asked for."""
|
||||
with pytest.raises(ValueError):
|
||||
rewrite('{"name": "scribe"}', "2026.09.01.2252")
|
||||
|
||||
|
||||
def test_the_rewrite_refuses_TWO_version_lines():
|
||||
"""A capped `subn` would report one replacement and look clean while the
|
||||
second `version` — possibly the real one — kept its old value."""
|
||||
two = '{\n "version": "0.1.48",\n "nested": {\n "version": "9.9.9"\n }\n}\n'
|
||||
with pytest.raises(ValueError):
|
||||
rewrite(two, "2026.09.01.2252")
|
||||
|
||||
|
||||
# ── The version-relevant set includes its own deriver ──────────────────────
|
||||
|
||||
|
||||
def test_the_mint_script_is_version_relevant():
|
||||
"""#3127 §3's asymmetry. A change to how the version is COMPUTED is
|
||||
compared against nothing — leave the deriver out of the set and a format
|
||||
change never forces a re-mint, so the manifest keeps a value in the old
|
||||
format indefinitely and nothing says so."""
|
||||
paths = check_plugin.version_relevant_paths()
|
||||
assert "scripts/mint_plugin_version.py" in paths
|
||||
for shipped in check_plugin.SHIPPED_PATHS:
|
||||
assert shipped in paths
|
||||
|
||||
|
||||
def test_the_checker_is_NOT_version_relevant():
|
||||
"""The inverse, and it is the easy mistake. A checker decides whether the
|
||||
lane goes red, not what the artifact reports — so its absence here is a
|
||||
decision, not an oversight."""
|
||||
assert "scripts/check_plugin.py" not in check_plugin.version_relevant_paths()
|
||||
|
||||
|
||||
# ── The check ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_check(here: str, there: str | None, changed_paths: list[str], monkeypatch):
|
||||
"""Drive `check_version_is_minted` with git stubbed. Returns the failures."""
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "_git",
|
||||
lambda *a: (0, "\n".join(changed_paths)) if a[0] == "diff" else (0, ""),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "manifest_version",
|
||||
lambda ref=None: here if ref is None else there,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
check_plugin, "manifest_text",
|
||||
lambda ref=None: fake_manifest(here if ref is None else (there or "0.0.0.0000")),
|
||||
)
|
||||
check_plugin.check_version_is_minted("origin/main")
|
||||
return list(check_plugin.failures)
|
||||
|
||||
|
||||
def test_content_changed_and_the_version_did_not_FAILS(monkeypatch):
|
||||
"""#2209, exactly. The headline, and the only reason the check exists."""
|
||||
failures = run_check(
|
||||
"2026.09.01.2252", "2026.09.01.2252",
|
||||
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
|
||||
)
|
||||
assert len(failures) == 1
|
||||
assert "still 2026.09.01.2252" in failures[0]
|
||||
assert "scribe_session_context.sh" in failures[0]
|
||||
|
||||
|
||||
def test_content_changed_and_the_version_moved_PASSES(monkeypatch):
|
||||
assert run_check(
|
||||
"2026.09.01.2252", "2026.08.30.1200",
|
||||
["plugin/hooks/scribe_session_context.sh"], monkeypatch,
|
||||
) == []
|
||||
|
||||
|
||||
def test_a_version_that_is_not_the_canonical_shape_FAILS(monkeypatch):
|
||||
"""`K4` returns the manifest string verbatim, so a malformed value is not
|
||||
rejected by the installer — it either sorts as an ordinary string or, when
|
||||
unreadable, forces a reinstall every session. Neither is loud (#3325)."""
|
||||
failures = run_check("0.1.48", "0.1.47", [], monkeypatch)
|
||||
assert len(failures) == 1
|
||||
assert "not YYYY.MM.DD.HHMM" in failures[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["2026.9.1.2252", "2026.09.01.252", "2026.09.01"])
|
||||
def test_an_UNPADDED_or_short_version_FAILS(bad, monkeypatch):
|
||||
"""The padding is the contract, not cosmetics — one shape for every version
|
||||
in the family (#3127 checklist 10)."""
|
||||
assert run_check(bad, "2026.08.30.1200", [], monkeypatch) != []
|
||||
|
||||
|
||||
def test_a_version_in_the_future_FAILS(monkeypatch):
|
||||
ahead = (datetime.now(timezone.utc) + timedelta(days=400)).strftime("%Y.%m.%d.%H%M")
|
||||
failures = run_check(ahead, "2026.08.30.1200", [], monkeypatch)
|
||||
assert len(failures) == 1
|
||||
assert "in the future" in failures[0]
|
||||
|
||||
|
||||
def test_a_version_minted_minutes_ago_is_NOT_in_the_future(monkeypatch):
|
||||
"""The guard has to tolerate ordinary skew: the mint happens on a
|
||||
workstation and the lane runs later, on another machine's clock."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y.%m.%d.%H%M")
|
||||
assert run_check(now, "2026.08.30.1200", [], monkeypatch) == []
|
||||
|
||||
|
||||
def test_nothing_changed_and_nothing_minted_PASSES(monkeypatch):
|
||||
assert run_check("2026.09.01.2252", "2026.09.01.2252", [], monkeypatch) == []
|
||||
|
||||
|
||||
def test_a_version_that_moved_with_no_content_change_is_NOT_a_failure(monkeypatch):
|
||||
"""Deliberately a pass. A needless re-mint costs one cache refresh; 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. The implication that
|
||||
matters is one-directional: content changed IMPLIES version moved."""
|
||||
assert run_check("2026.09.01.2252", "2026.08.30.1200", [], monkeypatch) == []
|
||||
|
||||
|
||||
def test_a_failed_diff_FAILS_rather_than_passing_quietly(monkeypatch):
|
||||
"""A check that cannot run must not report the same thing as a check that
|
||||
passed — #2663's lesson, and the reason this whole file's siblings exist."""
|
||||
monkeypatch.setattr(check_plugin, "_git", lambda *a: (128, "fatal"))
|
||||
monkeypatch.setattr(check_plugin, "manifest_version",
|
||||
lambda ref=None: "2026.09.01.2252")
|
||||
check_plugin.check_version_is_minted("origin/main")
|
||||
assert len(check_plugin.failures) == 1
|
||||
assert "could not run" in check_plugin.failures[0]
|
||||
|
||||
|
||||
# ── The real manifest ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_shipped_manifest_carries_a_minted_version():
|
||||
"""The end of the hand-bumped scheme, asserted on the real file. `0.1.48`
|
||||
was the last of 48 numbers a person typed."""
|
||||
version = json.loads(check_plugin.MANIFEST.read_text())["version"]
|
||||
assert VERSION_RE.match(version), (
|
||||
f"the shipped manifest says {version!r}, which is not a minted version"
|
||||
)
|
||||
|
||||
|
||||
def test_the_session_context_hook_still_reads_the_version_field():
|
||||
"""The marker #2220 asked for. The value's SHAPE changed, not the field or
|
||||
its reader — if this had to move, the derivation went somewhere it should
|
||||
not have."""
|
||||
hook = (check_plugin.HOOKS_DIR / "scribe_session_context.sh").read_text()
|
||||
assert re.search(r"jq\s+-r\s+'\.version", hook)
|
||||
Reference in New Issue
Block a user