diff --git a/Makefile b/Makefile index f9d672c..231f991 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index eafde1b..4e078a1 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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" }, diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index dceaf70..150e72c 100755 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -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: diff --git a/scripts/mint_plugin_version.py b/scripts/mint_plugin_version.py new file mode 100644 index 0000000..2d45d77 --- /dev/null +++ b/scripts/mint_plugin_version.py @@ -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()) diff --git a/tests/test_plugin_version_mint.py b/tests/test_plugin_version_mint.py new file mode 100644 index 0000000..8943e5f --- /dev/null +++ b/tests/test_plugin_version_mint.py @@ -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)