feat: export guidelines - the guideline pages as a generated GUIDELINES.md, pushed into the captured repositories behind the Guideline Push Gate (#179)
CI / verify (push) Successful in 5m40s
CI / pwsh (push) Successful in 1m59s
Release / release (push) Successful in 35s

Files changed:
- AGENTS.md
- CHANGES.md
- README.md
- VERSION
- docs/why-gates-are-code.md
- instructions/gates.md
- instructions/kb-profiles.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/export_cmd.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/search.py
- tools/chemenu/guideline_export.py
- tools/chemenu/kb_scan.py
- tools/chemenu/repo_capture.py
- tools/chemenu/search/filters.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_export_guidelines.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-06 06:48:07 +02:00
1 parent ccede04b97
commit 283cdae8be
25 files changed
+1753 -68

No files matched your search

+3 -1
View File
@@ -340,13 +340,15 @@ def test_network_yes_is_exactly_the_commands_that_can_reach_outside_this_checkou
not only the two `version_cmd.py` used to claim exclusivity for. `dist upgrade` is on the list
since `--latest`, which asks the release feed and downloads from it, and `raw fetch`
since it exists (Gitea #120) - its `--html` form stays offline, which does not turn the
command back to `no`. `raw capture` and `raw status` reach a git remote (Gitea #177). Pinned as an explicit
command back to `no`. `raw capture` and `raw status` reach a git remote (Gitea #177), and so
does `export guidelines --push` (Gitea #179). Pinned as an explicit
set so a command gaining or losing that reach is a deliberate edit here, not a silent
drift between the property and what the command actually does."""
expected = {
"raw fetch",
"raw capture",
"raw status",
"export guidelines",
"sync",
"publish",
"version check",
@@ -0,0 +1,553 @@
"""`export guidelines` and the Guideline Push Gate, against local repositories
over `file://` - never the network.
The instance is the `kb_dir` fixture tree made into a git checkout with its own
identity and a copy of the shipped `types/`, so the cleanliness check sees the
same two directories it sees in a real instance. Target repositories are bare
repositories, each fed from a work clone, because a push into a checked-out
branch is refused by git itself.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from typer.testing import CliRunner
from chemenu import config, guideline_export, repo_capture
from chemenu.cli import app
from chemenu.commands import export_cmd
from chemenu.commands.raw_cmd import raw_accept_command, raw_capture_command, raw_status_command
from chemenu.frontmatter_io import write_page
from chemenu.markdown_code import strip_code_spans
from chemenu.search import filters
from chemenu.search.registry import resolve
from chemenu.search.service import load_pages_by_path, run_search
from chemenu.search.types import SearchQuery
from chemenu.type_resolver import resolver
runner = CliRunner()
TAG = ["--tag", "guideline"]
def _git(cwd: Path, *args: str, check: bool = True) -> str:
return subprocess.run(
["git", "-c", "user.name=Fixture", "-c", "user.email=f@example.org", *args],
cwd=cwd, check=check, capture_output=True, text=True,
).stdout.strip()
def _bare(bare: Path, *args: str) -> str:
return subprocess.run(
["git", f"--git-dir={bare}", *args], check=True, capture_output=True, text=True
).stdout.strip()
class Target:
"""A bare repository - what the export pushes into - fed from a work
clone that plays the project's own developers."""
def __init__(self, base: Path, guidelines: bytes | None = None) -> None:
self.work = base / "work"
self.bare = base / "remote.git"
self.work.mkdir(parents=True)
_git(self.work, "init", "-q", "-b", "main")
(self.work / "README.md").write_text("# Project\n", encoding="utf-8")
(self.work / "docs").mkdir()
(self.work / "docs" / "a.md").write_text("# A\n", encoding="utf-8")
if guidelines is not None:
(self.work / "GUIDELINES.md").write_bytes(guidelines)
self.commit("initial")
_git(base, "clone", "-q", "--bare", str(self.work), str(self.bare))
_git(self.work, "remote", "add", "origin", self.url)
@property
def url(self) -> str:
return self.bare.as_uri()
def commit(self, message: str = "change") -> None:
_git(self.work, "add", "-A")
_git(self.work, "commit", "-q", "--allow-empty", "-m", message)
def advance(self, message: str = "upstream change") -> str:
"""A commit by someone else, pushed to the remote branch."""
_git(self.work, "pull", "-q", "--ff-only", "origin", "main", check=False)
(self.work / "docs" / "a.md").write_text(f"# A\n\n{message}\n", encoding="utf-8")
self.commit(message)
_git(self.work, "push", "-q", "origin", "HEAD:main")
return self.tip()
def tip(self) -> str:
return _bare(self.bare, "rev-parse", "refs/heads/main")
def refs(self) -> str:
return _bare(self.bare, "for-each-ref", "--format=%(refname) %(objectname)")
def file(self, rev: str = "refs/heads/main") -> bytes:
return subprocess.run(
["git", f"--git-dir={self.bare}", "show", f"{rev}:GUIDELINES.md"],
check=True, capture_output=True,
).stdout
STUB = b"<!-- wikitool:export kind=guidelines -->\n"
ALPHA_BODY = """
# Alpha Rule
Keep secrets in `.env` only[^s-handbook]. See [[Beta Rule]] and [[Beta Rule|the beta]],
also [[Beta Rule#Scope]] and [[Beta Rule#Scope|its scope]]. Legacy^[[Source - Handbook]] too.
## Details
Inline `[[Not A Link]]` and `[^not-a-cite]` stay as written.
```
[[Fenced|kept]] and [^fenced] and ^[[Fenced]]
```
<!-- wikitool:links -->
## Beziehungen
- **related:** [[Beta Rule]]
<!-- /wikitool:links -->
<!-- wikitool:footnotes -->
## Fußnoten
[^s-handbook]: [[Source - Handbook]]
<!-- /wikitool:footnotes -->
"""
BETA_BODY = """
Beta has no heading of its own.
### Scope
Everything.
## Footnotes
[^s-handbook]: [[Source - Handbook]]
"""
def _guideline(kb: Path, title: str, body: str, tags=("guideline",)) -> Path:
path = kb / "concepts" / f"{title}.md"
write_page(path, {"type": "types/concept.md", "tags": list(tags), "summary": f"{title}."}, body)
return path
def _commit_instance(root: Path, message: str = "instance change") -> None:
_git(root, "add", "-A")
_git(root, "commit", "-q", "-m", message)
@pytest.fixture
def instance(kb_dir, monkeypatch):
root = kb_dir.parent
shutil.copytree(config._PACKAGE_ROOT / "types", root / "types")
monkeypatch.setattr(config, "TYPES_DIR", root / "types")
monkeypatch.setattr(resolver, "_repo_root", root)
monkeypatch.setattr(repo_capture, "ALLOWED_SCHEMES", ("ssh", "https", "file"))
(root / "raw").mkdir(exist_ok=True)
(root / "incoming").mkdir(exist_ok=True)
(root / ".gitignore").write_text("tools/\n", encoding="utf-8")
_guideline(kb_dir, "Alpha Rule", ALPHA_BODY)
_guideline(kb_dir, "beta rule", BETA_BODY)
_git(root, "init", "-q", "-b", "main")
_git(root, "config", "user.name", "Instance Author")
_git(root, "config", "user.email", "author@example.org")
_commit_instance(root, "instance")
return root
def _manifest(root: Path, name: str, repo: str, ref: str = "main") -> Path:
bundle = root / "raw" / "2026" / "10" / name
bundle.mkdir(parents=True)
data = {
"schema": 1, "repo": repo, "ref": ref, "commit": "0" * 40, "paths": ["docs/**"],
"captured": "2026-10-05T00:00:00Z", "fidelity": "verbatim", "authority": "normative",
"files": [],
}
(bundle / repo_capture.MANIFEST_NAME).write_text(json.dumps(data), encoding="utf-8")
return bundle
@pytest.fixture
def target(instance, tmp_path):
t = Target(tmp_path / "targets" / "one", STUB)
_manifest(instance, "one", t.url)
_commit_instance(instance, "capture one")
return t
def _run(*args: str):
return runner.invoke(app, ["export", "guidelines", *args])
def _token(output: str) -> str:
match = re.search(r"--confirm (\w+) --push", output)
assert match, output
return match.group(1)
def _push(*extra: str):
"""Gate run, then the confirmed run - what an approved push looks like."""
gated = _run(*TAG, "--push", *extra)
assert gated.exit_code == 42, gated.output
return _run(*TAG, "--push", "--confirm", _token(gated.output), *extra)
# --- selection ----------------------------------------------------------------
def test_selection_is_searchs_own(instance, kb_dir):
_guideline(kb_dir, "Gamma Elsewhere", "\nNot a guideline.\n", tags=("other",))
_commit_instance(instance)
result = _run(*TAG)
assert result.exit_code == 0, result.output
exported = re.findall(r"^# (.+)$", result.output, re.MULTILINE)
predicates = tuple(filters.parse_predicate(r) for r in filters.raw_predicates(tag="guideline"))
search = run_search(SearchQuery(text=None, predicates=predicates, limit=0),
load_pages_by_path(), resolve(None))
expected = sorted((h.title for h in search.hits), key=lambda t: (t.casefold(), t))
assert exported == expected == ["Alpha Rule", "beta rule"]
@pytest.mark.parametrize("args, needle", [
((), "No predicate"),
(("--tag", "nothing-has-this"), "No page matches"),
(("--field", "no_such_field=x"), "unknown field"),
])
def test_refusals_print_no_export(instance, target, args, needle):
before = target.refs()
for extra in ((), ("--push",)):
result = _run(*args, *extra)
assert result.exit_code == 1, result.output
assert needle in result.output
assert "<!-- wikitool:export" not in result.output
assert target.refs() == before
def test_unreadable_frontmatter_anywhere_refuses(instance, kb_dir, target):
(kb_dir / "concepts" / "Broken.md").write_text("---\ntype: [unclosed\n---\nBody\n", encoding="utf-8")
_commit_instance(instance)
before = target.refs()
result = _run(*TAG, "--push")
assert result.exit_code == 1
assert "Broken.md" in result.output and "<!-- wikitool:export" not in result.output
assert target.refs() == before
@pytest.mark.parametrize("change", ["modified", "untracked-kb", "untracked-types"])
def test_dirty_kb_or_types_refuses(instance, kb_dir, target, change):
if change == "modified":
page = kb_dir / "concepts" / "Alpha Rule.md"
page.write_text(page.read_text(encoding="utf-8") + "\nmore\n", encoding="utf-8")
elif change == "untracked-kb":
(kb_dir / "concepts" / "draft.txt").write_text("x\n", encoding="utf-8")
else:
(instance / "types" / "draft.md").write_text("x\n", encoding="utf-8")
before = target.refs()
for extra in ((), ("--push",)):
result = _run(*TAG, *extra)
assert result.exit_code == 1, result.output
assert "uncommitted changes" in result.output
assert "<!-- wikitool:export" not in result.output
assert target.refs() == before
# --- rendering ----------------------------------------------------------------
def _export(*args: str) -> str:
result = _run(*(args or TAG))
assert result.exit_code == 0, result.output
return result.output
def test_rendering_resolves_links_and_drops_citations_outside_code(instance):
out = _export()
assert out.endswith("\n") and not out.endswith("\n\n") and "\r" not in out
assert "See Beta Rule and the beta,\nalso Beta Rule and its scope. Legacy too." in out
assert "Keep secrets in `.env` only." in out
assert "Inline `[[Not A Link]]` and `[^not-a-cite]` stay as written." in out
assert "```\n[[Fenced|kept]] and [^fenced] and ^[[Fenced]]\n```" in out
prose = strip_code_spans(out)
for forbidden in ("[[", "[^", "^[[", "wikitool:links", "wikitool:footnotes", "Beziehungen",
"Fußnoten", "Footnotes", "type: types/", "---\n"):
assert forbidden not in prose, forbidden
def test_headings(instance):
out = _export()
lines = out.split("\n")
assert lines[1] == "" and lines[2] == "# Alpha Rule" and lines[3] == ""
assert out.count("# Alpha Rule") == 1
assert "\n## Details\n" in out and "\n### Scope\n" in out
assert "\n\n# beta rule\n\nBeta has no heading of its own." in out
def test_header_line(instance):
first = _export().split("\n", 1)[0]
commit = _git(instance, "log", "-1", "--format=%H", "--", "kb/concepts/Alpha Rule.md",
"kb/concepts/beta rule.md")
assert first == (
f"<!-- wikitool:export kind=guidelines instance=local commit={commit} "
"- generated, do not edit by hand -->"
)
def test_instance_is_origin_without_userinfo(instance):
_git(instance, "remote", "add", "origin", "https://user:s3cret@git.example.org/team/wiki.git")
first = _export().split("\n", 1)[0]
assert "instance=https://git.example.org/team/wiki.git " in first
assert "s3cret" not in first and "user" not in first
@pytest.mark.parametrize("url, expected", [
("https://user:tok@host/x.git", "https://host/x.git"),
("ssh://git@host:2222/x.git", "ssh://host:2222/x.git"),
("git@host:team/x.git", "host:team/x.git"),
("https://host/x.git", "https://host/x.git"),
])
def test_strip_userinfo(url, expected):
assert guideline_export.strip_userinfo(url) == expected
def test_output_is_deterministic_and_ignores_unrelated_commits(instance, kb_dir):
first = _export()
assert _export() == first
(kb_dir / "concepts" / "Unrelated.md").write_text(
"---\ntype: types/concept.md\ntags: [other]\n---\n\nx\n", encoding="utf-8"
)
_commit_instance(instance, "unrelated")
assert _export() == first
# --- the gate and the push ----------------------------------------------------
def test_push_without_identity_refuses_before_any_target(instance, target):
_git(instance, "config", "--unset", "user.email")
before = target.refs()
result = _run(*TAG, "--push")
assert result.exit_code == 1, result.output
assert "user.email" in result.output
assert not re.search(r"--confirm \w{12}", result.output)
assert "NEEDS USER CLEARANCE" not in result.output
assert target.refs() == before
def test_push_without_token_is_gated(instance, target, tmp_path):
hand = Target(tmp_path / "targets" / "hand", b"# Our own rules\n")
_manifest(instance, "hand", hand.url)
_commit_instance(instance)
before = (target.refs(), hand.refs())
result = _run(*TAG, "--push")
assert result.exit_code == 42, result.output
assert (target.refs(), hand.refs()) == before
assert f"{target.url} main: new" in result.output
assert f"{hand.url} main: skipped" in result.output
assert "+# Alpha Rule" in result.output and "-<!-- wikitool:export kind=guidelines -->" in result.output
assert re.search(r"tools/wikitool export guidelines --confirm \w{12} --push --field tags=guideline",
result.output)
def test_confirmed_push_writes_one_commit_with_only_guidelines(instance, target):
old = target.tip()
stdout = _export()
result = _push()
assert result.exit_code == 0, result.output
new = target.tip()
assert f"written {old[:12]} -> {new[:12]}" in result.output
assert _bare(target.bare, "rev-parse", f"{new}^") == old
assert _bare(target.bare, "diff", "--name-only", old, new) == "GUIDELINES.md"
assert target.file() == stdout.encode("utf-8")
assert _bare(target.bare, "log", "-1", "--format=%an <%ae>|%cn <%ce>", new) == (
"Instance Author <author@example.org>|Instance Author <author@example.org>"
)
commit = stdout.split("commit=", 1)[1][:12]
assert _bare(target.bare, "log", "-1", "--format=%s", new) == (
f"Update GUIDELINES.md from local at {commit}"
)
again = _run(*TAG, "--push")
assert again.exit_code == 0, again.output
assert f"{target.url} main: unchanged" in again.output
assert "--confirm" not in again.output
assert target.tip() == new
def test_stale_tokens_are_gated_again(instance, kb_dir, target, tmp_path):
token = _token(_run(*TAG, "--push").output)
target.advance()
moved = _run(*TAG, "--push", "--confirm", token)
assert moved.exit_code == 42 and "does not match" in moved.output
token = _token(moved.output)
page = kb_dir / "concepts" / "beta rule.md"
page.write_text(page.read_text(encoding="utf-8") + "\nAmended.\n", encoding="utf-8")
_commit_instance(instance, "amend beta")
changed = _run(*TAG, "--push", "--confirm", token)
assert changed.exit_code == 42 and "+Amended." in changed.output
token = _token(changed.output)
second = Target(tmp_path / "targets" / "two", STUB)
_manifest(instance, "two", second.url)
_commit_instance(instance, "capture two")
added = _run(*TAG, "--push", "--confirm", token)
assert added.exit_code == 42 and f"{second.url} main: new" in added.output
before = (target.refs(), second.refs())
invented = _run(*TAG, "--push", "--confirm", "000000000000")
assert invented.exit_code == 42
assert (target.refs(), second.refs()) == before
def test_targets_that_do_not_take_part_are_left_alone(instance, target, tmp_path):
hand = Target(tmp_path / "targets" / "hand", b"# Our own rules\n")
none = Target(tmp_path / "targets" / "none")
foreign = Target(
tmp_path / "targets" / "foreign",
b"<!-- wikitool:export kind=guidelines instance=https://other.example/wiki commit=abc -->\n",
)
tagged = Target(tmp_path / "targets" / "tagged", STUB)
for name, t, ref in (("hand", hand, "main"), ("none", none, "main"),
("foreign", foreign, "main"), ("tagged", tagged, "v*")):
_manifest(instance, name, t.url, ref)
_commit_instance(instance)
before = {t.url: t.refs() for t in (hand, none, foreign, tagged)}
result = _push()
assert result.exit_code == 0, result.output
assert "hand-written" in result.output
assert "not opted in" in result.output
assert "another instance (https://other.example/wiki)" in result.output
assert "tag pattern" in result.output
assert {t.url: t.refs() for t in (hand, none, foreign, tagged)} == before
assert "written" in result.output # the participating target was still served
def test_a_branch_that_moved_is_rejected_not_forced(instance, target, monkeypatch):
# Translated git messages must not change the verdict: it is read from the
# porcelain status flag, not from the text.
monkeypatch.setenv("LANGUAGE", "de")
monkeypatch.setenv("LANG", "de_DE.UTF-8")
token = _token(_run(*TAG, "--push").output)
real_commit_file = repo_capture.commit_file
foreign: list[str] = []
def commit_then_race(*args, **kwargs):
foreign.append(target.advance("raced in"))
return real_commit_file(*args, **kwargs)
monkeypatch.setattr(repo_capture, "commit_file", commit_then_race)
result = _run(*TAG, "--push", "--confirm", token)
assert result.exit_code == 1, result.output
assert f"{target.url} main: rejected" in result.output
assert target.tip() == foreign[0]
def test_an_unreachable_target_does_not_stop_the_others(instance, target, tmp_path):
_manifest(instance, "gone", (tmp_path / "does-not-exist.git").as_uri())
_commit_instance(instance)
result = _push()
assert result.exit_code == 0, result.output
assert "does-not-exist.git main: skipped (not reachable" in result.output
assert f"{target.url} main: written" in result.output
def test_bundle_narrows_the_targets(instance, target, tmp_path):
other = Target(tmp_path / "targets" / "other", STUB)
_manifest(instance, "other", other.url)
_commit_instance(instance)
before = other.refs()
result = _push("--bundle", "raw/2026/10/one")
assert result.exit_code == 0, result.output
assert other.url not in result.output
assert other.refs() == before
unknown = _run(*TAG, "--push", "--bundle", "raw/2026/10/nope")
assert unknown.exit_code == 1 and "not a captured bundle" in unknown.output
def test_bundle_and_confirm_need_push(instance):
assert _run(*TAG, "--confirm", "abc").exit_code == 1
assert _run(*TAG, "--bundle", "raw/x").exit_code == 1
def test_raw_status_does_not_report_a_pushed_export(instance, tmp_path, capsys):
t = Target(tmp_path / "targets" / "captured", STUB)
raw_capture_command(repo_url=t.url, ref="main", paths=["**/*.md"], name="captured",
fidelity="verbatim", authority="normative", update=None)
raw_accept_command(files=[instance / "incoming" / "captured"], fidelity=None, authority=None,
page=None, replaces=None, dry_run=False)
_commit_instance(instance, "capture")
capsys.readouterr()
result = _push()
assert result.exit_code == 0 and "written" in result.output, result.output
raw_status_command(json_out=True)
rows = json.loads(capsys.readouterr().out)
assert len(rows) == 1 and rows[0]["error"] is None and rows[0]["changed"] is False
# --- no way back in -----------------------------------------------------------
def test_export_marker_is_defined_once():
package = Path(guideline_export.__file__).parent
definitions = [
path for path in package.rglob("*.py")
if "tests" not in path.parts and re.search(r'= b"<!-- wikitool:export"', path.read_text("utf-8"))
]
assert definitions == [Path(guideline_export.__file__)]
def test_a_real_export_is_excluded_from_capture(instance):
data = _export().encode("utf-8")
assert repo_capture._content_exclusion(data) is not None
assert repo_capture._content_exclusion(b"\xef\xbb\xbf" + data) is not None
def _snapshot(*dirs: Path) -> dict[str, bytes]:
return {p.as_posix(): p.read_bytes() for d in dirs for p in sorted(d.rglob("*")) if p.is_file()}
def test_raw_accept_refuses_an_export_file_and_a_folder_holding_one(instance):
incoming, raw = instance / "incoming", instance / "raw"
export = _export().encode("utf-8")
(incoming / "GUIDELINES.md").write_bytes(export)
(incoming / "pack").mkdir()
(incoming / "pack" / "notes.md").write_text("# Notes\n", encoding="utf-8")
(incoming / "pack" / "sub").mkdir()
(incoming / "pack" / "sub" / "rules.md").write_bytes(b"\xef\xbb\xbf" + export)
before = _snapshot(incoming, raw)
for path in (incoming / "GUIDELINES.md", incoming / "pack"):
result = runner.invoke(app, ["raw", "accept", str(path), "--fidelity", "verbatim",
"--authority", "normative"])
assert result.exit_code == 1, result.output
assert "guideline export" in result.output
assert _snapshot(incoming, raw) == before
def test_is_export_reads_the_first_line_only():
assert guideline_export.is_export(b"<!-- wikitool:export kind=x -->\nbody")
assert not guideline_export.is_export(b"# Title\n<!-- wikitool:export kind=x -->\n")
assert guideline_export.guidelines_header(STUB) == {}
assert guideline_export.guidelines_header(b"<!-- wikitool:export kind=other -->") is None
def test_confirm_token_ignores_targets_it_does_not_write():
t = export_cmd.Target("u", "main")
writes = export_cmd.Plan(t, "new", tip="a", blob="b")
skipped = export_cmd.Plan(export_cmd.Target("v", "main"), "skipped", "x")
assert export_cmd.confirm_token([writes, skipped]) == export_cmd.confirm_token([writes])
assert export_cmd.confirm_token([writes]) != export_cmd.confirm_token(
[export_cmd.Plan(t, "new", tip="a2", blob="b")]
)