diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index ac015e3d..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,19 +0,0 @@ -# Knowledge maintainers own the knowledge gate: its verifier bundle, workflows, -# signing key and this file, plus the knowledge tooling and the non-governed -# knowledge paths. With the ruleset's "require review from Code Owners" and -# "dismiss stale approvals", a change here cannot land without a maintainer -# approving its current head (doc/features/knowledge-service.md). -# -# Governed knowledge pages (knowledge/repos/**, knowledge/general/**) have NO -# code owner on purpose: they merge through the kb-gate signed verdict. A -# human-approved verdict must be approved by an owner of /.github/kb-gate/. -# -# @tzhouam is the only maintainer. GitHub does not let an author approve their -# own PR, so the human-approved path covers PRs opened by others; the owner's -# own changes here go through the owner's bypass of the code-owner ruleset -# (see doc/features/knowledge-service.md), never through kb-gate's. - -/.github/ @tzhouam -/knowledge/tools/ @tzhouam -/knowledge/skills/ @tzhouam -/knowledge/.claude/ @tzhouam diff --git a/.github/kb-gate/MANIFEST.sha256 b/.github/kb-gate/MANIFEST.sha256 deleted file mode 100644 index 8ceb6885..00000000 --- a/.github/kb-gate/MANIFEST.sha256 +++ /dev/null @@ -1,9 +0,0 @@ -5f6e269fbbb951f9a49c40474a3d71c556ac389f5ebd5545f0ac762f85361723 README.md -77d88b9671927ab95bc6ac9ed57aaa24226423e151843381c2a3fa90cd0cafde config.json -dad76104329e3f0b23385d87700fc0aae03996c16fc0b2527b6ef51b50a6cbec kb_gate.py -5743c4a6943c291073c5344d408eaec28e8bc7440a8ee113365588728ded5e8a l1.py -39f69aa816b83b673443601d38e57040521664c5aca407154ba40a8a35bc61d9 lifecycle.py -b0d0fab0210870f989da80f7ffba3978dbf723f4f087025524d1963c4081a009 ops.py -67f2ebd85e4e886f3facc35a181f575fd6291f1e4a4068f5daca99191609aace requirements.lock -e7fbe6f3d6b02fadfbd86e32e67d588ae698ae2de19ac91aa5db2a250cb93946 signing.py -5c5015e7e9e6314f84e0337a58d9aa1bb6f7ec21ec830e4907dc5c35bbca4a09 verdict.py diff --git a/.github/kb-gate/README.md b/.github/kb-gate/README.md deleted file mode 100644 index cb74bce2..00000000 --- a/.github/kb-gate/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# kb-gate verifier bundle - -The required check `kb-gate` runs **only** from this directory. It is generated by -`tools/build_kb_gate_bundle.py` from `src/infermatrix_copilot/knowledge_service/` -(`gate_verifier.py` → `kb_gate.py`, plus `l1`, `lifecycle`, `ops`, `signing`, -`verdict`); do not edit the `.py` files here by hand. The job installs -`requirements.lock` with `--require-hashes --no-deps` and checks `MANIFEST.sha256` -before running anything. - -Changing anything here is a `.github/**` change and needs CODEOWNERS approval, so a -code PR that weakens `src/` or `knowledge/tools/` cannot weaken the gate. - -`config.json`: - -- `holds_url`: the public HTTPS URL of the knowledge service's signed hold list - (`/public/holds.json` on the bot host). Until it is set, every PR that - touches `knowledge/` fails closed. PRs that do not touch `knowledge/` always pass. - -The service's public key is `.github/kb-gate.pub` (`ed25519 `, the output of -`kb keygen`). Without it, every knowledge PR fails closed. - -See `doc/features/knowledge-service.md` for the stages (PR precheck and merge group) -and what each checks. diff --git a/.github/kb-gate/config.json b/.github/kb-gate/config.json deleted file mode 100644 index a497977c..00000000 --- a/.github/kb-gate/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "holds_url": "" -} diff --git a/.github/kb-gate/kb_gate.py b/.github/kb-gate/kb_gate.py deleted file mode 100644 index 6c60a5f5..00000000 --- a/.github/kb-gate/kb_gate.py +++ /dev/null @@ -1,710 +0,0 @@ -# GENERATED by tools/build_kb_gate_bundle.py from src/infermatrix_copilot/knowledge_service/gate_verifier.py -- do not edit here. -"""kb-gate: the repository-side verifier for knowledge pull requests. - -It is the required check ``kb-gate`` and runs in two places under one name: - -* PR stage (``pull_request_target`` / ``issue_comment`` / dispatch): verifies - the PR against the current base and posts a ``kb-gate`` commit status on the - PR head. It only decides whether a PR may ENTER the merge queue. -* merge group stage (``merge_group``): verifies every segment of the group on - the exact commit that would land and exits non-zero on any failure. This is - what decides whether anything lands. - -A change that does not touch ``knowledge/`` passes. A knowledge change passes -only when all of these hold for it: - -1. L1: whitelisted paths only, regular files only, no new tree issues, on the - PR's own change AND on the tree that would land; -2. a valid Ed25519-signed verdict comment for this repository, PR and exact - head SHA, inside its issue window (the comment's author is irrelevant); -3. the signed patch manifest equals the change as applied (base independent); -4. ``auto``: the signed block table equals the blocks L1 derives, every block - passed, the consistency judgements cover the same owner directories and - their page hashes match the tree that would land; no page in the context - set changed between the judged base and the effective base; - ``human-approved``: every bound review is still APPROVED on this head, one - is by a knowledge maintainer (a CODEOWNERS owner of ``.github/kb-gate/``), - and ``kb:human-approved`` is set; -5. no ``kb:hold`` label and not held by the service's signed hold list (a - missing, forged or stale hold list fails closed). - -PR content is only ever READ through git objects; nothing from the PR is -executed or installed. This module is vendored into ``.github/kb-gate/`` by -``tools/build_kb_gate_bundle.py`` and runs from there, so it imports nothing -outside the standard library and its vendored siblings. -""" - -from __future__ import annotations - -import argparse -import fnmatch -import hashlib -import json -import os -import subprocess -import sys -import time -import urllib.error -import urllib.request -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import Any, Callable, Iterable, Mapping - -try: - from .l1 import Change, _dangling_refs as dangling_refs, check_changeset, check_tree - from .lifecycle import LifecycleError, Page - from .ops import all_rule_ids, repo_scope - from .signing import SignatureError, load_public_key, verify - from .verdict import VerdictError, check_binding, manifests_equal -except ImportError: # pragma: no cover - vendored verifier bundle - from l1 import Change, _dangling_refs as dangling_refs, check_changeset, check_tree # type: ignore[no-redef] - from lifecycle import LifecycleError, Page # type: ignore[no-redef] - from ops import all_rule_ids, repo_scope # type: ignore[no-redef] - from signing import SignatureError, load_public_key, verify # type: ignore[no-redef] - from verdict import VerdictError, check_binding, manifests_equal # type: ignore[no-redef] - -STATUS_CONTEXT = "kb-gate" -VERDICT_MARKER = "" -HOLD_LABEL = "kb:hold" -HUMAN_LABEL = "kb:human-approved" -HOLDS_MAX_AGE = 10 * 60 -MAINTAINER_PATH = ".github/kb-gate/kb_gate.py" -WHITELIST_CODES = ("path_not_whitelisted", "mode_not_regular") -KNOWLEDGE_PREFIX = "knowledge/" -GOVERNED_PREFIXES = ("knowledge/repos/", "knowledge/general/") -GOVERNED_SUFFIXES = (".md", ".yaml") -EXTERNAL_REF_DIRS = ("skills/", "plugins/", "adapters/", "doc/", "playbooks/") -EXTERNAL_SUFFIXES = (".md", ".yaml", ".yml", ".json", ".txt") -_ZERO = "0" * 40 - - -class GateError(RuntimeError): - """The verifier could not establish a fact it needs (fails closed).""" - - -# -- git (objects only; nothing from the PR is ever checked out or executed) --- - -class Git: - def __init__(self, cwd: str | Path): - self.cwd = str(cwd) - - def run(self, *args: str, input: bytes | None = None, ok: tuple[int, ...] = (0,)) -> bytes: - proc = subprocess.run(["git", *args], cwd=self.cwd, input=input, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) - if proc.returncode not in ok: - raise GateError(f"git {' '.join(args)} failed: {proc.stderr.decode(errors='replace')[:400]}") - return proc.stdout - - def rev(self, name: str) -> str: - return self.run("rev-parse", "--verify", "--quiet", f"{name}^{{commit}}").decode().strip() - - def tree_of(self, name: str) -> str: - return self.run("rev-parse", "--verify", f"{name}^{{tree}}").decode().strip() - - def parents(self, sha: str) -> list[str]: - return self.run("rev-list", "--parents", "-n", "1", sha).decode().split()[1:] - - def is_ancestor(self, older: str, newer: str) -> bool: - proc = subprocess.run(["git", "merge-base", "--is-ancestor", older, newer], cwd=self.cwd, - stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False) - if proc.returncode not in (0, 1): - raise GateError(f"cannot relate {older[:12]} and {newer[:12]}: " - f"{proc.stderr.decode(errors='replace')[:200]}") - return proc.returncode == 0 - - def merge_base(self, a: str, b: str) -> str: - return self.run("merge-base", a, b).decode().strip() - - def first_parent_chain(self, base: str, head: str) -> list[str]: - return self.run("rev-list", "--first-parent", "--reverse", f"{base}..{head}").decode().split() - - def merge_tree(self, base: str, head: str) -> str: - """The tree a merge of ``head`` into ``base`` would produce (PR stage).""" - proc = subprocess.run(["git", "merge-tree", "--write-tree", base, head], cwd=self.cwd, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) - if proc.returncode != 0 and b"--write-tree" in proc.stderr: # git < 2.38: no --write-tree - return self._index_merge(base, head) - if proc.returncode != 0: - raise GateError("the PR does not merge cleanly into the current base") - return proc.stdout.decode().split()[0] - - def _index_merge(self, base: str, head: str) -> str: - """Only merges where each path changed on at most one side; anything - else is reported as not merging cleanly (stricter, never looser).""" - import tempfile - - with tempfile.TemporaryDirectory() as scratch: - env = {**os.environ, "GIT_INDEX_FILE": str(Path(scratch) / "index")} - for args in (["read-tree", "-i", "-m", self.merge_base(base, head), base, head], ["write-tree"]): - proc = subprocess.run(["git", *args], cwd=self.cwd, env=env, check=False, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if proc.returncode != 0: - raise GateError("the PR does not merge cleanly into the current base") - return proc.stdout.decode().strip() - - def raw_diff(self, old: str, new: str) -> list[dict]: - """Every changed path as a manifest entry (renames are D + A).""" - out = self.run("diff", "--raw", "-z", "--no-renames", "--no-abbrev", old, new).split(b"\0") - entries: list[dict] = [] - index = 0 - while index < len(out) - 1: - meta = out[index].decode() - path = out[index + 1].decode("utf-8", "replace") - index += 2 - if not meta.startswith(":"): - raise GateError(f"unexpected git diff output: {meta!r}") - old_mode, new_mode, old_blob, new_blob, status = meta[1:].split() - entries.append({ - "path": path, "status": status[0], - "old_blob": "" if old_blob == _ZERO else old_blob, - "new_blob": "" if new_blob == _ZERO else new_blob, - "old_mode": "" if old_mode == "000000" else old_mode, - "new_mode": "" if new_mode == "000000" else new_mode, - }) - return entries - - def changed_names(self, old: str, new: str) -> list[str]: - out = self.run("diff", "--name-only", "-z", "--no-renames", old, new, "--", "knowledge/") - return [name.decode("utf-8", "replace") for name in out.split(b"\0") if name] - - def texts(self, rev: str, prefixes: tuple[str, ...], suffixes: tuple[str, ...]) -> dict[str, str]: - listing = self.run("ls-tree", "-r", "-z", "--full-tree", rev).split(b"\0") - wanted: list[tuple[str, str]] = [] - for entry in listing: - if not entry: - continue - meta, _, raw_path = entry.partition(b"\t") - mode, kind, oid = meta.decode().split() - name = raw_path.decode("utf-8", "replace") - if kind == "blob" and mode == "100644" and name.startswith(prefixes) and name.endswith(suffixes): - wanted.append((name, oid)) - if not wanted: - return {} - out = self.run("cat-file", "--batch", input="".join(f"{oid}\n" for _, oid in wanted).encode()) - texts: dict[str, str] = {} - cursor = 0 - for name, _oid in wanted: - header_end = out.index(b"\n", cursor) - size = int(out[cursor:header_end].split()[2]) - body = out[header_end + 1: header_end + 1 + size] - cursor = header_end + 1 + size + 1 - try: - texts[name] = body.decode("utf-8") - except UnicodeDecodeError: - continue - return texts - - def knowledge_files(self, rev: str) -> dict[str, str]: - files = self.texts(rev, GOVERNED_PREFIXES, GOVERNED_SUFFIXES) - return {name[len(KNOWLEDGE_PREFIX):]: text for name, text in files.items()} - - def external_texts(self, rev: str) -> dict[str, str]: - return self.texts(rev, EXTERNAL_REF_DIRS, EXTERNAL_SUFFIXES) - - def show(self, rev: str, path: str) -> str | None: - proc = subprocess.run(["git", "show", f"{rev}:{path}"], cwd=self.cwd, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False) - return proc.stdout.decode("utf-8", "replace") if proc.returncode == 0 else None - - -# -- GitHub REST (read, plus the PR-stage status) -------------------------------- - -class GitHub: - def __init__(self, repository: str, token: str, api: str = "https://api.github.com"): - self.repository, self._token, self._api = repository, token, api.rstrip("/") - - def _request(self, method: str, path: str, body: dict | None = None) -> Any: - data = None if body is None else json.dumps(body).encode() - request = urllib.request.Request(self._api + path, data=data, method=method, headers={ - "Accept": "application/vnd.github+json", "Authorization": f"Bearer {self._token}", - "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "kb-gate"}) - try: - with urllib.request.urlopen(request, timeout=30) as response: - return json.loads(response.read() or b"null") - except (urllib.error.URLError, TimeoutError, ValueError) as exc: - raise GateError(f"GitHub {method} {path} failed: {exc}") from exc - - def get(self, path: str) -> Any: - return self._request("GET", path) - - def get_all(self, path: str, *, pages: int = 10) -> list: - out: list = [] - sep = "&" if "?" in path else "?" - for page in range(1, pages + 1): - batch = self.get(f"{path}{sep}per_page=100&page={page}") - out.extend(batch) - if len(batch) < 100: - break - return out - - def post(self, path: str, body: dict) -> Any: - return self._request("POST", path, body) - - -# -- inputs from the protected checkout ------------------------------------------- - -def fetch_holds(url: str) -> Any: - if not url: - raise GateError("no hold-list URL is configured (.github/kb-gate/config.json)") - try: - with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "kb-gate"}), - timeout=20) as response: - return json.loads(response.read()) - except (urllib.error.URLError, TimeoutError, ValueError) as exc: - raise GateError(f"hold list unreachable: {exc}") from exc - - -def verify_holds(envelope: Any, public_key, *, now: float) -> dict: - holds = verify("kb-holds", envelope, public_key) - if not 0 <= now - float(holds["issued_at"]) <= HOLDS_MAX_AGE: - raise GateError("hold list is stale (the knowledge service may be down)") - return holds - - -def parse_codeowners(text: str | None) -> list[tuple[str, tuple[str, ...]]]: - rules = [] - for line in (text or "").splitlines(): - line = line.split("#", 1)[0].strip() - if not line: - continue - pattern, *owners = line.split() - rules.append((pattern, tuple(owner.lstrip("@").lower() for owner in owners))) - return rules - - -def _codeowner_match(pattern: str, path: str) -> bool: - anchored = pattern.startswith("/") - pattern = pattern.lstrip("/") - if pattern.endswith("/"): - pattern += "**" - candidates = [path] if anchored or "/" in pattern.rstrip("*") else \ - [path] + [path.split("/", i)[-1] for i in range(1, path.count("/") + 1)] - for candidate in candidates: - if fnmatch.fnmatchcase(candidate, pattern) or fnmatch.fnmatchcase(candidate, pattern + "/**") \ - or (pattern.endswith("/**") and candidate.startswith(pattern[:-2])): - return True - return False - - -def code_owners(rules: list[tuple[str, tuple[str, ...]]], path: str) -> tuple[str, ...]: - """Owners of ``path``: the LAST matching line wins. Team entries never - match a person here (their membership is not readable with the job token), - so a team-only path cannot be human-approved through this verifier.""" - owners: tuple[str, ...] = () - for pattern, names in rules: - if _codeowner_match(pattern, path): - owners = names - return tuple(name for name in owners if "/" not in name) - - -# -- the verification of one change ------------------------------------------------ - -@dataclass -class Context: - git: Git - github: Any - repository: str - public_key: Any # None when .github/kb-gate.pub is missing - holds_loader: Callable[[], Any] - codeowners: list[tuple[str, tuple[str, ...]]] - now: float - _holds: dict | None = None - - def holds(self) -> dict: - if self._holds is None: - self._holds = verify_holds(self.holds_loader(), self.public_key, now=self.now) - return self._holds - - -def governed(path: str) -> bool: - """A page the knowledge lifecycle governs (what ``knowledge_files`` reads).""" - return path.startswith(GOVERNED_PREFIXES) and path.endswith(GOVERNED_SUFFIXES) - - -def touches_knowledge(entries: Iterable[Mapping[str, str]]) -> bool: - return any(entry["path"].startswith(KNOWLEDGE_PREFIX) for entry in entries) - - -def _sha256(text: str) -> str: - return hashlib.sha256(text.encode("utf-8")).hexdigest() - - -def verdict_envelopes(comments: Iterable[Mapping[str, Any]]) -> list[Any]: - out = [] - for comment in comments: - body = str(comment.get("body") or "") - if VERDICT_MARKER not in body: - continue - rest = body.split(VERDICT_MARKER, 1)[1] - start = rest.find("```json") - if start < 0: - continue - rest = rest[start + len("```json"):] - end = rest.find("```") - try: - out.append(json.loads(rest[:end] if end >= 0 else rest)) - except ValueError: - continue - return out - - -def find_verdict(ctx: Context, pr: int, head_sha: str) -> tuple[dict | None, list[str]]: - """The newest valid verdict for exactly this PR head, and why others failed.""" - comments = ctx.github.get_all(f"/repos/{ctx.repository}/issues/{pr}/comments") - valid, rejected = [], [] - for envelope in verdict_envelopes(comments): - try: - verdict = verify("kb-gate-verdict", envelope, ctx.public_key) - check_binding(verdict, repository=ctx.repository, pr=pr, head_sha=head_sha, now=ctx.now) - valid.append(verdict) - except (SignatureError, VerdictError, KeyError, TypeError, ValueError) as exc: - rejected.append(str(exc)) - if not valid: - return None, rejected - return max(valid, key=lambda v: float(v["issued_at"])), rejected - - -def _owner_dir(path: str) -> str: - return str(PurePosixPath(path).parent) - - -def _scope_root(path: str) -> str: - parts = PurePosixPath(path).parts - return "/".join(parts[:2]) if parts[:1] == ("repos",) else parts[0] - - -def context_paths(touched: set[str], owner_dirs: set[str], post: Mapping[str, str], - base: Mapping[str, str]) -> Callable[[str], bool]: - """The pages whose change since the judged base invalidates L2: the touched - pages, every page in their owner directories, the scope's _routes.yaml and - _index.md files (scope root and owner directories), and pages holding rules - the change supersedes.""" - roots = {_scope_root(path) for path in touched} - dirs = owner_dirs | {_owner_dir(path) for path in touched} - superseded: set[str] = set() - for path in touched: - text = post.get(path) - if not text or not path.endswith(".md"): - continue - try: - sections = Page.parse(text).rules() - except (LifecycleError, ValueError): - continue - for section in sections: - try: - target = section.footer.supersedes - except LifecycleError: - continue - for rule_id in [t.strip() for t in target.split(",") if t.strip()]: - superseded.update(p for p, t in base.items() - if p.endswith(".md") and rule_id in all_rule_ids({p: t})) - - def relevant(path: str) -> bool: - name = PurePosixPath(path).name - parent = _owner_dir(path) - return (path in touched or path in superseded or parent in dirs - or (name in ("_routes.yaml", "_index.md") and parent in roots | dirs)) - return relevant - - -def verify_change(ctx: Context, *, pr: dict, head_sha: str, pre: str, post: str, final: str, - effective_base: str, stage: str, verdict: dict | None = None, - check_holds: bool = True) -> list[str]: - """Problems with one PR's knowledge change (empty when it passes). - - ``pre``/``post`` are the commits (or trees) around this PR's change as it - is applied; ``final`` is the tree that would land; ``effective_base`` is - the state the change is applied on (current main, or the first parent of - the merge-group segment). The publisher's local gate passes the verdict it - was handed (its signature and binding already checked) and no hold list: - a pause there is the signed control record.""" - number = int(pr["number"]) - entries = ctx.git.raw_diff(pre, post) - if not touches_knowledge(entries): - return [] - if ctx.public_key is None: - return [".github/kb-gate.pub is not committed: knowledge changes cannot be verified"] - problems: list[str] = [] - labels = {str(label.get("name")) for label in pr.get("labels") or []} - if pr.get("state") != "open": - problems.append(f"PR #{number} is not open") - if str((pr.get("head") or {}).get("sha")) != head_sha: - problems.append(f"PR #{number} head moved from {head_sha[:12]}") - if stage == "merge_group" and pr.get("draft"): - problems.append(f"PR #{number} is a draft (paused)") - if HOLD_LABEL in labels: - problems.append(f"PR #{number} carries {HOLD_LABEL}") - - pre_files, post_files = ctx.git.knowledge_files(pre), ctx.git.knowledge_files(post) - final_files = ctx.git.knowledge_files(final) - changes = [Change(e["path"], e["status"] if e["status"] in "AMD" else "M", e["old_mode"], e["new_mode"]) - for e in entries] - for e in entries: - if e["status"] not in ("A", "M", "D"): - problems.append(f"{e['path']}: change type {e['status']} is not allowed") - touched = {e["path"][len(KNOWLEDGE_PREFIX):] for e in entries if e["path"].startswith(KNOWLEDGE_PREFIX)} - - try: - holds = ctx.holds() if check_holds else {} - if holds.get("global"): - problems.append("the knowledge service holds every repository (global pause)") - if number in {int(p) for p in holds.get("prs") or []}: - problems.append(f"PR #{number} is on the service's hold list") - held = set(holds.get("repos") or []) - # a scope is its repository name, or "general" for knowledge/general/ - scopes = {repo_scope(p).split("/")[-1] for p in touched if p.startswith(("repos/", "general/"))} - for scope in sorted(scopes & held): - problems.append(f"repository {scope} is paused by the knowledge service") - except (GateError, SignatureError, LifecycleError, KeyError, TypeError, ValueError) as exc: - problems.append(f"hold list: {exc}") - - rejected: list[str] = [] - if verdict is None: - verdict, rejected = find_verdict(ctx, number, head_sha) - if verdict is None: - detail = f" (rejected: {'; '.join(rejected[:3])})" if rejected else "" - problems.append(f"no valid signed verdict for PR #{number} at {head_sha[:12]}{detail}") - # still report L1, so the author sees every structural problem at once - result = check_changeset(pre_files, post_files, changes, external_texts={}) - return problems + [f"L1 {i.code} {i.path} {i.detail}".rstrip() for i in result.issues] - - if not manifests_equal(verdict.get("manifest") or [], entries): - problems.append("the signed patch manifest does not match the change as applied") - if verdict.get("facts"): - problems.append("the verdict carries upstream fact attestations this verifier cannot re-check") - - human = verdict["source"] == "human-approved" - # a maintainer's approval replaces the auto-merge whitelist (knowledge/tools, - # skills, scripts, mixed PRs) and L2, never the rest of L1: non-governed - # paths are not pages, so the page lifecycle checks run on governed ones only - l1_changes = [c for c in changes if governed(c.path)] if human else changes - result = check_changeset(pre_files, post_files, l1_changes, - external_texts=ctx.git.external_texts(final), - release=str(verdict.get("release") or "")) - waived = WHITELIST_CODES if human else () - problems.extend(f"L1 {i.code} {i.path} {i.detail}".rstrip() for i in result.issues if i.code not in waived) - # what this change retires or purges must not be cited anywhere in the tree - # that would land (a PR queued behind it, or main, may have added a citation) - gone = set(result.retired) | set(result.purged) - if gone: - try: - problems.extend(f"L1 {i.code} {i.path} {i.detail} (in the tree that would land)" - for i in dangling_refs(final_files, gone)) - except (LifecycleError, ValueError) as exc: - problems.append(f"L1 the tree that would land does not parse: {exc}") - - if verdict["source"] == "auto": - if result.external_refs: - problems.append("retired rules are still cited outside knowledge/ (needs a companion PR)") - signed_blocks = verdict.get("blocks") or [] - if {b.block_id for b in result.blocks} != {b.get("block_id") for b in signed_blocks}: - problems.append("the signed block table does not cover exactly the changed blocks") - if any(b.get("verdict") != "pass" for b in signed_blocks): - problems.append("a signed block did not pass") - owner_dirs = {_owner_dir(b.path) for b in result.blocks} - consistency = verdict.get("consistency") or [] - if {c.get("owner_dir") for c in consistency} != owner_dirs: - problems.append("the consistency judgement does not cover exactly the changed owner directories") - for item in consistency: - directory = item.get("owner_dir") - actual = {p: _sha256(t) for p, t in final_files.items() - if _owner_dir(p) == directory and p.endswith(".md")} - if item.get("verdict") != "consistent": - problems.append(f"{directory}: consistency was not judged consistent") - if item.get("pages") != actual: - problems.append(f"{directory}: pages changed since the consistency judgement") - base_sha = str(verdict["context_base_sha"]) - try: - if not ctx.git.is_ancestor(base_sha, effective_base): - problems.append("the judged context base is not an ancestor of the effective base") - else: - relevant = context_paths(touched, owner_dirs, post_files, pre_files) - moved = [n[len(KNOWLEDGE_PREFIX):] for n in ctx.git.changed_names(base_sha, effective_base)] - stale = sorted(p for p in moved if relevant(p)) - if stale: - problems.append("context changed since the verdict was judged: " + ", ".join(stale[:5])) - except GateError as exc: - problems.append(f"context base: {exc}") - else: - problems.extend(_human_approval_problems(ctx, pr, verdict, head_sha, labels)) - return problems - - -def _human_approval_problems(ctx: Context, pr: dict, verdict: dict, head_sha: str, - labels: set[str]) -> list[str]: - problems = [] - if HUMAN_LABEL not in labels: - problems.append(f"{HUMAN_LABEL} was removed") - number = int(pr["number"]) - reviewers = {str(r).lower() for r in verdict.get("reviewers") or []} - approvers = set() - for review_id in verdict.get("review_ids") or []: - review = ctx.github.get(f"/repos/{ctx.repository}/pulls/{number}/reviews/{int(review_id)}") - login = str((review.get("user") or {}).get("login") or "").lower() - if review.get("state") != "APPROVED": - problems.append(f"review {review_id} is no longer APPROVED") - elif review.get("commit_id") != head_sha: - problems.append(f"review {review_id} approved another head") - elif login not in reviewers: - problems.append(f"review {review_id} is by someone the verdict does not name") - else: - approvers.add(login) - if approvers: - # a later review by the same person (changes requested, dismissal) - # withdraws the approval even though the bound review still reads APPROVED - latest: dict[str, dict] = {} - for review in ctx.github.get_all(f"/repos/{ctx.repository}/pulls/{number}/reviews"): - login = str((review.get("user") or {}).get("login") or "").lower() - if login and review.get("state") in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"): - latest[login] = review - for login in sorted(approvers): - review = latest.get(login) or {} - if review.get("state") != "APPROVED" or review.get("commit_id") != head_sha: - problems.append(f"{login}'s latest review no longer approves this head") - approvers.discard(login) - # knowledge maintainers = the CODEOWNERS of the gate itself. Governed pages - # deliberately have no code owners (that would force a review on every auto - # PR); paths outside the whitelist get GitHub's native code-owner review. - maintainers = set(code_owners(ctx.codeowners, MAINTAINER_PATH)) - if not approvers & maintainers: - problems.append("no valid approving review by a knowledge maintainer (CODEOWNERS of .github/kb-gate/)") - return problems - - -def tree_problems(git: Git, base: str, final: str) -> list[str]: - """L1 on the tree that would land: no tree-level issue that base lacks.""" - before = {(i.code, i.detail) for i in check_tree(git.knowledge_files(base))} - return [f"L1 {i.code} {i.path} {i.detail}".rstrip() - for i in check_tree(git.knowledge_files(final)) if (i.code, i.detail) not in before] - - -# -- the two stages ----------------------------------------------------------------- - -def verify_pr(ctx: Context, number: int, *, base_ref: str = "main", - remote: str = "origin") -> tuple[str, list[str]]: - """(the head SHA verified, its problems). The status goes on THAT head, so - a push during verification can only leave the new head without a status.""" - pr = ctx.github.get(f"/repos/{ctx.repository}/pulls/{number}") - head = str(pr["head"]["sha"]) - return head, _verify_pr_head(ctx, pr, head, base_ref=base_ref, remote=remote) - - -def _verify_pr_head(ctx: Context, pr: dict, head: str, *, base_ref: str, remote: str) -> list[str]: - number = int(pr["number"]) - ctx.git.run("fetch", "--quiet", "--no-tags", remote, - f"+refs/pull/{number}/head:refs/kb-gate/pr-{number}", f"+refs/heads/{base_ref}:refs/kb-gate/base") - if ctx.git.rev(f"refs/kb-gate/pr-{number}") != head: - return [f"fetched head does not match PR #{number}'s head {head[:12]} (retry)"] - base_tip = ctx.git.rev("refs/kb-gate/base") - merge_base = ctx.git.merge_base(base_tip, head) - if not touches_knowledge(ctx.git.raw_diff(merge_base, head)): - return [] - try: - final = ctx.git.merge_tree(base_tip, head) - except GateError as exc: - return [str(exc)] - problems = verify_change(ctx, pr=pr, head_sha=head, pre=merge_base, post=head, final=final, - effective_base=base_tip, stage="pull_request") - return problems + tree_problems(ctx.git, base_tip, final) - - -def _pr_for_head(ctx: Context, sha: str) -> dict: - matches = [pr for pr in ctx.github.get_all(f"/repos/{ctx.repository}/pulls?state=open") - if str((pr.get("head") or {}).get("sha")) == sha] - if len(matches) != 1: - raise GateError(f"{len(matches)} open PRs have head {sha[:12]}; the group cannot be attributed") - return ctx.github.get(f"/repos/{ctx.repository}/pulls/{int(matches[0]['number'])}") - - -def verify_merge_group(ctx: Context, head_sha: str, *, base_ref: str = "main", - remote: str = "origin") -> list[str]: - """Every segment of the group, each against its own first parent.""" - ctx.git.run("fetch", "--quiet", "--no-tags", remote, f"+refs/heads/{base_ref}:refs/kb-gate/base") - main_tip = ctx.git.rev("refs/kb-gate/base") - if not ctx.git.is_ancestor(main_tip, head_sha): - return [f"the merge group {head_sha[:12]} is not built on the current {base_ref}"] - chain = ctx.git.first_parent_chain(main_tip, head_sha) - if not chain or chain[-1] != head_sha: - return ["the merge group has no segments"] - problems: list[str] = [] - any_knowledge = False - for commit in chain: - parents = ctx.git.parents(commit) - if len(parents) != 2: - return [f"segment {commit[:12]} is not a two-parent merge commit; the group cannot be attributed"] - first, second = parents - if not touches_knowledge(ctx.git.raw_diff(first, commit)): - continue - any_knowledge = True - if ctx.public_key is None: - return [".github/kb-gate.pub is not committed: knowledge changes cannot be verified"] - try: - pr = _pr_for_head(ctx, second) - except GateError as exc: - return [str(exc)] - found = verify_change(ctx, pr=pr, head_sha=second, pre=first, post=commit, final=head_sha, - effective_base=first, stage="merge_group") - problems.extend(f"PR #{pr['number']}: {p}" for p in found) - if any_knowledge: - problems.extend(tree_problems(ctx.git, main_tip, head_sha)) - return problems - - -# -- entry point ---------------------------------------------------------------------- - -def _load_context(args, github) -> Context: - root = Path(args.root) - config_path = Path(__file__).resolve().parent / "config.json" - config = json.loads(config_path.read_text()) if config_path.is_file() else {} - pub = root / ".github" / "kb-gate.pub" - public_key = load_public_key(pub.read_text()) if pub.is_file() else None - codeowners_path = root / ".github" / "CODEOWNERS" - holds_url = str(config.get("holds_url") or "") - return Context( - git=Git(root), github=github, repository=args.repository, public_key=public_key, - holds_loader=lambda: fetch_holds(holds_url), - codeowners=parse_codeowners(codeowners_path.read_text() if codeowners_path.is_file() else ""), - now=time.time(), - ) - - -def main(argv: list[str] | None = None, *, github=None) -> int: - parser = argparse.ArgumentParser(prog="kb-gate") - sub = parser.add_subparsers(dest="stage", required=True) - pr_stage = sub.add_parser("pr") - pr_stage.add_argument("--pr", type=int, required=True) - pr_stage.add_argument("--post-status", action="store_true") - pr_stage.add_argument("--target-url", default="") - group = sub.add_parser("merge-group") - group.add_argument("--head-sha", required=True) - for stage in (pr_stage, group): - stage.add_argument("--repository", required=True) - stage.add_argument("--root", default=".") - stage.add_argument("--base-ref", default="main") - args = parser.parse_args(argv) - github = github or GitHub(args.repository, os.environ.get("GITHUB_TOKEN", ""), - os.environ.get("GITHUB_API_URL", "https://api.github.com")) - ctx = _load_context(args, github) - head = "" - try: - if args.stage == "pr": - head = str(github.get(f"/repos/{args.repository}/pulls/{args.pr}")["head"]["sha"]) - head, problems = verify_pr(ctx, args.pr, base_ref=args.base_ref) - else: - problems = verify_merge_group(ctx, args.head_sha, base_ref=args.base_ref) - except (GateError, SignatureError, VerdictError, LifecycleError, KeyError, TypeError, ValueError) as exc: - problems = [f"verifier error: {exc}"] - for problem in problems: - print(f"kb-gate: {problem}") - print("kb-gate: " + ("FAIL" if problems else "PASS")) - if args.stage == "pr" and args.post_status and head: - description = (problems[0] if problems else "knowledge verdict verified")[:139] - github.post(f"/repos/{args.repository}/statuses/{head}", { - "state": "failure" if problems else "success", "context": STATUS_CONTEXT, - "description": description, **({"target_url": args.target_url} if args.target_url else {})}) - return 0 - return 1 if problems else 0 - - -if __name__ == "__main__": # pragma: no cover - sys.exit(main()) diff --git a/.github/kb-gate/l1.py b/.github/kb-gate/l1.py deleted file mode 100644 index fd75d6d6..00000000 --- a/.github/kb-gate/l1.py +++ /dev/null @@ -1,503 +0,0 @@ -# GENERATED by tools/build_kb_gate_bundle.py from src/infermatrix_copilot/knowledge_service/l1.py -- do not edit here. -"""L1: the deterministic half of the knowledge quality gate. - -Two entry points, both pure functions over ``{knowledge-relative path: text}`` -mappings so the service, the gate and the vendored verifier agree exactly: - -* ``check_tree(files)`` — structural issues in one tree (footers, supersede - links, tombstones, ``_routes.yaml``). -* ``check_changeset(base, head, changes)`` — what one change set may do: only - whitelisted files and modes, no new duplicate or reused rule IDs, no dangling - references to rules it retired or purged, no NEW tree issues, and a - classification of every change into blocks: - - ``rule`` a rule section added / edited / retired / superseded / purged. - Needs a per-block L2 verdict (keyed by ``block_id``). - ``prose`` any other wording change (page head, non-rule sections, - guides, ``_routes.yaml``). Needs an L2 prose verdict. - ``mechanical`` ``updated:``/``sources:`` frontmatter, index lines for new - pages, tombstones. Recomputed here; a mismatch is an issue, - never a block a verdict could cover. - -Standard library + PyYAML + ``lifecycle``/``ops`` only (vendored by kb-gate). -""" - -from __future__ import annotations - -import hashlib -import json -import re -from dataclasses import dataclass -from pathlib import PurePosixPath -from typing import Iterable, Mapping - -import yaml - -try: - from .lifecycle import ( - FRONTMATTER, LifecycleError, Page, Section, expected_sources, - ) - from .ops import ( - INDEX_NAME, TOMBSTONES_NAME, all_rule_ids, all_tombstoned_ids, - load_tombstones, page_over_capacity, render_tombstones, - ) -except ImportError: # pragma: no cover - vendored verifier bundle - from lifecycle import ( # type: ignore[no-redef] - FRONTMATTER, LifecycleError, Page, Section, expected_sources, - ) - from ops import ( # type: ignore[no-redef] - INDEX_NAME, TOMBSTONES_NAME, all_rule_ids, all_tombstoned_ids, - load_tombstones, page_over_capacity, render_tombstones, - ) - -ROUTES_NAME = "_routes.yaml" -KNOWLEDGE_PREFIX = "knowledge/" -_WHITELIST = ( - re.compile(r"knowledge/(?:repos/[^/]+|general)/(?:[^/]+/)*[^/]+\.md"), - re.compile(r"knowledge/repos/[^/]+/_routes\.yaml"), - re.compile(r"knowledge/(?:repos/[^/]+|general)/_tombstones\.yaml"), -) -_DENY = re.compile(r"knowledge/(?:skills|tools|\.claude)/|knowledge/[^/]+$") -_REGULAR_MODE = "100644" -_INDEX_LINE = re.compile(r"- \[[^\]\n]+\]\((?P[^)/\s]+\.md)\)") - - -@dataclass(frozen=True) -class Issue: - code: str - path: str - detail: str - - def to_dict(self) -> dict: - return {"code": self.code, "path": self.path, "detail": self.detail} - - -@dataclass(frozen=True) -class Change: - """One changed path as git reports it (repository-relative).""" - - path: str - status: str # A, M, D (renames are reported as D + A) - old_mode: str = "" - new_mode: str = "" - - -@dataclass(frozen=True) -class Block: - kind: str # rule | prose - path: str # knowledge-relative - rule_id: str # "" for prose - op: str # add | edit | retire | supersede | purge | prose - sha256: str # of the text the verdict judges - - @property - def block_id(self) -> str: - identity = json.dumps([self.kind, self.path, self.rule_id, self.op, self.sha256], - separators=(",", ":")) - return hashlib.sha256(identity.encode("utf-8")).hexdigest() - - def to_dict(self) -> dict: - return {"block_id": self.block_id, "kind": self.kind, "path": self.path, - "rule_id": self.rule_id, "op": self.op, "sha256": self.sha256} - - -@dataclass(frozen=True) -class ChangesetResult: - issues: tuple[Issue, ...] - blocks: tuple[Block, ...] - retired: tuple[str, ...] - purged: tuple[str, ...] - external_refs: tuple[tuple[str, str], ...] = () # (rule_id, repo path) - - @property - def ok(self) -> bool: - return not self.issues - - -def _sha(text: str) -> str: - return hashlib.sha256(text.encode("utf-8")).hexdigest() - - -def _norm(text: str) -> str: - """Section text without trailing blank lines: appending a section adds a - separator to the previous one, which is layout, not a change of words.""" - return text.rstrip("\n") + "\n" - - -# -- tree-level ---------------------------------------------------------------- - -def _rule_pages(files: Mapping[str, str]) -> dict[str, Page]: - pages: dict[str, Page] = {} - for path, text in files.items(): - if path.endswith(".md"): - page = Page.parse(text) - if page.rules(): - pages[path] = page - return pages - - -def check_tree(files: Mapping[str, str]) -> list[Issue]: - issues: list[Issue] = [] - footers: dict[str, tuple[str, object]] = {} - for path, page in _rule_pages(files).items(): - for section in page.rules(): - try: - footer = section.footer - except LifecycleError as exc: - issues.append(Issue("footer_invalid", path, f"{section.rule_id}: {exc}")) - continue - footers.setdefault(section.rule_id, (path, footer)) - for rule_id, (path, footer) in footers.items(): - if footer.superseded_by: - target = footers.get(footer.superseded_by) - if target is None or target[1].supersedes != rule_id: - issues.append(Issue("supersede_link_broken", path, - f"{rule_id} -> {footer.superseded_by} has no matching supersedes")) - if footer.supersedes: - source = footers.get(footer.supersedes) - if source is not None and source[1].superseded_by != rule_id: - issues.append(Issue("supersede_link_broken", path, - f"{rule_id} supersedes {footer.supersedes}, which does not name it")) - try: - tombstoned = all_tombstoned_ids(files) - except (LifecycleError, yaml.YAMLError) as exc: - issues.append(Issue("tombstones_invalid", "", str(exc))) - tombstoned = set() - for rule_id in sorted(tombstoned & set(all_rule_ids(files))): - issues.append(Issue("tombstoned_id_reused", "", rule_id)) - for path, text in files.items(): - if PurePosixPath(path).name == ROUTES_NAME: - issues.extend(_check_routes(path, text, files)) - return issues - - -def _check_routes(path: str, text: str, files: Mapping[str, str]) -> list[Issue]: - try: - data = yaml.safe_load(text) - except yaml.YAMLError as exc: - return [Issue("routes_invalid", path, f"not YAML: {exc}")] - if not isinstance(data, dict) or data.get("schema_version") != 1: - return [Issue("routes_invalid", path, "schema_version must be 1")] - issues = [] - seen: set[str] = set() - for index, owner in enumerate(data.get("owners") or []): - if not isinstance(owner, dict) or not owner.get("owner") or not owner.get("path"): - issues.append(Issue("routes_invalid", path, f"owners[{index}] needs owner and path")) - continue - if owner["owner"] in seen: - issues.append(Issue("routes_invalid", path, f"duplicate owner {owner['owner']}")) - seen.add(owner["owner"]) - if str(owner["path"]) not in files: - issues.append(Issue("routes_page_missing", path, str(owner["path"]))) - for key in ("signals", "scope_prefixes"): - if not isinstance(owner.get(key) or [], list): - issues.append(Issue("routes_invalid", path, f"{owner['owner']}.{key} must be a list")) - models = data.get("models") - if models is not None and (not isinstance(models, dict) or not models.get("dir") or not models.get("page")): - issues.append(Issue("routes_invalid", path, "models needs dir and page")) - return issues - - -# -- change-set level ---------------------------------------------------------- - -def _whitelist_issues(changes: Iterable[Change]) -> list[Issue]: - issues = [] - for change in changes: - path = change.path - if not path.startswith(KNOWLEDGE_PREFIX) or _DENY.match(path) \ - or not any(p.fullmatch(path) for p in _WHITELIST): - issues.append(Issue("path_not_whitelisted", path, "only knowledge rule/guide pages, _routes.yaml and _tombstones.yaml auto-merge")) - continue - if change.status != "D" and change.new_mode != _REGULAR_MODE: - issues.append(Issue("mode_not_regular", path, f"mode {change.new_mode or '?'} (need 100644)")) - return issues - - -def _text_issues(path: str, text: str | None) -> list[Issue]: - if text is None: - return [] - if "\x00" in text or "�" in text: - return [Issue("not_utf8_text", path, "binary or non-UTF-8 content")] - return [] - - -def _frontmatter_mechanical(path: str, base: Page, head: Page) -> list[Issue]: - """Only ``updated:`` and ``sources:`` may change, and sources exactly as recomputed.""" - base_data, head_data = base.frontmatter_data(), head.frontmatter_data() - issues = [] - for key in sorted(set(base_data) | set(head_data)): - if key in ("updated", "sources"): - continue - if base_data.get(key) != head_data.get(key): - issues.append(Issue("frontmatter_changed", path, f"{key} may not change")) - if str(head_data.get("updated", "")) < str(base_data.get("updated", "")): - issues.append(Issue("frontmatter_changed", path, "updated went backwards")) - want = expected_sources(base.sources(), head) - if head.sources() != want: - issues.append(Issue("sources_mismatch", path, f"expected {want}, got {head.sources()}")) - return issues - - -def _new_page_mechanical(path: str, head: Page) -> list[Issue]: - data = head.frontmatter_data() - issues = [] - if data.get("type") != "rule": - issues.append(Issue("new_page_invalid", path, "a new page must be type: rule")) - if not str(data.get("title") or "").strip(): - issues.append(Issue("new_page_invalid", path, "a new page needs a title")) - want = expected_sources([], head) - if head.sources() != want: - issues.append(Issue("sources_mismatch", path, f"expected {want}, got {head.sources()}")) - return issues - - -def _classify_page(path: str, base_text: str | None, head_text: str | None, - blocks: list[Block], issues: list[Issue], - retired: list[str], purged: list[tuple[str, str]], - purge_retired_at: dict[str, str]) -> None: - base = Page.parse(base_text) if base_text is not None else None - head = Page.parse(head_text) if head_text is not None else None - if head is None: - issues.append(Issue("page_deleted", path, "pages are never deleted; purge rules instead")) - return - base_is_rule = base is not None and ( - base.frontmatter_data().get("type") == "rule" or bool(base.rules())) - is_rule_page = base_is_rule or head.frontmatter_data().get("type") == "rule" - if base_is_rule and head.frontmatter_data().get("type") != base.frontmatter_data().get("type"): - # a page may not leave the lifecycle by changing its type - issues.append(Issue("page_type_changed", path, "a rule page keeps its type")) - if not is_rule_page: - if base is None or base.render() != head.render(): - blocks.append(Block("prose", path, "", "prose", _sha(head.render()))) - return - if base is None: - issues.extend(_new_page_mechanical(path, head)) - base_rules: dict[str, Section] = {} - base_other: list[str] = [] - base_head = "" - else: - issues.extend(_frontmatter_mechanical(path, base, head)) - base_rules = {s.rule_id: s for s in base.rules()} - base_other = [_norm(s.text) for s in base.sections if not s.is_rule] - base_head = base.head - head_rules = {s.rule_id: s for s in head.rules()} - head_other = [_norm(s.text) for s in head.sections if not s.is_rule] - if (base is None and head.head.strip() != f"# {head.frontmatter_data().get('title', '')}") \ - or (base is not None and head.head != base_head) or head_other != base_other: - blocks.append(Block("prose", path, "", "prose", - _sha(head.head + "".join(head_other)))) - for rule_id, section in head_rules.items(): - old = base_rules.get(rule_id) - try: - footer = section.footer - old_footer = old.footer if old is not None else None - except LifecycleError as exc: - issues.append(Issue("footer_invalid", path, f"{rule_id}: {exc}")) - continue - if old is None: - if footer.status != "active": - issues.append(Issue("rule_added_retired", path, rule_id)) - blocks.append(Block("rule", path, rule_id, "add", _sha(_norm(section.text)))) - continue - if _norm(old.text) == _norm(section.text): - continue - if old_footer.status == "retired": - issues.append(Issue("retired_rule_changed", path, f"{rule_id} is retired and frozen")) - continue - if old_footer.protected and not footer.protected: - issues.append(Issue("protection_removed", path, rule_id)) - if footer.status == "retired": - if section.body_without_footer != old.body_without_footer: - issues.append(Issue("retire_changed_text", path, f"{rule_id}: retiring may not reword")) - if old_footer.protected or footer.protected: - issues.append(Issue("protected_rule_retired", path, rule_id)) - op = "supersede" if footer.reason == "superseded" else "retire" - retired.extend(dict.fromkeys((rule_id, *section.nested_rule_ids))) - blocks.append(Block("rule", path, rule_id, op, _sha(_norm(section.text)))) - else: - if footer != old_footer: - issues.append(Issue("footer_changed", path, f"{rule_id}: an edit keeps its footer")) - if old_footer.protected or footer.protected: - issues.append(Issue("protected_rule_edited", path, rule_id)) - if set(section.nested_rule_ids) != set(old.nested_rule_ids): - # a nested rule leaves only by retiring and purging its parent - issues.append(Issue("nested_rule_ids_changed", path, - f"{rule_id}: {sorted(set(old.nested_rule_ids) ^ set(section.nested_rule_ids))}")) - blocks.append(Block("rule", path, rule_id, "edit", _sha(_norm(section.text)))) - for rule_id, old in base_rules.items(): - if rule_id in head_rules: - continue - if old.footer.status != "retired": - issues.append(Issue("active_rule_removed", path, f"{rule_id}: retire before purging")) - continue - if old.footer.protected: - issues.append(Issue("protected_rule_purged", path, rule_id)) - purged.extend((rid, path) for rid in dict.fromkeys((rule_id, *old.nested_rule_ids))) - purge_retired_at[rule_id] = old.footer.retired_at - blocks.append(Block("rule", path, rule_id, "purge", _sha(_norm(old.text)))) - - -def _index_mechanical(path: str, base_text: str | None, head_text: str | None, - new_pages: set[str], issues: list[Issue], blocks: list[Block], - head: Mapping[str, str] | None = None) -> None: - if base_text is None or head_text is None: - issues.append(Issue("index_added_or_deleted", path, "index files are never created or deleted by a change set")) - return - directory = str(PurePosixPath(path).parent) - expected_new = sorted(PurePosixPath(p).name for p in new_pages - if str(PurePosixPath(p).parent) == directory - and p.endswith(".md") and PurePosixPath(p).name != INDEX_NAME) - # pages already in the directory but missing from the old index may be - # linked too (the release sweep's T1 repair); new pages MUST be - allowed = set(expected_new) | { - PurePosixPath(p).name for p in (head or {}) - if str(PurePosixPath(p).parent) == directory and p.endswith(".md") - and PurePosixPath(p).name != INDEX_NAME and f"]({PurePosixPath(p).name})" not in base_text - } - for name in expected_new: # required whatever else the index change does - if f"]({name})" not in head_text: - issues.append(Issue("index_missing", path, f"new page {name} is not linked")) - if not head_text.startswith(base_text.rstrip("\n") + "\n") and head_text != base_text: - blocks.append(Block("prose", path, "", "prose", _sha(head_text))) - return - added = head_text[len(base_text.rstrip("\n") + "\n"):].splitlines() - files = [] - for line in added: - match = _INDEX_LINE.fullmatch(line.strip()) - if match is None: - blocks.append(Block("prose", path, "", "prose", _sha(head_text))) - return - files.append(match.group("file")) - if not set(expected_new) <= set(files) or not set(files) <= allowed or len(files) != len(set(files)): - issues.append(Issue("index_mismatch", path, - f"index lines must cover new pages {expected_new} and only link unlisted pages; got {files}")) - - -def check_changeset( - base: Mapping[str, str], - head: Mapping[str, str], - changes: Iterable[Change], - *, - external_texts: Mapping[str, str] | None = None, - release: str = "", -) -> ChangesetResult: - """Gate one change set. ``base``/``head`` map knowledge-relative paths - (``repos/...``) to text; ``changes`` are repository-relative git changes. - ``external_texts`` (repo path -> text, outside ``knowledge/``) is scanned - for references to rules this change retires or purges. ``release`` is the - trusted current release (from the signed verdict); when given, purges must - carry it as ``purged_at``. A purge is always rejected in the release that - retired the rule.""" - changes = list(changes) - issues = _whitelist_issues(changes) - blocks: list[Block] = [] - retired: list[str] = [] - purged: list[tuple[str, str]] = [] # (rule id, page) - purge_retired_at: dict[str, str] = {} - knowledge_paths = [c.path[len(KNOWLEDGE_PREFIX):] for c in changes - if c.path.startswith(KNOWLEDGE_PREFIX)] - for path in knowledge_paths: - issues.extend(_text_issues(path, head.get(path))) - if path.endswith(".md") and head.get(path) is not None: - over = page_over_capacity(head[path]) - if over: - issues.append(Issue("page_over_capacity", path, over)) - new_pages = {p for p in knowledge_paths if p not in base and p in head} - for page_path in sorted(new_pages): - index = str(PurePosixPath(page_path).with_name(INDEX_NAME)) - if page_path.endswith(".md") and PurePosixPath(page_path).name != INDEX_NAME \ - and index not in knowledge_paths: - issues.append(Issue("index_missing", page_path, f"{index} must list the new page")) - tombstone_paths = [] - for path in sorted(knowledge_paths): - name = PurePosixPath(path).name - if name == INDEX_NAME: - _index_mechanical(path, base.get(path), head.get(path), new_pages, issues, blocks, head) - elif name == TOMBSTONES_NAME: - tombstone_paths.append(path) - elif name == ROUTES_NAME: - blocks.append(Block("prose", path, "", "prose", _sha(head.get(path) or ""))) - elif path.endswith(".md"): - try: - _classify_page(path, base.get(path), head.get(path), blocks, issues, - retired, purged, purge_retired_at) - except (LifecycleError, yaml.YAMLError) as exc: - issues.append(Issue("page_unparseable", path, str(exc))) - # tombstones: exactly the IDs purged in this change set, appended - for path in tombstone_paths: - try: - before = load_tombstones(base.get(path)) - after = load_tombstones(head.get(path)) - except (LifecycleError, yaml.YAMLError) as exc: - issues.append(Issue("tombstones_invalid", path, str(exc))) - continue - scope = str(PurePosixPath(path).parent) - purged_here = [rid for rid, page_path in purged if page_path.startswith(scope + "/")] - appended = after[len(before):] - if after[:len(before)] != before or sorted(i["id"] for i in appended) != sorted(purged_here): - issues.append(Issue("tombstones_mismatch", path, f"expected appended tombstones for {sorted(purged_here)}")) - for item in appended: - retired_at = purge_retired_at.get(str(item["id"])) - purged_at = str(item.get("purged_at") or "") - if retired_at is None: - continue # a nested ID; its parent carries the timing - if not purged_at or purged_at == retired_at: - issues.append(Issue("purge_too_early", path, - f"{item['id']}: purged in the release it was retired ({retired_at})")) - if release and purged_at != release: - issues.append(Issue("purge_release_mismatch", path, - f"{item['id']}: purged_at {purged_at} is not this release {release}")) - if head.get(path) != render_tombstones(after): - issues.append(Issue("tombstones_mismatch", path, "tombstones are not in canonical form")) - for rule_id, _page in purged: - if rule_id not in all_tombstoned_ids(head): - issues.append(Issue("purge_without_tombstone", "", rule_id)) - # no NEW duplicate IDs, no reuse of retired or tombstoned IDs - base_ids, head_ids = all_rule_ids(base), all_rule_ids(head) - for rule_id, count in head_ids.items(): - if count > base_ids.get(rule_id, 0) and count > 1: - issues.append(Issue("duplicate_rule_id", "", rule_id)) - base_tomb = all_tombstoned_ids(base) - for block in blocks: - if block.op == "add" and (block.rule_id in base_tomb or block.rule_id in base_ids): - issues.append(Issue("rule_id_reused", block.path, block.rule_id)) - # dangling references to what this change set retired or purged - gone = set(retired) | {rid for rid, _page in purged} - if gone: - issues.extend(_dangling_refs(head, gone)) - external = tuple( - (rule_id, path) - for path, text in sorted((external_texts or {}).items()) - for rule_id in sorted(gone) if re.search(rf"(? list[Issue]: - """References in ACTIVE text to rules that are now retired or purged. - Allowed: the rule's own section, lifecycle footers, tombstones.""" - issues = [] - for path, text in head.items(): - if not path.endswith(".md"): - continue - page = Page.parse(text) - chunks = [page.head] + [ - s.body_without_footer for s in page.sections - if s.rule_id not in gone and (not s.is_rule or _active(s)) - ] - body = "\n".join(chunks) - for rule_id in sorted(gone): - if re.search(rf"(? bool: - try: - return section.footer.status == "active" - except LifecycleError: - return True diff --git a/.github/kb-gate/lifecycle.py b/.github/kb-gate/lifecycle.py deleted file mode 100644 index dafe6531..00000000 --- a/.github/kb-gate/lifecycle.py +++ /dev/null @@ -1,424 +0,0 @@ -# GENERATED by tools/build_kb_gate_bundle.py from src/infermatrix_copilot/knowledge_service/lifecycle.py -- do not edit here. -"""Rule pages as data: sections, lifecycle footers, and byte-preserving render. - -A rule page is frontmatter, a head (title + preamble), then sections. A section -starts at every ``## `` heading, and also at a ``### — `` heading -whose enclosing ``## `` section is not itself a rule (owner pages group rules -under topic headings). A section whose heading is ``## <ID> — <title>`` or -``### <ID> — <title>`` is a rule; ``###`` rules INSIDE a ``##`` rule stay part -of it as nested rules. Each rule may end -with one machine-readable footer, an HTML comment that renders invisibly:: - - <!-- kb:rule status=retired since=v0.30.0 retired_at=v0.31.0 reason=upstream-removed evidence="PR #8201" superseded_by=BENCH-1c --> - -No footer means ``status=active``, which is every rule written before the -lifecycle existed. Parsing then rendering an untouched page returns its exact -bytes, so an operation changes only the sections it targets. - -This module needs only the standard library and PyYAML and imports nothing -else from the package: the ``kb-gate`` verifier bundle vendors it verbatim. -""" - -from __future__ import annotations - -import hashlib -import re -from dataclasses import dataclass, field, replace -from typing import Iterable - -import yaml - -RULE_HEADING = re.compile(r"^(?P<level>#{2,3})\s+(?P<rule>[A-Za-z0-9][A-Za-z0-9-]{1,40})\s+[—-]\s+\S") -_TOP_RULE = re.compile(r"^##\s+[A-Za-z0-9][A-Za-z0-9-]{1,40}\s+[—-]\s+\S") -ANY_RULE_HEADING = re.compile( - r"^#{2,6}\s+(?P<rule>[A-Za-z0-9][A-Za-z0-9-]{1,40})(?=\s|$)", re.MULTILINE) -FOOTER = re.compile(r"^<!--\s*kb:rule\s+(?P<attrs>.*?)\s*-->\s*$") -_ATTR = re.compile(r'(?P<key>[a-z_]+)=(?:"(?P<quoted>[^"]*)"|(?P<bare>[^\s"]+))') -FRONTMATTER = re.compile(r"\A---\r?\n(?P<body>.*?)\r?\n---(?:\r?\n|\Z)", re.DOTALL) -CITATION = re.compile(r"\^\[(?P<ref>[^\]\n]{1,60})\]") - -STATUSES = ("active", "retired") -FOOTER_KEYS = ( - "status", "since", "retired_at", "reason", "evidence", - "supersedes", "superseded_by", "protected", -) -RETIRE_REASONS = ("upstream-removed", "superseded", "incorrect", "duplicate") - - -class LifecycleError(ValueError): - """A page, footer, or operation violates the rule lifecycle contract.""" - - -@dataclass(frozen=True) -class Footer: - status: str = "active" - since: str = "" - retired_at: str = "" - reason: str = "" - evidence: str = "" - supersedes: str = "" - superseded_by: str = "" - protected: bool = False - - @classmethod - def parse(cls, attrs: str) -> "Footer": - values: dict[str, str] = {} - consumed = 0 - for match in _ATTR.finditer(attrs): - if attrs[consumed:match.start()].strip(): - raise LifecycleError(f"malformed kb:rule footer: {attrs!r}") - key = match.group("key") - if key not in FOOTER_KEYS: - raise LifecycleError(f"unknown kb:rule footer key: {key}") - if key in values: - raise LifecycleError(f"repeated kb:rule footer key: {key}") - values[key] = match.group("quoted") if match.group("quoted") is not None \ - else match.group("bare") - consumed = match.end() - if attrs[consumed:].strip(): - raise LifecycleError(f"malformed kb:rule footer: {attrs!r}") - status = values.get("status", "active") - if status not in STATUSES: - raise LifecycleError(f"kb:rule status must be one of {STATUSES}: {status}") - protected = values.get("protected", "false") - if protected not in {"true", "false"}: - raise LifecycleError("kb:rule protected must be true or false") - footer = cls( - status=status, - since=values.get("since", ""), - retired_at=values.get("retired_at", ""), - reason=values.get("reason", ""), - evidence=values.get("evidence", ""), - supersedes=values.get("supersedes", ""), - superseded_by=values.get("superseded_by", ""), - protected=protected == "true", - ) - footer.check() - return footer - - def check(self) -> None: - if self.status == "retired": - missing = [key for key in ("retired_at", "reason", "evidence") - if not getattr(self, key)] - if missing: - raise LifecycleError(f"retired rule footer lacks {', '.join(missing)}") - if self.reason not in RETIRE_REASONS: - raise LifecycleError( - f"retire reason must be one of {RETIRE_REASONS}: {self.reason}") - if self.reason == "superseded" and not self.superseded_by: - raise LifecycleError("a superseded rule must name superseded_by") - elif self.retired_at or self.superseded_by: - raise LifecycleError("an active rule cannot carry retired_at/superseded_by") - - def render(self) -> str: - parts = [] - for key in FOOTER_KEYS: - value = getattr(self, key) - if key == "protected": - if value: - parts.append("protected=true") - continue - if key == "status": - parts.append(f"status={value}") - continue - if not value: - continue - if re.search(r'[\s"]', value): - if '"' in value: - raise LifecycleError(f"footer value may not contain a quote: {key}") - parts.append(f'{key}="{value}"') - else: - parts.append(f"{key}={value}") - return f"<!-- kb:rule {' '.join(parts)} -->" - - -@dataclass(frozen=True) -class Section: - """One level-two section, kept as its exact source text.""" - - text: str # includes the heading line and its trailing newline(s) - - @property - def heading(self) -> str: - return self.text.split("\n", 1)[0] - - @property - def rule_id(self) -> str: - match = RULE_HEADING.match(self.heading) - return match.group("rule") if match else "" - - @property - def level(self) -> int: - match = RULE_HEADING.match(self.heading) - return len(match.group("level")) if match else 2 - - @property - def is_rule(self) -> bool: - return bool(self.rule_id) - - def _footer_line(self) -> tuple[int, str] | None: - lines = self.text.rstrip("\n").split("\n") - for index in range(len(lines) - 1, 0, -1): - line = lines[index].strip() - if not line: - continue - return (index, line) if FOOTER.match(line) else None - return None - - @property - def footer(self) -> Footer: - found = self._footer_line() - if found is None: - return Footer() - return Footer.parse(FOOTER.match(found[1]).group("attrs")) - - @property - def has_footer(self) -> bool: - return self._footer_line() is not None - - @property - def body_without_footer(self) -> str: - """The rule's words: the section minus its footer, trailing blanks trimmed.""" - found = self._footer_line() - lines = self.text.rstrip("\n").split("\n") - if found is not None: - lines = lines[:found[0]] - return "\n".join(lines).rstrip() + "\n" - - @property - def content_sha256(self) -> str: - return hashlib.sha256(self.body_without_footer.encode("utf-8")).hexdigest() - - @property - def nested_rule_ids(self) -> tuple[str, ...]: - return tuple(m.group("rule") for m in ANY_RULE_HEADING.finditer(self.text)) - - @property - def citations(self) -> tuple[str, ...]: - return tuple(dict.fromkeys(m.group("ref").strip() for m in CITATION.finditer(self.text))) - - def with_footer(self, footer: Footer) -> "Section": - footer.check() - trailing = self.text[len(self.text.rstrip("\n")):] or "\n" - return Section(self.body_without_footer + "\n" + footer.render() + trailing) - - -@dataclass(frozen=True) -class Page: - frontmatter: str # the exact "---...---\n" block, or "" when absent - head: str # text between frontmatter and the first "## " section - sections: tuple[Section, ...] = field(default_factory=tuple) - - @classmethod - def parse(cls, text: str) -> "Page": - match = FRONTMATTER.match(text) - frontmatter = match.group(0) if match else "" - rest = text[len(frontmatter):] - chunks = _split_sections(rest) - head = chunks[0] - sections = tuple(Section(chunk) for chunk in chunks[1:]) - return cls(frontmatter, head, sections) - - def render(self) -> str: - return self.frontmatter + self.head + "".join(s.text for s in self.sections) - - # -- rules --------------------------------------------------------------- - - def rules(self) -> tuple[Section, ...]: - return tuple(s for s in self.sections if s.is_rule) - - def rule(self, rule_id: str) -> Section: - for section in self.sections: - if section.rule_id == rule_id: - return section - raise LifecycleError(f"rule {rule_id} is not on this page") - - def has_rule(self, rule_id: str) -> bool: - return any(section.rule_id == rule_id for section in self.sections) - - def replace_section(self, rule_id: str, new: Section | None) -> "Page": - out = [] - found = False - for section in self.sections: - if section.rule_id == rule_id: - found = True - if new is not None: - out.append(new) - else: - out.append(section) - if not found: - raise LifecycleError(f"rule {rule_id} is not on this page") - return replace(self, sections=tuple(out)) - - def append_section(self, section: Section) -> "Page": - sections = list(self.sections) - if sections and not sections[-1].text.endswith("\n\n"): - last = sections[-1].text - sections[-1] = Section(last.rstrip("\n") + "\n\n") - elif not sections and self.head and not self.head.endswith("\n\n"): - return replace(self, head=self.head.rstrip("\n") + "\n\n", sections=(section,)) - sections.append(section) - return replace(self, sections=tuple(sections)) - - # -- frontmatter --------------------------------------------------------- - - def frontmatter_field(self, key: str) -> str | None: - match = re.search(rf"(?m)^{re.escape(key)}:[ \t]*(?P<value>.*)$", self.frontmatter) - return match.group("value").strip() if match else None - - def with_frontmatter_field(self, key: str, value: str) -> "Page": - if not self.frontmatter: - raise LifecycleError("page has no frontmatter") - pattern = re.compile(rf"(?m)^{re.escape(key)}:[^\r\n]*$") - if not pattern.search(self.frontmatter): - raise LifecycleError(f"frontmatter has no {key} field") - return replace(self, frontmatter=pattern.sub(f"{key}: {value}", self.frontmatter, count=1)) - - def frontmatter_data(self) -> dict: - match = FRONTMATTER.match(self.frontmatter) - if not match: - return {} - try: - data = yaml.safe_load(match.group("body")) or {} - except yaml.YAMLError as exc: - raise LifecycleError(f"frontmatter is not valid YAML: {exc}") from exc - if not isinstance(data, dict): - raise LifecycleError("frontmatter must be a mapping") - return data - - def sources(self) -> list[str]: - value = self.frontmatter_data().get("sources") - if value is None: - return [] - if not isinstance(value, list): - raise LifecycleError("frontmatter sources must be a list") - return [str(item) for item in value] - - def with_sources(self, items: list[str]) -> "Page": - """Rewrite ``sources:`` in the page's own style (flow or block list).""" - match = re.search( - r"(?m)^sources:[ \t]*(?P<flow>\[.*\])?[ \t]*\r?\n(?P<block>(?:[ \t]+-[^\r\n]*\r?\n)*)", - self.frontmatter, - ) - if match is None: - raise LifecycleError("frontmatter has no sources field") - if match.group("flow") is not None or not match.group("block"): - rendered = f"sources: {render_flow_list(items)}\n" - else: - indent = re.match(r"[ \t]+", match.group("block")).group(0) - rendered = "sources:\n" + "".join( - f'{indent}- "{_quote_free(item)}"\n' for item in items) - return replace( - self, - frontmatter=self.frontmatter[:match.start()] + rendered - + self.frontmatter[match.end():], - ) - - -_FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})") - - -def _split_sections(text: str) -> list[str]: - """Split at ``## `` headings, and at every ``### `` heading whose enclosing - ``## `` section is not a rule (so a level-three rule ends at its next - sibling); ``###`` inside a ``##`` rule stays nested. Fences never split.""" - chunks: list[str] = [] - current: list[str] = [] - fence: str | None = None - top_is_rule = False - for line in text.splitlines(keepends=True): - match = _FENCE.match(line) - if match: - marker = match.group(1) - if fence is None: - fence = marker[0] * len(marker) - elif marker[0] == fence[0] and len(marker) >= len(fence): - fence = None - split = False - if fence is None and not match: - if line.startswith("## "): - split = True - top_is_rule = bool(_TOP_RULE.match(line)) - elif line.startswith("### ") and not top_is_rule: - # every ### under a topic heading is its own section, so a - # level-three rule ends at its next sibling heading - split = True - if split: - chunks.append("".join(current)) - current = [line] - else: - current.append(line) - chunks.append("".join(current)) - return chunks - - -def parse_flow_list(raw: str) -> list[str]: - """Parse the YAML flow sequence the tree uses for ``sources:``/``tags:``.""" - raw = raw.strip() - if not (raw.startswith("[") and raw.endswith("]")): - raise LifecycleError(f"expected a [flow, list]: {raw!r}") - inner = raw[1:-1].strip() - if not inner: - return [] - items: list[str] = [] - for match in re.finditer(r'\s*(?:"(?P<q>[^"]*)"|(?P<b>[^,]+?))\s*(?:,|$)', inner): - value = match.group("q") if match.group("q") is not None else match.group("b") - if value is not None and value.strip(): - items.append(value.strip()) - return items - - -def _quote_free(item: str) -> str: - if '"' in item: - raise LifecycleError(f"list item may not contain a quote: {item!r}") - return item - - -def render_flow_list(items: Iterable[str]) -> str: - rendered = [] - for item in items: - if re.fullmatch(r"[A-Za-z0-9_./:@+-]+", item): - rendered.append(item) - else: - if '"' in item: - raise LifecycleError(f"list item may not contain a quote: {item!r}") - rendered.append(f'"{item}"') - return "[" + ", ".join(rendered) + "]" - - -def expected_sources(before: list[str], page: Page) -> list[str]: - """Deterministic ``sources:`` after a change: the old list, in order, plus - every citation an ACTIVE rule on the page makes that is not yet listed, in - order of first appearance. Nothing is ever removed (history stays cited).""" - out = list(before) - for section in page.rules(): - if section.footer.status != "active": - continue - for ref in section.citations: - if ref not in out: - out.append(ref) - return out - - -def visible_text(text: str) -> str: - """The page as reviewers and agents should see it: retired rules removed. - - A retired rule keeps its text on disk for one release cycle so the - retirement can be reviewed and reverted, but it must stop being SERVED at - once. Pages without retired rules are returned unchanged, byte for byte. - """ - if "kb:rule" not in text: - return text - page = Page.parse(text) - kept = [] - for section in page.sections: - try: - retired = section.is_rule and section.footer.status == "retired" - except LifecycleError: - retired = False # L1 rejects malformed footers; never hide on doubt - if not retired: - kept.append(section) - if len(kept) == len(page.sections): - return text - return replace(page, sections=tuple(kept)).render() - diff --git a/.github/kb-gate/ops.py b/.github/kb-gate/ops.py deleted file mode 100644 index 7e18421d..00000000 --- a/.github/kb-gate/ops.py +++ /dev/null @@ -1,376 +0,0 @@ -# GENERATED by tools/build_kb_gate_bundle.py from src/infermatrix_copilot/knowledge_service/ops.py -- do not edit here. -"""Knowledge Ops API 2.0: typed changes to rule pages, with the lifecycle. - -The v1 curation path (``KnowledgeCurator``) can only append a section. The -knowledge service needs every change a maintainer makes by hand, expressed as a -typed operation the quality gate can judge one by one: - -==================== ========================================================= -``add`` append a new rule (optionally on a new ``rules-*.md`` page) -``edit_same_meaning`` reword an active rule under the same ID (gate must confirm - the meaning is unchanged) -``replace`` retire an active rule as ``superseded`` and add its - successor under a NEW ID, atomically -``retire`` retire an active rule (upstream removed it, it was wrong, - or it duplicates another); its text stays for one cycle -``purge`` delete a retired rule's text and tombstone its ID forever -==================== ========================================================= - -``apply_operations`` is a pure function over a mapping of knowledge-relative -paths to text, so the service, the gate and the verifier all compute exactly -the same result. After the operations it writes the MECHANICAL consequences the -verifier later recomputes: ``updated:``, ``sources:`` (append-only), an index -line for every new page, and tombstones for purged IDs. - -Standard library + PyYAML + ``lifecycle`` only: vendored by the verifier bundle. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from pathlib import PurePosixPath -from typing import Iterable, Mapping - -import yaml - -try: # package import; the verifier bundle vendors both modules side by side - from .lifecycle import ( - ANY_RULE_HEADING, RETIRE_REASONS, RULE_HEADING, Footer, LifecycleError, - Page, Section, expected_sources, - ) -except ImportError: # pragma: no cover - exercised by the vendored bundle - from lifecycle import ( # type: ignore[no-redef] - ANY_RULE_HEADING, RETIRE_REASONS, RULE_HEADING, Footer, LifecycleError, - Page, Section, expected_sources, - ) - -KNOWLEDGE_OPS_API_VERSION = "2.0.0" -OP_KINDS = ("add", "edit_same_meaning", "replace", "retire", "purge") -TOMBSTONES_NAME = "_tombstones.yaml" -INDEX_NAME = "_index.md" -_RULE_PAGE = re.compile(r"rules(?:-[a-z0-9][a-z0-9-]*)?\.md") -_DATE = re.compile(r"\d{4}-\d{2}-\d{2}") -_MAX_SECTION_CHARS = 16 * 1024 -# Mirrors knowledge/tools/check_knowledge_tree.py's split gate (>= 32 KiB or -# >= 500 non-empty lines fails the whole tree). -PAGE_MAX_BYTES = 32 * 1024 -PAGE_MAX_LINES = 500 - - -def page_over_capacity(text: str) -> str: - size = len(text.encode("utf-8")) - lines = sum(1 for line in text.splitlines() if line.strip()) - if size >= PAGE_MAX_BYTES or lines >= PAGE_MAX_LINES: - return f"{size} bytes / {lines} non-empty lines (limit < {PAGE_MAX_BYTES} / < {PAGE_MAX_LINES})" - return "" - - -@dataclass(frozen=True) -class KnowledgeOperation: - kind: str - page: str - rule_id: str - section_markdown: str = "" - new_rule_id: str = "" - new_page: str = "" - reason: str = "" - evidence: str = "" - page_title: str = "" - allow_protected: bool = False - - def to_dict(self) -> dict: - return {key: value for key, value in self.__dict__.items() if value not in ("", False)} - - @classmethod - def from_dict(cls, data: Mapping) -> "KnowledgeOperation": - unknown = set(data) - set(cls.__dataclass_fields__) - if unknown: - raise LifecycleError(f"unknown operation fields: {sorted(unknown)}") - return cls(**{key: data[key] for key in data}) - - -@dataclass(frozen=True) -class OperationsResult: - files: dict[str, str] # every created or changed path -> new text - touched_rules: tuple[str, ...] # rule IDs whose sections changed - created_pages: tuple[str, ...] = field(default_factory=tuple) - - -def repo_scope(page: str) -> str: - """``repos/<repo>`` or ``general`` for a knowledge-relative page path.""" - parts = PurePosixPath(page).parts - if len(parts) >= 2 and parts[0] == "repos": - return f"repos/{parts[1]}" - if parts and parts[0] == "general": - return "general" - raise LifecycleError(f"page is outside repos/<repo>/ and general/: {page}") - - -def tombstones_path(page: str) -> str: - return f"{repo_scope(page)}/{TOMBSTONES_NAME}" - - -def load_tombstones(text: str | None) -> list[dict]: - if not text: - return [] - data = yaml.safe_load(text) or {} - if not isinstance(data, dict) or data.get("schema_version") != 1: - raise LifecycleError("tombstones must be schema_version 1") - items = data.get("ids") or [] - if not isinstance(items, list) or any( - not isinstance(item, dict) or not item.get("id") for item in items - ): - raise LifecycleError("tombstones ids must be a list of {id, ...}") - return items - - -def render_tombstones(items: list[dict]) -> str: - lines = [ - "# IDs of purged knowledge rules. An ID listed here is never reused.", - "# Written by the knowledge service; recomputed by the kb-gate verifier.", - "schema_version: 1", - "ids:", - ] - for item in items: - lines.append( - f" - {{id: {item['id']}, purged_at: {item.get('purged_at', '')}, " - f"page: {item.get('page', '')}}}" - ) - return "\n".join(lines) + "\n" - - -def all_rule_ids(files: Mapping[str, str]) -> dict[str, int]: - """Heading IDs at any level across every rule page, with their counts.""" - counts: dict[str, int] = {} - for path, text in files.items(): - if not path.endswith(".md"): - continue - for match in ANY_RULE_HEADING.finditer(_without_fences(text)): - counts[match.group("rule")] = counts.get(match.group("rule"), 0) + 1 - return counts - - -def all_tombstoned_ids(files: Mapping[str, str]) -> set[str]: - out: set[str] = set() - for path, text in files.items(): - if PurePosixPath(path).name == TOMBSTONES_NAME: - out.update(str(item["id"]) for item in load_tombstones(text)) - return out - - -def _without_fences(text: str) -> str: - out, fence = [], None - for line in text.splitlines(keepends=True): - stripped = line.lstrip() - if stripped.startswith(("```", "~~~")): - marker = stripped[:3] - fence = None if fence == marker else (fence or marker) - out.append("\n") - continue - out.append("\n" if fence else line) - return "".join(out) - - -def _check_section(section_markdown: str, rule_id: str, level: int = 2) -> Section: - text = section_markdown.strip("\n") + "\n" - section = Section(text) - marks = "#" * level - if section.rule_id != rule_id or section.level != level: - raise LifecycleError(f"section heading must be '{marks} {rule_id} — <title>'") - if level == 2 and len(re.findall(r"(?m)^## ", text)) != 1: - raise LifecycleError("a rule section has exactly one level-two heading") - if level == 3 and (re.search(r"(?m)^## ", text) or len( - [m for m in re.finditer(r"(?m)^### ", text)]) != 1): - raise LifecycleError("a level-three rule section has exactly one heading and no ## heading") - if section.has_footer: - raise LifecycleError("the service writes lifecycle footers; do not include one") - if not 80 <= len(text) <= _MAX_SECTION_CHARS: - raise LifecycleError("section length is outside 80..16384 characters") - return section - - -def _new_page_text(page: str, title: str, today: str, template: Page | None) -> str: - tags = "[]" - if template is not None and template.frontmatter_field("tags") is not None: - tags = template.frontmatter_field("tags") - if not title or '"' in title: - raise LifecycleError("a new page needs a title without quotes") - return ( - "---\n" - f'title: "{title}"\n' - f"created: {today}\n" - f"updated: {today}\n" - "type: rule\n" - f"tags: {tags}\n" - "sources: []\n" - "---\n\n" - f"# {title}\n\n" - ) - - -def index_line(page: str, title: str) -> str: - return f"- [{title}]({PurePosixPath(page).name})\n" - - -def apply_operations( - files: Mapping[str, str], - operations: Iterable[KnowledgeOperation], - *, - release: str, - today: str, -) -> OperationsResult: - """Apply typed operations to ``files`` (knowledge-relative path -> text). - - Raises ``LifecycleError`` on the first invalid operation; nothing is - partially applied because the input mapping is never mutated. - """ - if not _DATE.fullmatch(today): - raise LifecycleError("today must be YYYY-MM-DD") - if not release or re.search(r'[\s"]', release): - raise LifecycleError("release must be a non-empty token") - work: dict[str, str] = dict(files) - original_ids = all_rule_ids(files) - tombstoned = all_tombstoned_ids(files) - added_ids: set[str] = set() - touched_pages: dict[str, list[str]] = {} # page -> sources before any change - created: list[str] = [] - touched_rules: list[str] = [] - purged: dict[str, list[dict]] = {} - - def page_of(path: str, *, create_title: str = "") -> Page: - if not _RULE_PAGE.fullmatch(PurePosixPath(path).name): - raise LifecycleError(f"not a rule page (rules.md / rules-<topic>.md): {path}") - repo_scope(path) - text = work.get(path) - if text is None: - if not create_title: - raise LifecycleError(f"page does not exist: {path}") - sibling = str(PurePosixPath(path).with_name("rules.md")) - template = Page.parse(work[sibling]) if sibling in work else None - index = str(PurePosixPath(path).with_name(INDEX_NAME)) - if index not in work: - raise LifecycleError(f"a new page's directory needs an {INDEX_NAME}: {index}") - text = _new_page_text(path, create_title, today, template) - work[path] = text - created.append(path) - work[index] = work[index].rstrip("\n") + "\n" + index_line(path, create_title) - page = Page.parse(text) - if page.frontmatter_field("type") != "rule": - raise LifecycleError(f"page is not type: rule: {path}") - touched_pages.setdefault(path, page.sources() if path not in created else []) - return page - - def fresh_id(rule_id: str) -> None: - if rule_id in original_ids or rule_id in added_ids: - raise LifecycleError(f"rule ID already exists in the tree: {rule_id}") - if rule_id in tombstoned: - raise LifecycleError(f"rule ID was purged and is reserved forever: {rule_id}") - added_ids.add(rule_id) - - def active(page: Page, rule_id: str, op: KnowledgeOperation) -> Section: - section = page.rule(rule_id) - footer = section.footer - if footer.status != "active": - raise LifecycleError(f"rule {rule_id} is not active") - if footer.protected and not op.allow_protected: - raise LifecycleError(f"rule {rule_id} is protected; only the human path may change it") - return section - - for op in operations: - if op.kind not in OP_KINDS: - raise LifecycleError(f"unknown operation kind: {op.kind}") - if op.kind == "add": - section = _check_section(op.section_markdown, op.rule_id) - fresh_id(op.rule_id) - for nested in section.nested_rule_ids: - if nested != op.rule_id: - fresh_id(nested) - page = page_of(op.page, create_title=op.page_title) - page = page.append_section(section.with_footer(Footer(status="active", since=release))) - work[op.page] = page.render() - touched_rules.append(op.rule_id) - elif op.kind == "edit_same_meaning": - page = page_of(op.page) - old = active(page, op.rule_id, op) - section = _check_section(op.section_markdown, op.rule_id, old.level) - if set(section.nested_rule_ids) != set(old.nested_rule_ids): - raise LifecycleError("an edit may not add or drop nested rule IDs") - trailing = old.text[len(old.text.rstrip("\n")):] - new = Section(section.text.rstrip("\n") + (trailing or "\n")) - if old.has_footer: - new = new.with_footer(old.footer) - work[op.page] = page.replace_section(op.rule_id, new).render() - touched_rules.append(op.rule_id) - elif op.kind == "replace": - if not op.new_rule_id or op.new_rule_id == op.rule_id: - raise LifecycleError("replace needs a different new_rule_id") - if not op.evidence: - raise LifecycleError("replace needs evidence") - page = page_of(op.page) - old = active(page, op.rule_id, op) - section = _check_section(op.section_markdown, op.new_rule_id) - fresh_id(op.new_rule_id) - for nested in section.nested_rule_ids: - if nested != op.new_rule_id: - fresh_id(nested) - retired = old.with_footer(Footer( - status="retired", since=old.footer.since, retired_at=release, - reason="superseded", evidence=op.evidence, - supersedes=old.footer.supersedes, # keep the chain A -> B -> C - superseded_by=op.new_rule_id, protected=old.footer.protected, - )) - work[op.page] = page.replace_section(op.rule_id, retired).render() - target = op.new_page or op.page - target_page = page_of(target, create_title=op.page_title) - successor = section.with_footer(Footer( - status="active", since=release, supersedes=op.rule_id)) - work[target] = target_page.append_section(successor).render() - touched_rules += [op.rule_id, op.new_rule_id] - elif op.kind == "retire": - if op.reason not in RETIRE_REASONS or op.reason == "superseded": - raise LifecycleError("retire reason must be upstream-removed, incorrect or duplicate; use replace for superseded") - if not op.evidence: - raise LifecycleError("retire needs evidence") - page = page_of(op.page) - old = active(page, op.rule_id, op) - retired = old.with_footer(Footer( - status="retired", since=old.footer.since, retired_at=release, - reason=op.reason, evidence=op.evidence, - supersedes=old.footer.supersedes, protected=old.footer.protected, - )) - work[op.page] = page.replace_section(op.rule_id, retired).render() - touched_rules.append(op.rule_id) - elif op.kind == "purge": - page = page_of(op.page) - old = page.rule(op.rule_id) - if old.footer.status != "retired": - raise LifecycleError(f"only a retired rule can be purged: {op.rule_id}") - if old.footer.protected and not op.allow_protected: - raise LifecycleError(f"rule {op.rule_id} is protected; only the human path may purge it") - if old.footer.retired_at == release: - raise LifecycleError("a rule is purged one full release after it was retired") - work[op.page] = page.replace_section(op.rule_id, None).render() - for rid in dict.fromkeys((op.rule_id, *old.nested_rule_ids)): - purged.setdefault(tombstones_path(op.page), []).append( - {"id": rid, "purged_at": release, "page": op.page}) - touched_rules.append(op.rule_id) - - for path, before_sources in touched_pages.items(): - page = Page.parse(work[path]) - page = page.with_frontmatter_field("updated", today) - page = page.with_sources(expected_sources(before_sources, page)) - work[path] = page.render() - for path, entries in purged.items(): - work[path] = render_tombstones(load_tombstones(work.get(path)) + entries) - for path in touched_pages: - over = page_over_capacity(work[path]) - if over: - raise LifecycleError(f"page full, add to a new rules-<topic>.md page instead: {path}: {over}") - - changed = {path: text for path, text in work.items() if files.get(path) != text} - return OperationsResult( - files=changed, - touched_rules=tuple(dict.fromkeys(touched_rules)), - created_pages=tuple(created), - ) diff --git a/.github/kb-gate/requirements.lock b/.github/kb-gate/requirements.lock deleted file mode 100644 index 895725e5..00000000 --- a/.github/kb-gate/requirements.lock +++ /dev/null @@ -1,227 +0,0 @@ -# GENERATED by tools/build_kb_gate_bundle.py --lock. Install with: -# pip install --require-hashes --no-deps -r .github/kb-gate/requirements.lock -cryptography==50.0.1 \ - --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ - --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ - --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ - --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ - --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ - --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ - --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ - --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ - --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ - --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ - --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ - --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ - --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ - --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ - --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ - --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ - --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ - --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ - --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ - --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ - --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ - --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ - --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ - --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ - --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ - --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ - --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ - --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ - --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ - --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ - --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ - --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ - --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ - --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ - --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ - --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ - --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ - --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ - --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ - --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ - --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ - --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ - --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ - --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ - --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ - --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef -cffi==2.1.1 \ - --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ - --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ - --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ - --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ - --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ - --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ - --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ - --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ - --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ - --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ - --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ - --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ - --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ - --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ - --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ - --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ - --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ - --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ - --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ - --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ - --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ - --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ - --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ - --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ - --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ - --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ - --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ - --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ - --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ - --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ - --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ - --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ - --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ - --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ - --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ - --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ - --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ - --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ - --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ - --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ - --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ - --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ - --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ - --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ - --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ - --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ - --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ - --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ - --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ - --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ - --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ - --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ - --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ - --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ - --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ - --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ - --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ - --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ - --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ - --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ - --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ - --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ - --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ - --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ - --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ - --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ - --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ - --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ - --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ - --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ - --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ - --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ - --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ - --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ - --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ - --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ - --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ - --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ - --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ - --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ - --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ - --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ - --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ - --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ - --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ - --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ - --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ - --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ - --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ - --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ - --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ - --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ - --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ - --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ - --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ - --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ - --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ - --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ - --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ - --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 -pycparser==3.0 \ - --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ - --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 -PyYAML==6.0.3 \ - --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ - --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ - --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ - --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ - --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ - --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ - --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ - --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ - --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ - --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ - --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ - --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ - --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ - --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ - --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ - --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ - --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ - --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ - --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ - --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ - --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ - --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ - --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ - --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ - --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ - --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ - --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ - --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ - --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ - --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ - --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ - --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ - --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ - --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ - --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ - --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ - --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ - --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ - --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ - --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ - --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ - --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ - --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ - --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ - --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ - --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ - --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ - --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ - --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ - --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ - --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ - --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ - --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ - --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ - --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ - --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ - --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ - --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ - --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ - --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ - --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ - --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ - --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ - --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ - --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ - --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ - --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ - --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ - --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ - --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ - --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ - --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ - --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 diff --git a/.github/kb-gate/signing.py b/.github/kb-gate/signing.py deleted file mode 100644 index 9c1934a6..00000000 --- a/.github/kb-gate/signing.py +++ /dev/null @@ -1,118 +0,0 @@ -# GENERATED by tools/build_kb_gate_bundle.py from src/infermatrix_copilot/knowledge_service/signing.py -- do not edit here. -"""Ed25519 envelopes for knowledge-gate verdicts, outbox items and control records. - -An envelope is ``{"payload": <object>, "key_id": <str>, "signature": <b64>}``. -The signature covers the canonical JSON of the payload (sorted keys, compact -separators, UTF-8) prefixed with a purpose tag, so a verdict signature can never -be replayed as an outbox item or a control record and vice versa. - -Only ``cryptography`` beyond the standard library: the kb-gate verifier bundle -vendors this module and pins that dependency by hash. -""" - -from __future__ import annotations - -import base64 -import hashlib -import json -import os -from pathlib import Path -from typing import Any - -from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import ( - Ed25519PrivateKey, Ed25519PublicKey, -) - -PURPOSES = ("kb-gate-verdict", "kb-outbox-item", "kb-control", "kb-holds", "kb-ack") - - -class SignatureError(ValueError): - """An envelope is malformed, signed for another purpose, or forged.""" - - -def canonical_json(value: Any) -> bytes: - return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - - -def _message(purpose: str, payload: Any) -> bytes: - if purpose not in PURPOSES: - raise SignatureError(f"unknown signature purpose: {purpose}") - return purpose.encode("ascii") + b"\0" + canonical_json(payload) - - -def public_key_id(public: Ed25519PublicKey) -> str: - raw = public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) - return "ed25519:" + hashlib.sha256(raw).hexdigest()[:16] - - -def public_key_text(public: Ed25519PublicKey) -> str: - """The one-line form committed as ``.github/kb-gate.pub``.""" - raw = public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) - return "ed25519 " + base64.b64encode(raw).decode("ascii") - - -def load_public_key(text: str) -> Ed25519PublicKey: - parts = text.strip().split() - if len(parts) != 2 or parts[0] != "ed25519": - raise SignatureError("public key must be 'ed25519 <base64>'") - try: - return Ed25519PublicKey.from_public_bytes(base64.b64decode(parts[1], validate=True)) - except (ValueError, TypeError) as exc: - raise SignatureError("public key is not a valid Ed25519 key") from exc - - -def generate_private_key(path: str | Path) -> Ed25519PrivateKey: - """Create a new private key file readable only by its owner.""" - path = Path(path) - if path.exists(): - raise SignatureError(f"refusing to overwrite an existing key: {path}") - key = Ed25519PrivateKey.generate() - data = key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) - path.parent.mkdir(parents=True, exist_ok=True) - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(fd, "wb") as handle: - handle.write(data) - return key - - -def load_private_key(path: str | Path) -> Ed25519PrivateKey: - path = Path(path) - mode = path.stat().st_mode & 0o077 - if mode: - raise SignatureError(f"private key must not be readable by group/others: {path}") - key = serialization.load_pem_private_key(path.read_bytes(), password=None) - if not isinstance(key, Ed25519PrivateKey): - raise SignatureError("private key is not Ed25519") - return key - - -def sign(purpose: str, payload: Any, key: Ed25519PrivateKey) -> dict: - signature = key.sign(_message(purpose, payload)) - return { - "purpose": purpose, - "payload": payload, - "key_id": public_key_id(key.public_key()), - "signature": base64.b64encode(signature).decode("ascii"), - } - - -def verify(purpose: str, envelope: Any, public: Ed25519PublicKey) -> Any: - """Return the payload of a valid envelope for ``purpose``; raise otherwise.""" - if not isinstance(envelope, dict) or set(envelope) != {"purpose", "payload", "key_id", "signature"}: - raise SignatureError("envelope must have exactly purpose, payload, key_id and signature") - if envelope["purpose"] != purpose: - raise SignatureError(f"envelope is signed for {envelope['purpose']!r}, not {purpose!r}") - if envelope["key_id"] != public_key_id(public): - raise SignatureError("envelope was signed by a different key") - try: - signature = base64.b64decode(envelope["signature"], validate=True) - public.verify(signature, _message(purpose, envelope["payload"])) - except (InvalidSignature, ValueError, TypeError) as exc: - raise SignatureError("signature does not verify") from exc - return envelope["payload"] diff --git a/.github/kb-gate/verdict.py b/.github/kb-gate/verdict.py deleted file mode 100644 index 59f44c4c..00000000 --- a/.github/kb-gate/verdict.py +++ /dev/null @@ -1,149 +0,0 @@ -# GENERATED by tools/build_kb_gate_bundle.py from src/infermatrix_copilot/knowledge_service/verdict.py -- do not edit here. -"""The kb-gate verdict: what the knowledge service signs and the verifier checks. - -A verdict binds a decision to ONE pull request state: - -* identity: repository, PR number, exact head SHA, nonce, issue time and a - 72-hour issue window (it must first verify within it); -* the complete patch manifest: every changed path with change type, pre-image - and post-image git blob IDs and file modes, compared independently of any - base SHA (so a queued PR is checked against what the queue actually applies); -* the per-block judgements for every rule and prose block L1 produced, the - change-set consistency judgement with the page hashes it saw, and the - context base those judgements were made against; -* upstream fact attestations (the verifier re-checks public upstreams); -* who decided: ``auto`` (L1 + L2 + consistency) or ``human-approved`` (with the - approving review IDs and reviewers). - -Standard library only (plus ``signing`` for the envelope): vendored by kb-gate. -""" - -from __future__ import annotations - -import hashlib -import re -import secrets -from dataclasses import dataclass -from typing import Any, Iterable, Mapping - -SCHEMA = "kb-gate-verdict/1" -ISSUE_WINDOW_SECONDS = 72 * 3600 -SOURCES = ("auto", "human-approved") -_SHA40 = re.compile(r"[0-9a-f]{40}") - - -class VerdictError(ValueError): - """A verdict is malformed or does not match what it claims to cover.""" - - -def git_blob_id(data: bytes | str | None) -> str: - """The git object ID of a blob with this content ("" for an absent file).""" - if data is None: - return "" - if isinstance(data, str): - data = data.encode("utf-8") - return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() - - -@dataclass(frozen=True) -class ManifestEntry: - path: str - status: str # A | M | D - old_blob: str - new_blob: str - old_mode: str - new_mode: str - - def to_dict(self) -> dict: - return {"path": self.path, "status": self.status, "old_blob": self.old_blob, - "new_blob": self.new_blob, "old_mode": self.old_mode, "new_mode": self.new_mode} - - -def manifest_from_files(base: Mapping[str, str], head: Mapping[str, str], *, - prefix: str = "knowledge/") -> list[dict]: - """Patch manifest for a change expressed as knowledge-relative text maps.""" - entries = [] - for rel in sorted(set(base) | set(head)): - before, after = base.get(rel), head.get(rel) - if before == after: - continue - status = "A" if before is None else "D" if after is None else "M" - entries.append(ManifestEntry( - path=prefix + rel, status=status, - old_blob=git_blob_id(before), new_blob=git_blob_id(after), - old_mode="" if before is None else "100644", - new_mode="" if after is None else "100644", - ).to_dict()) - return entries - - -def canonical_manifest(entries: Iterable[Mapping[str, Any]]) -> list[dict]: - """Sorted, field-complete manifest; the comparison form (no base SHA).""" - out = [] - for entry in entries: - missing = {"path", "status", "old_blob", "new_blob", "old_mode", "new_mode"} - set(entry) - if missing: - raise VerdictError(f"manifest entry lacks {sorted(missing)}") - out.append({key: str(entry[key]) for key in - ("path", "status", "old_blob", "new_blob", "old_mode", "new_mode")}) - return sorted(out, key=lambda item: item["path"]) - - -def build_verdict(*, repository: str, pr: int, head_sha: str, context_base_sha: str, - manifest: list[dict], blocks: list[dict], consistency: list[dict], - release: str, upstream: dict, facts: list[dict], models: dict, - source: str = "auto", review_ids: tuple[int, ...] = (), - reviewers: tuple[str, ...] = (), issued_at: float) -> dict: - if source not in SOURCES: - raise VerdictError(f"source must be one of {SOURCES}") - if not _SHA40.fullmatch(head_sha) or not _SHA40.fullmatch(context_base_sha): - raise VerdictError("head_sha and context_base_sha must be 40-character hex SHAs") - if source == "human-approved" and not (review_ids and reviewers): - raise VerdictError("a human-approved verdict names its approving reviews") - for block in blocks: - if block.get("verdict") != "pass": - raise VerdictError(f"only passing blocks are signed: {block.get('block_id')}") - for item in consistency: - if item.get("verdict") != "consistent": - raise VerdictError("only a consistent change set is signed") - return { - "schema": SCHEMA, - "repository": repository, - "pr": int(pr), - "head_sha": head_sha, - "context_base_sha": context_base_sha, - "issued_at": float(issued_at), - "valid_until": float(issued_at) + ISSUE_WINDOW_SECONDS, - "nonce": secrets.token_hex(16), - "source": source, - "review_ids": [int(value) for value in review_ids], - "reviewers": list(reviewers), - "release": release, - "upstream": upstream, - "manifest": canonical_manifest(manifest), - "blocks": sorted(blocks, key=lambda b: b["block_id"]), - "consistency": consistency, - "facts": facts, - "models": models, - } - - -def check_binding(verdict: Mapping[str, Any], *, repository: str, pr: int, head_sha: str, - now: float) -> None: - """Identity half of verification: the verdict names THIS PR state and is - within its issue window. Replaying a verdict on another PR, another head, - or after the window fails here.""" - if verdict.get("schema") != SCHEMA: - raise VerdictError("unknown verdict schema") - if verdict.get("repository") != repository or int(verdict.get("pr", -1)) != int(pr): - raise VerdictError("verdict is for another repository or pull request") - if verdict.get("head_sha") != head_sha: - raise VerdictError("verdict is for another head commit") - if not float(verdict.get("issued_at", 0)) <= now <= float(verdict.get("valid_until", 0)): - raise VerdictError("verdict is outside its issue window") - if verdict.get("source") not in SOURCES: - raise VerdictError("verdict source is invalid") - - -def manifests_equal(signed: Iterable[Mapping[str, Any]], actual: Iterable[Mapping[str, Any]]) -> bool: - return canonical_manifest(signed) == canonical_manifest(actual) diff --git a/.github/workflows/kb-gate.yml b/.github/workflows/kb-gate.yml deleted file mode 100644 index 85dcfb15..00000000 --- a/.github/workflows/kb-gate.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: kb-gate - -# The required check for knowledge pull requests (doc/features/knowledge-service.md). -# -# Two stages, one check name: -# * precheck (pull_request_target / issue_comment / workflow_dispatch): verifies -# the PR against current main and posts the `kb-gate` commit STATUS on the PR -# head. It only decides whether the PR may enter the merge queue. -# * merge group (merge_group): the job named `kb-gate` verifies every segment of -# the group on the exact commit that would land. This decides what lands. -# -# Security: the precheck runs with the BASE branch's workflow and bundle -# (pull_request_target), reads the PR only as git objects, never checks it out, -# never installs or executes anything from it, and holds no secret (the repository -# is public, so PR refs are fetched anonymously; the token only reads the API and -# posts the status). Both jobs -# run the verifier ONLY from .github/kb-gate/ (pinned, hash-locked deps). - -on: - pull_request_target: - types: [opened, synchronize, reopened, labeled, unlabeled, edited, ready_for_review, converted_to_draft] - issue_comment: - types: [created, edited] - workflow_dispatch: - inputs: - pr: - description: PR number to (re)check - required: true - merge_group: - types: [checks_requested] - -permissions: - contents: read - -jobs: - precheck: - if: >- - github.event_name == 'pull_request_target' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && - contains(github.event.comment.body, '<!-- kb-gate:verdict:v1 -->')) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - statuses: write - concurrency: - group: kb-gate-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr }} - cancel-in-progress: true - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.repository.default_branch }} - fetch-depth: 0 - persist-credentials: false - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install the pinned verifier - run: | - cd .github/kb-gate && sha256sum --check --strict MANIFEST.sha256 - python -m venv "$RUNNER_TEMP/kb-gate" - "$RUNNER_TEMP/kb-gate/bin/pip" install --quiet --require-hashes --no-deps -r requirements.lock - - name: Verify and post the kb-gate status - env: - GITHUB_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number || github.event.issue.number || inputs.pr }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - "$RUNNER_TEMP/kb-gate/bin/python" -E -s .github/kb-gate/kb_gate.py pr \ - --repository "$GITHUB_REPOSITORY" --pr "$PR" --post-status --target-url "$RUN_URL" - - kb-gate: - if: github.event_name == 'merge_group' - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: false - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install the pinned verifier - run: | - cd .github/kb-gate && sha256sum --check --strict MANIFEST.sha256 - python -m venv "$RUNNER_TEMP/kb-gate" - "$RUNNER_TEMP/kb-gate/bin/pip" install --quiet --require-hashes --no-deps -r requirements.lock - - name: Verify every segment of the merge group - env: - GITHUB_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.merge_group.head_sha }} - BASE_REF: ${{ github.event.merge_group.base_ref }} - run: | - "$RUNNER_TEMP/kb-gate/bin/python" -E -s .github/kb-gate/kb_gate.py merge-group \ - --repository "$GITHUB_REPOSITORY" --head-sha "$HEAD_SHA" --base-ref "${BASE_REF#refs/heads/}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e22e731f..66c38218 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -184,12 +184,6 @@ jobs: - name: SPEC coverage and freshness run: python tools/check_spec_freshness.py --strict - # A reminder, not a gate: a stale bundle keeps kb-gate at its previous - # strictness. Regenerating it is a .github/** change (CODEOWNERS review). - - name: kb-gate bundle matches its source - continue-on-error: true - run: python tools/build_kb_gate_bundle.py --check - # Written, tested, and until now invoked only from prose (AGENTS.md, DEVELOPMENT.md). - name: Knowledge linters run: | diff --git a/adapters/vllm_omni/kb-calibration/README.md b/adapters/vllm_omni/kb-calibration/README.md index 9861599c..94ad9f87 100644 --- a/adapters/vllm_omni/kb-calibration/README.md +++ b/adapters/vllm_omni/kb-calibration/README.md @@ -1,4 +1,4 @@ -# vllm-omni kb-gate calibration set +# vllm-omni judge calibration set `infermatrix-copilot kb calibrate --repo vllm-omni` runs the pinned judge over these cases exactly as the knowledge gate does. The repository may leave diff --git a/doc/architecture/SPEC/kb_service.md b/doc/architecture/SPEC/kb_service.md index 8fcbed3b..2c5801b5 100644 --- a/doc/architecture/SPEC/kb_service.md +++ b/doc/architecture/SPEC/kb_service.md @@ -10,11 +10,10 @@ `LifecycleConfigError`);`load_registry` 返回按仓库名的注册表,`general` 由服务配置。 - `ledger`:单个 SQLite,所有表按 `repo` 分区;心跳租约保证单实例;模式变化即代际 +1; `bump_generation` 使该仓库(或 `*`)之前签发的 outbox 项全部失效,可同时暂停。 -- `outbox`:服务端签发 outbox 项、控制记录与暂停清单,读取发布器签名的回执; +- `outbox`:服务端签发 outbox 项与控制记录,读取发布器签名的回执; 发布器端 `check_item`(签名、控制记录新鲜度、过期、代际、暂停、私有上游; 只有 auto_merge 仓库才有非停止类写动作,shadow 与 disabled 一律不发布; - `pause`/`close` 总可执行); - `verify_holds` 供 kb-gate 使用(过期即失败)。 + `close`/`open_revert_pr` 总可执行)。 - `cli`:`infermatrix-copilot kb keygen|status|pause|resume|control`。 ## 不变量 @@ -43,23 +42,22 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv - `runner`:经标准 executor 运行 `kb-*` playbook。CLI 新增 `kb run`、`kb calibrate`。 ## 2026-09-28 合并流程、激活、巡检与调度 -- `merge`:publisher 回执推进状态(pr_requested → pr_open → verdict_posted → queued → merged); - 对 PR 的精确 head 签发 kb-gate 判定(清单、逐块、一致性)并经 outbox `post_verdict` 发布; - PR 阶段 `kb-gate` 成功后才发 `enqueue`,每仓库同时至多 1 个排队;head 变化 → human; - 关闭未合并 → closed;合并后记录退役以便下个发版 purge。`pause_open_prs` 发出出队 + 转 draft 项。 +- `merge`:publisher 回执推进状态(pr_requested → pr_open → merge_requested → merged,v8 见下文); + 对 PR 的精确 head 签发判定(清单、逐块、一致性),随 `merge` 项交给发布器,每仓库同时至多 1 个;head 变化 → human; + 关闭未合并 → closed;合并后记录退役以便下个发版 purge。 - `activate`:每个 main SHA 生成不可变快照(`snapshots/<sha>/knowledge` + MANIFEST), 加载校验、树级检查、各仓库 `_routes.yaml` 可加载后原子切换 `active`;支持回滚;保留最近 10 个及当前激活。 - `sweep`:发版触发(首次只记录基线)/ 兜底周期;T1 结构报告(唯一自动修复是补索引链接); T2/T3 按规则页让生成器对照发版 diff 逐条给出 keep/edit/replace/retire;purge;全局熔断强制 human。 -- `scheduler`(`kb serve`):终身持有租约;每 tick 重签控制记录与暂停清单、处理回执、按仓库 - advance/intake/sweep(仓库间隔离)、main 变化即激活;24 小时内 2 个知识 PR 被人关闭 → 暂停该仓库并出队。 -- CLI:`kb serve [--once]`、`kb activate`、`kb rollback --to SHA`;`kb pause` 同时为已开 PR 发暂停项。 +- `scheduler`(`kb serve`):终身持有租约;每 tick 重签控制记录、处理回执、按仓库 + advance/intake/sweep(仓库间隔离)、main 变化即激活;24 小时内 2 个知识 PR 被人关闭 → 暂停该仓库。 +- CLI:`kb serve [--once]`、`kb activate`、`kb rollback --to SHA`;`kb pause`/`kb resume` 只改代际与暂停状态。 - 每个变更集记录其待回执的 outbox 项(`pending_item`):未过期时不重复签发;回执只作用于与之匹配的项和 对应的前置状态,迟到的重复项回执不改变状态。 - 回滚后写入 `rollback_pin`:调度器不会重新激活被回滚的那个 main,只有更新的 main 才会激活。 - 巡检按页记录进度(`sweep_progress`):页面评估失败与“保持”区分,失败页在下次重试,3 次后交给人; 所有页面落定后基线才推进;兜底巡检只做 T1 与 purge。 -- 熔断暂停与状态变化在同一加锁事务中发布控制记录与暂停清单。 +- 熔断暂停与状态变化在同一加锁事务中发布控制记录。 ## 2026-09-28 发布器 @@ -71,12 +69,10 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv 绝不猜测是否已执行;`open_pr` 只复用 head 恰为重建提交的已打开 PR(companion 还须是 draft); 提交由签名内容在 `base_sha` 上用临时索引重建,日期取自该项,重试得到同一 SHA;分支已存在且内容不同则拒绝, 绝不覆盖。路径必须是受治理的知识页面(`knowledge/{repos/<r>,general}/**.md|yaml`,无 `..`)。 -- 恢复:`post_verdict` 只在未暂停、当前代际下签发,发布它时先移除 `kb:hold` 标签并确认已移除,再发布评论,使评论触发的预检不再因暂停标签失败。 - 失败的 `pause`/`close` 不回执,留在 outbox 中每轮重试直到成功;格式错误的控制记录或项目记入 trace 并跳过。 +- 恢复:失败的 `close`/`open_issue`/`post_findings` 不回执,留在 outbox 中每轮重试直到成功;格式错误的控制记录或项目记入 trace 并跳过。 SSH 读取或回执失败只记入 trace,不终止进程;完成记录保留,下一轮重发回执而不重复动作。 -- 动作:`open_pr`/`open_companion_pr`(始终 draft)、`post_verdict` 与 `enqueue`/`update_branch`(先核对 PR - 仍打开且 head 未变;`enqueue` 先把 draft 转为 ready,再 `gh pr merge --auto --match-head-commit`)、 - `pause`(程序 P:出队 → 关闭自动合并 → 转 draft,确认已是 draft,再加 `kb:hold`)、`close`。 +- 动作:`open_pr`、`open_companion_pr`(始终 draft)、`merge`(v8,见下文)、`post_findings`、`open_revert_pr`、 + `open_issue`、`close`。 - 双重门控:无 `ALLOW_POST=1` 只记录将要执行的动作;推送分支还需 `ALLOW_PUSH=1`。无法验证或不可执行的项 既不执行也不回执。每轮与每个决定都写入 `traces/publisher.jsonl`。 - 测试:`test_kb_publisher.py`(端到端 open_pr → ack → pr_open、dry-run、崩溃后不重复执行、伪造/未配置/过期、 @@ -97,56 +93,33 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv - CLI:`kb traces`、`kb replay --record ID --model P:M[:E]`、`kb export --out FILE`。 ## 2026-09-28 过期判定:重签、重建或转人工 -`merge.advance` 记录每次签发判定的时间(`verdict_issued_at`)与入队时间(`queued_at`),并处理不再能通过门禁的判定: -- 判定签发超过 48 小时(有效期 72 小时):`verdict_posted` 直接回到 `pr_open` 重签;`queued` 以及入队 2 小时仍未合并的 - PR **不按计时释放队列名额**,而是先下发 `pause`(出队 + 转 draft),其回执把它送回 `pr_open`,再重签并重新入队。 -- 尚未过期的 `enqueue` 项未回执时不重签(其回执决定:进入 `queued` 后走上面的出队路径)。 -- PR 阶段 `kb-gate` 失败(只看签发当前判定之后发布的状态),按描述(验证器的第一个问题)分类:暂停清单/不可达/ - 验证器错误、我们自己暂停留下的 `kb:hold` 等瞬时问题 → 等待;判定过期或 - 缺失 → 重签;上下文改变、一致性页面变化、无法干净合并、清单不符 → **重建**:只有持有租约的调度器执行,在当前 main - 上重新应用同样的操作并重新过质量门,暂存为 `rebuild` 变更集(证据带 `rebuild of <旧 id>` 标记,与暂存原子写入, - 中断后再次执行会找到它而不会重复暂存)。只有重建**通过**质量门才取代旧 PR:旧变更集进入 `superseding`,`close` 项 - 过期即重发,直到观测到 PR 已关闭才记为 `superseded`(不计入被人工推翻的熔断);重建未通过、操作无法再应用或没有 - 可重放的操作 → `rebuild_failed` 并转人工,旧 PR 保持打开。其他失败(变更本身有问题)→ `gate_failed` 并转人工一次。 - -## 2026-09-28 外部知识 PR(来源④)与 human-approved 判定 -`external.poll_external`(调度器按 intake 间隔、全局未暂停时调用,需持有租约)处理知识仓库中**非服务创建**、非 draft、 -触碰 `knowledge/` 的打开 PR,每个 head 在每条路径(auto / human)上只评一次(auto 路径转人工的 head 在维护者审批后 -无需新推送即可按 human 路径重新评判): -- `kb:human-approved` 且知识维护者(main 上 `.github/kb-gate/` 的 CODEOWNERS)对**当前 head** 的审批仍有效(同一人的 - 后续审阅覆盖之前的)→ 只在受治理页面上跑 L1(审批替代白名单与 L2,不替代其余 L1)→ 签发 `human-approved` 判定, - 绑定这些审阅。 -- 否则全部路径须为受治理页面 → 以**当前 main 加上 PR 改动**完整过质量门(L1、L2、一致性)→ 通过则签发 `auto` 判定。 - PR 的前像必须等于 main(否则队列落地的改动与签名不同),不等则请作者 rebase。 +(v8 改写)发布器本地门禁拒绝 `merge` 时按理由分类(见“v8:发布器本地门禁与合并”):瞬时问题 → 回到 `pr_open` 重签; +上下文改变、一致性页面变化、无法干净合并、清单不符 → **重建**:只有持有租约的调度器执行,在当前 main +上重新应用同样的操作并重新过质量门,暂存为 `rebuild` 变更集(证据带 `rebuild of <旧 id>` 标记,与暂存原子写入, +中断后再次执行会找到它而不会重复暂存)。只有重建**通过**质量门才取代旧 PR:旧变更集进入 `superseding`,`close` 项 +过期即重发,直到观测到 PR 已关闭才记为 `superseded`(不计入被人工推翻的熔断);重建未通过、操作无法再应用或没有 +可重放的操作 → `rebuild_failed` 并转人工,旧 PR 保持打开。变更本身的问题见“v8:精炼—复检”。 +## 2026-09-28 外部知识 PR(来源④) +(v8 改写:没有 human-approved 路径,每日复检见下文)`external.poll_external`(调度器、全局未暂停时调用,需持有租约) +处理知识仓库中**非服务创建**、非 draft、触碰 `knowledge/` 的打开 PR,每个 head 只评一次: +- 全部路径须为受治理页面 → 以**当前 main 加上 PR 改动**完整过质量门(L1、L2、一致性)→ 通过则签发 `auto` 判定。 + PR 的前像必须等于 main(否则合并落地的改动与签名不同),不等则请作者 rebase。 与服务自己的 PR 一样,`auto` 判定要求当前通过的评审校准;否则记为 `calibration_required`,校准恢复后才重新评判 - (期间不重复调用付费评审)。维护者审批读取全部分页的审阅,后续页上的撤回同样生效。 -- 其余情况(跨多个仓库、白名单外路径未经审批、L1 失败、质量门未通过)转人工,每个 head 一次。 -- 判定清单直接取自 git(`merge-base..head` 的完整原始 diff),覆盖白名单外路径;`context_base_sha` 为评判时的 main。 + (期间不重复调用付费评审)。 +- 其余情况(跨多个仓库、受治理页面以外的路径——要求作者拆分、质量门未通过)不合并,理由写入发现评论。 +- 判定清单直接取自 git(`merge-base..head` 的完整原始 diff);`context_base_sha` 为评判时的 main。 - 暂存为 `external` 变更集后走普通合并流程。作者推送新 head → `head_changed`(不转人工);上下文失效 → `stale_context`; - 两者都在下次轮询时重新评判。签发后维护者撤回审批(最新审阅不再批准该 head)→ 转人工并下发 `pause`(出队 + draft); - 在 pause 回执成功前保持原状态(继续占用队列名额),回执后才记为 `approval_withdrawn`,剩余审批不会让同一 head 重新暂存。外部 PR 没有操作列表,L1 发现的退役/删除规则写入 `retirements`/`purges`,合并后同样进入 + 两者都在下次复检时重新评判。外部 PR 没有操作列表,L1 发现的退役/删除规则写入 `retirements`/`purges`,合并后同样进入 退役账本(之后的发版巡检据此 purge)。作者关闭自己的 PR 不计入熔断。只触碰非受治理知识路径的 PR 归入 `general`(若其接收 人工 PR),否则归入第一个接收人工 PR 的仓库。 - ## 2026-09-28 每日合并审计 `audit.audit_main`(调度器每 24 小时)沿 main 的 first-parent 历史从上次审计的提交向后检查:每个相对第一父改动了 `knowledge/` 的提交,都必须是账本已记录合并的变更集。证明只看结构、从不信提交信息:提交 SHA 为某变更集的 merge SHA,或它是双亲 -合并、第二父正是某个已合并变更集记录的 head(合并队列落地的形态),且它对知识的改动恰好就是该 head 的改动(改动的每个 +合并、第二父正是某个已合并变更集记录的 head,且它对知识的改动恰好就是该 head 的改动(改动的每个 知识路径都在该 PR 的改动内且内容与 head 相同;夹带额外改动的合并同样被发现)。否则(admin bypass、直接推送、账本漏记)对其涉及的仓库暂停自动合并(改动在仓库范围之外 → 全局暂停)、 -出队其打开的知识 PR 并转人工;不在 `auto_merge` 的仓库只写 `unrecorded_merge` trace。首次运行只记录基线;晚于 +并转人工;不在 `auto_merge` 的仓库只写 `unrecorded_merge` trace。首次运行只记录基线;晚于 2 小时宽限期的提交留到下次,避免与调度器记录合并赛跑。暂停逻辑与熔断共用 `Scheduler._pause`。 -## 2026-09-29 暂停清单端点 -`holds_server`(`kb holds-server`)只以 `GET/HEAD /holds.json` 提供 `<state_dir>/public/holds.json`(`Cache-Control: no-store`), -其余路径一律 404,不列目录、不跟随路径、不接受写入;清单尚不存在时返回 503(门禁视为不可达并失败关闭)。默认只绑定 -`127.0.0.1`,由 bot 主机的反向代理加 TLS 后对外,URL 写入 `.github/kb-gate/config.json` 的 `holds_url`。 - -## 2026-09-29 暂停以观测确认 -`pause_open_prs` 记录每个暂停请求的时间与原因。回执只代表发布器的说法:状态 `paused` 的变更集只有在 GitHub 显示该 PR -为 draft 时才记为已确认(draft 不能在合并队列中,也不能再次入队);否则重发 `pause`。请求 5 分钟后仍未回执或未确认 → -转人工一次,附出队与 `gh pr ready <n> --undo` 命令。`kb status` 报告每个仓库 `pause_unconfirmed` 数量,熔断/回滚在其归零 -前不算完成;`kb resume` 清除这些记录。 - ## 2026-09-29 巡检汇总 issue 每次巡检完成(`complete`)后,`report.publish_summary` 把汇总(T1 发现、修复的索引、保留/失败的页面、变更集及其状态、 各类操作数、待人工处理数、模型调用次数/token/耗时)写入 `<state_dir>/reports/sweep-<repo>-<tag>.md`;仅当仓库为 @@ -164,7 +137,7 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝 质量门唯一的问题是"知识目录外引用了本次退役/替换/删除的规则"时(非巡检暂存、非强制人工),`companion.stage_companion` 自动起草配套变更:被替换规则的 ID 改为新 ID;引用已退役规则的列表项/表格行删除,其余提及去掉 ID;只改配套白名单 (`skills/`、`plugins/`、`adapters/`、`doc/`、`playbooks/`)。配套 PR 经 `open_companion_pr` 以 draft 创建并打 `kb:companion` -标签,进入人工队列;发布器拒绝在其上执行 `post_verdict`/`enqueue`(ready 与合并只能由人完成),且拒绝白名单外路径 +标签,进入人工队列;发布器拒绝在其上执行 `merge`(ready 与合并只能由人完成),且拒绝白名单外路径 (`knowledge/`、`.github/`、`src/`、`tools/`)。知识变更集处于 `companion_pending`(规则保持 active);配套 PR 合并后,调度器 在当前 main 上重建它(外部引用已清零 → 正常过门与发布);配套 PR 被关闭则知识变更转人工,且不计入熔断。 @@ -197,23 +170,22 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝 事件 ID 与记录的 `event_id` 一致,重复投递幂等。 ## 2026-09-29 v8:发布器本地门禁与合并 -- 服务不再发布判定评论、不再等待 GitHub `kb-gate` 状态、不再入合并队列:`pr_open` 时签发绑定 PR/head 的判定,随 +- 服务不发布判定评论、不等待 GitHub 状态检查、不入合并队列:`pr_open` 时签发绑定 PR/head 的判定,随 `merge` 项交给发布器(每仓库同时至多一个 `merge_requested`)。回执:成功 → `merged`(合并 SHA、落地后复核结果); 落地后复核失败 → 暂停该仓库并转人工;拒绝 → 按理由分为重建(上下文/页面哈希变化、无法干净合并、清单不符 → `rebuild_needed`,由持有租约的调度器在当前 main 上重建)、瞬时(main 持续变化、控制记录变化、git/gh 问题 → 回到 `pr_open` 重签)、变更本身的问题(→ `gate_failed`,转人工,从不按原样重试)。未回执的 `merge` 过期后回到 `pr_open`。 - v7 的 `verdict_posted`/`queued` 记录按 `pr_open` 重签。 - 落地后复核:回执中的 `passed`/`failed` 为终态(`failed` 暂停该仓库并转人工),即使 GitHub 先显示已合并、回执后到也照常处理; `unknown`(发布器无法运行,或崩溃后恢复的合并)由服务 `verify_merged` 在自己的克隆上重跑,无法运行则下一轮重试。 - `local_gate`:发布器在自己安装的 Copilot 版本上,对 `main` tip 与 PR head 的合并树运行 `gate_verifier.verify_change` - (直接传入已验签的判定、不读暂停清单),另要求每个路径都在该项所属仓库的知识目录内、判定来源为 `auto`,并检查合并树 + (直接传入已验签的判定),另要求每个路径都在该项所属仓库的知识目录内、判定来源为 `auto`,并检查合并树 无新增树级问题;`post_merge_problems` 在 GitHub 实际生成的合并提交上重跑。 - 发布器 `merge`:整轮持有跨进程 `flock`(第二个实例直接退出);验签与绑定 → PR 打开且目标为 `main`、head 一致 → 取 main 与 PR head 跑本地门禁 → main 变化则重来(至多 3 次)→ 重读控制记录(暂停/代际变化即放弃)→ 写合并意图 → `gh pr merge --merge --match-head-commit` → 读取合并提交并做落地后复核。合并之后的任何异常都不会变成失败回执; 每轮开始先恢复有意图无完成记录的项(已合并则补发回执,未合并则丢弃意图、下一轮重试)。 - `gh pr merge` 报错时无法确定是否已合并:保留意图、不写记录也不回执,由下一轮恢复向 GitHub 确认。遗留的 `kb:hold` - 标签(v7 暂停留下)在合并前移除并确认。 + `gh pr merge` 报错时无法确定是否已合并:保留意图、不写记录也不回执,由下一轮恢复向 GitHub 确认。draft PR + 从不被转为 ready 或合并(瞬时拒绝,作者转回 ready 后再合并)。 只有 GitHub 显示 `MERGED` 才算合并:若 `main` 要求合并队列或开启了自动合并,`gh pr merge` 只会入队——此时关闭自动合并并 拒绝(转人工,要求 `main` 允许发布器直接合并);只有经 GraphQL 确认既不在合并队列中(必要时 `dequeuePullRequest`)也没有自动合并,才记为拒绝,否则保留意图,由恢复流程继续取消或接收 后来发生的合并。存在未结的合并意图时该项跳过,直到恢复流程从 GitHub 得到结果。 @@ -259,3 +231,16 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝 - 激活本身(`activate()`,含 `kb activate`)核对从当前激活快照到目标 SHA 的每个知识提交都可信(首次激活除外),否则 `ActivationError`;调度器在激活前做同样的检查并只记录 `activation_blocked`。入 intake 的候选事件带上该提交的 diff。 - 尚未实现:`kb accept-unknown`(接受未知提交内容而不撤回),目前通过合并精确撤回(服务开出的或手工的)解除。 + +## 2026-09-29 v8:移除 v7 的 GitHub 端机制 +- 删除:`.github/workflows/kb-gate.yml`、`.github/kb-gate/` 验证包与 `tools/build_kb_gate_bundle.py`、`.github/CODEOWNERS`、 + 暂停清单(`publish_holds`/`verify_holds`/`holds_server`/`kb holds-server`、签名用途 `kb-holds`)、`human-approved` 路径 + (维护者审批、`kb:human-approved`、`approval_withdrawn`)、outbox 项 `post_verdict`/`enqueue`/`pause`/`update_branch` 及其 + 发布器动作、暂停确认(`pause_open_prs`/`pause_unconfirmed`/`resume_paused_prs`、`kb status` 的 `pause_unconfirmed`)、 + 状态 `verdict_posted`/`queued`/`paused`(v7 从未在生产启用自动合并,没有需要迁移的记录)。 +- 暂停只由代际与暂停状态表达:服务不再签发 `merge`,发布器在执行前与合并前两次拒绝旧代际或暂停仓库的项; + 暂停期间仍跟踪 PR 的合并、关闭与撤回,恢复后下一轮为同一 head 重签判定。 +- 判定只有 `auto` 来源(`verdict.SOURCES`),不再带 `review_ids`/`reviewers`。 +- 测试:`test_kb_local_gate_verifier.py`(取代 `test_kb_gate_verifier.py`;本地门禁上的重放/过期/伪造/非 auto 来源、清单与块表、 + 混合路径与可执行位、跨仓库、上下文失效、落地树中的悬空引用);`test_kb_publisher.py`(暂停不触碰 PR、恢复后重签合并、 + draft 不合并、`close` 重试);`test_kb_external.py`(受治理页面以外的路径从不合并)。 diff --git a/doc/architecture/SPEC/knowledge_service.md b/doc/architecture/SPEC/knowledge_service.md index e3227ffa..c7931209 100644 --- a/doc/architecture/SPEC/knowledge_service.md +++ b/doc/architecture/SPEC/knowledge_service.md @@ -24,7 +24,7 @@ Knowledge Ops API 2.0 sits beside the v1 curator and does not change it: / purge changes with their mechanical consequences, and `l1` is the deterministic half of the quality gate (`check_tree`, `check_changeset`). The three modules need only the standard library and PyYAML and import nothing else -from the package, so the kb-gate verifier bundle can vendor them. +from the package. `release_audit` loads a repository's release-audit plugin from its adapter directory and runs it with a baseline generated for the audited SHA pair: @@ -33,48 +33,33 @@ reported as reconciliation. `signing` provides Ed25519 envelopes whose signature covers a purpose tag plus the canonical JSON payload, so a signature made for one purpose (gate verdict, -outbox item, control record, hold list, publisher ack) never verifies for -another. It needs only `cryptography` and is vendored by the kb-gate verifier. +outbox item, control record, publisher ack) never verifies for another. It +needs only `cryptography`. ## 2026-09-28 verdict -`verdict` defines the signed kb-gate verdict (`kb-gate-verdict/1`): identity +`verdict` defines the signed gate verdict (`kb-gate-verdict/1`): identity (repository, PR, exact head, nonce, 72-hour issue window), the complete patch manifest compared by pre/post git blob IDs independent of any base SHA, the passing per-block judgements, the consistency judgement with the page hashes it -saw, upstream fact attestations and the decision source (`auto` or -`human-approved` with its reviews). `check_binding` rejects replay on another -PR, head or window. Standard library only; vendored by kb-gate. +saw, upstream fact attestations and the decision source (always `auto` since +v8). `check_binding` rejects replay on another PR, head or window. Standard +library only. ## 2026-09-28 T1 索引修复 L1 的索引检查允许为目录中已存在但未列入索引的页面补链接(巡检 T1 修复),新页面仍必须被链接。 -## 2026-09-28 kb-gate 验证器 -`gate_verifier` 是仓库端必需检查 `kb-gate`。`tools/build_kb_gate_bundle.py` 把它和 -`l1`、`lifecycle`、`ops`、`signing`、`verdict` 原样复制到 `.github/kb-gate/`(附 -`requirements.lock` 按哈希固定依赖,`MANIFEST.sha256` 为包内文件哈希),工作流只从该目录运行。 -- PR 阶段(`pull_request_target`/带判定标记的 `issue_comment`/手动):用 git 对象读取 PR(从不检出 - 或执行),在 `merge-tree` 结果上跑 L1,把 `kb-gate` 状态发布到**已验证的**那个 head。 -- merge group 阶段:`main..head` 的 first-parent 链每段必须是双亲合并提交;第二父按 open PR 的 - head 唯一反查;每个触碰 `knowledge/` 的段以其第一父为有效 base 独立验证,最后在整棵落地树上跑 L1。 -- 知识变更通过条件:签名有效且绑定本仓库/PR/head、在签发有效期内;清单与实际变更(与 base 无关) - 相等;`auto` 要求块表与 L1 分块完全一致且全部 pass、一致性判定覆盖相同 owner 目录且页面哈希与落地树 - 相同、`context_base_sha..有效 base` 未改动上下文集合;`human-approved` 要求绑定的审阅仍为 - APPROVED、针对当前 head、由 `.github/kb-gate/` 的 CODEOWNERS(知识维护者)之一给出,且标签仍在。 - 另需无 `kb:hold`、签名暂停清单新鲜(≤10 分钟)且未命中;清单缺失/伪造/过期一律失败。 - `human-approved` 豁免自动合并白名单(`knowledge/tools`、`skills`、脚本、混合 PR),页面生命周期 L1 只作用于 - 受治理页面,其余 L1 不豁免; - 本变更退役/删除的规则 ID 在落地树中不得被引用(排在后面的 PR 或 main 可能新增了引用)。 - 暂停清单按范围匹配:`repos/<r>/` 对应仓库名 `<r>`,`general/` 对应 `general`。 -- 不变量:失败即关闭——缺公钥、缺 `holds_url`、无法归属的 merge group、带上游事实证明的判定都失败; - 未触碰 `knowledge/` 的变更不读取任何判定或暂停清单,总是通过。git < 2.38 时 PR 阶段用临时索引做 - 只接受单侧改动的三方合并(更严格,不更宽松)。 -- 测试:`test_kb_gate_verifier.py`(重放/过期/伪造、清单漏项、块表不全、混合路径与可执行位、暂停清单、 - 上下文失效、状态发布、人工审批撤销、merge group 逐段与前序同目录 PR、验证包新鲜与自包含、独立运行)。 - -## 2026-09-28 human-approved:最新审阅 -验证器除核对签名绑定的审阅仍为 APPROVED 外,还读取该 PR 的全部审阅:每位被引用审阅者的**最新**审阅必须仍批准当前 head; -之后的 changes requested 或 dismiss 使该审批失效(即使被绑定的那条审阅仍显示 APPROVED)。 - +## 2026-09-28 知识验证器(v8:发布器本地门禁) +`gate_verifier.verify_change(ctx, pr=, head_sha=, pre=, post=, final=, effective_base=, verdict=)` 是发布器本地门禁 +(`kb_service.local_gate`)在合并前与合并后运行的检查;v7 的仓库端 `kb-gate` 工作流、`.github/kb-gate/` 验证包、 +merge group 逐段验证、暂停清单与 `human-approved` 审批核对已于 2026-09-29 删除。用 git 对象读取 PR(从不检出或执行)。 +- 知识变更通过条件:PR 打开且 head 未变;来源为 `auto`;清单与实际变更(与 base 无关)相等; + L1 在 PR 自身改动与将要落地的树上都通过;块表与 L1 分块完全一致且全部 pass、一致性判定覆盖相同 owner 目录且页面哈希与落地树 + 相同、`context_base_sha..有效 base` 未改动上下文集合;本变更退役/删除的规则 ID 在落地树中不得被引用 + (main 可能新增了引用);知识目录外仍引用退役规则则需配套 PR。 +- 不变量:失败即关闭——带上游事实证明的判定失败(服务目前不签发事实);未触碰 `knowledge/` 的变更总是通过。 + 判定的签名与绑定由调用方(`local_gate.check_verdict`)验证。 +- 测试:`test_kb_local_gate_verifier.py`(重放/过期/伪造/非 auto 来源、清单漏项、块表不全、混合路径与可执行位、 + 跨仓库、上下文失效、落地树中的悬空引用)。 ## 2026-09-29 verify_change 供本地门禁调用 -`verify_change` 新增 `verdict=`(调用方已验签与绑定的判定,跳过从 PR 评论查找)与 `check_holds=`(本地门禁不读暂停清单, -暂停由签名控制记录表达)。其余检查不变。 +`verify_change` 只接受调用方已验签与绑定的判定(`verdict=`),不再从 PR 评论查找判定,也不读暂停清单; +暂停由签名控制记录表达。 diff --git a/doc/architecture/SPEC/knowledge_service/l1.md b/doc/architecture/SPEC/knowledge_service/l1.md index 630001db..3fe1f133 100644 --- a/doc/architecture/SPEC/knowledge_service/l1.md +++ b/doc/architecture/SPEC/knowledge_service/l1.md @@ -1,6 +1,6 @@ # knowledge_service/l1.py —— 规范 -<!-- verified-against: 2026-09-28 --> +<!-- verified-against: 2026-09-29 --> `LOC ~420 · 知识质量门的确定性一层(L1) · refactor-status: stable` diff --git a/doc/architecture/SPEC/knowledge_service/lifecycle.md b/doc/architecture/SPEC/knowledge_service/lifecycle.md index fcc9996c..df8416a3 100644 --- a/doc/architecture/SPEC/knowledge_service/lifecycle.md +++ b/doc/architecture/SPEC/knowledge_service/lifecycle.md @@ -1,6 +1,6 @@ # knowledge_service/lifecycle.py —— 规范 -<!-- verified-against: 2026-09-28 --> +<!-- verified-against: 2026-09-29 --> `LOC ~330 · 规则页数据模型与生命周期尾注 · refactor-status: stable` @@ -17,7 +17,7 @@ with_footer/body_without_footer/content_sha256/citations`、`Footer.parse/render 无退役规则时逐字节原样返回)。 ## 不变量 -- 仅依赖标准库与 PyYAML、不 import 包内其他模块:kb-gate 验证包原样携带。 +- 仅依赖标准库与 PyYAML、不 import 包内其他模块。 - 尾注键未知、重复、格式错误或状态约束不满足时抛 `LifecycleError`。 ## 测试 diff --git a/doc/architecture/SPEC/knowledge_service/ops.md b/doc/architecture/SPEC/knowledge_service/ops.md index 78ed3dcc..d9b9ebc7 100644 --- a/doc/architecture/SPEC/knowledge_service/ops.md +++ b/doc/architecture/SPEC/knowledge_service/ops.md @@ -1,6 +1,6 @@ # knowledge_service/ops.py —— 规范 -<!-- verified-against: 2026-09-28 --> +<!-- verified-against: 2026-09-29 --> `LOC ~360 · Knowledge Ops API 2.0(类型化知识变更) · refactor-status: stable` diff --git a/doc/architecture/SPEC/playbooks/PLAYBOOKS.md b/doc/architecture/SPEC/playbooks/PLAYBOOKS.md index 5d1d0254..803c53b0 100644 --- a/doc/architecture/SPEC/playbooks/PLAYBOOKS.md +++ b/doc/architecture/SPEC/playbooks/PLAYBOOKS.md @@ -1,6 +1,6 @@ # playbooks/*.yaml —— 规范 -<!-- verified-against: 2026-09-28 --> +<!-- verified-against: 2026-09-29 --> `9 个文件 · 声明式编排数据 · refactor-status: ok` diff --git a/doc/features/knowledge-service.md b/doc/features/knowledge-service.md index 48aed1fa..717a5d41 100644 --- a/doc/features/knowledge-service.md +++ b/doc/features/knowledge-service.md @@ -4,9 +4,9 @@ > | | | > |---|---| > | **状态** | 🚧 分阶段落地。已合并:Direct 路由多仓库化与按请求知识视图(#203)、Knowledge Ops API 2.0 与 L1(#204)、generated-baseline 发版审计(#205)、服务核心(#206)、intake 与质量门(#207);本 PR 加入合并流程、快照激活、发版巡检与调度器;本页随后续 PR 更新 | -> | **做什么** | 知识的创建、维护、退役、删除与发版巡检全部在 Copilot;每次变更经质量门,通过即经合并队列自动合并;ReviewBot 只读消费 | +> | **做什么** | 知识的创建、维护、退役、删除与发版巡检全部在 Copilot;每次变更经质量门,通过后由发布器在本地门禁复核合并结果再合并;ReviewBot 只读消费 | > | **怎么开关** | 每个仓库在 `adapters/<repo>/manifest.yaml` 的 `knowledge_lifecycle`(人工审阅的高风险段)中设置 `enabled` 与 `mode: shadow\|auto_merge` | -> | **设计** | 经 GPT-6 sol 评审批准的设计文档(知识库管理重构设计,多仓库,v7) | +> | **设计** | 经 GPT-6 sol 评审批准的设计文档(知识库管理重构设计,多仓库,v8) | > | **硬边界** | bot 主机不持有任何 GitHub 写凭据;所有 GitHub 写动作由 GPU 盒上的发布器以 owner 账号执行,且只执行服务签名、未过期、代际有效的 outbox 项;私有上游仓库只能 shadow,不产生任何公开产物 | ## 组成 @@ -15,23 +15,24 @@ |---|---|---| | 仓库配置与注册表 | `kb_service/config.py` | 解析每个 adapter 的 `knowledge_lifecycle`;`general/` 由服务配置 | | 账本 | `kb_service/ledger.py` | 单个 SQLite(`$KB_STATE_DIR/kb.db`),所有表按 `repo` 分区;单实例租约;代际号 | -| 签名 | `knowledge_service/signing.py` | Ed25519 信封,按用途(判定、outbox、控制记录、暂停清单、回执)隔离签名 | -| outbox | `kb_service/outbox.py` | 服务写签名项、控制记录与暂停清单;发布器执行前用 `check_item` 复核 | +| 签名 | `knowledge_service/signing.py` | Ed25519 信封,按用途(判定、outbox、控制记录、回执)隔离签名 | +| outbox | `kb_service/outbox.py` | 服务写签名项与控制记录;发布器执行前用 `check_item` 复核 | | CLI | `infermatrix-copilot kb …` | `keygen`、`status`、`pause`、`resume`、`control` | ## 代际与暂停 任何暂停、熔断、回滚或切回 `shadow` 都会使该仓库(或全局 `*`)代际 +1,并立即重签 -控制记录与暂停清单。发布器只执行代际与当前一致、未过期(`enqueue`/`post_verdict` -30 分钟,其余 24 小时)且控制记录签发不超过 10 分钟的项;`pause` 与 `close` 总是可执行。 +控制记录。发布器只执行代际与当前一致、未过期(`merge` 30 分钟,其余 24 小时)且控制记录签发 +不超过 10 分钟的项;`close` 与 `open_revert_pr` 总是可执行。 只有 `auto_merge` 仓库会产生非停止类写动作:`shadow` 只记录“将要做什么”,不开 PR、 不发评论;模式变化本身也会使代际 +1。 -暂停清单发布在服务的公开 HTTP 端点,kb-gate 在合并队列中读取它,过期或不可达即失败。 +发布器是知识 PR 唯一的合并方,所以暂停只需停止下发 `merge`,并在合并前复读控制记录: +不需要出队、转 draft、暂停标签或 GitHub 端的暂停清单。 ## 运维 ```bash -infermatrix-copilot kb keygen --out /etc/infermatrix-kb/service.pem # 公钥提交到 .github/kb-gate.pub +infermatrix-copilot kb keygen --out /etc/infermatrix-kb/service.pem # 公钥交给发布器(KB_SERVICE_PUBKEY) export KB_SIGNING_KEY=/etc/infermatrix-kb/service.pem KB_STATE_DIR=/var/lib/infermatrix-kb infermatrix-copilot kb status infermatrix-copilot kb pause --repo vllm-omni --reason "rollback drill" @@ -73,40 +74,16 @@ T1/T2/T3 与 purge,每个规则页一个变更集,全部经过质量门。 评审服务读取知识:将 `KNOWLEDGE_ROOT` 指向 `$KB_STATE_DIR/active`。 -## 仓库端门禁 `kb-gate` +## 本地门禁(取代 v7 的仓库端 `kb-gate`) -必需检查 `kb-gate`(`.github/workflows/kb-gate.yml`)只从受 CODEOWNERS 保护的验证包 -`.github/kb-gate/` 运行,从不导入 `src/`、`tools/`、`knowledge/tools/`。 +仓库端没有知识门禁的工作流、验证包、CODEOWNERS、合并队列或 ruleset:发布器在合并前于 +`merge-tree(main, head)` 上运行 `knowledge_service.gate_verifier.verify_change`(签名判定、清单一致、L1、 +逐块与一致性判定、上下文未变),通过才合并,合并后再检查落地的提交。只接受 `auto` 判定, +没有 `human-approved` 路径:改动受治理页面以外路径的知识 PR 不会被自动合并,作者会在发现评论里被要求拆分。 +绕过发布器直接合并或推送的知识变更由每轮审计发现、暂停并撤回(见上文)。 -- **PR 预检**:`pull_request_target`、带判定标记的评论或手动触发;在当前 `main` 上验证后, - 把 `kb-gate` 状态发布到 PR head。只决定能否入队。 -- **merge group**:名为 `kb-gate` 的作业逐段验证将要落地的提交;失败则 PR 被移出队列。 -- 未触碰 `knowledge/` 的 PR 总是通过;知识 PR 需要有效签名判定、清单一致、L1 通过、 - 暂停清单新鲜且未命中(见 SPEC `knowledge_service.md` 的 kb-gate 验证器一节)。 - -修改验证逻辑后重新生成验证包(属于 `.github/**` 改动,需要知识维护者审批): - -```bash -python tools/build_kb_gate_bundle.py # 重新生成 -python tools/build_kb_gate_bundle.py --check # CI 提醒(非阻塞) -python tools/build_kb_gate_bundle.py --lock # 从 PyPI 重新按哈希固定依赖 -``` - -暂停清单端点(bot 主机): - -```bash -infermatrix-copilot kb holds-server --port 8765 # 只提供 GET /holds.json,默认绑定 127.0.0.1 -# 反向代理加 TLS,例如 Caddy: handle /kb/holds.json { rewrite * /holds.json; reverse_proxy 127.0.0.1:8765 } -``` - -切换前的负责人步骤:提交 `.github/kb-gate.pub`(`kb keygen` 的公钥);在 -`.github/kb-gate/config.json` 填入暂停清单的公开 HTTPS 地址(`$KB_STATE_DIR/public/holds.json`); -仓库 admin 开启合并队列(merge commit)并配置两个 ruleset: -- `main-gate`(无 bypass):必须经合并队列合并,必需检查 `kb-gate` 限定 GitHub Actions 来源,禁止删除与强推; -- `main-code-owners`:要求 PR、Code Owner 审阅、新推送后需重新批准;bypass 仅 @tzhouam(唯一维护者, - GitHub 不允许作者批准自己的 PR,其本人对 `.github/**` 等路径的修改经此 bypass 合并,但仍受 `main-gate` 约束)。 -唯一维护者意味着 `human-approved` 路径只适用于他人开的 PR;服务自己开的 PR 若转人工,由负责人关闭或修改后重新过门。 -在此之前,所有触碰 `knowledge/` 的 PR 都会被 `kb-gate` 拒绝(失败即关闭),代码 PR 不受影响。 +切换前的负责人步骤:`main` 必须允许发布器直接合并(不开合并队列、不开自动合并); +按设计 §P3 的顺序关闭 v7 遗留的自动合并并卸载 v7 发布器。 ## 发布器(GPU 盒) diff --git a/playbooks/kb-merge.yaml b/playbooks/kb-merge.yaml index b11e7a9a..7af10710 100644 --- a/playbooks/kb-merge.yaml +++ b/playbooks/kb-merge.yaml @@ -1,6 +1,7 @@ # Knowledge service merge flow for ONE repository: apply publisher acks, sign -# verdicts for opened PRs, enqueue once the PR-stage kb-gate passes (one queued -# knowledge PR per repository), record merges. Candidate: scheduler-driven. +# verdicts for opened PRs and hand them to the publisher in a merge item (its +# local gate decides; one merge in flight per repository), record merges. +# Candidate: scheduler-driven. name: kb-merge version: 1 status: candidate diff --git a/pyproject.toml b/pyproject.toml index 409ba2fc..45f4e49f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dev = ["pytest>=8.0"] # MCP server (Claude Code / Codex integration) — kept optional so a standalone # CLI install stays dependency-free. mcp = ["mcp>=1.8,<2"] -# Knowledge service (signing, verdict envelopes); the kb-gate verifier pins it by hash. +# Knowledge service (signing, verdict envelopes). kb = ["cryptography>=42"] [project.scripts] diff --git a/src/infermatrix_copilot/kb_service/cli.py b/src/infermatrix_copilot/kb_service/cli.py index ab46b9e1..5d1bf890 100644 --- a/src/infermatrix_copilot/kb_service/cli.py +++ b/src/infermatrix_copilot/kb_service/cli.py @@ -10,7 +10,6 @@ kb serve [--once] the scheduler (holds the single-writer lease) kb activate activate the knowledge snapshot of the repository's main now kb rollback --to SHA point `active` back at an earlier snapshot - kb holds-server [--host H] [--port P] serve ONLY the signed hold list (read-only) kb traces [--kind K] [--changeset ID] [--rule ID] [--limit N] query trace/1 records kb replay --record ID --model PROVIDER:MODEL[:EFFORT] re-ask a recorded call kb export --out FILE [--role judge|generator] dataset (calibration-safe) @@ -81,18 +80,8 @@ def _outbox(state_dir: Path, ledger): return Outbox(state_dir, _signing_key(), ledger, clock=time.time) -def _public(registry) -> set[str]: - return {name for name, lifecycle in registry.items() if lifecycle.publishes} - - -def _unconfirmed(ledger, repo: str) -> int: - from .merge import pause_unconfirmed - - return pause_unconfirmed(ledger, repo) - - def _refresh(state_dir: Path, ledger, registry) -> None: - _outbox(state_dir, ledger).transition(lambda: None, public_repos=_public(registry)) + _outbox(state_dir, ledger).transition(lambda: None) def main(argv: list[str] | None = None) -> int: @@ -120,9 +109,6 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("activate") rollback = sub.add_parser("rollback") rollback.add_argument("--to", required=True) - holds = sub.add_parser("holds-server") - holds.add_argument("--host", default="127.0.0.1", help="bind address; put TLS in front of it") - holds.add_argument("--port", type=int, default=8765) traces = sub.add_parser("traces") traces.add_argument("--kind") traces.add_argument("--changeset") @@ -154,11 +140,6 @@ def main(argv: list[str] | None = None) -> int: if args.command == "publish": return _publish(args) - if args.command == "holds-server": - from .holds_server import serve_holds - - serve_holds(_state_dir(args.state_dir), host=args.host, port=args.port) - return 0 if args.command in {"traces", "replay", "export"}: return _traces_command(args, _state_dir(args.state_dir)) @@ -174,11 +155,9 @@ def main(argv: list[str] | None = None) -> int: "active_snapshot": ledger.active_snapshot(), "repos": [ {**row, "configured": row["repo"] in registry or row["repo"] == "*", - "open_changesets": len(ledger.changesets(row["repo"], ("open", "pr_open", "signed", "queued"))) + "open_changesets": len(ledger.changesets(row["repo"], ("pr_open", "merge_requested"))) if row["repo"] != "*" else None, - "human_queue": len(ledger.human_queue(row["repo"])) if row["repo"] != "*" else None, - # a breaker/rollback is not done while any of these remain - "pause_unconfirmed": _unconfirmed(ledger, row["repo"]) if row["repo"] != "*" else None} + "human_queue": len(ledger.human_queue(row["repo"])) if row["repo"] != "*" else None} for row in ledger.all_repo_states() ], } @@ -193,28 +172,12 @@ def main(argv: list[str] | None = None) -> int: # the state change and its signed publication happen under one # interprocess lock, so no concurrent refresh can publish a # pre-pause control record after the pause + # (the publisher is the only merger: a pause needs nothing on GitHub) if args.command == "pause": generation = _outbox(state_dir, ledger).transition( - lambda: ledger.bump_generation(repo, pause=True, reason=args.reason), - public_repos=_public(registry)) - # the pause itself: dequeue + draft every open knowledge PR - from . import merge - - outbox = _outbox(state_dir, ledger) - paused_repos = list(registry) if repo == "*" else [repo] - issued = sum(merge.pause_open_prs(ledger, outbox, r, args.reason) for r in paused_repos) - print(f"pause items issued for {issued} open knowledge PR(s)") + lambda: ledger.bump_generation(repo, pause=True, reason=args.reason)) else: - from . import merge - - def _resume(): - generation = ledger.resume(repo) - for name in (list(registry) if repo == "*" else [repo]): - merge.resume_paused_prs(ledger, name) - return generation - - # paused PRs leave the hold list in the same published transition - generation = _outbox(state_dir, ledger).transition(_resume, public_repos=_public(registry)) + generation = _outbox(state_dir, ledger).transition(lambda: ledger.resume(repo)) print(f"{args.command}d {repo}: generation {generation}") return 0 if args.command == "control": diff --git a/src/infermatrix_copilot/kb_service/external.py b/src/infermatrix_copilot/kb_service/external.py index f4826f15..d75b2a5d 100644 --- a/src/infermatrix_copilot/kb_service/external.py +++ b/src/infermatrix_copilot/kb_service/external.py @@ -1,16 +1,12 @@ -"""Knowledge PRs the service did not open (source ④), and the human-approved path. +"""Knowledge PRs the service did not open (source ④). The scheduler polls the knowledge repository's open PRs. For every non-draft -PR that touches ``knowledge/`` and is not one of the service's own: - -* **human-approved**: the PR carries ``kb:human-approved`` and a knowledge - maintainer (a CODEOWNERS owner of ``.github/kb-gate/`` on main) approved - its CURRENT head. L1 must pass on the governed pages (the maintainer's - approval replaces the auto-merge whitelist and L2, never the rest of L1); - the service then signs a ``human-approved`` verdict naming those reviews. -* **auto**: every changed path is a governed page. The change is judged with - the full quality gate (L1, L2, consistency) against CURRENT main with the - PR's changes applied; a pass gets an ``auto`` verdict. +PR that touches ``knowledge/`` and is not one of the service's own, when every +changed path is a governed page, the change is judged with the full quality +gate (L1, L2, consistency) against CURRENT main with the PR's changes applied; +a pass gets an ``auto`` verdict. There is no human-approved path (design v8): +a PR changing anything else is not merged by the service and its author is +told so. A PR that does not pass is never merged (design v8, D12): the author is told why in ONE findings comment on the PR, updated in place, and the PR is checked @@ -20,8 +16,8 @@ merge refused meanwhile, is judged at the next daily pass. Judging "on current main" requires the PR's pre-images to equal main's: -otherwise the merge queue would apply different changes than were signed, and -the author is asked to rebase. +otherwise the merge would apply different changes than were signed, and the +author is asked to rebase. """ from __future__ import annotations @@ -29,15 +25,13 @@ import json from typing import Any -from ..knowledge_service.gate_verifier import MAINTAINER_PATH, WHITELIST_CODES, code_owners, parse_codeowners -from ..knowledge_service.l1 import Change, check_changeset +from ..knowledge_service.l1 import Change from ..knowledge_service.ops import repo_scope from .gate import run_gate from .merge import IN_FLIGHT, knowledge_repository from .runtime import calibration_current KIND = "external" -HUMAN_LABEL = "kb:human-approved" GOVERNED = ("knowledge/repos/", "knowledge/general/") SUFFIXES = (".md", ".yaml") # statuses after which a head is judged again at the next daily pass: it did @@ -65,43 +59,6 @@ def _pulls(rt) -> list[dict]: return out -def _maintainers(rt, main_sha: str) -> set[str]: - text = rt.knowledge.show(main_sha, ".github/CODEOWNERS") or "" - return set(code_owners(parse_codeowners(text), MAINTAINER_PATH)) - - -def approval_stands(rt, changeset: dict) -> bool: - """Whether a human-approved external change set's approvals still stand - (each named maintainer's latest review approves the signed head).""" - detail = changeset["detail"] - ids, _names = _approvals(rt, int(changeset["pr_number"]), changeset["head_sha"], - {str(n).lower() for n in detail.get("reviewers") or []}) - return bool(ids) and set(detail.get("reviewers") or []) <= set(_names) - - -def _approvals(rt, number: int, head: str, maintainers: set[str]) -> tuple[list[int], list[str]]: - """Reviews still standing as approvals of THIS head by maintainers (a later - review by the same person replaces an earlier one).""" - reviews: list[dict] = [] - for page in range(1, 51): # every review: a later one on a further page may retract an approval - batch = rt.github.get(f"/repos/{knowledge_repository()}/pulls/{number}/reviews", - per_page=100, page=page) or [] - reviews.extend(batch) - if len(batch) < 100: - break - latest: dict[str, dict] = {} - for review in reviews: - login = str((review.get("user") or {}).get("login") or "").lower() - if login and review.get("state") in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"): - latest[login] = review - ids, names = [], [] - for login, review in sorted(latest.items()): - if review.get("state") == "APPROVED" and review.get("commit_id") == head and login in maintainers: - ids.append(int(review["id"])) - names.append(login) - return ids, names - - def _scope(paths: list[str], registry: dict) -> tuple[Any, str]: """The one lifecycle a change belongs to, or (None, why not).""" scopes = set() @@ -129,19 +86,11 @@ def _scope(paths: list[str], registry: dict) -> tuple[Any, str]: return lifecycle, "" -def _path_of(changeset: dict) -> str: - detail = changeset["detail"] - return detail.get("path") or ("human" if detail.get("source") == "human-approved" else "auto") - - -def _judged(rt, number: int, head: str, path: str) -> bool: - """Whether this head was already judged on this path. The key includes the - path: a head sent to people on the auto path is judged again once a - maintainer approves it (no push needed), while the paid auto judge still - runs once per head.""" +def _judged(rt, number: int, head: str) -> bool: + """Whether this head was already judged (the paid judge runs once per head).""" for lifecycle in rt.registry.values(): for changeset in rt.ledger.changesets(lifecycle.repo, _ALL): - if changeset["kind"] == KIND and changeset["pr_number"] == number and _path_of(changeset) == path \ + if changeset["kind"] == KIND and changeset["pr_number"] == number \ and changeset["head_sha"] == head and changeset["status"] not in REJUDGE: if changeset["status"] == "calibration_required" and calibration_current(rt, lifecycle): rt.ledger.update_changeset(changeset["id"], status="superseded") @@ -164,7 +113,7 @@ def _ours(rt, number: int) -> bool: _ALL = tuple(dict.fromkeys(( *IN_FLIGHT, "gated", "merged", "closed", "failed", "human", "head_changed", "stale_context", "superseded", "superseding", "gate_failed", "rebuild_failed", - "paused", "shadow_recorded", "calibration_required", "approval_withdrawn"))) + "paused", "shadow_recorded", "calibration_required"))) def poll_external(rt, *, force: bool = False) -> list[str]: @@ -280,13 +229,10 @@ def _decision_reasons(decision: dict) -> list[str]: def _poll(rt, owner: str, *, force: bool = False) -> list[str]: events: list[str] = [] main_sha = rt.knowledge.fetch() - maintainers: set[str] | None = None for pr in _pulls(rt): number = int(pr["number"]) head = str((pr.get("head") or {}).get("sha") or "") - labels = {str(label.get("name")) for label in pr.get("labels") or []} - human = HUMAN_LABEL in labels - if pr.get("draft") or not head or _ours(rt, number) or _judged(rt, number, head, "human" if human else "auto"): + if pr.get("draft") or not head or _ours(rt, number) or _judged(rt, number, head): continue if not _due(rt, number, force): continue # checked today already: once a day, not on every push @@ -297,22 +243,18 @@ def _poll(rt, owner: str, *, force: bool = False) -> list[str]: paths = [entry["path"] for entry in manifest] if not any(p.startswith("knowledge/") for p in paths): continue - path = "human" if human else "auto" lifecycle, why = _scope([p for p in paths if p.startswith("knowledge/")], rt.registry) if lifecycle is None: - events.append(_to_people(rt, None, number, head, f"PR #{number} {why}", path, + events.append(_to_people(rt, None, number, head, f"PR #{number} {why}", findings_via=_findings_lifecycle(rt, paths))) _checked(rt, number) continue - if human: - maintainers = maintainers if maintainers is not None else _maintainers(rt, main_sha) - review_ids, reviewers = _approvals(rt, number, head, maintainers) - if not review_ids: - continue # labeled but not (yet) approved on this head by a maintainer: wait - elif not all(_governed(p) for p in paths): + if not all(_governed(p) for p in paths): + outside = sorted(p for p in paths if not _governed(p)) events.append(_to_people(rt, lifecycle, number, head, - f"PR #{number} changes paths outside the governed knowledge pages; " - f"a knowledge maintainer can approve it and add {HUMAN_LABEL}", path)) + f"PR #{number} changes paths outside the governed knowledge pages " + f"({', '.join(outside[:3])}); the service only merges governed pages: " + "split those changes into their own PR")) _checked(rt, number) continue base_main = rt.knowledge.knowledge_files(main_sha) @@ -323,7 +265,7 @@ def _poll(rt, owner: str, *, force: bool = False) -> list[str]: moved = [rel for rel in rels if base_main.get(rel) != pre.get(rel)] if moved: events.append(_to_people(rt, lifecycle, number, head, - f"PR #{number} must be rebased: main changed {moved[:3]} since it branched", path)) + f"PR #{number} must be rebased: main changed {moved[:3]} since it branched")) _checked(rt, number) continue head_files = dict(base_main) @@ -333,40 +275,26 @@ def _poll(rt, owner: str, *, force: bool = False) -> list[str]: else: head_files.pop(rel, None) changes = [Change(e["path"], e["status"] if e["status"] in "AMD" else "M", e["old_mode"], e["new_mode"]) - for e in (governed if human else manifest)] + for e in manifest] release = rt.release_for(lifecycle.repo) detail: dict[str, Any] = {"operations": [], "event_ids": [], "base_sha": main_sha, "release": release, "manifest": manifest, "external_pr": number, "title": str(pr.get("title") or ""), "author": str((pr.get("user") or {}).get("login") or "")} - if human: - result = check_changeset(base_main, head_files, changes, - external_texts=rt.knowledge.external_texts(main_sha), release=release) - issues = [i for i in result.issues if i.code not in WHITELIST_CODES] - if issues: - events.append(_to_people(rt, lifecycle, number, head, f"PR #{number} fails L1: " + "; ".join( - f"{i.code} {i.path} {i.detail}" for i in issues[:3]), path)) - _checked(rt, number) - continue - detail.update(source="human-approved", review_ids=review_ids, reviewers=reviewers, - generator="human", judge="human", decision={"blocks": [], "consistency": []}, - **_lifecycle_bookkeeping(result, head_files)) - status = "pr_open" - else: - evidence = [{"source_reference": f"PR #{number}", "title": detail["title"], - "body": str(pr.get("body") or "")[:3000], "author": detail["author"]}] - decision = run_gate(base=base_main, head=head_files, changes=changes, - external_texts=rt.knowledge.external_texts(main_sha), evidence=evidence, - gateway=rt.gateway, judge=rt.judge, release=release, - repo_dir=lifecycle.knowledge_dir, protected_rules=lifecycle.protected_rules, - retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files) - detail.update(source="auto", generator="human", judge=rt.judge.label(), decision=decision.to_dict(), - **_lifecycle_bookkeeping(decision.l1, head_files)) - status = {"pass": "pr_open", "fail": "failed", "human": "human"}[decision.status] - if status == "pr_open" and lifecycle.auto_merge and not calibration_current(rt, lifecycle): - # an auto verdict is only as good as the judge (as for our own PRs) - status = "calibration_required" - detail["decision"]["reasons"] = [*detail["decision"].get("reasons", []), - "judge calibration missing or outdated"] + evidence = [{"source_reference": f"PR #{number}", "title": detail["title"], + "body": str(pr.get("body") or "")[:3000], "author": detail["author"]}] + decision = run_gate(base=base_main, head=head_files, changes=changes, + external_texts=rt.knowledge.external_texts(main_sha), evidence=evidence, + gateway=rt.gateway, judge=rt.judge, release=release, + repo_dir=lifecycle.knowledge_dir, protected_rules=lifecycle.protected_rules, + retire_ratio=lifecycle.retire_ratio, max_files=lifecycle.max_files) + detail.update(source="auto", generator="human", judge=rt.judge.label(), decision=decision.to_dict(), + **_lifecycle_bookkeeping(decision.l1, head_files)) + status = {"pass": "pr_open", "fail": "failed", "human": "human"}[decision.status] + if status == "pr_open" and lifecycle.auto_merge and not calibration_current(rt, lifecycle): + # an auto verdict is only as good as the judge (as for our own PRs) + status = "calibration_required" + detail["decision"]["reasons"] = [*detail["decision"].get("reasons", []), + "judge calibration missing or outdated"] if status == "pr_open" and not lifecycle.auto_merge: status = "shadow_recorded" changeset_id = rt.ledger.new_changeset_id(lifecycle.repo, KIND) @@ -380,8 +308,7 @@ def _poll(rt, owner: str, *, force: bool = False) -> list[str]: rt.ledger.update_changeset(changeset_id, pr_number=number, head_sha=head) rt.trace("decision", context={"repo": lifecycle.repo, "changeset_id": changeset_id, "pr": number, "playbook": "kb-external", "step": "gate"}, - result={"status": status, "source": detail["source"], - "reviewers": detail.get("reviewers", [])}) + result={"status": status, "source": detail["source"]}) post_findings(rt, lifecycle, number, head, status, [] if status in ("pr_open", "shadow_recorded") else _decision_reasons(detail["decision"])) _checked(rt, number) @@ -400,14 +327,14 @@ def _lifecycle_bookkeeping(result, head_files: dict[str, str]) -> dict: "purges": list(result.purged)} -def _to_people(rt, lifecycle, number: int, head: str, reason: str, path: str, findings_via=None) -> str: +def _to_people(rt, lifecycle, number: int, head: str, reason: str, findings_via=None) -> str: """Record a head the gate cannot take as is; the author is told why.""" repo = lifecycle.repo if lifecycle is not None else next(iter(rt.registry)) changeset_id = rt.ledger.new_changeset_id(repo, KIND) rt.save_changeset_files(changeset_id, {"base_sha": "", "files": {}, "deleted": [], "evidence": []}) rt.ledger.stage_intake(rt.lease_owner, repo, changeset_id, kind=KIND, status="human", verdicts=[], human_reason="", drafted_events=[], - detail={"external_pr": number, "reason": reason, "path": path}) + detail={"external_pr": number, "reason": reason}) rt.ledger.update_changeset(changeset_id, pr_number=number, head_sha=head) post_findings(rt, lifecycle or findings_via, number, head, "human", [reason]) return f"external {changeset_id} PR #{number} to people" diff --git a/src/infermatrix_copilot/kb_service/holds_server.py b/src/infermatrix_copilot/kb_service/holds_server.py deleted file mode 100644 index 9bbf9c16..00000000 --- a/src/infermatrix_copilot/kb_service/holds_server.py +++ /dev/null @@ -1,75 +0,0 @@ -"""The public, read-only endpoint for the signed hold list. - -The kb-gate verifier (a GitHub-hosted runner with no secrets) reads the -service's signed hold list over HTTPS and fails any knowledge segment it -cannot fetch, verify, or that is stale. This server exposes exactly one file, -``<state_dir>/public/holds.json``, at ``GET /holds.json``; everything else is -404. It never lists directories, follows paths, accepts writes, or serves -anything but that signed file, so running it exposes nothing the kb-gate does -not already publish. Put TLS in front of it (the bot host's reverse proxy) and -set that URL as ``holds_url`` in ``.github/kb-gate/config.json``. -""" - -from __future__ import annotations - -import threading -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path - -ROUTE = "/holds.json" -MAX_BYTES = 1 << 20 - - -def make_handler(holds_path: Path): - class HoldsHandler(BaseHTTPRequestHandler): - server_version = "kb-holds" - sys_version = "" - - def _serve(self, with_body: bool) -> None: - if self.path.split("?", 1)[0] != ROUTE: - self.send_error(HTTPStatus.NOT_FOUND) - return - try: - data = holds_path.read_bytes()[:MAX_BYTES] - except OSError: - # no hold list yet: the gate treats unreachable as held - self.send_error(HTTPStatus.SERVICE_UNAVAILABLE) - return - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - if with_body: - self.wfile.write(data) - - def do_GET(self) -> None: # noqa: N802 - http.server API - self._serve(True) - - def do_HEAD(self) -> None: # noqa: N802 - self._serve(False) - - def log_message(self, format: str, *args) -> None: # noqa: A002 - quiet by default - return - - return HoldsHandler - - -def serve_holds(state_dir: str | Path, *, host: str = "127.0.0.1", port: int = 8765, - stop: threading.Event | None = None) -> ThreadingHTTPServer: - """Serve until ``stop`` is set (blocking); returns the server when stopped.""" - server = ThreadingHTTPServer((host, port), make_handler(Path(state_dir) / "public" / "holds.json")) - server.daemon_threads = True - if stop is None: - try: - server.serve_forever() - finally: - server.server_close() - return server - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - stop.wait() - server.shutdown() - server.server_close() - return server diff --git a/src/infermatrix_copilot/kb_service/ledger.py b/src/infermatrix_copilot/kb_service/ledger.py index e5dbcb6d..b59a6ec1 100644 --- a/src/infermatrix_copilot/kb_service/ledger.py +++ b/src/infermatrix_copilot/kb_service/ledger.py @@ -229,7 +229,7 @@ def release_lease(self, owner: str) -> None: def ensure_repo(self, repo: str, mode: str) -> bool: """Register ``repo`` or record its configured mode. A CHANGE of mode bumps the generation in the same transaction, so nothing issued under - the previous mode (e.g. an enqueue before a switch to shadow) survives + the previous mode (e.g. a merge item before a switch to shadow) survives a round trip back. Returns whether the generation changed.""" now = self._clock() with self.tx() as cur: diff --git a/src/infermatrix_copilot/kb_service/local_gate.py b/src/infermatrix_copilot/kb_service/local_gate.py index a93be9c4..58a734ae 100644 --- a/src/infermatrix_copilot/kb_service/local_gate.py +++ b/src/infermatrix_copilot/kb_service/local_gate.py @@ -80,10 +80,9 @@ def gate(clone: Path, *, repository: str, repo: str, pr: dict, head_sha: str, ma final = git.merge_tree(main_sha, head_sha) except GateError as exc: return [str(exc)] - ctx = Context(git=git, github=None, repository=repository, public_key=public_key, - holds_loader=lambda: {}, codeowners=[], now=now) + ctx = Context(git=git, repository=repository, now=now) problems += verify_change(ctx, pr=pr, head_sha=head_sha, pre=base, post=head_sha, final=final, - effective_base=main_sha, stage="local", verdict=verdict, check_holds=False) + effective_base=main_sha, verdict=verdict) problems += tree_problems(git, main_sha, final) return problems @@ -99,9 +98,8 @@ def post_merge_problems(clone: Path, *, repository: str, pr: dict, head_sha: str if len(parents) != 2 or parents[1] != head_sha: return [f"merge commit {merge_sha[:12]} is not a merge of the verified head"] first = parents[0] - ctx = Context(git=git, github=None, repository=repository, public_key=public_key, - holds_loader=lambda: {}, codeowners=[], now=now) + ctx = Context(git=git, repository=repository, now=now) landed = {**pr, "state": "open"} # it was open when it merged problems = verify_change(ctx, pr=landed, head_sha=head_sha, pre=first, post=merge_sha, final=merge_sha, - effective_base=first, stage="local", verdict=verdict, check_holds=False) + effective_base=first, verdict=verdict) return problems + tree_problems(git, first, merge_sha) diff --git a/src/infermatrix_copilot/kb_service/merge.py b/src/infermatrix_copilot/kb_service/merge.py index f41cf5bf..41b6ecbf 100644 --- a/src/infermatrix_copilot/kb_service/merge.py +++ b/src/infermatrix_copilot/kb_service/merge.py @@ -14,38 +14,25 @@ Anything else off the happy path goes to people or stops: a head that no longer matches what was signed (``head_changed`` → human), a PR closed -unmerged (``closed``), a paused repository (no ``merge`` is issued). +unmerged (``closed``). A paused repository gets no ``merge`` item, and the +publisher refuses items of an older generation: since it is the only thing +that merges, a pause takes effect at once (nothing to dequeue or draft). The service only READS GitHub; every write is an outbox item. """ from __future__ import annotations -import json import os from typing import Any from ..knowledge_service.signing import sign from ..knowledge_service.verdict import build_verdict, manifest_from_files -VERDICT_MARKER = "<!-- kb-gate:verdict:v1 -->" -# verdict_posted/queued: v7 records (merge queue), signed again as pr_open -IN_FLIGHT = ("pr_requested", "pr_open", "verdict_posted", "queued", "merge_requested", "rebuild_needed") -# a verdict must first verify within 72 hours: re-sign well before that -RESIGN_AFTER = 48 * 3600 +IN_FLIGHT = ("pr_requested", "pr_open", "merge_requested", "rebuild_needed") # margin for the publisher's clock when deciding an item has certainly expired CLOCK_SKEW = 5 * 60 -# a pause not confirmed on GitHub by then goes to people (procedure P, step 4) -PAUSE_CONFIRM = 5 * 60 -# a queued PR that neither merged nor left the queue by then was dropped by a -# failing merge group (GitHub does not tell us): re-sign and re-enqueue -QUEUE_STALL = 2 * 3600 -# kb-gate failures, by what the service can do about them (matched against the -# status description, which is the verifier's first problem) -TRANSIENT_GATE = ("hold list", "paused by the knowledge service", "global pause", "unreachable", - "verifier error", "fetched head does not match", - "carries kb:hold") # left by our own pause until the re-signed verdict lifts it -RESIGN_GATE = ("outside its issue window", "no valid signed verdict") +# local-gate refusals the service answers by rebuilding on current main REBUILD_GATE = ("context changed since the verdict", "pages changed since the consistency judgement", "does not merge cleanly", "signed patch manifest does not match") # a refused merge caused by how the repository is set up, not by the change: @@ -66,8 +53,6 @@ def knowledge_repository() -> str: TRANSITIONS = { "open_pr": ("pr_requested", "pr_open"), "open_companion_pr": ("pr_requested", "companion_open"), - "post_verdict": ("pr_open", "verdict_posted"), - "enqueue": ("verdict_posted", "queued"), "merge": ("merge_requested", "merged"), "open_revert_pr": ("pr_requested", "revert_open"), } @@ -110,39 +95,6 @@ def apply_acks(rt, acks: list[dict]) -> None: except KeyError: continue kind = ack.get("kind") - if kind == "pause": - # under the publication lock that `kb resume` also holds, so a resume - # cannot slip between reading the pause state and recording the ack - with rt.outbox.publication_lock(): - current = rt.ledger.changeset(changeset_id) - if ack.get("ok") and current["status"] == "paused": - # a re-pause retry completed: nothing is pending any more - if (current.get("pending_item") or {}).get("id") == ack.get("item_id"): - rt.ledger.update_changeset(changeset_id, pending_item=None) - continue - if not ack.get("ok") or current["status"] not in ("pr_open", "verdict_posted", "queued"): - continue - if current["detail"].get("withdrawn"): - live_until = float(current["detail"].get("enqueue_expires_at") or 0) + CLOCK_SKEW - # decided by when the ACKED pause was issued, never by when its - # ack arrived: only a pause issued after every enqueue died is final - pending = current.get("pending_item") or {} - acked_issued = float(pending.get("issued_at") or 0) \ - if pending.get("kind") == "pause" and pending.get("id") == ack.get("item_id") else 0.0 - if acked_issued < live_until: - # an enqueue signed earlier could still run after this - # pause: pause again once it has certainly expired - rt.ledger.update_changeset(changeset_id, pending_item=None, detail={ - **current["detail"], "pause_again_after": live_until}) - else: - rt.ledger.update_changeset(changeset_id, status="approval_withdrawn", pending_item=None) - elif is_paused(rt.ledger, current["repo"]): - rt.ledger.update_changeset(changeset_id, status="paused", pending_item=None) - else: - # resumed before this (late) pause landed: the PR is now - # dequeued and draft, so re-sign and re-enqueue it - rt.ledger.update_changeset(changeset_id, status="pr_open", pending_item=None) - continue pending = changeset.get("pending_item") or {} if kind == "merge" and ack.get("ok") and changeset["status"] == "merged" \ and ack.get("merge_sha") and ack.get("merge_sha") == changeset.get("merge_sha"): @@ -168,15 +120,12 @@ def apply_acks(rt, acks: list[dict]) -> None: if kind in ("open_pr", "open_companion_pr", "open_revert_pr"): fields.update(pr_number=int(ack["pr"]), head_sha=str(ack["head_sha"]), branch=str(ack.get("branch", ""))) - elif kind == "enqueue": - fields["detail"] = {**changeset["detail"], "queued_at": rt.clock()} rt.ledger.update_changeset(changeset_id, **fields) def _pause_repo(rt, repo: str, reason: str) -> None: """Stop automatic merging for ``repo`` (a new generation voids every item).""" - public = {name for name, lc in rt.registry.items() if lc.publishes} - rt.outbox.transition(lambda: rt.ledger.bump_generation(repo, pause=True, reason=reason), public_repos=public) + rt.outbox.transition(lambda: rt.ledger.bump_generation(repo, pause=True, reason=reason)) def _record_post_check(rt, changeset: dict, result: str, problems: list) -> None: @@ -274,7 +223,7 @@ def _apply_merge_ack(rt, changeset: dict, ack: dict) -> None: def sign_verdict(rt, changeset: dict) -> dict: - """The signed kb-gate verdict for this change set's PR at its recorded head.""" + """The signed verdict for this change set's PR at its recorded head.""" detail = changeset["detail"] data = rt.load_changeset_files(changeset["id"]) base = rt.knowledge.knowledge_files(detail["base_sha"]) @@ -291,16 +240,12 @@ def sign_verdict(rt, changeset: dict) -> dict: for b in decision["blocks"]] consistency = [{"owner_dir": c["owner_dir"], "verdict": c["verdict"], "pages": c["pages"]} for c in decision["consistency"]] - human = detail.get("source") == "human-approved" verdict = build_verdict( repository=knowledge_repository(), pr=int(changeset["pr_number"]), head_sha=changeset["head_sha"], - context_base_sha=detail["base_sha"], manifest=manifest, blocks=[] if human else blocks, - consistency=[] if human else consistency, + context_base_sha=detail["base_sha"], manifest=manifest, blocks=blocks, consistency=consistency, release=detail["release"], upstream={}, facts=[], models={"generator": detail["generator"], "judge": detail["judge"]}, - source="human-approved" if human else "auto", - review_ids=tuple(detail.get("review_ids") or ()), reviewers=tuple(detail.get("reviewers") or ()), - issued_at=rt.clock(), + source="auto", issued_at=rt.clock(), ) return sign("kb-gate-verdict", verdict, rt.outbox._key) @@ -309,67 +254,6 @@ def _pr_state(rt, number: int) -> dict: return rt.github.get(f"/repos/{knowledge_repository()}/pulls/{number}") -def _gate_status(rt, sha: str) -> str: - return _gate(rt, sha)[0] - - -def _gate(rt, sha: str, *, since: float = 0.0) -> tuple[str, str]: - """(state, description) of the latest kb-gate status on ``sha``; a status - posted before ``since`` (the current verdict) is not about it: ("", "").""" - combined = rt.github.get(f"/repos/{knowledge_repository()}/commits/{sha}/status") - for status in combined.get("statuses", []): - if status.get("context") == "kb-gate": - posted = _epoch(status.get("updated_at") or status.get("created_at")) - if since and posted and posted < since: - return "", "" - return str(status.get("state") or ""), str(status.get("description") or "") - return "", "" - - -def _epoch(value) -> float: - if not value: - return 0.0 - import datetime as dt - - try: - return dt.datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() - except ValueError: - return 0.0 - - -def _live_enqueue_until(rt, changeset: dict) -> float: - """The latest expiry of any enqueue signed for this change set that the - publisher could still execute, from every durable record: the recorded - value, the pending item, and unacknowledged signed items still in the - outbox (an item leaves the outbox only once its ack is collected).""" - latest = float(changeset["detail"].get("enqueue_expires_at") or 0) - pending = changeset.get("pending_item") or {} - if pending.get("kind") == "enqueue": - latest = max(latest, float(pending.get("expires_at") or 0)) - for path in sorted(rt.outbox.outbox_dir.glob("*.json")): - if path.name == "control.json": - continue - try: - payload = json.loads(path.read_text(encoding="utf-8"))["payload"] - except (OSError, ValueError, KeyError, TypeError): - continue - if payload.get("kind") == "enqueue" and (payload.get("body") or {}).get("changeset_id") == changeset["id"]: - latest = max(latest, float(payload.get("expires_at") or 0)) - return latest - - -def _enqueue_outstanding(rt, changeset: dict) -> bool: - """A signed enqueue that the publisher may still execute (unexpired).""" - pending = changeset.get("pending_item") or {} - return pending.get("kind") == "enqueue" and float(pending.get("expires_at", 0)) > rt.clock() - - -def _resign(rt, changeset: dict, why: str) -> None: - """Back to pr_open: the next pass signs a fresh verdict for the same head.""" - rt.ledger.update_changeset(changeset["id"], status="pr_open", pending_item=None) - _outcome(rt, changeset, "resigned", reason=why) - - REBUILD_MARK = "rebuild of " @@ -411,7 +295,7 @@ def to_people(reason: str) -> None: detail = changeset["detail"] raw = detail.get("operations") or [] if not raw: # a mechanical change set (index fixes): nothing to re-apply - to_people(f"kb-gate failed ({why}); no operations to rebuild") + to_people(f"the local gate refused it ({why}); no operations to rebuild") return None new_id = _rebuild_of(rt, lifecycle.repo, changeset["id"]) if new_id is None: @@ -463,7 +347,7 @@ def advance(rt, lifecycle) -> list[str]: return events paused = is_paused(rt.ledger, lifecycle.repo) merging = rt.ledger.changesets(lifecycle.repo, ("merge_requested",)) - for changeset in rt.ledger.changesets(lifecycle.repo, (*IN_FLIGHT, "superseding", "paused", + for changeset in rt.ledger.changesets(lifecycle.repo, (*IN_FLIGHT, "superseding", "companion_open", "revert_open")): number = changeset["pr_number"] if number is None: @@ -471,9 +355,6 @@ def advance(rt, lifecycle) -> list[str]: try: pr: dict[str, Any] = _pr_state(rt, int(number)) except Exception: # noqa: BLE001 - GitHub down or rate-limited - # the overdue-pause escalation must not depend on GitHub answering - if changeset["status"] == "paused" or paused: - _pause_unconfirmed(rt, lifecycle.repo, changeset, events) events.append(f"observe_failed {changeset['id']}") continue if changeset["status"] == "companion_open": @@ -551,32 +432,8 @@ def advance(rt, lifecycle) -> list[str]: _outcome(rt, changeset, "closed_unmerged") # overturned by people: a negative label events.append(f"closed {changeset['id']}") continue - # pause tracking comes before head checks: a paused PR stays watched - if changeset["status"] == "paused": - # an ack is the publisher's word; the pause counts once GitHub shows - # the PR as a draft (a draft cannot be in, or re-enter, the queue) - if pr.get("draft"): - if not changeset["detail"].get("pause_confirmed"): - # a pending retry is done with: a later undo gets a fresh pause - rt.ledger.update_changeset(changeset["id"], pending_item=None, detail={ - **changeset["detail"], "pause_confirmed": True}) - events.append(f"pause_confirmed {changeset['id']}") - else: - if changeset["detail"].get("pause_confirmed"): # un-drafted since: not paused any more - rt.ledger.update_changeset(changeset["id"], pending_item=None, detail={ - **changeset["detail"], "pause_confirmed": False, "pause_alerted": False, - "pause_requested_at": rt.clock()}) - changeset = rt.ledger.changeset(changeset["id"]) - issue_once(rt, lifecycle.repo, changeset, "pause", { - "changeset_id": changeset["id"], "pr": int(number), - "reason": "re-pausing: GitHub does not show the PR as a draft"}) - _pause_unconfirmed(rt, lifecycle.repo, changeset, events) - continue if paused: - # the pause itself is issued by `kb pause` / breakers; one that has - # not even been acked in time goes to people - _pause_unconfirmed(rt, lifecycle.repo, changeset, events) - continue + continue # merges, closes and reverts are still followed above; nothing is signed head = str((pr.get("head") or {}).get("sha") or "") if head != changeset["head_sha"] and changeset["kind"] == "external": # authors push: the new head is simply judged again at the next poll @@ -589,27 +446,6 @@ def advance(rt, lifecycle) -> list[str]: rt.ledger.enqueue_human(lifecycle.repo, f"PR #{number} head changed after signing", changeset["id"]) events.append(f"head_changed {changeset['id']}") continue - if changeset["kind"] == "external" and changeset["detail"].get("source") == "human-approved" \ - and changeset["status"] in ("verdict_posted", "queued"): - from .external import approval_stands - - withdrawn = bool(changeset["detail"].get("withdrawn")) - if withdrawn or not approval_stands(rt, changeset): - # a withdrawn approval must stop the PR: dequeue + draft. The - # change set keeps its status (and its queue slot) until the - # pause is acked; only then is it approval_withdrawn - if not withdrawn: - rt.ledger.update_changeset(changeset["id"], detail={ - **changeset["detail"], "withdrawn": True, - "enqueue_expires_at": _live_enqueue_until(rt, changeset)}) - rt.ledger.enqueue_human(lifecycle.repo, f"PR #{number}: approval withdrawn", changeset["id"]) - changeset = rt.ledger.changeset(changeset["id"]) - events.append(f"approval_withdrawn {changeset['id']}") - if rt.clock() >= float(changeset["detail"].get("pause_again_after") or 0): - issue_once(rt, lifecycle.repo, changeset, "pause", { - "changeset_id": changeset["id"], "pr": int(number), - "reason": "a knowledge maintainer withdrew the approval"}) - continue if changeset["status"] == "merge_requested": pending = changeset.get("pending_item") or {} if pending.get("kind") == "merge" and float(pending.get("expires_at") or 0) + CLOCK_SKEW < rt.clock(): @@ -628,7 +464,7 @@ def advance(rt, lifecycle) -> list[str]: events.append(f"rebuilt {changeset['id']} as {new_id}" if new_id else f"rebuild_failed {changeset['id']}") continue - if changeset["status"] in ("pr_open", "verdict_posted", "queued"): + if changeset["status"] == "pr_open": if merging: continue # one merge in flight per repository envelope = sign_verdict(rt, changeset) @@ -679,66 +515,3 @@ def record_retirements(rt, changeset: dict) -> None: rt.ledger.record_retirement(changeset["repo"], item["rule_id"], item["page"], release) for rule_id in changeset["detail"].get("purges", []): rt.ledger.mark_purged(changeset["repo"], rule_id) - - -def pause_open_prs(ledger, outbox, repo: str, reason: str) -> int: - """Issue always-executable pause items (dequeue + draft) for every open - knowledge PR of ``repo``; they are marked paused once the publisher acks, - and confirmed once GitHub shows them as drafts.""" - count = 0 - if outbox is None: - return count - for changeset in ledger.changesets(repo, ("pr_open", "verdict_posted", "queued")): - if changeset["pr_number"] is None: - continue - item = outbox.issue(repo, "pause", {"changeset_id": changeset["id"], "pr": int(changeset["pr_number"]), - "reason": reason}) - ledger.update_changeset(changeset["id"], detail={ - **changeset["detail"], "pause_requested_at": item.issued_at, "pause_reason": reason}) - count += 1 - return count - - -def _pause_unconfirmed(rt, repo: str, changeset: dict, events: list[str]) -> None: - """Alert people once when a requested pause is not confirmed in time.""" - detail = changeset["detail"] - requested = float(detail.get("pause_requested_at") or 0) - if not requested or detail.get("pause_alerted") or detail.get("pause_confirmed") \ - or rt.clock() - requested < PAUSE_CONFIRM: - return - number = changeset["pr_number"] - rt.ledger.update_changeset(changeset["id"], detail={**detail, "pause_alerted": True}) - rt.ledger.enqueue_human(repo, ( - f"pause of PR #{number} not confirmed after {PAUSE_CONFIRM // 60} minutes " - f"({detail.get('pause_reason', '')}): remove it from the merge queue and run " - f"`gh pr ready {number} --undo -R {knowledge_repository()}`"), changeset["id"]) - events.append(f"pause_unconfirmed {changeset['id']}") - - -def pause_unconfirmed(ledger, repo: str) -> int: - """How many of ``repo``'s PRs are paused on paper but not yet on GitHub.""" - pending = [cs for cs in ledger.changesets(repo, ("pr_open", "verdict_posted", "queued")) - if cs["detail"].get("pause_requested_at")] - acked = [cs for cs in ledger.changesets(repo, ("paused",)) if not cs["detail"].get("pause_confirmed")] - return len(pending) + len(acked) - - -def resume_paused_prs(ledger, repo: str) -> int: - """After ``kb resume``: paused knowledge PRs go back to ``pr_open``. The - resumed repository has a NEW generation, so the next pass signs a fresh - verdict for the PR's current head and re-enqueues (the publisher's enqueue - marks the draft PR ready first); a head that moved goes to people.""" - count = 0 - tracking = ("pause_requested_at", "pause_reason", "pause_alerted", "pause_confirmed") - for changeset in ledger.changesets(repo, ("pr_open", "verdict_posted", "queued")): - if any(key in changeset["detail"] for key in tracking): # requested but never acked - ledger.update_changeset(changeset["id"], detail={ - k: v for k, v in changeset["detail"].items() if k not in tracking}) - for changeset in ledger.changesets(repo, ("paused",)): - if changeset["pr_number"] is None: - continue - detail = {k: v for k, v in changeset["detail"].items() if k not in tracking} - ledger.update_changeset(changeset["id"], status="pr_open", pending_item=None, detail=detail) - count += 1 - return count - diff --git a/src/infermatrix_copilot/kb_service/outbox.py b/src/infermatrix_copilot/kb_service/outbox.py index adfc9e6a..f0798d30 100644 --- a/src/infermatrix_copilot/kb_service/outbox.py +++ b/src/infermatrix_copilot/kb_service/outbox.py @@ -8,8 +8,6 @@ outbox/<id>.json signed item (purpose kb-outbox-item) outbox/control.json signed control record, refreshed every minute inbox/acks/<id>.json the publisher's result for an item - public/holds.json signed hold list, served read-only over HTTP; the - kb-gate verifier fails knowledge segments it names Staleness is enforced on both sides. Each item carries the repository's and the global generation at issue time and an expiry; any pause, circuit break, @@ -36,21 +34,18 @@ from ..knowledge_service.signing import canonical_json, sign, verify ITEM_KINDS = ( - "open_pr", "open_companion_pr", "post_verdict", "enqueue", - "update_branch", "pause", "close", "open_issue", "merge", "post_findings", "open_revert_pr", + "open_pr", "open_companion_pr", "close", "open_issue", "merge", "post_findings", "open_revert_pr", ) # they only ever stop or undo: executable while a repository is paused (a # revert is issued BECAUSE the repository was paused for an unknown change) -ALWAYS_EXECUTABLE = frozenset({"pause", "close", "open_revert_pr"}) +ALWAYS_EXECUTABLE = frozenset({"close", "open_revert_pr"}) ITEM_TTL = { "open_pr": 24 * 3600, "open_companion_pr": 24 * 3600, - "post_verdict": 30 * 60, "enqueue": 30 * 60, "merge": 30 * 60, "post_findings": 24 * 3600, - "open_revert_pr": 24 * 3600, - "update_branch": 24 * 3600, "pause": 24 * 3600, "close": 24 * 3600, + "merge": 30 * 60, "post_findings": 24 * 3600, + "open_revert_pr": 24 * 3600, "close": 24 * 3600, "open_issue": 7 * 24 * 3600, } CONTROL_MAX_AGE = 10 * 60 -HOLDS_MAX_AGE = 10 * 60 class OutboxError(RuntimeError): @@ -112,7 +107,7 @@ def from_payload(cls, payload: Any) -> "OutboxItem": class Outbox: - """Service side: write signed items, control records, holds; read acks.""" + """Service side: write signed items and control records; read acks.""" def __init__(self, state_dir: str | Path, key, ledger, *, clock): self.root = Path(state_dir) @@ -158,10 +153,6 @@ def outbox_dir(self) -> Path: def acks_dir(self) -> Path: return self.root / "inbox" / "acks" - @property - def holds_path(self) -> Path: - return self.root / "public" / "holds.json" - def issue(self, repo: str, kind: str, body: dict) -> OutboxItem: if kind not in ITEM_KINDS: raise OutboxError(f"unknown outbox item kind: {kind}") @@ -197,41 +188,14 @@ def refresh_control(self) -> dict: atomic_write_json(self.outbox_dir / "control.json", sign("kb-control", payload, self._key)) return payload - def transition(self, change, *, public_repos: set[str]) -> Any: - """Apply a ledger state change (pause/resume/mode) and republish the - control record and hold list, all under the publication lock.""" + def transition(self, change) -> Any: + """Apply a ledger state change (pause/resume/mode) and re-sign the + control record at once, under the publication lock.""" with self.publication_lock(): result = change() self.refresh_control() - sequence = int(self._ledger.get_cursor("*", "holds_sequence") or 0) + 1 - self._ledger.set_cursor("*", "holds_sequence", str(sequence)) - self.publish_holds(sequence=sequence, public_repos=public_repos) return result - def publish_holds(self, *, sequence: int, public_repos: set[str]) -> dict: - """Signed hold list: paused repositories and PRs whose change sets are - paused. It is served publicly, so ONLY repositories in ``public_repos`` - (public upstreams) may appear; a private upstream's name, pause state - or PRs never do (it publishes nothing, so it has no PR to hold).""" - with self.publication_lock(): - return self._publish_holds(sequence, frozenset(public_repos)) - - def _publish_holds(self, sequence: int, public_repos: frozenset[str]) -> dict: - states = [row for row in self._ledger.all_repo_states() - if row["repo"] == "*" or row["repo"] in public_repos] - paused = [row["repo"] for row in states if row["paused"] and row["repo"] != "*"] - global_hold = any(row["repo"] == "*" and row["paused"] for row in states) - prs = sorted({ - int(cs["pr_number"]) - for row in states if row["repo"] != "*" - for cs in self._ledger.changesets(row["repo"], ("paused",)) - if cs["pr_number"] is not None - }) - payload = {"issued_at": self._clock(), "sequence": sequence, "global": global_hold, - "repos": sorted(paused), "prs": prs} - atomic_write_json(self.holds_path, sign("kb-holds", payload, self._key)) - return payload - def collect_acks(self, public_key) -> list[dict]: """Read publisher acks (signed by the PUBLISHER's key), record them, remove the files.""" results = [] @@ -284,11 +248,3 @@ def check_item(envelope: Any, control_envelope: Any, public_key, *, now: float, if current["mode"] != "auto_merge" or not repo_auto_merge: raise OutboxError(f"repository is not in auto_merge mode (control: {current['mode']})") return item - - -def verify_holds(envelope: Any, public_key, *, now: float) -> dict: - """Used by the kb-gate verifier: a stale or unverifiable hold list fails closed.""" - holds = verify("kb-holds", envelope, public_key) - if now - float(holds["issued_at"]) > HOLDS_MAX_AGE: - raise OutboxError("hold list is stale") - return holds diff --git a/src/infermatrix_copilot/kb_service/publisher.py b/src/infermatrix_copilot/kb_service/publisher.py index 2c20f29a..eab9607c 100644 --- a/src/infermatrix_copilot/kb_service/publisher.py +++ b/src/infermatrix_copilot/kb_service/publisher.py @@ -3,7 +3,7 @@ It runs on the GPU box as the repository owner, through that user's existing ``gh`` login, which never leaves the box and never enters a model environment. The knowledge service holds no GitHub write credential; this is the only place -knowledge PRs are opened, commented on, queued, paused or closed. +knowledge PRs are opened, commented on, merged or closed. One round (``kb publish --once``; without it, a round per interval): @@ -54,12 +54,11 @@ # companions are recognized by their branch too (kb/<repo>/<repo>-companion-<id>), # so a label that failed to apply cannot make one mergeable by automation COMPANION_BRANCH = re.compile(r"kb/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+-companion-[0-9a-f]+") -HOLD_LABEL = "kb:hold" PUSHING = frozenset({"open_pr", "open_companion_pr", "open_revert_pr"}) -# retried until done, never acked as failed: stops (pause, close) and reports +# retried until done, never acked as failed: a stop (close) and reports # (open_issue, post_findings: a transient GitHub error must not lose a sweep # report or an author's findings) -RETRIED = frozenset({"pause", "close", "open_issue", "post_findings"}) +RETRIED = frozenset({"close", "open_issue", "post_findings"}) class MergeUncertain(RuntimeError): @@ -418,14 +417,6 @@ def _handle(self, item_id: str, control: Any) -> str: return "performed" if ack["ok"] else "failed" # the actions --------------------------------------------------------------- - def _checked_head(self, item: OutboxItem) -> dict: - pr = self.github.pr(int(item.body["pr"])) - if pr.get("state") != "OPEN": - raise PublishError(f"PR #{item.body['pr']} is {pr.get('state')}") - if pr.get("headRefOid") != item.body["head_sha"]: - raise PublishError(f"PR #{item.body['pr']} head moved to {str(pr.get('headRefOid'))[:12]}") - return pr - def _do_open_pr(self, item: OutboxItem, *, draft: bool = False, allowed: re.Pattern = GOVERNED_PATH, what: str = "governed knowledge pages") -> dict: body = item.body @@ -504,54 +495,6 @@ def _refuse_companion(self, pr: dict) -> None: if labelled or COMPANION_BRANCH.fullmatch(str(pr.get("headRefName") or "")): raise PublishError("a companion PR is readied and merged only by people") - def _do_post_verdict(self, item: OutboxItem) -> dict: - """A verdict is only issued for an unpaused repository, under its current - generation, so posting one also lifts an earlier pause's kb:hold label - (first, so the precheck the comment triggers no longer sees it).""" - self._refuse_companion(self._checked_head(item)) - number = str(item.body["pr"]) - self.github.gh("pr", "edit", number, "--repo", self.github.repository, "--remove-label", HOLD_LABEL, - ok_fail=True) # fails when the label is absent; what matters is checked next - if HOLD_LABEL in {str(label.get("name")) for label in self.github.pr(int(number)).get("labels") or []}: - raise PublishError(f"PR #{number} still carries {HOLD_LABEL}; the gate would reject it") - self.github.gh("pr", "comment", number, "--repo", self.github.repository, - "--body-file", "-", input=str(item.body["comment"])) - return {"pr": int(item.body["pr"]), "head_sha": item.body["head_sha"]} - - def _do_enqueue(self, item: OutboxItem) -> dict: - pr = self._checked_head(item) - self._refuse_companion(pr) - number = str(item.body["pr"]) - if pr.get("isDraft"): # a resumed PR was turned into a draft by its pause - self.github.gh("pr", "ready", number, "--repo", self.github.repository) - self.github.gh("pr", "merge", number, "--repo", self.github.repository, "--merge", "--auto", - "--match-head-commit", str(item.body["head_sha"])) - return {"pr": int(number), "head_sha": item.body["head_sha"]} - - def _do_pause(self, item: OutboxItem) -> dict: - """Procedure P: out of the merge queue, then draft (the guarantee), then - the kb:hold label (a record only).""" - number = str(item.body["pr"]) - pr = self.github.pr(int(number)) - if pr.get("state") != "OPEN": - return {"pr": int(number)} # merged or closed already: nothing to stop - self.github.gh("api", "graphql", "-f", "query=mutation($id:ID!){dequeuePullRequest(input:{id:$id})" - "{clientMutationId}}", "-f", f"id={pr['id']}", ok_fail=True) - self.github.gh("pr", "merge", number, "--repo", self.github.repository, "--disable-auto", ok_fail=True) - if not pr.get("isDraft"): - self.github.gh("pr", "ready", number, "--repo", self.github.repository, "--undo") - if not self.github.pr(int(number)).get("isDraft"): - raise PublishError(f"PR #{number} is still not a draft") - self.github.gh("pr", "edit", number, "--repo", self.github.repository, "--add-label", HOLD_LABEL, - ok_fail=True) - return {"pr": int(number)} - - def _do_update_branch(self, item: OutboxItem) -> dict: - self._checked_head(item) - self.github.gh("api", "-X", "PUT", f"repos/{self.github.repository}/pulls/{item.body['pr']}/update-branch", - "-f", f"expected_head_sha={item.body['head_sha']}") - return {"pr": int(item.body["pr"])} - def _do_open_issue(self, item: OutboxItem) -> dict: """One issue per title (a sweep report): an existing one is reused.""" title = str(item.body["title"]) @@ -587,26 +530,19 @@ def _do_merge(self, item: OutboxItem) -> dict: body = item.body number, head = int(body["pr"]), str(body["head_sha"]) - verdict = local_gate.check_verdict(body["verdict"], self.service_public_key, + pr = self.github.pr(number) + self._refuse_companion(pr) # whatever the item carries + verdict = local_gate.check_verdict(body.get("verdict"), self.service_public_key, repository=self.github.repository, pr=number, head_sha=head, now=self.clock()) - pr = self.github.pr(number) if pr.get("state") != "OPEN": raise PublishError(f"PR #{number} is {pr.get('state')}") if pr.get("baseRefName") != "main": raise GateRefused([f"PR #{number} targets {pr.get('baseRefName')}, not main"]) if pr.get("headRefOid") != head: raise PublishError(f"PR #{number} head moved to {str(pr.get('headRefOid'))[:12]}") - self._refuse_companion(pr) - labels = {str(label.get("name")) for label in pr.get("labels") or []} - if HOLD_LABEL in labels: - # a v7 pause left it: a merge item exists only for an unpaused - # repository under its current generation, so the hold is over - self.github.gh("pr", "edit", str(number), "--repo", self.github.repository, - "--remove-label", HOLD_LABEL, ok_fail=True) - pr = self.github.pr(number) - if HOLD_LABEL in {str(label.get("name")) for label in pr.get("labels") or []}: - raise PublishError(f"PR #{number} still carries {HOLD_LABEL}; retried next round") + if pr.get("isDraft"): # its author marked it work in progress: never readied by automation + raise PublishError(f"not merging: PR #{number} is a draft") remote = self._ensure_clone() rest = {"number": number, "state": "open", "head": {"sha": head}, "draft": bool(pr.get("isDraft")), "labels": pr.get("labels") or []} @@ -631,8 +567,6 @@ def _do_merge(self, item: OutboxItem) -> dict: else: raise PublishError(f"main kept moving while PR #{number} was checked; retried next round") self._recheck_control() - if pr.get("isDraft"): # a v7 pause left it a draft; GitHub will not merge a draft - self.github.gh("pr", "ready", str(number), "--repo", self.github.repository) intent = self._intent_path(item.id) intent.parent.mkdir(parents=True, exist_ok=True) intent.write_text(json.dumps({"pr": number, "head_sha": head, "changeset_id": body["changeset_id"], diff --git a/src/infermatrix_copilot/kb_service/runtime.py b/src/infermatrix_copilot/kb_service/runtime.py index 0e724ac5..dc6ef3c1 100644 --- a/src/infermatrix_copilot/kb_service/runtime.py +++ b/src/infermatrix_copilot/kb_service/runtime.py @@ -345,7 +345,7 @@ def publish(rt: KbRuntime, lifecycle: RepoLifecycle, changeset_id: str) -> str: Shadow (and any repository that does not publish) records what WOULD be opened and writes nothing to the outbox. The rest of the merge flow - (verdict signing, enqueue, activation) is driven by the scheduler once the + (verdict signing, the merge item, activation) is driven by the scheduler once the publisher reports the PR.""" changeset = rt.ledger.changeset(changeset_id) if changeset["status"] != "gated": @@ -388,7 +388,7 @@ def _pr_body(changeset_id: str, detail: dict) -> str: for op in detail["operations"]: lines.append(f"| {op['kind']} | `{op['page']}` | {op.get('new_rule_id') or op['rule_id']} |") lines += ["", f"Generator: `{detail['generator']}` · judge: `{detail['judge']}`.", - "Merge eligibility is decided by the kb-gate check in the merge queue."] + "The publisher merges it only if its local gate passes on the exact merge result."] return "\n".join(lines) diff --git a/src/infermatrix_copilot/kb_service/scheduler.py b/src/infermatrix_copilot/kb_service/scheduler.py index 91688974..9aec70cd 100644 --- a/src/infermatrix_copilot/kb_service/scheduler.py +++ b/src/infermatrix_copilot/kb_service/scheduler.py @@ -65,13 +65,10 @@ def _record(self, repo: str, event: str, **detail) -> None: with path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(entry, ensure_ascii=False) + "\n") - def _public_repos(self) -> set[str]: - return {name for name, lc in self.rt.registry.items() if lc.publishes} - def tick(self) -> None: rt = self.rt if rt.outbox is not None: - rt.outbox.transition(lambda: None, public_repos=self._public_repos()) + rt.outbox.transition(lambda: None) if rt.publisher_public_key is not None: merge.apply_acks(rt, rt.outbox.collect_acks(rt.publisher_public_key)) for lifecycle in rt.registry.values(): @@ -188,20 +185,18 @@ def _overturn_breaker(self, lifecycle) -> None: self._pause(lifecycle.repo, f"overturn breaker: {len(recent)} knowledge PRs closed unmerged within 24h") def _pause(self, repo: str, reason: str) -> None: - """Pause a repository ("*": all of them) and dequeue its open PRs.""" + """Pause a repository ("*": all of them): no merge is issued, and the + publisher refuses items of the older generation.""" rt = self.rt if rt.ledger.repo_state(repo)["paused"]: rt.ledger.enqueue_human(repo if repo != "*" else next(iter(rt.registry)), reason) return # publish the pause in the same locked transition as the state change, - # so the publisher and the gate see it at once, then dequeue open PRs + # so the publisher sees it at once if rt.outbox is not None: - rt.outbox.transition(lambda: rt.ledger.bump_generation(repo, pause=True, reason=reason), - public_repos=self._public_repos()) + rt.outbox.transition(lambda: rt.ledger.bump_generation(repo, pause=True, reason=reason)) else: rt.ledger.bump_generation(repo, pause=True, reason=reason) - for name in ([repo] if repo != "*" else [lc.repo for lc in rt.registry.values()]): - merge.pause_open_prs(rt.ledger, rt.outbox, name, reason) rt.ledger.enqueue_human(repo if repo != "*" else next(iter(rt.registry)), reason) self._record(repo, "paused", reason=reason) diff --git a/src/infermatrix_copilot/kb_service/sources.py b/src/infermatrix_copilot/kb_service/sources.py index 71cab98b..7c82e087 100644 --- a/src/infermatrix_copilot/kb_service/sources.py +++ b/src/infermatrix_copilot/kb_service/sources.py @@ -122,7 +122,7 @@ def export(self, rev: str, dest: str | Path) -> Path: return dest def raw_manifest(self, old: str, new: str) -> list[dict]: - """Every changed path as a kb-gate manifest entry (renames are D + A), + """Every changed path as a verdict manifest entry (renames are D + A), the exact form the verifier recomputes.""" out = self._git("diff", "--raw", "-z", "--no-renames", "--no-abbrev", old, new).split(b"\0") entries, zero = [], "0" * 40 diff --git a/src/infermatrix_copilot/kb_service/sweep.py b/src/infermatrix_copilot/kb_service/sweep.py index 54c6089f..4f14dd1d 100644 --- a/src/infermatrix_copilot/kb_service/sweep.py +++ b/src/infermatrix_copilot/kb_service/sweep.py @@ -13,7 +13,7 @@ A sweep-wide circuit breaker (more than ``retire_ratio`` of the repository's active rules retired, or more than ``max_files`` files touched across the -sweep) sends every sweep change set to people instead of the merge queue. +sweep) sends every sweep change set to people instead of to the publisher. """ from __future__ import annotations diff --git a/src/infermatrix_copilot/knowledge_service/gate_verifier.py b/src/infermatrix_copilot/knowledge_service/gate_verifier.py index eee5d2b7..69e9dfba 100644 --- a/src/infermatrix_copilot/knowledge_service/gate_verifier.py +++ b/src/infermatrix_copilot/knowledge_service/gate_verifier.py @@ -1,74 +1,38 @@ -"""kb-gate: the repository-side verifier for knowledge pull requests. +"""The knowledge verifier: the checks the publisher's local gate runs (design v8). -It is the required check ``kb-gate`` and runs in two places under one name: - -* PR stage (``pull_request_target`` / ``issue_comment`` / dispatch): verifies - the PR against the current base and posts a ``kb-gate`` commit status on the - PR head. It only decides whether a PR may ENTER the merge queue. -* merge group stage (``merge_group``): verifies every segment of the group on - the exact commit that would land and exits non-zero on any failure. This is - what decides whether anything lands. - -A change that does not touch ``knowledge/`` passes. A knowledge change passes -only when all of these hold for it: +The publisher is the only thing that merges knowledge PRs; right before each +merge it runs ``verify_change`` on the exact tree that would land (see +``kb_service.local_gate``). A change passes only when all of these hold: 1. L1: whitelisted paths only, regular files only, no new tree issues, on the - PR's own change AND on the tree that would land; -2. a valid Ed25519-signed verdict comment for this repository, PR and exact - head SHA, inside its issue window (the comment's author is irrelevant); -3. the signed patch manifest equals the change as applied (base independent); -4. ``auto``: the signed block table equals the blocks L1 derives, every block - passed, the consistency judgements cover the same owner directories and - their page hashes match the tree that would land; no page in the context - set changed between the judged base and the effective base; - ``human-approved``: every bound review is still APPROVED on this head, one - is by a knowledge maintainer (a CODEOWNERS owner of ``.github/kb-gate/``), - and ``kb:human-approved`` is set; -5. no ``kb:hold`` label and not held by the service's signed hold list (a - missing, forged or stale hold list fails closed). - -PR content is only ever READ through git objects; nothing from the PR is -executed or installed. This module is vendored into ``.github/kb-gate/`` by -``tools/build_kb_gate_bundle.py`` and runs from there, so it imports nothing -outside the standard library and its vendored siblings. + PR's own change AND on the tree that would land (including references to + rules the change retires or purges); +2. the signed patch manifest equals the change as applied (base independent); +3. the signed block table equals the blocks L1 derives, every block passed, + the consistency judgements cover the same owner directories and their page + hashes match the tree that would land; +4. no page in the context set changed between the judged base and the + effective base. + +The verdict's signature and binding (repository, PR, head, issue window) are +checked by the caller. PR content is only ever READ through git objects; +nothing from the PR is executed or installed. """ from __future__ import annotations -import argparse -import fnmatch import hashlib -import json import os import subprocess -import sys -import time -import urllib.error -import urllib.request from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Any, Callable, Iterable, Mapping - -try: - from .l1 import Change, _dangling_refs as dangling_refs, check_changeset, check_tree - from .lifecycle import LifecycleError, Page - from .ops import all_rule_ids, repo_scope - from .signing import SignatureError, load_public_key, verify - from .verdict import VerdictError, check_binding, manifests_equal -except ImportError: # pragma: no cover - vendored verifier bundle - from l1 import Change, _dangling_refs as dangling_refs, check_changeset, check_tree # type: ignore[no-redef] - from lifecycle import LifecycleError, Page # type: ignore[no-redef] - from ops import all_rule_ids, repo_scope # type: ignore[no-redef] - from signing import SignatureError, load_public_key, verify # type: ignore[no-redef] - from verdict import VerdictError, check_binding, manifests_equal # type: ignore[no-redef] - -STATUS_CONTEXT = "kb-gate" -VERDICT_MARKER = "<!-- kb-gate:verdict:v1 -->" -HOLD_LABEL = "kb:hold" -HUMAN_LABEL = "kb:human-approved" -HOLDS_MAX_AGE = 10 * 60 -MAINTAINER_PATH = ".github/kb-gate/kb_gate.py" -WHITELIST_CODES = ("path_not_whitelisted", "mode_not_regular") +from typing import Callable, Iterable, Mapping + +from .l1 import Change, _dangling_refs as dangling_refs, check_changeset, check_tree +from .lifecycle import LifecycleError, Page +from .ops import all_rule_ids +from .verdict import manifests_equal + KNOWLEDGE_PREFIX = "knowledge/" GOVERNED_PREFIXES = ("knowledge/repos/", "knowledge/general/") GOVERNED_SUFFIXES = (".md", ".yaml") @@ -206,113 +170,13 @@ def show(self, rev: str, path: str) -> str | None: return proc.stdout.decode("utf-8", "replace") if proc.returncode == 0 else None -# -- GitHub REST (read, plus the PR-stage status) -------------------------------- - -class GitHub: - def __init__(self, repository: str, token: str, api: str = "https://api.github.com"): - self.repository, self._token, self._api = repository, token, api.rstrip("/") - - def _request(self, method: str, path: str, body: dict | None = None) -> Any: - data = None if body is None else json.dumps(body).encode() - request = urllib.request.Request(self._api + path, data=data, method=method, headers={ - "Accept": "application/vnd.github+json", "Authorization": f"Bearer {self._token}", - "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "kb-gate"}) - try: - with urllib.request.urlopen(request, timeout=30) as response: - return json.loads(response.read() or b"null") - except (urllib.error.URLError, TimeoutError, ValueError) as exc: - raise GateError(f"GitHub {method} {path} failed: {exc}") from exc - - def get(self, path: str) -> Any: - return self._request("GET", path) - - def get_all(self, path: str, *, pages: int = 10) -> list: - out: list = [] - sep = "&" if "?" in path else "?" - for page in range(1, pages + 1): - batch = self.get(f"{path}{sep}per_page=100&page={page}") - out.extend(batch) - if len(batch) < 100: - break - return out - - def post(self, path: str, body: dict) -> Any: - return self._request("POST", path, body) - - -# -- inputs from the protected checkout ------------------------------------------- - -def fetch_holds(url: str) -> Any: - if not url: - raise GateError("no hold-list URL is configured (.github/kb-gate/config.json)") - try: - with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "kb-gate"}), - timeout=20) as response: - return json.loads(response.read()) - except (urllib.error.URLError, TimeoutError, ValueError) as exc: - raise GateError(f"hold list unreachable: {exc}") from exc - - -def verify_holds(envelope: Any, public_key, *, now: float) -> dict: - holds = verify("kb-holds", envelope, public_key) - if not 0 <= now - float(holds["issued_at"]) <= HOLDS_MAX_AGE: - raise GateError("hold list is stale (the knowledge service may be down)") - return holds - - -def parse_codeowners(text: str | None) -> list[tuple[str, tuple[str, ...]]]: - rules = [] - for line in (text or "").splitlines(): - line = line.split("#", 1)[0].strip() - if not line: - continue - pattern, *owners = line.split() - rules.append((pattern, tuple(owner.lstrip("@").lower() for owner in owners))) - return rules - - -def _codeowner_match(pattern: str, path: str) -> bool: - anchored = pattern.startswith("/") - pattern = pattern.lstrip("/") - if pattern.endswith("/"): - pattern += "**" - candidates = [path] if anchored or "/" in pattern.rstrip("*") else \ - [path] + [path.split("/", i)[-1] for i in range(1, path.count("/") + 1)] - for candidate in candidates: - if fnmatch.fnmatchcase(candidate, pattern) or fnmatch.fnmatchcase(candidate, pattern + "/**") \ - or (pattern.endswith("/**") and candidate.startswith(pattern[:-2])): - return True - return False - - -def code_owners(rules: list[tuple[str, tuple[str, ...]]], path: str) -> tuple[str, ...]: - """Owners of ``path``: the LAST matching line wins. Team entries never - match a person here (their membership is not readable with the job token), - so a team-only path cannot be human-approved through this verifier.""" - owners: tuple[str, ...] = () - for pattern, names in rules: - if _codeowner_match(pattern, path): - owners = names - return tuple(name for name in owners if "/" not in name) - - # -- the verification of one change ------------------------------------------------ @dataclass class Context: git: Git - github: Any repository: str - public_key: Any # None when .github/kb-gate.pub is missing - holds_loader: Callable[[], Any] - codeowners: list[tuple[str, tuple[str, ...]]] now: float - _holds: dict | None = None - - def holds(self) -> dict: - if self._holds is None: - self._holds = verify_holds(self.holds_loader(), self.public_key, now=self.now) - return self._holds def governed(path: str) -> bool: @@ -328,41 +192,6 @@ def _sha256(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() -def verdict_envelopes(comments: Iterable[Mapping[str, Any]]) -> list[Any]: - out = [] - for comment in comments: - body = str(comment.get("body") or "") - if VERDICT_MARKER not in body: - continue - rest = body.split(VERDICT_MARKER, 1)[1] - start = rest.find("```json") - if start < 0: - continue - rest = rest[start + len("```json"):] - end = rest.find("```") - try: - out.append(json.loads(rest[:end] if end >= 0 else rest)) - except ValueError: - continue - return out - - -def find_verdict(ctx: Context, pr: int, head_sha: str) -> tuple[dict | None, list[str]]: - """The newest valid verdict for exactly this PR head, and why others failed.""" - comments = ctx.github.get_all(f"/repos/{ctx.repository}/issues/{pr}/comments") - valid, rejected = [], [] - for envelope in verdict_envelopes(comments): - try: - verdict = verify("kb-gate-verdict", envelope, ctx.public_key) - check_binding(verdict, repository=ctx.repository, pr=pr, head_sha=head_sha, now=ctx.now) - valid.append(verdict) - except (SignatureError, VerdictError, KeyError, TypeError, ValueError) as exc: - rejected.append(str(exc)) - if not valid: - return None, rejected - return max(valid, key=lambda v: float(v["issued_at"])), rejected - - def _owner_dir(path: str) -> str: return str(PurePosixPath(path).parent) @@ -407,32 +236,22 @@ def relevant(path: str) -> bool: def verify_change(ctx: Context, *, pr: dict, head_sha: str, pre: str, post: str, final: str, - effective_base: str, stage: str, verdict: dict | None = None, - check_holds: bool = True) -> list[str]: + effective_base: str, verdict: dict) -> list[str]: """Problems with one PR's knowledge change (empty when it passes). ``pre``/``post`` are the commits (or trees) around this PR's change as it is applied; ``final`` is the tree that would land; ``effective_base`` is - the state the change is applied on (current main, or the first parent of - the merge-group segment). The publisher's local gate passes the verdict it - was handed (its signature and binding already checked) and no hold list: - a pause there is the signed control record.""" + the state the change is applied on (current main, or the merge commit's + first parent). ``verdict`` is the signed verdict the caller verified.""" number = int(pr["number"]) entries = ctx.git.raw_diff(pre, post) if not touches_knowledge(entries): return [] - if ctx.public_key is None: - return [".github/kb-gate.pub is not committed: knowledge changes cannot be verified"] problems: list[str] = [] - labels = {str(label.get("name")) for label in pr.get("labels") or []} if pr.get("state") != "open": problems.append(f"PR #{number} is not open") if str((pr.get("head") or {}).get("sha")) != head_sha: problems.append(f"PR #{number} head moved from {head_sha[:12]}") - if stage == "merge_group" and pr.get("draft"): - problems.append(f"PR #{number} is a draft (paused)") - if HOLD_LABEL in labels: - problems.append(f"PR #{number} carries {HOLD_LABEL}") pre_files, post_files = ctx.git.knowledge_files(pre), ctx.git.knowledge_files(post) final_files = ctx.git.knowledge_files(final) @@ -443,47 +262,19 @@ def verify_change(ctx: Context, *, pr: dict, head_sha: str, pre: str, post: str, problems.append(f"{e['path']}: change type {e['status']} is not allowed") touched = {e["path"][len(KNOWLEDGE_PREFIX):] for e in entries if e["path"].startswith(KNOWLEDGE_PREFIX)} - try: - holds = ctx.holds() if check_holds else {} - if holds.get("global"): - problems.append("the knowledge service holds every repository (global pause)") - if number in {int(p) for p in holds.get("prs") or []}: - problems.append(f"PR #{number} is on the service's hold list") - held = set(holds.get("repos") or []) - # a scope is its repository name, or "general" for knowledge/general/ - scopes = {repo_scope(p).split("/")[-1] for p in touched if p.startswith(("repos/", "general/"))} - for scope in sorted(scopes & held): - problems.append(f"repository {scope} is paused by the knowledge service") - except (GateError, SignatureError, LifecycleError, KeyError, TypeError, ValueError) as exc: - problems.append(f"hold list: {exc}") - - rejected: list[str] = [] - if verdict is None: - verdict, rejected = find_verdict(ctx, number, head_sha) - if verdict is None: - detail = f" (rejected: {'; '.join(rejected[:3])})" if rejected else "" - problems.append(f"no valid signed verdict for PR #{number} at {head_sha[:12]}{detail}") - # still report L1, so the author sees every structural problem at once - result = check_changeset(pre_files, post_files, changes, external_texts={}) - return problems + [f"L1 {i.code} {i.path} {i.detail}".rstrip() for i in result.issues] - + if verdict.get("source") != "auto": + problems.append("only auto verdicts merge (there is no human-approved path)") if not manifests_equal(verdict.get("manifest") or [], entries): problems.append("the signed patch manifest does not match the change as applied") if verdict.get("facts"): problems.append("the verdict carries upstream fact attestations this verifier cannot re-check") - human = verdict["source"] == "human-approved" - # a maintainer's approval replaces the auto-merge whitelist (knowledge/tools, - # skills, scripts, mixed PRs) and L2, never the rest of L1: non-governed - # paths are not pages, so the page lifecycle checks run on governed ones only - l1_changes = [c for c in changes if governed(c.path)] if human else changes - result = check_changeset(pre_files, post_files, l1_changes, + result = check_changeset(pre_files, post_files, changes, external_texts=ctx.git.external_texts(final), release=str(verdict.get("release") or "")) - waived = WHITELIST_CODES if human else () - problems.extend(f"L1 {i.code} {i.path} {i.detail}".rstrip() for i in result.issues if i.code not in waived) + problems.extend(f"L1 {i.code} {i.path} {i.detail}".rstrip() for i in result.issues) # what this change retires or purges must not be cited anywhere in the tree - # that would land (a PR queued behind it, or main, may have added a citation) + # that would land (main may have added a citation meanwhile) gone = set(result.retired) | set(result.purged) if gone: try: @@ -492,81 +283,37 @@ def verify_change(ctx: Context, *, pr: dict, head_sha: str, pre: str, post: str, except (LifecycleError, ValueError) as exc: problems.append(f"L1 the tree that would land does not parse: {exc}") - if verdict["source"] == "auto": - if result.external_refs: - problems.append("retired rules are still cited outside knowledge/ (needs a companion PR)") - signed_blocks = verdict.get("blocks") or [] - if {b.block_id for b in result.blocks} != {b.get("block_id") for b in signed_blocks}: - problems.append("the signed block table does not cover exactly the changed blocks") - if any(b.get("verdict") != "pass" for b in signed_blocks): - problems.append("a signed block did not pass") - owner_dirs = {_owner_dir(b.path) for b in result.blocks} - consistency = verdict.get("consistency") or [] - if {c.get("owner_dir") for c in consistency} != owner_dirs: - problems.append("the consistency judgement does not cover exactly the changed owner directories") - for item in consistency: - directory = item.get("owner_dir") - actual = {p: _sha256(t) for p, t in final_files.items() - if _owner_dir(p) == directory and p.endswith(".md")} - if item.get("verdict") != "consistent": - problems.append(f"{directory}: consistency was not judged consistent") - if item.get("pages") != actual: - problems.append(f"{directory}: pages changed since the consistency judgement") - base_sha = str(verdict["context_base_sha"]) - try: - if not ctx.git.is_ancestor(base_sha, effective_base): - problems.append("the judged context base is not an ancestor of the effective base") - else: - relevant = context_paths(touched, owner_dirs, post_files, pre_files) - moved = [n[len(KNOWLEDGE_PREFIX):] for n in ctx.git.changed_names(base_sha, effective_base)] - stale = sorted(p for p in moved if relevant(p)) - if stale: - problems.append("context changed since the verdict was judged: " + ", ".join(stale[:5])) - except GateError as exc: - problems.append(f"context base: {exc}") - else: - problems.extend(_human_approval_problems(ctx, pr, verdict, head_sha, labels)) - return problems - - -def _human_approval_problems(ctx: Context, pr: dict, verdict: dict, head_sha: str, - labels: set[str]) -> list[str]: - problems = [] - if HUMAN_LABEL not in labels: - problems.append(f"{HUMAN_LABEL} was removed") - number = int(pr["number"]) - reviewers = {str(r).lower() for r in verdict.get("reviewers") or []} - approvers = set() - for review_id in verdict.get("review_ids") or []: - review = ctx.github.get(f"/repos/{ctx.repository}/pulls/{number}/reviews/{int(review_id)}") - login = str((review.get("user") or {}).get("login") or "").lower() - if review.get("state") != "APPROVED": - problems.append(f"review {review_id} is no longer APPROVED") - elif review.get("commit_id") != head_sha: - problems.append(f"review {review_id} approved another head") - elif login not in reviewers: - problems.append(f"review {review_id} is by someone the verdict does not name") + if result.external_refs: + problems.append("retired rules are still cited outside knowledge/ (needs a companion PR)") + signed_blocks = verdict.get("blocks") or [] + if {b.block_id for b in result.blocks} != {b.get("block_id") for b in signed_blocks}: + problems.append("the signed block table does not cover exactly the changed blocks") + if any(b.get("verdict") != "pass" for b in signed_blocks): + problems.append("a signed block did not pass") + owner_dirs = {_owner_dir(b.path) for b in result.blocks} + consistency = verdict.get("consistency") or [] + if {c.get("owner_dir") for c in consistency} != owner_dirs: + problems.append("the consistency judgement does not cover exactly the changed owner directories") + for item in consistency: + directory = item.get("owner_dir") + actual = {p: _sha256(t) for p, t in final_files.items() + if _owner_dir(p) == directory and p.endswith(".md")} + if item.get("verdict") != "consistent": + problems.append(f"{directory}: consistency was not judged consistent") + if item.get("pages") != actual: + problems.append(f"{directory}: pages changed since the consistency judgement") + base_sha = str(verdict["context_base_sha"]) + try: + if not ctx.git.is_ancestor(base_sha, effective_base): + problems.append("the judged context base is not an ancestor of the effective base") else: - approvers.add(login) - if approvers: - # a later review by the same person (changes requested, dismissal) - # withdraws the approval even though the bound review still reads APPROVED - latest: dict[str, dict] = {} - for review in ctx.github.get_all(f"/repos/{ctx.repository}/pulls/{number}/reviews"): - login = str((review.get("user") or {}).get("login") or "").lower() - if login and review.get("state") in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED"): - latest[login] = review - for login in sorted(approvers): - review = latest.get(login) or {} - if review.get("state") != "APPROVED" or review.get("commit_id") != head_sha: - problems.append(f"{login}'s latest review no longer approves this head") - approvers.discard(login) - # knowledge maintainers = the CODEOWNERS of the gate itself. Governed pages - # deliberately have no code owners (that would force a review on every auto - # PR); paths outside the whitelist get GitHub's native code-owner review. - maintainers = set(code_owners(ctx.codeowners, MAINTAINER_PATH)) - if not approvers & maintainers: - problems.append("no valid approving review by a knowledge maintainer (CODEOWNERS of .github/kb-gate/)") + relevant = context_paths(touched, owner_dirs, post_files, pre_files) + moved = [n[len(KNOWLEDGE_PREFIX):] for n in ctx.git.changed_names(base_sha, effective_base)] + stale = sorted(p for p in moved if relevant(p)) + if stale: + problems.append("context changed since the verdict was judged: " + ", ".join(stale[:5])) + except GateError as exc: + problems.append(f"context base: {exc}") return problems @@ -575,135 +322,3 @@ def tree_problems(git: Git, base: str, final: str) -> list[str]: before = {(i.code, i.detail) for i in check_tree(git.knowledge_files(base))} return [f"L1 {i.code} {i.path} {i.detail}".rstrip() for i in check_tree(git.knowledge_files(final)) if (i.code, i.detail) not in before] - - -# -- the two stages ----------------------------------------------------------------- - -def verify_pr(ctx: Context, number: int, *, base_ref: str = "main", - remote: str = "origin") -> tuple[str, list[str]]: - """(the head SHA verified, its problems). The status goes on THAT head, so - a push during verification can only leave the new head without a status.""" - pr = ctx.github.get(f"/repos/{ctx.repository}/pulls/{number}") - head = str(pr["head"]["sha"]) - return head, _verify_pr_head(ctx, pr, head, base_ref=base_ref, remote=remote) - - -def _verify_pr_head(ctx: Context, pr: dict, head: str, *, base_ref: str, remote: str) -> list[str]: - number = int(pr["number"]) - ctx.git.run("fetch", "--quiet", "--no-tags", remote, - f"+refs/pull/{number}/head:refs/kb-gate/pr-{number}", f"+refs/heads/{base_ref}:refs/kb-gate/base") - if ctx.git.rev(f"refs/kb-gate/pr-{number}") != head: - return [f"fetched head does not match PR #{number}'s head {head[:12]} (retry)"] - base_tip = ctx.git.rev("refs/kb-gate/base") - merge_base = ctx.git.merge_base(base_tip, head) - if not touches_knowledge(ctx.git.raw_diff(merge_base, head)): - return [] - try: - final = ctx.git.merge_tree(base_tip, head) - except GateError as exc: - return [str(exc)] - problems = verify_change(ctx, pr=pr, head_sha=head, pre=merge_base, post=head, final=final, - effective_base=base_tip, stage="pull_request") - return problems + tree_problems(ctx.git, base_tip, final) - - -def _pr_for_head(ctx: Context, sha: str) -> dict: - matches = [pr for pr in ctx.github.get_all(f"/repos/{ctx.repository}/pulls?state=open") - if str((pr.get("head") or {}).get("sha")) == sha] - if len(matches) != 1: - raise GateError(f"{len(matches)} open PRs have head {sha[:12]}; the group cannot be attributed") - return ctx.github.get(f"/repos/{ctx.repository}/pulls/{int(matches[0]['number'])}") - - -def verify_merge_group(ctx: Context, head_sha: str, *, base_ref: str = "main", - remote: str = "origin") -> list[str]: - """Every segment of the group, each against its own first parent.""" - ctx.git.run("fetch", "--quiet", "--no-tags", remote, f"+refs/heads/{base_ref}:refs/kb-gate/base") - main_tip = ctx.git.rev("refs/kb-gate/base") - if not ctx.git.is_ancestor(main_tip, head_sha): - return [f"the merge group {head_sha[:12]} is not built on the current {base_ref}"] - chain = ctx.git.first_parent_chain(main_tip, head_sha) - if not chain or chain[-1] != head_sha: - return ["the merge group has no segments"] - problems: list[str] = [] - any_knowledge = False - for commit in chain: - parents = ctx.git.parents(commit) - if len(parents) != 2: - return [f"segment {commit[:12]} is not a two-parent merge commit; the group cannot be attributed"] - first, second = parents - if not touches_knowledge(ctx.git.raw_diff(first, commit)): - continue - any_knowledge = True - if ctx.public_key is None: - return [".github/kb-gate.pub is not committed: knowledge changes cannot be verified"] - try: - pr = _pr_for_head(ctx, second) - except GateError as exc: - return [str(exc)] - found = verify_change(ctx, pr=pr, head_sha=second, pre=first, post=commit, final=head_sha, - effective_base=first, stage="merge_group") - problems.extend(f"PR #{pr['number']}: {p}" for p in found) - if any_knowledge: - problems.extend(tree_problems(ctx.git, main_tip, head_sha)) - return problems - - -# -- entry point ---------------------------------------------------------------------- - -def _load_context(args, github) -> Context: - root = Path(args.root) - config_path = Path(__file__).resolve().parent / "config.json" - config = json.loads(config_path.read_text()) if config_path.is_file() else {} - pub = root / ".github" / "kb-gate.pub" - public_key = load_public_key(pub.read_text()) if pub.is_file() else None - codeowners_path = root / ".github" / "CODEOWNERS" - holds_url = str(config.get("holds_url") or "") - return Context( - git=Git(root), github=github, repository=args.repository, public_key=public_key, - holds_loader=lambda: fetch_holds(holds_url), - codeowners=parse_codeowners(codeowners_path.read_text() if codeowners_path.is_file() else ""), - now=time.time(), - ) - - -def main(argv: list[str] | None = None, *, github=None) -> int: - parser = argparse.ArgumentParser(prog="kb-gate") - sub = parser.add_subparsers(dest="stage", required=True) - pr_stage = sub.add_parser("pr") - pr_stage.add_argument("--pr", type=int, required=True) - pr_stage.add_argument("--post-status", action="store_true") - pr_stage.add_argument("--target-url", default="") - group = sub.add_parser("merge-group") - group.add_argument("--head-sha", required=True) - for stage in (pr_stage, group): - stage.add_argument("--repository", required=True) - stage.add_argument("--root", default=".") - stage.add_argument("--base-ref", default="main") - args = parser.parse_args(argv) - github = github or GitHub(args.repository, os.environ.get("GITHUB_TOKEN", ""), - os.environ.get("GITHUB_API_URL", "https://api.github.com")) - ctx = _load_context(args, github) - head = "" - try: - if args.stage == "pr": - head = str(github.get(f"/repos/{args.repository}/pulls/{args.pr}")["head"]["sha"]) - head, problems = verify_pr(ctx, args.pr, base_ref=args.base_ref) - else: - problems = verify_merge_group(ctx, args.head_sha, base_ref=args.base_ref) - except (GateError, SignatureError, VerdictError, LifecycleError, KeyError, TypeError, ValueError) as exc: - problems = [f"verifier error: {exc}"] - for problem in problems: - print(f"kb-gate: {problem}") - print("kb-gate: " + ("FAIL" if problems else "PASS")) - if args.stage == "pr" and args.post_status and head: - description = (problems[0] if problems else "knowledge verdict verified")[:139] - github.post(f"/repos/{args.repository}/statuses/{head}", { - "state": "failure" if problems else "success", "context": STATUS_CONTEXT, - "description": description, **({"target_url": args.target_url} if args.target_url else {})}) - return 0 - return 1 if problems else 0 - - -if __name__ == "__main__": # pragma: no cover - sys.exit(main()) diff --git a/src/infermatrix_copilot/knowledge_service/l1.py b/src/infermatrix_copilot/knowledge_service/l1.py index 75d3092c..bb0bd49a 100644 --- a/src/infermatrix_copilot/knowledge_service/l1.py +++ b/src/infermatrix_copilot/knowledge_service/l1.py @@ -1,7 +1,7 @@ """L1: the deterministic half of the knowledge quality gate. Two entry points, both pure functions over ``{knowledge-relative path: text}`` -mappings so the service, the gate and the vendored verifier agree exactly: +mappings so the service's gate and the publisher's local gate agree exactly: * ``check_tree(files)`` — structural issues in one tree (footers, supersede links, tombstones, ``_routes.yaml``). @@ -18,7 +18,7 @@ pages, tombstones. Recomputed here; a mismatch is an issue, never a block a verdict could cover. -Standard library + PyYAML + ``lifecycle``/``ops`` only (vendored by kb-gate). +Standard library + PyYAML + ``lifecycle``/``ops`` only. """ from __future__ import annotations @@ -32,22 +32,11 @@ import yaml -try: - from .lifecycle import ( - FRONTMATTER, LifecycleError, Page, Section, expected_sources, - ) - from .ops import ( - INDEX_NAME, TOMBSTONES_NAME, all_rule_ids, all_tombstoned_ids, - load_tombstones, page_over_capacity, render_tombstones, - ) -except ImportError: # pragma: no cover - vendored verifier bundle - from lifecycle import ( # type: ignore[no-redef] - FRONTMATTER, LifecycleError, Page, Section, expected_sources, - ) - from ops import ( # type: ignore[no-redef] - INDEX_NAME, TOMBSTONES_NAME, all_rule_ids, all_tombstoned_ids, - load_tombstones, page_over_capacity, render_tombstones, - ) +from .lifecycle import LifecycleError, Page, Section, expected_sources +from .ops import ( + INDEX_NAME, TOMBSTONES_NAME, all_rule_ids, all_tombstoned_ids, + load_tombstones, page_over_capacity, render_tombstones, +) ROUTES_NAME = "_routes.yaml" KNOWLEDGE_PREFIX = "knowledge/" diff --git a/src/infermatrix_copilot/knowledge_service/lifecycle.py b/src/infermatrix_copilot/knowledge_service/lifecycle.py index 242d9410..0f05f728 100644 --- a/src/infermatrix_copilot/knowledge_service/lifecycle.py +++ b/src/infermatrix_copilot/knowledge_service/lifecycle.py @@ -15,7 +15,7 @@ bytes, so an operation changes only the sections it targets. This module needs only the standard library and PyYAML and imports nothing -else from the package: the ``kb-gate`` verifier bundle vendors it verbatim. +else from the package. """ from __future__ import annotations diff --git a/src/infermatrix_copilot/knowledge_service/ops.py b/src/infermatrix_copilot/knowledge_service/ops.py index 183cde9d..9ab8ca26 100644 --- a/src/infermatrix_copilot/knowledge_service/ops.py +++ b/src/infermatrix_copilot/knowledge_service/ops.py @@ -21,7 +21,7 @@ verifier later recomputes: ``updated:``, ``sources:`` (append-only), an index line for every new page, and tombstones for purged IDs. -Standard library + PyYAML + ``lifecycle`` only: vendored by the verifier bundle. +Standard library + PyYAML + ``lifecycle`` only. """ from __future__ import annotations @@ -33,16 +33,9 @@ import yaml -try: # package import; the verifier bundle vendors both modules side by side - from .lifecycle import ( - ANY_RULE_HEADING, RETIRE_REASONS, RULE_HEADING, Footer, LifecycleError, - Page, Section, expected_sources, - ) -except ImportError: # pragma: no cover - exercised by the vendored bundle - from lifecycle import ( # type: ignore[no-redef] - ANY_RULE_HEADING, RETIRE_REASONS, RULE_HEADING, Footer, LifecycleError, - Page, Section, expected_sources, - ) +from .lifecycle import ( + ANY_RULE_HEADING, RETIRE_REASONS, Footer, LifecycleError, Page, Section, expected_sources, +) KNOWLEDGE_OPS_API_VERSION = "2.0.0" OP_KINDS = ("add", "edit_same_meaning", "replace", "retire", "purge") @@ -127,7 +120,7 @@ def load_tombstones(text: str | None) -> list[dict]: def render_tombstones(items: list[dict]) -> str: lines = [ "# IDs of purged knowledge rules. An ID listed here is never reused.", - "# Written by the knowledge service; recomputed by the kb-gate verifier.", + "# Written by the knowledge service; recomputed by the publisher's local gate.", "schema_version: 1", "ids:", ] diff --git a/src/infermatrix_copilot/knowledge_service/signing.py b/src/infermatrix_copilot/knowledge_service/signing.py index 454b1a72..3f68a744 100644 --- a/src/infermatrix_copilot/knowledge_service/signing.py +++ b/src/infermatrix_copilot/knowledge_service/signing.py @@ -5,8 +5,7 @@ separators, UTF-8) prefixed with a purpose tag, so a verdict signature can never be replayed as an outbox item or a control record and vice versa. -Only ``cryptography`` beyond the standard library: the kb-gate verifier bundle -vendors this module and pins that dependency by hash. +Only ``cryptography`` beyond the standard library. """ from __future__ import annotations @@ -24,7 +23,7 @@ Ed25519PrivateKey, Ed25519PublicKey, ) -PURPOSES = ("kb-gate-verdict", "kb-outbox-item", "kb-control", "kb-holds", "kb-ack") +PURPOSES = ("kb-gate-verdict", "kb-outbox-item", "kb-control", "kb-ack") class SignatureError(ValueError): @@ -47,7 +46,7 @@ def public_key_id(public: Ed25519PublicKey) -> str: def public_key_text(public: Ed25519PublicKey) -> str: - """The one-line form committed as ``.github/kb-gate.pub``.""" + """The one-line form handed to the publisher (the file ``KB_SERVICE_PUBKEY`` names).""" raw = public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) return "ed25519 " + base64.b64encode(raw).decode("ascii") diff --git a/src/infermatrix_copilot/knowledge_service/verdict.py b/src/infermatrix_copilot/knowledge_service/verdict.py index 14b2fddb..aa53b908 100644 --- a/src/infermatrix_copilot/knowledge_service/verdict.py +++ b/src/infermatrix_copilot/knowledge_service/verdict.py @@ -1,4 +1,4 @@ -"""The kb-gate verdict: what the knowledge service signs and the verifier checks. +"""The gate verdict: what the knowledge service signs and the publisher's local gate checks. A verdict binds a decision to ONE pull request state: @@ -6,15 +6,15 @@ 72-hour issue window (it must first verify within it); * the complete patch manifest: every changed path with change type, pre-image and post-image git blob IDs and file modes, compared independently of any - base SHA (so a queued PR is checked against what the queue actually applies); + base SHA (so it is checked against what the merge actually applies); * the per-block judgements for every rule and prose block L1 produced, the change-set consistency judgement with the page hashes it saw, and the context base those judgements were made against; * upstream fact attestations (the verifier re-checks public upstreams); -* who decided: ``auto`` (L1 + L2 + consistency) or ``human-approved`` (with the - approving review IDs and reviewers). +* who decided: always ``auto`` (L1 + L2 + consistency); there is no + human-approved path (design v8). -Standard library only (plus ``signing`` for the envelope): vendored by kb-gate. +Standard library only (plus ``signing`` for the envelope). """ from __future__ import annotations @@ -27,7 +27,7 @@ SCHEMA = "kb-gate-verdict/1" ISSUE_WINDOW_SECONDS = 72 * 3600 -SOURCES = ("auto", "human-approved") +SOURCES = ("auto",) _SHA40 = re.compile(r"[0-9a-f]{40}") @@ -91,14 +91,11 @@ def canonical_manifest(entries: Iterable[Mapping[str, Any]]) -> list[dict]: def build_verdict(*, repository: str, pr: int, head_sha: str, context_base_sha: str, manifest: list[dict], blocks: list[dict], consistency: list[dict], release: str, upstream: dict, facts: list[dict], models: dict, - source: str = "auto", review_ids: tuple[int, ...] = (), - reviewers: tuple[str, ...] = (), issued_at: float) -> dict: + source: str = "auto", issued_at: float) -> dict: if source not in SOURCES: raise VerdictError(f"source must be one of {SOURCES}") if not _SHA40.fullmatch(head_sha) or not _SHA40.fullmatch(context_base_sha): raise VerdictError("head_sha and context_base_sha must be 40-character hex SHAs") - if source == "human-approved" and not (review_ids and reviewers): - raise VerdictError("a human-approved verdict names its approving reviews") for block in blocks: if block.get("verdict") != "pass": raise VerdictError(f"only passing blocks are signed: {block.get('block_id')}") @@ -115,8 +112,6 @@ def build_verdict(*, repository: str, pr: int, head_sha: str, context_base_sha: "valid_until": float(issued_at) + ISSUE_WINDOW_SECONDS, "nonce": secrets.token_hex(16), "source": source, - "review_ids": [int(value) for value in review_ids], - "reviewers": list(reviewers), "release": release, "upstream": upstream, "manifest": canonical_manifest(manifest), diff --git a/test/test_kb_companion.py b/test/test_kb_companion.py index 4e526e4b..c995bcc5 100644 --- a/test/test_kb_companion.py +++ b/test/test_kb_companion.py @@ -115,13 +115,11 @@ def test_the_publisher_keeps_companions_to_their_whitelist_and_never_merges_them assert gh.prs[number]["isDraft"] is True and "kb:companion" in gh.prs[number]["labels"] head = gh.prs[number]["headRefOid"] rt.outbox.refresh_control() - rt.outbox.issue("demo", "enqueue", {"changeset_id": "cok", "pr": number, "head_sha": head}) - rt.outbox.issue("demo", "post_verdict", {"changeset_id": "cok", "pr": number, "head_sha": head, - "comment": "x"}) - assert pub.run_once()["failed"] == 2 - assert gh.prs[number]["isDraft"] is True and not gh.prs[number]["queued"] + rt.outbox.issue("demo", "merge", {"changeset_id": "cok", "pr": number, "head_sha": head}) + assert pub.run_once()["failed"] == 1 + assert gh.prs[number]["isDraft"] is True and gh.prs[number]["state"] == "OPEN" acks = [json.loads(p.read_text())["payload"] for p in (tmp_path / "state" / "inbox" / "acks").glob("*.json")] - assert all("only by people" in a["error"] for a in acks if a["kind"] in ("enqueue", "post_verdict")) + assert all("only by people" in a["error"] for a in acks if a["kind"] == "merge") def test_a_rebuild_that_fails_to_fetch_is_retried_and_the_companion_stays_open(tmp_path): @@ -182,8 +180,8 @@ def test_an_unlabelled_companion_is_still_never_merged_by_automation(tmp_path): "queued": False, "body": ""} for path in list((tmp_path / "state" / "outbox").glob("[0-9]*.json")): path.unlink() - rt.outbox.issue("demo", "enqueue", {"changeset_id": "x", "pr": number, "head_sha": "e" * 40}) - assert pub.run_once()["failed"] == 1 and gh.prs[number]["isDraft"] and not gh.prs[number]["queued"] + rt.outbox.issue("demo", "merge", {"changeset_id": "x", "pr": number, "head_sha": "e" * 40}) + assert pub.run_once()["failed"] == 1 and gh.prs[number]["isDraft"] and gh.prs[number]["state"] == "OPEN" def _companion_open(tmp_path, rt, lifecycle, publisher, knowledge_id, pr): @@ -205,11 +203,10 @@ def test_a_companion_merged_during_a_pause_is_rebuilt_after_resume(tmp_path): collect_events(rt, lifecycle) knowledge_id = run_intake(rt, lifecycle) companion_id = _companion_open(tmp_path, rt, lifecycle, publisher, knowledge_id, 53) - rt.outbox.transition(lambda: rt.ledger.bump_generation("demo", pause=True, reason="drill"), - public_repos={"demo"}) + rt.outbox.transition(lambda: rt.ledger.bump_generation("demo", pause=True, reason="drill")) assert merge.advance(rt, lifecycle) == [] assert rt.ledger.changeset(knowledge_id)["status"] == "companion_pending" - rt.outbox.transition(lambda: rt.ledger.resume("demo"), public_repos={"demo"}) + rt.outbox.transition(lambda: rt.ledger.resume("demo")) events = merge.advance(rt, lifecycle) assert any(e.startswith(f"rebuilt {knowledge_id}") or e == f"rebuild_failed {knowledge_id}" for e in events) assert rt.ledger.changeset(companion_id)["status"] == "merged" diff --git a/test/test_kb_external.py b/test/test_kb_external.py index bcbf7d1c..98261eb0 100644 --- a/test/test_kb_external.py +++ b/test/test_kb_external.py @@ -1,8 +1,7 @@ -"""Knowledge PRs the service did not open: auto gate, human approval, people.""" +"""Knowledge PRs the service did not open: the auto gate, findings, people.""" from __future__ import annotations -import json import subprocess import uuid @@ -24,17 +23,11 @@ class PullsGitHub(KnowledgeGitHub): def __init__(self): super().__init__() self.open: dict[int, dict] = {} - self.reviews: dict[int, list[dict]] = {} def _answer(self, url): path = url.split("?", 1)[0] if path.endswith(f"/repos/{REPO}/pulls"): return list(self.open.values()) if "page=1" in url else [] - if "/reviews" in path: - reviews = self.reviews.get(int(path.split("/pulls/")[1].split("/")[0]), []) - from urllib.parse import parse_qs, urlsplit - page = int(parse_qs(urlsplit(url).query).get("page", ["1"])[0]) - return reviews[(page - 1) * 100: page * 100] return super()._answer(url) @@ -44,12 +37,7 @@ def _setup(tmp_path): github.prs = rt.github.prs rt.github = github rt.lease_owner = rt.ledger.acquire_lease("scheduler") - origin = tmp_path / "origin" - (origin / ".github").mkdir(exist_ok=True) - (origin / ".github" / "CODEOWNERS").write_text("/.github/ @alice\n") - _git(origin, "add", "-A") - _git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "owners") - return rt, lifecycle, origin + return rt, lifecycle, tmp_path / "origin" def _open_human_pr(rt, origin, number, files: dict[str, str | None], *, labels=(), draft=False): @@ -110,41 +98,20 @@ def test_a_human_knowledge_pr_passes_the_auto_gate_and_gets_a_verdict(tmp_path): assert verdict["blocks"] and all(b["verdict"] == "pass" for b in verdict["blocks"]) -def test_paths_outside_the_governed_pages_need_a_maintainer(tmp_path): +def test_paths_outside_the_governed_pages_are_never_merged(tmp_path): rt, lifecycle, origin = _setup(tmp_path) - head = _open_human_pr(rt, origin, 8, {"knowledge/tools/check.md": "tool notes\n"}) + _open_human_pr(rt, origin, 8, {"knowledge/tools/check.md": "tool notes\n"}) (event,) = _daily(rt) assert event.endswith("to people") assert rt.ledger.human_queue("demo") == [] # the author is told, not people's queue - assert "kb:human-approved" in _findings(tmp_path, 8)[-1] and "not passed" in _findings(tmp_path, 8)[-1] - assert len(_daily(rt)) == 1 # not passed: checked again the next day - # a maintainer approves the CURRENT head and labels it: a human-approved verdict - rt.github.open[8]["labels"] = [{"name": "kb:human-approved"}] - rt.github.reviews[8] = [{"id": 99, "state": "APPROVED", "commit_id": head, "user": {"login": "alice"}}] - head2 = _open_human_pr(rt, origin, 8, {"knowledge/tools/check.md": "tool notes v2\n"}, - labels=("kb:human-approved",)) - rt.github.reviews[8] = [{"id": 99, "state": "APPROVED", "commit_id": head, "user": {"login": "alice"}}] - assert _daily(rt) == [] # approval is for the old head: wait - rt.github.reviews[8].append({"id": 100, "state": "APPROVED", "commit_id": head2, "user": {"login": "alice"}}) - (event,) = _daily(rt) - assert "human-approved pr_open" in event - (changeset,) = [cs for cs in rt.ledger.changesets("demo", ("pr_open",)) if cs["kind"] == "external"] + findings = _findings(tmp_path, 8)[-1] + assert "not passed" in findings and "knowledge/tools/check.md" in findings and "split" in findings + assert "human-approved" not in findings + rt.github.open[8]["labels"] = [{"name": "kb:human-approved"}] # a label changes nothing any more + (event,) = _daily(rt) # not passed: checked again the next day + assert event.endswith("to people") merge.advance(rt, lifecycle) - verdict = _verdict(tmp_path, rt) - assert verdict["source"] == "human-approved" and verdict["review_ids"] == [100] - assert verdict["reviewers"] == ["alice"] and verdict["blocks"] == [] - assert changeset["head_sha"] == head2 - - -def test_approvals_by_non_maintainers_or_later_retracted_do_not_count(tmp_path): - rt, lifecycle, origin = _setup(tmp_path) - head = _open_human_pr(rt, origin, 9, {"knowledge/tools/x.md": "x\n"}, labels=("kb:human-approved",)) - rt.github.reviews[9] = [{"id": 1, "state": "APPROVED", "commit_id": head, "user": {"login": "mallory"}}, - {"id": 2, "state": "APPROVED", "commit_id": head, "user": {"login": "alice"}}, - {"id": 3, "state": "DISMISSED", "commit_id": head, "user": {"login": "alice"}}] - assert _daily(rt) == [] - - + assert not _items(tmp_path, "merge") def test_a_pr_branched_before_main_changed_the_same_page_must_be_rebased(tmp_path): rt, lifecycle, origin = _setup(tmp_path) _open_human_pr(rt, origin, 10, _add_rule_files()) @@ -199,35 +166,13 @@ def _local_gate(tmp_path, rt, number): def test_verdicts_for_external_prs_pass_the_publishers_local_gate(tmp_path): - import pytest - - from infermatrix_copilot.knowledge_service.verdict import VerdictError - rt, lifecycle, origin = _setup(tmp_path) rt.clock = __import__("time").time # verdict windows are checked against the real clock _open_human_pr(rt, origin, 7, _add_rule_files()) _daily(rt) merge.advance(rt, lifecycle) assert _local_gate(tmp_path, rt, 7) == [] - - head = _open_human_pr(rt, origin, 8, {"knowledge/tools/check.md": "tool notes\n"}, labels=("kb:human-approved",)) - rt.github.reviews[8] = [{"id": 100, "state": "APPROVED", "commit_id": head, "user": {"login": "alice"}}] assert [e for e in _daily(rt) if "PR #7" in e and "findings" not in e] == [] # not judged again - rt.ledger.update_changeset(rt.ledger.changesets("demo", ("merge_requested",))[0]["id"], status="merged") - merge.advance(rt, lifecycle) - with pytest.raises(VerdictError, match="only auto verdicts merge"): - _local_gate(tmp_path, rt, 8) # v8: a human-approved verdict never merges - - -def test_a_retraction_on_a_later_page_of_reviews_is_seen(tmp_path): - rt, lifecycle, origin = _setup(tmp_path) - head = _open_human_pr(rt, origin, 20, {"knowledge/tools/x.md": "x\n"}, labels=("kb:human-approved",)) - noise = [{"id": 1000 + i, "state": "COMMENTED", "commit_id": head, "user": {"login": f"u{i}"}} - for i in range(120)] - rt.github.reviews[20] = [{"id": 1, "state": "APPROVED", "commit_id": head, "user": {"login": "alice"}}, - *noise, - {"id": 2, "state": "CHANGES_REQUESTED", "commit_id": head, "user": {"login": "alice"}}] - assert _daily(rt) == [] def test_an_auto_verdict_needs_a_current_judge_calibration(tmp_path): @@ -246,19 +191,6 @@ def test_an_auto_verdict_needs_a_current_judge_calibration(tmp_path): assert event.endswith("auto pr_open") -def test_a_head_sent_to_people_can_be_approved_without_a_new_push(tmp_path): - rt, lifecycle, origin = _setup(tmp_path) - head = _open_human_pr(rt, origin, 30, {"knowledge/tools/y.md": "y\n"}) - (event,) = _daily(rt) - assert event.endswith("to people") - assert len(_daily(rt)) == 1 # still not passing: checked again the next day - rt.github.open[30]["labels"] = [{"name": "kb:human-approved"}] - rt.github.reviews[30] = [{"id": 7, "state": "APPROVED", "commit_id": head, "user": {"login": "alice"}}] - (event,) = _daily(rt) - assert "human-approved pr_open" in event - assert _daily(rt) == [] - - def test_rules_an_external_pr_retires_enter_the_retirement_ledger(tmp_path): rt, lifecycle, origin = _setup(tmp_path) (origin / "skills" / "x.md").write_text("no citations here\n") # else it needs a companion PR @@ -272,7 +204,7 @@ def test_rules_an_external_pr_retires_enter_the_retirement_ledger(tmp_path): assert "auto pr_open" in events[0], (events, rt.ledger.human_queue("demo")) (changeset,) = [cs for cs in rt.ledger.changesets("demo", ("pr_open",)) if cs["kind"] == "external"] assert changeset["detail"]["retirements"] == [{"rule_id": "DEMO-1a", "page": PAGE}] - rt.ledger.update_changeset(changeset["id"], status="verdict_posted") + rt.ledger.update_changeset(changeset["id"], status="merge_requested") rt.github.prs[41] = {"state": "closed", "merged": True, "merge_commit_sha": "f" * 40, "head": {"sha": changeset["head_sha"]}} merge.advance(rt, lifecycle) diff --git a/test/test_kb_flow.py b/test/test_kb_flow.py index 1dfdc6d5..682b3732 100644 --- a/test/test_kb_flow.py +++ b/test/test_kb_flow.py @@ -215,7 +215,7 @@ def test_a_receipt_arriving_after_github_showed_the_merge_still_counts(tmp_path) def test_merged_retirements_become_purge_candidates_next_release(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) cs = rt.ledger.new_changeset_id("demo", "sweep") - rt.ledger.stage_intake(rt.ledger.acquire_lease("t"), "demo", cs, kind="sweep", status="queued", + rt.ledger.stage_intake(rt.ledger.acquire_lease("t"), "demo", cs, kind="sweep", status="merge_requested", verdicts=[], human_reason="", drafted_events=[], detail={"release": "v1", "operations": [ {"kind": "retire", "page": PAGE, "rule_id": "DEMO-1a"}]}) @@ -388,7 +388,7 @@ def test_scheduler_tick_runs_intake_and_signs_control(tmp_path): assert any(e["event"] == "intake" for e in scheduler.log) -def test_overturn_breaker_pauses_and_dequeues(tmp_path): +def test_overturn_breaker_pauses_and_signs_nothing_more(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) owner = rt.ledger.acquire_lease("t") for n in (1, 2): @@ -396,15 +396,14 @@ def test_overturn_breaker_pauses_and_dequeues(tmp_path): rt.ledger.stage_intake(owner, "demo", cs, detail={}, status="closed", verdicts=[], human_reason="", drafted_events=[]) open_cs = rt.ledger.new_changeset_id("demo", "intake") - rt.ledger.stage_intake(owner, "demo", open_cs, detail={}, status="queued", verdicts=[], + rt.ledger.stage_intake(owner, "demo", open_cs, detail={}, status="pr_open", verdicts=[], human_reason="", drafted_events=[]) rt.ledger.update_changeset(open_cs, pr_number=77, head_sha="a" * 40) rt.ledger.release_lease(owner) Scheduler(rt)._overturn_breaker(lifecycle) assert rt.ledger.repo_state("demo")["paused"] == 1 - assert _items(tmp_path, "pause")[0]["body"]["pr"] == 77 - - + rt.github.prs[77] = {"state": "open", "merged": False, "head": {"sha": "a" * 40}} + assert merge.advance(rt, lifecycle) == [] and not _items(tmp_path, "merge") def test_scheduler_isolates_a_failing_repository(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) broken = replace(lifecycle, repo="broken", full_name="org/broken", knowledge_dir="repos/broken") @@ -476,8 +475,7 @@ def test_overturn_breaker_publishes_the_pause_at_once(tmp_path): Scheduler(rt)._overturn_breaker(lifecycle) public = load_public_key(public_key_text(rt.outbox._key.public_key())) control = verify("kb-control", json.loads((tmp_path / "state" / "outbox" / "control.json").read_text()), public) - holds = verify("kb-holds", json.loads((tmp_path / "state" / "public" / "holds.json").read_text()), public) - assert control["repos"]["demo"]["paused"] is True and holds["repos"] == ["demo"] + assert control["repos"]["demo"]["paused"] is True def test_scheduler_does_not_undo_a_rollback_until_main_moves(tmp_path): @@ -647,20 +645,15 @@ def do_rollback(): -def test_resume_returns_paused_prs_to_re_signing(tmp_path): +def test_resume_signs_a_fresh_verdict(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) changeset_id, publisher = _open_pr(tmp_path, rt, lifecycle) _ack(tmp_path, rt, publisher, kind="open_pr", changeset_id=changeset_id, ok=True, pr=42, head_sha="d" * 40) rt.ledger.bump_generation("demo", pause=True, reason="drill") - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True, item_id="p1") - assert rt.ledger.changeset(changeset_id)["status"] == "paused" - rt.ledger.resume("demo") - merge.resume_paused_prs(rt.ledger, "demo") - assert rt.ledger.changeset(changeset_id)["status"] == "pr_open" rt.github.prs[42] = {"state": "open", "merged": False, "head": {"sha": "d" * 40}} + assert merge.advance(rt, lifecycle) == [] # no verdict while paused + rt.ledger.resume("demo") assert merge.advance(rt, lifecycle) == [f"merge issued {changeset_id}"] # fresh verdict - - def test_gated_change_sets_are_published_after_an_interruption(tmp_path): def answer(role, prompt): if role.name == "generator": @@ -684,77 +677,14 @@ def answer(role, prompt): -def test_a_pause_ack_after_resume_sends_the_pr_back_to_re_signing(tmp_path): - rt, lifecycle = _flow_runtime(tmp_path) - changeset_id, publisher = _open_pr(tmp_path, rt, lifecycle) - _ack(tmp_path, rt, publisher, kind="open_pr", changeset_id=changeset_id, ok=True, pr=42, head_sha="d" * 40) - rt.ledger.bump_generation("demo", pause=True, reason="drill") - merge.pause_open_prs(rt.ledger, rt.outbox, "demo", "drill") - rt.ledger.resume("demo") - merge.resume_paused_prs(rt.ledger, "demo") # nothing paused yet: the ack is late - assert rt.ledger.changeset(changeset_id)["status"] == "pr_open" - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True, item_id="late") - assert rt.ledger.changeset(changeset_id)["status"] == "pr_open" # not stranded as paused - rt.github.prs[42] = {"state": "open", "merged": False, "head": {"sha": "d" * 40}} - assert merge.advance(rt, lifecycle) == [f"merge issued {changeset_id}"] - - -def test_global_pause_is_respected_by_acks_advance_and_publish(tmp_path): +def test_global_pause_is_respected_by_advance(tmp_path): rt, lifecycle = _flow_runtime(tmp_path) changeset_id, publisher = _open_pr(tmp_path, rt, lifecycle) _ack(tmp_path, rt, publisher, kind="open_pr", changeset_id=changeset_id, ok=True, pr=42, head_sha="d" * 40) rt.ledger.bump_generation("*", pause=True, reason="all stop") - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True, item_id="g") - assert rt.ledger.changeset(changeset_id)["status"] == "paused" # not sent back to re-signing - rt.ledger.update_changeset(changeset_id, status="pr_open") rt.github.prs[42] = {"state": "open", "merged": False, "head": {"sha": "d" * 40}} assert merge.advance(rt, lifecycle) == [] # no verdict while paused - - -def test_pause_ack_waits_for_a_resume_holding_the_publication_lock(tmp_path): - import threading - - rt, lifecycle = _flow_runtime(tmp_path) - changeset_id, publisher = _open_pr(tmp_path, rt, lifecycle) - _ack(tmp_path, rt, publisher, kind="open_pr", changeset_id=changeset_id, ok=True, pr=42, head_sha="d" * 40) - rt.ledger.bump_generation("demo", pause=True, reason="drill") - ack = {"item_id": "p", "kind": "pause", "changeset_id": changeset_id, "ok": True} - holding, release, errors = threading.Event(), threading.Event(), [] - - def resume_holding_lock(): # what `kb resume` does, in another "process" - from infermatrix_copilot.kb_service.ledger import Ledger - try: - other = Ledger(rt.ledger.path) - with rt.outbox.publication_lock(): - holding.set() - release.wait(5) - other.resume("demo") - merge.resume_paused_prs(other, "demo") - except BaseException as exc: # pragma: no cover - errors.append(exc) - - def apply_ack(): - from dataclasses import replace as dc_replace - from infermatrix_copilot.kb_service.ledger import Ledger - try: - merge.apply_acks(dc_replace(rt, ledger=Ledger(rt.ledger.path)), [ack]) - except BaseException as exc: # pragma: no cover - errors.append(exc) - - resumer = threading.Thread(target=resume_holding_lock) - resumer.start() - assert holding.wait(5) - acker = threading.Thread(target=apply_ack) - acker.start() - acker.join(0.5) - assert acker.is_alive() # the ack waits for the resume's lock - release.set() - resumer.join(5) - acker.join(5) - assert not errors, errors - assert rt.ledger.changeset(changeset_id)["status"] == "pr_open" # never stranded as paused - - + assert rt.ledger.changeset(changeset_id)["status"] == "pr_open" def test_prune_keeps_snapshots_pinned_by_unfinished_runs(tmp_path): import json import os diff --git a/test/test_kb_gate_verifier.py b/test/test_kb_gate_verifier.py deleted file mode 100644 index acbc6109..00000000 --- a/test/test_kb_gate_verifier.py +++ /dev/null @@ -1,486 +0,0 @@ -"""The kb-gate verifier (PR precheck and merge group) and its pinned bundle, offline.""" - -from __future__ import annotations - -import ast -import json -import os -import subprocess -import sys -import time -from pathlib import Path - -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from infermatrix_copilot.kb_service.gate import changes_between, run_gate, signable_blocks -from infermatrix_copilot.kb_service.models import ModelReply, ModelRole -from infermatrix_copilot.knowledge_service import gate_verifier as gv -from infermatrix_copilot.knowledge_service.ops import KnowledgeOperation as Op, apply_operations -from infermatrix_copilot.knowledge_service.signing import public_key_text, sign -from infermatrix_copilot.knowledge_service.verdict import build_verdict, manifest_from_files - -ROOT = Path(__file__).resolve().parents[1] -REPO = "acme/kb" -PAGE = "repos/demo/core/rules.md" -JUDGE = ModelRole("judge", "codex", "gpt-6-sol", "medium") -KEY = Ed25519PrivateKey.generate() - - -def _rule(rule_id: str, cite: str = "PR #10") -> str: - return (f"## {rule_id} — keep the demo queue bounded\n\n" - "- 触发:修改 demo queue 的容量或背压逻辑时。\n" - "- 强制:队列满时拒绝新请求并返回明确错误,不能无界增长。\n" - f"- 验收:测试覆盖满队列拒绝路径。 ^[{cite}]\n") - - -def _tree() -> dict[str, str]: - return { - "repos/demo/_index.md": "# demo\n", - "repos/demo/core/_index.md": "# core\n\n- [rules](rules.md)\n", - "repos/demo/api/_index.md": "# api\n", - "repos/demo/_routes.yaml": ( - "schema_version: 1\nowners:\n - owner: core\n path: repos/demo/core/rules.md\n" - " signals: [queue]\n scope_prefixes: [demo/core/]\n"), - PAGE: ("---\ntitle: \"Demo core rules\"\ncreated: 2026-09-01\nupdated: 2026-09-01\n" - "type: rule\ntags: [demo]\nsources: [\"PR #10\"]\n---\n\n# Demo core rules\n\n" - + _rule("DEMO-1a")), - } - - -class _Gateway: - def call_json(self, role, *, system, prompt, validate=None): - if '"directory"' in prompt: - data = {"verdict": "consistent", "conflicts": []} - else: - dims = json.loads(prompt.split("<untrusted_data>\n", 1)[1].rsplit("\n</untrusted_data>", 1)[0]) - data = {"dimensions": {d: "yes" for d in dims["dimensions_to_answer"]}, "reasons": {}} - return ModelReply(role, data, json.dumps(data), role.model, {}, 0.1) - - -# -- a git "remote" with main and PR refs, and the verifier's clone ----------------- - -def _git(cwd: Path, *args: str, env: dict | None = None) -> str: - return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True, - env={**os.environ, **(env or {})}).stdout.strip() - - -def _write(repo: Path, files: dict[str, str | None], *, prefix: str = "knowledge/") -> None: - for rel, text in files.items(): - path = repo / (prefix + rel) - if text is None: - path.unlink() - continue - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _commit(repo: Path, message: str) -> str: - _git(repo, "add", "-A") - _git(repo, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "--allow-empty", "-m", message) - return _git(repo, "rev-parse", "HEAD") - - -class World: - """remote repo + verifier clone + a fake GitHub API.""" - - def __init__(self, tmp_path: Path): - self.remote = tmp_path / "remote" - self.remote.mkdir() - _git(self.remote, "init", "-q", "-b", "main") - _write(self.remote, _tree()) - (self.remote / "src").mkdir() - (self.remote / "src" / "app.py").write_text("x = 1\n") - self.main = _commit(self.remote, "base") - self.ws = tmp_path / "ws" - _git(tmp_path, "clone", "-q", str(self.remote), str(self.ws)) - self.prs: dict[int, dict] = {} - self.comments: dict[int, list[dict]] = {} - self.reviews: dict[tuple[int, int], dict] = {} - self.posts: list[tuple[str, dict]] = [] - self.holds = {"global": False, "repos": [], "prs": []} - self.holds_age = 0.0 - self.codeowners = "/.github/ @alice\n" - - # branches ---------------------------------------------------------------- - def branch(self, number: int, files: dict[str, str | None], *, code: dict[str, str] | None = None, - start: str | None = None) -> str: - _git(self.remote, "checkout", "-q", "-B", f"pr-{number}", start or self.main) - _write(self.remote, files) - if code: - _write(self.remote, code, prefix="") - head = _commit(self.remote, f"pr {number}") - _git(self.remote, "update-ref", f"refs/pull/{number}/head", head) - _git(self.remote, "checkout", "-q", "main") - self.prs[number] = {"number": number, "state": "open", "draft": False, "labels": [], - "head": {"sha": head}, "base": {"ref": "main"}} - return head - - def advance_main(self, files: dict[str, str | None], *, code: dict[str, str] | None = None) -> str: - _git(self.remote, "checkout", "-q", "main") - _write(self.remote, files) - if code: - _write(self.remote, code, prefix="") - self.main = _commit(self.remote, "main moves") - return self.main - - def merge_group(self, numbers: list[int]) -> str: - """gh-readonly-queue style: one merge commit per PR on top of main.""" - _git(self.remote, "checkout", "-q", "-B", "queue", self.main) - for number in numbers: - _git(self.remote, "-c", "user.name=q", "-c", "user.email=q@e", "merge", "-q", "--no-ff", - "-m", f"Merge pull request #{number}", self.prs[number]["head"]["sha"]) - head = _git(self.remote, "rev-parse", "HEAD") - _git(self.remote, "checkout", "-q", "main") - _git(self.ws, "fetch", "-q", "origin", f"+{head}:refs/queue/head") - return head - - # verdicts ---------------------------------------------------------------- - def verdict(self, number: int, head_files: dict[str, str], *, key=KEY, source="auto", pr=None, - head_sha=None, issued_at=None, mutate=None, **kw) -> dict: - base = _tree() - head = {**base, **head_files} - touched = set(head_files) - decision = run_gate(base=base, head=head, changes=changes_between(base, head), external_texts={}, - evidence=[], gateway=_Gateway(), judge=JUDGE, release="v1", repo_dir="repos/demo", - retire_ratio=1.0) - assert decision.status == "pass", decision.reasons - verdict = build_verdict( - repository=REPO, pr=pr or number, head_sha=head_sha or self.prs[number]["head"]["sha"], - context_base_sha=kw.pop("context_base_sha", self.main), - manifest=manifest_from_files({k: v for k, v in base.items() if k in touched}, - {k: v for k, v in head.items() if k in touched}), - blocks=[] if source != "auto" else signable_blocks(decision), - consistency=[] if source != "auto" else [ - {"owner_dir": c["owner_dir"], "verdict": c["verdict"], "pages": c["pages"]} - for c in decision.consistency], - release="v1", upstream={}, facts=[], models={}, source=source, - issued_at=time.time() - 5 if issued_at is None else issued_at, **kw) - if mutate: - mutate(verdict) - envelope = sign("kb-gate-verdict", verdict, key) - self.comments.setdefault(number, []).append( - {"body": f"{gv.VERDICT_MARKER}\n```json\n{json.dumps(envelope, ensure_ascii=False)}\n```\n"}) - return verdict - - # the fake GitHub API ----------------------------------------------------- - def get(self, path: str): - path = path.split("?", 1)[0] - parts = path.strip("/").split("/") - if parts[-2:] == ["comments"] or parts[-1] == "comments": - return self.comments.get(int(parts[-2]), []) - if len(parts) == 6 and parts[5] == "reviews": - return [r for (n, _id), r in sorted(self.reviews.items()) if n == int(parts[4])] - if len(parts) == 7 and parts[5] == "reviews": - return self.reviews[(int(parts[4]), int(parts[6]))] - if parts[-1] == "pulls": - return [pr for pr in self.prs.values() if pr["state"] == "open"] - if parts[-2] == "pulls": - return self.prs[int(parts[-1])] - raise AssertionError(path) - - def get_all(self, path: str, *, pages: int = 10): - return self.get(path) - - def post(self, path: str, body: dict): - self.posts.append((path, body)) - return {} - - def context(self) -> gv.Context: - payload = {"issued_at": time.time() - self.holds_age, "sequence": 1, **self.holds} - return gv.Context(git=gv.Git(self.ws), github=self, repository=REPO, public_key=KEY.public_key(), - holds_loader=lambda: sign("kb-holds", payload, KEY), - codeowners=gv.parse_codeowners(self.codeowners), now=time.time()) - - -def _add_rule() -> dict[str, str]: - base = _tree() - return apply_operations(base, [Op("add", PAGE, "DEMO-2a", _rule("DEMO-2a", "PR #11"))], - release="v1", today="2026-09-28").files - - -def _pr(world: World, ctx=None, number: int = 1) -> list[str]: - _head, problems = gv.verify_pr(ctx or world.context(), number) - return problems - - -@pytest.fixture -def world(tmp_path): - return World(tmp_path) - - -# -- PR stage ---------------------------------------------------------------------- - -def test_a_pr_that_does_not_touch_knowledge_passes_without_a_verdict(world): - world.branch(1, {}, code={"src/app.py": "x = 2\n"}) - assert _pr(world) == [] - - -def test_a_signed_auto_verdict_for_this_head_passes(world): - files = _add_rule() - world.branch(1, files) - world.verdict(1, files) - assert _pr(world) == [] - - -def test_a_knowledge_pr_without_a_valid_verdict_fails(world): - files = _add_rule() - world.branch(1, files) - assert any("no valid signed verdict" in p for p in _pr(world)) - # replayed from another PR, bound to another head, signed by another key, expired - world.verdict(1, files, pr=2) - world.verdict(1, files, head_sha="f" * 40) - world.verdict(1, files, key=Ed25519PrivateKey.generate()) - world.verdict(1, files, issued_at=time.time() - 73 * 3600) - problems = _pr(world) - assert len(problems) == 1 and "no valid signed verdict" in problems[0] - assert "another repository or pull request" in problems[0] - - -def test_a_verdict_whose_manifest_omits_a_change_fails(world): - files = _add_rule() - world.branch(1, files) - world.verdict(1, files, mutate=lambda v: v.update(manifest=[])) - assert "the signed patch manifest does not match the change as applied" in _pr(world) - - -def test_a_block_table_that_does_not_cover_the_change_fails(world): - files = _add_rule() - world.branch(1, files) - world.verdict(1, files, mutate=lambda v: v.update(blocks=v["blocks"][1:])) - assert "the signed block table does not cover exactly the changed blocks" in _pr(world) - - -def test_mixed_code_and_knowledge_and_executable_files_fail_even_with_a_verdict(world): - files = _add_rule() - world.branch(1, files, code={"src/app.py": "x = 3\n"}) - world.verdict(1, files) - problems = _pr(world) - assert "the signed patch manifest does not match the change as applied" in problems - assert any(p.startswith("L1 path_not_whitelisted src/app.py") for p in problems) - - world.branch(2, {}) - _git(world.remote, "checkout", "-q", "pr-2") - page = world.remote / "knowledge" / PAGE - page.write_text(page.read_text() + "\n") - page.chmod(0o755) - head = _commit(world.remote, "exec bit") - _git(world.remote, "update-ref", "refs/pull/2/head", head) - _git(world.remote, "checkout", "-q", "main") - world.prs[2]["head"]["sha"] = head - problems = _pr(world, number=2) - assert any(p.startswith("L1 ") and "100755" in p for p in problems), problems - - -def test_holds_labels_and_a_stale_or_missing_hold_list_fail_closed(world): - files = _add_rule() - world.branch(1, files) - world.verdict(1, files) - assert _pr(world) == [] - world.prs[1]["labels"] = [{"name": "kb:hold"}] - assert "PR #1 carries kb:hold" in _pr(world) - world.prs[1]["labels"] = [] - for holds, needle in (({"global": True}, "global pause"), ({"prs": [1]}, "hold list"), - ({"repos": ["demo"]}, "repository demo is paused")): - world.holds = {"global": False, "repos": [], "prs": [], **holds} - assert any(needle in p for p in _pr(world)), holds - world.holds = {"global": False, "repos": [], "prs": []} - world.holds_age = 11 * 60 - assert any("stale" in p for p in _pr(world)) - world.holds_age = 0 - ctx = world.context() - - def unreachable(): - raise gv.GateError("hold list unreachable: timeout") - ctx.holds_loader = unreachable - assert any("unreachable" in p for p in _pr(world, ctx)) - - -def test_a_context_change_since_judging_invalidates_the_verdict(world): - files = _add_rule() - world.branch(1, files) - world.verdict(1, files) - world.advance_main({}, code={"src/app.py": "x = 9\n"}) # unrelated: still fine - assert _pr(world) == [] - world.advance_main({"repos/demo/core/_index.md": "# core\n\n- [rules](rules.md)\n\nmore\n"}) - problems = _pr(world) - assert any("context changed since the verdict was judged: repos/demo/core/_index.md" in p for p in problems) - - -def test_the_status_is_posted_on_the_verified_head(world): - files = _add_rule() - head = world.branch(1, files) - world.verdict(1, files) - root = world.ws - (root / ".github").mkdir() - (root / ".github" / "kb-gate.pub").write_text(public_key_text(KEY.public_key()) + "\n") - original = gv.fetch_holds - payload = {"issued_at": time.time(), "sequence": 1, "global": False, "repos": [], "prs": []} - gv.fetch_holds = lambda url: sign("kb-holds", payload, KEY) - try: - assert gv.main(["pr", "--repository", REPO, "--pr", "1", "--root", str(root), "--post-status"], - github=world) == 0 - finally: - gv.fetch_holds = original - assert world.posts == [(f"/repos/{REPO}/statuses/{head}", - {"state": "success", "context": "kb-gate", "description": "knowledge verdict verified"})] - - -# -- human-approved ------------------------------------------------------------------ - -def test_a_human_approved_verdict_needs_a_live_maintainer_approval_on_this_head(world): - files = _add_rule() - head = world.branch(1, files) - world.verdict(1, files, source="human-approved", review_ids=(7,), reviewers=("alice",)) - world.prs[1]["labels"] = [{"name": "kb:human-approved"}] - world.reviews[(1, 7)] = {"state": "APPROVED", "commit_id": head, "user": {"login": "alice"}} - assert _pr(world) == [] - world.reviews[(1, 7)]["state"] = "DISMISSED" - assert "review 7 is no longer APPROVED" in _pr(world) - world.reviews[(1, 7)]["state"] = "APPROVED" - world.prs[1]["labels"] = [] - assert "kb:human-approved was removed" in _pr(world) - world.prs[1]["labels"] = [{"name": "kb:human-approved"}] - world.codeowners = "/.github/ @bob\n" - assert any("knowledge maintainer" in p for p in _pr(world)) - - -def test_a_human_approval_admits_paths_outside_the_auto_merge_whitelist(world): - tool = {"tools/check.py": "print('ok')\n"} - head = world.branch(1, tool) - manifest = manifest_from_files({}, tool) - world.verdict(1, {}, source="human-approved", review_ids=(7,), reviewers=("alice",), - mutate=lambda v: v.update(manifest=manifest)) - world.reviews[(1, 7)] = {"state": "APPROVED", "commit_id": head, "user": {"login": "alice"}} - world.prs[1]["labels"] = [{"name": "kb:human-approved"}] - assert _pr(world) == [] - world.verdict(1, {}, mutate=lambda v: v.update(manifest=manifest, source="auto")) - world.comments[1] = world.comments[1][1:] # only the auto verdict remains - assert any(p.startswith("L1 path_not_whitelisted knowledge/tools/check.py") for p in _pr(world)) - - -def test_a_human_approved_edit_of_a_non_governed_markdown_file_passes(world): - world.advance_main({"skills/demo/SKILL.md": "# demo skill\n"}) - edit = {"skills/demo/SKILL.md": "# demo skill\n\nmore\n"} - head = world.branch(1, edit) - manifest = manifest_from_files({"skills/demo/SKILL.md": "# demo skill\n"}, edit) - world.verdict(1, {}, source="human-approved", review_ids=(7,), reviewers=("alice",), - mutate=lambda v: v.update(manifest=manifest)) - world.reviews[(1, 7)] = {"state": "APPROVED", "commit_id": head, "user": {"login": "alice"}} - world.prs[1]["labels"] = [{"name": "kb:human-approved"}] - assert _pr(world) == [] - - -def test_a_hold_on_general_applies_to_general_pages(world): - world.branch(1, {"general/guide.md": "# guide\n"}) - world.holds["repos"] = ["general"] - assert "repository general is paused by the knowledge service" in _pr(world) - - -# -- merge group ------------------------------------------------------------------- - -def test_the_merge_group_verifies_every_knowledge_segment(world): - files = _add_rule() - world.branch(1, files) - world.branch(2, {}, code={"src/app.py": "x = 5\n"}) - world.verdict(1, files) - # knowledge first, code last: the knowledge segment is still verified - assert gv.verify_merge_group(world.context(), world.merge_group([1, 2])) == [] - assert gv.verify_merge_group(world.context(), world.merge_group([2, 1])) == [] - world.comments[1] = [] - problems = gv.verify_merge_group(world.context(), world.merge_group([1, 2])) - assert problems and all(p.startswith("PR #1: ") for p in problems) - - -def test_the_merge_group_rechecks_draft_and_hold_state(world): - files = _add_rule() - world.branch(1, files) - world.verdict(1, files) - group = world.merge_group([1]) - world.prs[1]["draft"] = True - assert "PR #1: PR #1 is a draft (paused)" in gv.verify_merge_group(world.context(), group) - world.prs[1]["draft"] = False - world.holds["prs"] = [1] - assert any("hold list" in p for p in gv.verify_merge_group(world.context(), group)) - - -def test_a_merge_group_segment_that_is_not_a_merge_commit_is_rejected(world): - files = _add_rule() - head = world.branch(1, files) - world.verdict(1, files) - _git(world.ws, "fetch", "-q", "origin", f"+{head}:refs/queue/head") # fast-forward "group" - problems = gv.verify_merge_group(world.context(), head) - assert problems == [f"segment {head[:12]} is not a two-parent merge commit; the group cannot be attributed"] - - -def test_a_later_queued_pr_in_the_same_owner_directory_invalidates_the_earlier_verdict(world): - files = _add_rule() - world.branch(1, files) - world.verdict(1, files) - other = {"repos/demo/core/_index.md": "# core\n\n- [rules](rules.md)\n\nnote\n"} - world.branch(3, other) - world.verdict(3, other) # judged on main; PR 1 lands in front of it in the group - problems = gv.verify_merge_group(world.context(), world.merge_group([1, 3])) - assert any(p.startswith("PR #3: ") and "context changed" in p for p in problems) - - -def test_a_rule_retired_in_front_of_a_pr_that_cites_it_fails_in_the_landing_tree(world): - base = _tree() - retire = apply_operations(base, [Op("retire", PAGE, "DEMO-1a", reason="upstream-removed", - evidence="PR #13")], release="v1", today="2026-09-28").files - text = _rule("DEMO-3a", "PR #12").replace("队列满时", "与 DEMO-1a 一致,队列满时") - cite = apply_operations(base, [Op("add", "repos/demo/api/rules.md", "DEMO-3a", text, - page_title="Demo api rules")], release="v1", today="2026-09-28").files - world.branch(1, retire) - world.branch(3, cite) - world.verdict(1, retire) - world.verdict(3, cite) - assert _pr(world, number=1) == [] and _pr(world, number=3) == [] # each is fine alone - for order in ([1, 3], [3, 1]): - problems = gv.verify_merge_group(world.context(), world.merge_group(order)) - assert any(p.startswith("PR #1: L1 dangling_reference repos/demo/api/rules.md DEMO-1a") - for p in problems), (order, problems) - - -# -- the pinned bundle ------------------------------------------------------------- - -def test_the_bundle_is_fresh(): - proc = subprocess.run([sys.executable, str(ROOT / "tools" / "build_kb_gate_bundle.py"), "--check"], - capture_output=True, text=True) - assert proc.returncode == 0, proc.stdout - - -def test_the_bundle_imports_only_the_stdlib_its_siblings_and_pinned_deps(): - bundle = ROOT / ".github" / "kb-gate" - siblings = {p.stem for p in bundle.glob("*.py")} - allowed = siblings | {"yaml", "cryptography"} | set(sys.stdlib_module_names) - for path in bundle.glob("*.py"): - for node in ast.walk(ast.parse(path.read_text())): - names = [] - if isinstance(node, ast.Import): - names = [a.name for a in node.names] - elif isinstance(node, ast.ImportFrom) and node.level == 0: - names = [node.module or ""] - for name in names: - assert name.split(".")[0] in allowed, f"{path.name} imports {name}" - lock = (bundle / "requirements.lock").read_text() - for pin in ("cryptography==", "cffi==", "pycparser==", "PyYAML=="): - assert pin in lock - assert "--hash=sha256:" in lock - - -def test_the_bundled_verifier_runs_standalone(world, tmp_path): - """Run .github/kb-gate/kb_gate.py as the workflow does, outside the source - tree: a code-only group passes, a knowledge group without the key fails.""" - world.branch(2, {}, code={"src/app.py": "x = 5\n"}) - group = world.merge_group([2]) - script = ROOT / ".github" / "kb-gate" / "kb_gate.py" - env = {"PATH": os.environ["PATH"], "HOME": str(tmp_path), "GITHUB_TOKEN": "none"} - run = lambda head: subprocess.run( # noqa: E731 - [sys.executable, "-E", "-s", str(script), "merge-group", "--repository", REPO, - "--head-sha", head, "--root", str(world.ws)], cwd=tmp_path, env=env, capture_output=True, text=True) - proc = run(group) - assert proc.returncode == 0, proc.stdout + proc.stderr - world.branch(1, _add_rule()) - proc = run(world.merge_group([1])) - assert proc.returncode == 1 and "kb-gate.pub is not committed" in proc.stdout diff --git a/test/test_kb_holds_server.py b/test/test_kb_holds_server.py deleted file mode 100644 index f942c17d..00000000 --- a/test/test_kb_holds_server.py +++ /dev/null @@ -1,65 +0,0 @@ -"""The hold-list endpoint serves exactly one signed file, read-only.""" - -from __future__ import annotations - -import json -import threading -import urllib.error -import urllib.request - -import pytest -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from infermatrix_copilot.kb_service.holds_server import make_handler -from infermatrix_copilot.knowledge_service import gate_verifier as gv -from infermatrix_copilot.knowledge_service.signing import sign - - -@pytest.fixture -def server(tmp_path): - from http.server import ThreadingHTTPServer - - holds = tmp_path / "public" / "holds.json" - srv = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(holds)) - thread = threading.Thread(target=srv.serve_forever, daemon=True) - thread.start() - yield srv, holds, f"http://127.0.0.1:{srv.server_address[1]}" - srv.shutdown() - srv.server_close() - - -def _status(url, method="GET"): - try: - with urllib.request.urlopen(urllib.request.Request(url, method=method), timeout=5) as response: - return response.status, response.read(), response.headers - except urllib.error.HTTPError as exc: - return exc.code, b"", exc.headers - - -def test_the_signed_hold_list_is_served_and_verifies_through_the_gate(server): - import time - - srv, holds, base = server - key = Ed25519PrivateKey.generate() - holds.parent.mkdir(parents=True) - payload = {"issued_at": time.time(), "sequence": 3, "global": False, "repos": ["demo"], "prs": [7]} - holds.write_text(json.dumps(sign("kb-holds", payload, key))) - status, body, headers = _status(base + "/holds.json") - assert status == 200 and headers["Cache-Control"] == "no-store" - # exactly what the kb-gate verifier does with it - envelope = gv.fetch_holds(base + "/holds.json") - assert gv.verify_holds(envelope, key.public_key(), now=time.time())["prs"] == [7] - - -def test_nothing_else_is_served(server, tmp_path): - srv, holds, base = server - holds.parent.mkdir(parents=True) - holds.write_text("{}") - (tmp_path / "kb.db").write_text("secret ledger") - for path in ("/", "/kb.db", "/../kb.db", "/public/", "/public/holds.json", "/holds.json/../kb.db"): - assert _status(base + path)[0] == 404, path - assert _status(base + "/holds.json", method="POST")[0] in (404, 405, 501) - - -def test_a_missing_hold_list_is_unavailable_not_empty(server): - assert _status(server[2] + "/holds.json")[0] == 503 diff --git a/test/test_kb_local_gate_verifier.py b/test/test_kb_local_gate_verifier.py new file mode 100644 index 00000000..9a4c925e --- /dev/null +++ b/test/test_kb_local_gate_verifier.py @@ -0,0 +1,252 @@ +"""The knowledge verifier as the publisher's local gate runs it: adversarial cases, offline.""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from infermatrix_copilot.kb_service.gate import changes_between, run_gate, signable_blocks +from infermatrix_copilot.kb_service.models import ModelReply, ModelRole +from infermatrix_copilot.kb_service import local_gate +from infermatrix_copilot.knowledge_service.gate_verifier import Git +from infermatrix_copilot.knowledge_service.ops import KnowledgeOperation as Op, apply_operations +from infermatrix_copilot.knowledge_service.signing import sign +from infermatrix_copilot.knowledge_service.verdict import VerdictError +from infermatrix_copilot.knowledge_service.verdict import build_verdict, manifest_from_files + +REPO = "acme/kb" +PAGE = "repos/demo/core/rules.md" +JUDGE = ModelRole("judge", "codex", "gpt-6-sol", "medium") +KEY = Ed25519PrivateKey.generate() + + +def _rule(rule_id: str, cite: str = "PR #10") -> str: + return (f"## {rule_id} — keep the demo queue bounded\n\n" + "- 触发:修改 demo queue 的容量或背压逻辑时。\n" + "- 强制:队列满时拒绝新请求并返回明确错误,不能无界增长。\n" + f"- 验收:测试覆盖满队列拒绝路径。 ^[{cite}]\n") + + +def _tree() -> dict[str, str]: + return { + "repos/demo/_index.md": "# demo\n", + "repos/demo/core/_index.md": "# core\n\n- [rules](rules.md)\n", + "repos/demo/api/_index.md": "# api\n", + "repos/demo/_routes.yaml": ( + "schema_version: 1\nowners:\n - owner: core\n path: repos/demo/core/rules.md\n" + " signals: [queue]\n scope_prefixes: [demo/core/]\n"), + PAGE: ("---\ntitle: \"Demo core rules\"\ncreated: 2026-09-01\nupdated: 2026-09-01\n" + "type: rule\ntags: [demo]\nsources: [\"PR #10\"]\n---\n\n# Demo core rules\n\n" + + _rule("DEMO-1a")), + } + + +class _Gateway: + def call_json(self, role, *, system, prompt, validate=None): + if '"directory"' in prompt: + data = {"verdict": "consistent", "conflicts": []} + else: + dims = json.loads(prompt.split("<untrusted_data>\n", 1)[1].rsplit("\n</untrusted_data>", 1)[0]) + data = {"dimensions": {d: "yes" for d in dims["dimensions_to_answer"]}, "reasons": {}} + return ModelReply(role, data, json.dumps(data), role.model, {}, 0.1) + + +# -- a git "remote" with main and PR refs, and the verifier's clone ----------------- + +def _git(cwd: Path, *args: str, env: dict | None = None) -> str: + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True, + env={**os.environ, **(env or {})}).stdout.strip() + + +def _write(repo: Path, files: dict[str, str | None], *, prefix: str = "knowledge/") -> None: + for rel, text in files.items(): + path = repo / (prefix + rel) + if text is None: + path.unlink() + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _commit(repo: Path, message: str) -> str: + _git(repo, "add", "-A") + _git(repo, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "--allow-empty", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +class World: + """A remote with main and PR refs, and the publisher's clone of it.""" + + def __init__(self, tmp_path: Path): + self.remote = tmp_path / "remote" + self.remote.mkdir() + _git(self.remote, "init", "-q", "-b", "main") + _write(self.remote, _tree()) + (self.remote / "src").mkdir() + (self.remote / "src" / "app.py").write_text("x = 1\n") + self.main = _commit(self.remote, "base") + self.ws = tmp_path / "ws" + _git(tmp_path, "clone", "-q", "--bare", str(self.remote), str(self.ws)) + self.heads: dict[int, str] = {} + self.envelopes: dict[int, dict] = {} + + def branch(self, number: int, files: dict[str, str | None], *, code: dict[str, str] | None = None) -> str: + _git(self.remote, "checkout", "-q", "-B", f"pr-{number}", self.main) + _write(self.remote, files) + if code: + _write(self.remote, code, prefix="") + head = _commit(self.remote, f"pr {number}") + _git(self.remote, "update-ref", f"refs/pull/{number}/head", head) + _git(self.remote, "checkout", "-q", "main") + self.heads[number] = head + return head + + def advance_main(self, files: dict[str, str | None], *, code: dict[str, str] | None = None) -> str: + _git(self.remote, "checkout", "-q", "main") + _write(self.remote, files) + if code: + _write(self.remote, code, prefix="") + self.main = _commit(self.remote, "main moves") + return self.main + + def verdict(self, number: int, head_files: dict[str, str], *, key=KEY, source="auto", pr=None, + head_sha=None, issued_at=None, mutate=None, **kw) -> dict: + base = _tree() + head = {**base, **head_files} + touched = set(head_files) + decision = run_gate(base=base, head=head, changes=changes_between(base, head), external_texts={}, + evidence=[], gateway=_Gateway(), judge=JUDGE, release="v1", repo_dir="repos/demo", + retire_ratio=1.0) + assert decision.status == "pass", decision.reasons + verdict = build_verdict( + repository=REPO, pr=pr or number, head_sha=head_sha or self.heads[number], + context_base_sha=kw.pop("context_base_sha", self.main), + manifest=manifest_from_files({k: v for k, v in base.items() if k in touched}, + {k: v for k, v in head.items() if k in touched}), + blocks=signable_blocks(decision), + consistency=[{"owner_dir": c["owner_dir"], "verdict": c["verdict"], "pages": c["pages"]} + for c in decision.consistency], + release="v1", upstream={}, facts=[], models={}, + issued_at=time.time() - 5 if issued_at is None else issued_at, **kw) + verdict["source"] = source # anything else than auto is forged + if mutate: + mutate(verdict) + self.envelopes[number] = sign("kb-gate-verdict", verdict, key) + return verdict + + +def _gate(world: World, number: int = 1) -> list[str]: + """The publisher's local gate on merge-tree(main, head) with the handed-over verdict.""" + _git(world.ws, "fetch", "-q", "origin", "+refs/heads/main:refs/heads/main", + f"+refs/pull/{number}/head:refs/kb/pr-{number}") + head = world.heads[number] + verdict = local_gate.check_verdict(world.envelopes[number], KEY.public_key(), repository=REPO, pr=number, + head_sha=head, now=time.time()) + pr = {"number": number, "state": "open", "head": {"sha": head}, "labels": []} + return local_gate.gate(world.ws, repository=REPO, repo="demo", pr=pr, head_sha=head, + main_sha=Git(world.ws).rev("refs/heads/main"), verdict=verdict, + public_key=KEY.public_key(), now=time.time()) + + +def _add_rule() -> dict[str, str]: + base = _tree() + return apply_operations(base, [Op("add", PAGE, "DEMO-2a", _rule("DEMO-2a", "PR #11"))], + release="v1", today="2026-09-28").files + + +@pytest.fixture +def world(tmp_path): + return World(tmp_path) + + +def test_a_signed_verdict_for_this_head_passes(world): + files = _add_rule() + world.branch(1, files) + world.verdict(1, files) + assert _gate(world) == [] + + +@pytest.mark.parametrize("variant", ["other_pr", "other_head", "other_key", "expired", "human"]) +def test_a_verdict_for_something_else_is_refused(world, variant): + files = _add_rule() + world.branch(1, files) + kw = {"other_pr": {"pr": 2}, "other_head": {"head_sha": "f" * 40}, + "other_key": {"key": Ed25519PrivateKey.generate()}, + "expired": {"issued_at": time.time() - 73 * 3600}, "human": {"source": "human-approved"}}[variant] + world.verdict(1, files, **kw) + with pytest.raises(VerdictError): + _gate(world) + + +def test_a_verdict_whose_manifest_omits_a_change_fails(world): + files = _add_rule() + world.branch(1, files) + world.verdict(1, files, mutate=lambda v: v.update(manifest=[])) + assert "the signed patch manifest does not match the change as applied" in _gate(world) + + +def test_a_block_table_that_does_not_cover_the_change_fails(world): + files = _add_rule() + world.branch(1, files) + world.verdict(1, files, mutate=lambda v: v.update(blocks=v["blocks"][1:])) + assert "the signed block table does not cover exactly the changed blocks" in _gate(world) + + +def test_mixed_code_and_knowledge_and_executable_files_fail_even_with_a_verdict(world): + files = _add_rule() + world.branch(1, files, code={"src/app.py": "x = 3\n"}) + world.verdict(1, files) + problems = _gate(world) + assert "src/app.py: outside knowledge/ (split the PR)" in problems + assert "the signed patch manifest does not match the change as applied" in problems + + world.branch(2, {}) + _git(world.remote, "checkout", "-q", "pr-2") + page = world.remote / "knowledge" / PAGE + page.write_text(page.read_text() + "\n") + page.chmod(0o755) + head = _commit(world.remote, "exec bit") + _git(world.remote, "update-ref", "refs/pull/2/head", head) + _git(world.remote, "checkout", "-q", "main") + world.heads[2] = head + world.verdict(2, {}, mutate=lambda v: v.update(manifest=[])) + assert any(p.startswith("L1 ") and "100755" in p for p in _gate(world, 2)) + + +def test_a_change_to_another_repository_than_the_items_is_refused(world): + files = {"repos/other/x.md": "# x\n"} + world.branch(1, files) + world.verdict(1, {}, mutate=lambda v: v.update(manifest=[])) + assert any("belongs to other, not to demo" in p for p in _gate(world)) + + +def test_a_context_change_since_judging_invalidates_the_verdict(world): + files = _add_rule() + world.branch(1, files) + world.verdict(1, files) + world.advance_main({}, code={"src/app.py": "x = 9\n"}) # unrelated: still fine + assert _gate(world) == [] + world.advance_main({"repos/demo/core/_index.md": "# core\n\n- [rules](rules.md)\n\nmore\n"}) + problems = _gate(world) + assert any("context changed since the verdict was judged: repos/demo/core/_index.md" in p for p in problems) + + +def test_a_rule_retired_while_main_cites_it_fails_in_the_landing_tree(world): + base = _tree() + retire = apply_operations(base, [Op("retire", PAGE, "DEMO-1a", reason="upstream-removed", + evidence="PR #13")], release="v1", today="2026-09-28").files + text = _rule("DEMO-3a", "PR #12").replace("队列满时", "与 DEMO-1a 一致,队列满时") + cite = apply_operations(base, [Op("add", "repos/demo/api/rules.md", "DEMO-3a", text, + page_title="Demo api rules")], release="v1", today="2026-09-28").files + world.branch(1, retire) + world.verdict(1, retire) + assert _gate(world) == [] # fine on its own + world.advance_main(cite) # main now cites DEMO-1a + world.verdict(1, retire, context_base_sha=world.main) + assert any(p.startswith("L1 dangling_reference repos/demo/api/rules.md DEMO-1a") for p in _gate(world)) diff --git a/test/test_kb_pause_confirm.py b/test/test_kb_pause_confirm.py deleted file mode 100644 index 4de1c689..00000000 --- a/test/test_kb_pause_confirm.py +++ /dev/null @@ -1,141 +0,0 @@ -"""A pause counts once GitHub shows it; unconfirmed pauses go to people.""" - -from __future__ import annotations - -from infermatrix_copilot.kb_service import merge -from test_kb_flow import _ack, _flow_runtime, _items, _open_pr - -HEAD = "d" * 40 - - -def _paused_repo(tmp_path): - rt, lifecycle = _flow_runtime(tmp_path) - changeset_id, publisher = _open_pr(tmp_path, rt, lifecycle) - _ack(tmp_path, rt, publisher, kind="open_pr", changeset_id=changeset_id, ok=True, pr=42, head_sha=HEAD) - rt.github.prs[42] = {"state": "open", "merged": False, "draft": False, "head": {"sha": HEAD}} - rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill"), - public_repos={lifecycle.repo}) - assert merge.pause_open_prs(rt.ledger, rt.outbox, lifecycle.repo, "drill") == 1 - return rt, lifecycle, changeset_id, publisher - - -def _later(rt, seconds): - start = rt.clock() - rt.clock = lambda: start + seconds - - -def test_an_unacked_pause_goes_to_people_once_after_five_minutes(tmp_path): - rt, lifecycle, changeset_id, _ = _paused_repo(tmp_path) - assert merge.advance(rt, lifecycle) == [] - assert merge.pause_unconfirmed(rt.ledger, "demo") == 1 - _later(rt, merge.PAUSE_CONFIRM + 1) - assert merge.advance(rt, lifecycle) == [f"pause_unconfirmed {changeset_id}"] - (alert,) = [item for item in rt.ledger.human_queue("demo") if "not confirmed" in item["reason"]] - assert "gh pr ready 42 --undo" in alert["reason"] and "drill" in alert["reason"] - assert merge.advance(rt, lifecycle) == [] # once - - -def test_an_acked_pause_is_confirmed_only_when_github_shows_a_draft(tmp_path): - rt, lifecycle, changeset_id, publisher = _paused_repo(tmp_path) - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True) - assert rt.ledger.changeset(changeset_id)["status"] == "paused" - pauses = len(_items(tmp_path, "pause")) - assert merge.advance(rt, lifecycle) == [] # not a draft on GitHub: pause again - assert len(_items(tmp_path, "pause")) == pauses + 1 - assert merge.pause_unconfirmed(rt.ledger, "demo") == 1 - _later(rt, merge.PAUSE_CONFIRM + 1) - assert merge.advance(rt, lifecycle) == [f"pause_unconfirmed {changeset_id}"] - rt.github.prs[42]["draft"] = True - assert merge.advance(rt, lifecycle) == [f"pause_confirmed {changeset_id}"] - assert merge.pause_unconfirmed(rt.ledger, "demo") == 0 - assert merge.advance(rt, lifecycle) == [] - - -def test_resume_clears_the_pause_bookkeeping(tmp_path): - rt, lifecycle, changeset_id, publisher = _paused_repo(tmp_path) - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True) - rt.github.prs[42]["draft"] = True - merge.advance(rt, lifecycle) - rt.ledger.resume("demo") - merge.resume_paused_prs(rt.ledger, "demo") - detail = rt.ledger.changeset(changeset_id)["detail"] - assert not {"pause_requested_at", "pause_alerted", "pause_confirmed"} & set(detail) - assert merge.pause_unconfirmed(rt.ledger, "demo") == 0 - - -def test_kb_status_reports_unconfirmed_pauses(tmp_path, monkeypatch, capsys): - import json - - from infermatrix_copilot.kb_service import cli - - rt, lifecycle, changeset_id, _ = _paused_repo(tmp_path) - monkeypatch.setattr(cli, "_registry", lambda: rt.registry) - monkeypatch.delenv("KB_SIGNING_KEY", raising=False) - assert cli.main(["--state-dir", str(tmp_path / "state"), "status"]) == 0 - report = json.loads(capsys.readouterr().out) - (demo,) = [row for row in report["repos"] if row["repo"] == "demo"] - assert demo["pause_unconfirmed"] == 1 - - -def test_a_confirmed_pause_that_is_undone_counts_as_unconfirmed_again(tmp_path): - rt, lifecycle, changeset_id, publisher = _paused_repo(tmp_path) - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True) - rt.github.prs[42]["draft"] = True - merge.advance(rt, lifecycle) - assert merge.pause_unconfirmed(rt.ledger, "demo") == 0 - rt.github.prs[42]["draft"] = False # someone marked it ready again - merge.advance(rt, lifecycle) - assert merge.pause_unconfirmed(rt.ledger, "demo") == 1 - _later(rt, merge.PAUSE_CONFIRM + 1) - assert merge.advance(rt, lifecycle) == [f"pause_unconfirmed {changeset_id}"] - - -def test_resume_before_the_ack_clears_the_request(tmp_path): - rt, lifecycle, changeset_id, _ = _paused_repo(tmp_path) - rt.ledger.resume("demo") - merge.resume_paused_prs(rt.ledger, "demo") - assert merge.pause_unconfirmed(rt.ledger, "demo") == 0 - assert "pause_requested_at" not in rt.ledger.changeset(changeset_id)["detail"] - - -def test_a_paused_pr_whose_head_moves_stays_watched(tmp_path): - rt, lifecycle, changeset_id, publisher = _paused_repo(tmp_path) - rt.github.prs[42]["head"]["sha"] = "e" * 40 - _later(rt, merge.PAUSE_CONFIRM + 1) - assert merge.advance(rt, lifecycle) == [f"pause_unconfirmed {changeset_id}"] - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True) - assert merge.pause_unconfirmed(rt.ledger, "demo") == 1 - rt.github.prs[42]["draft"] = True - assert merge.advance(rt, lifecycle) == [f"pause_confirmed {changeset_id}"] - - -def test_a_completed_retry_never_blocks_the_next_re_pause(tmp_path): - rt, lifecycle, changeset_id, publisher = _paused_repo(tmp_path) - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True) - merge.advance(rt, lifecycle) # not a draft: a retry is issued - _ack(tmp_path, rt, publisher, kind="pause", changeset_id=changeset_id, ok=True) # the retry ran - assert rt.ledger.changeset(changeset_id)["pending_item"] is None - rt.github.prs[42]["draft"] = True - merge.advance(rt, lifecycle) # confirmed - rt.github.prs[42]["draft"] = False # ready again - before = len(_items(tmp_path, "pause")) - merge.advance(rt, lifecycle) - assert len(_items(tmp_path, "pause")) == before + 1 # a fresh pause at once - - -def test_overdue_pauses_escalate_even_when_github_cannot_be_read(tmp_path): - rt, lifecycle, changeset_id, publisher = _paused_repo(tmp_path) - - def down(url): - raise RuntimeError("GitHub 503") - - rt.github._fetch = down - _later(rt, merge.PAUSE_CONFIRM + 1) - events = merge.advance(rt, lifecycle) # unacked pause, GitHub down - assert f"pause_unconfirmed {changeset_id}" in events and f"observe_failed {changeset_id}" in events - - rt2, lifecycle2, changeset2, publisher2 = _paused_repo(tmp_path / "acked") - _ack(tmp_path / "acked", rt2, publisher2, kind="pause", changeset_id=changeset2, ok=True) - rt2.github._fetch = down - _later(rt2, merge.PAUSE_CONFIRM + 1) - assert f"pause_unconfirmed {changeset2}" in merge.advance(rt2, lifecycle2) # acked, never confirmed diff --git a/test/test_kb_publisher.py b/test/test_kb_publisher.py index 71128cc9..454c5612 100644 --- a/test/test_kb_publisher.py +++ b/test/test_kb_publisher.py @@ -128,6 +128,8 @@ def __call__(self, argv, *, cwd=None, input=None, stdout=None, stderr=None, chec elif args[args.index("--remove-label") + 1] in labels: labels.remove(args[args.index("--remove-label") + 1]) elif args[:2] == ["pr", "close"]: + if "close" in self.fail: + return subprocess.CompletedProcess(argv, 1, b"", b"HTTP 502") self.prs[int(args[2])]["state"] = "CLOSED" elif args[:2] == ["api", "graphql"] and args[3].startswith("query=query("): if self.raise_on_query: @@ -256,41 +258,24 @@ def test_open_pr_refuses_paths_outside_governed_knowledge_pages(tmp_path): assert all(not a["ok"] and "outside governed knowledge pages" in a["error"] for a in acks) -def test_post_verdict_enqueue_pause_and_close(tmp_path): +def test_close_is_performed_once_and_a_closed_pr_is_left_alone(tmp_path): rt, lifecycle, changeset_id, pub, gh = _setup(tmp_path) pub.run_once() - head = gh.prs[42]["headRefOid"] - _issue(rt, lifecycle, changeset_id, "post_verdict", pr=42, head_sha="f" * 40, comment="stale") - _issue(rt, lifecycle, changeset_id, "post_verdict", pr=42, head_sha=head, comment="<!-- kb-gate:verdict:v1 -->") - summary = pub.run_once() - assert summary["performed"] == 1 and summary["failed"] == 1 - assert gh.comments == [(42, "<!-- kb-gate:verdict:v1 -->")] - gh.prs[42]["isDraft"] = True # paused earlier, then resumed - rt.outbox.refresh_control() - _issue(rt, lifecycle, changeset_id, "enqueue", pr=42, head_sha=head) - assert pub.run_once()["performed"] == 1 - assert gh.prs[42]["isDraft"] is False and gh.prs[42]["queued"] is True - _issue(rt, lifecycle, changeset_id, "pause", pr=42, reason="drill") - assert pub.run_once()["performed"] == 1 - assert gh.prs[42]["queued"] is False and gh.prs[42]["isDraft"] is True and "kb:hold" in gh.prs[42]["labels"] _issue(rt, lifecycle, changeset_id, "close", pr=42, reason="superseded") - _issue(rt, lifecycle, changeset_id, "pause", pr=42, reason="after close") - assert pub.run_once()["performed"] == 2 and gh.prs[42]["state"] == "CLOSED" - - -def test_pause_is_performed_even_while_the_repository_is_paused(tmp_path): - rt, lifecycle, changeset_id, pub, gh = _setup(tmp_path) - pub.run_once() - _collect(rt) # the PR is open, so the pause applies to it - rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill"), - public_repos={lifecycle.repo}) - merge.pause_open_prs(rt.ledger, rt.outbox, lifecycle.repo, "drill") - _collect(rt) - assert pub.run_once()["performed"] == 1 and gh.prs[42]["isDraft"] is True - _collect(rt) - assert rt.ledger.changeset(changeset_id)["status"] == "paused" - - + assert pub.run_once()["performed"] == 1 and gh.prs[42]["state"] == "CLOSED" + _issue(rt, lifecycle, changeset_id, "close", pr=42, reason="again") + assert pub.run_once()["performed"] == 1 and gh.prs[42]["state"] == "CLOSED" +def test_a_pause_stops_merges_without_touching_the_pr(tmp_path): + """The publisher is the only merger: a pause is the new generation alone, + nothing is dequeued, drafted or labelled; stops (close) still run.""" + rt, lifecycle, changeset_id, pub, gh, head = _merge_ready(tmp_path) + rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill")) + calls = len(gh.calls) + assert pub.run_once()["performed"] == 0 + assert gh.calls[calls:] == [] and gh.prs[42]["state"] == "OPEN" and not gh.prs[42]["isDraft"] + assert merge.advance(rt, lifecycle) == [] # nothing is signed while paused + _issue(rt, lifecycle, changeset_id, "close", pr=42, reason="drill") + assert pub.run_once()["performed"] == 1 and gh.prs[42]["state"] == "CLOSED" def _merge_ready(tmp_path): """The service's PR is open and its merge item (with a signed verdict) is issued.""" rt, lifecycle, changeset_id, pub, gh = _setup(tmp_path) @@ -426,30 +411,18 @@ def test_an_unsettled_recovery_blocks_the_item_until_github_answers(tmp_path): def test_a_paused_then_resumed_pr_merges(tmp_path): - rt, lifecycle, changeset_id, pub, gh = _setup(tmp_path) - pub.run_once() - _collect(rt) - head = gh.prs[42]["headRefOid"] - rt.github.prs[42] = {"number": 42, "state": "open", "merged": False, "head": {"sha": head}} - rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill"), - public_repos={lifecycle.repo}) - merge.pause_open_prs(rt.ledger, rt.outbox, lifecycle.repo, "drill") - pub.run_once() - _collect(rt) - assert gh.prs[42]["isDraft"] and "kb:hold" in gh.prs[42]["labels"] - - def _resume(): - rt.ledger.resume(lifecycle.repo) - merge.resume_paused_prs(rt.ledger, lifecycle.repo) - rt.outbox.transition(_resume, public_repos={lifecycle.repo}) - rt.github.prs[42]["draft"] = True + rt, lifecycle, changeset_id, pub, gh, head = _merge_ready(tmp_path) + rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill")) + assert pub.run_once()["performed"] == 0 # the old item is refused, never acked + rt.outbox.transition(lambda: rt.ledger.resume(lifecycle.repo)) + now = rt.clock() + rt.clock = rt.outbox._clock = pub.clock = lambda: now + 36 * 60 # the refused item expired + assert merge.advance(rt, lifecycle) == [f"merge_expired {changeset_id}"] assert merge.advance(rt, lifecycle) == [f"merge issued {changeset_id}"] - assert pub.run_once()["performed"] == 1 - assert gh.prs[42]["state"] == "MERGED" and "kb:hold" not in gh.prs[42]["labels"] + rt.outbox.refresh_control() + assert pub.run_once()["performed"] == 1 and gh.prs[42]["state"] == "MERGED" _collect(rt) assert rt.ledger.changeset(changeset_id)["status"] == "merged" - - def test_a_second_publisher_does_nothing_while_one_runs(tmp_path): import fcntl import os @@ -474,8 +447,7 @@ def test_a_pause_issued_while_the_gate_ran_stops_the_merge(tmp_path): def gate_then_pause(*args, **kwargs): problems = real(*args, **kwargs) - rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill"), - public_repos={lifecycle.repo}) + rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill")) return problems local_gate.gate = gate_then_pause try: @@ -486,22 +458,16 @@ def gate_then_pause(*args, **kwargs): assert not any(call[:2] == ["pr", "merge"] for call in gh.calls) -def test_a_failed_pause_is_retried_not_acknowledged(tmp_path): +def test_a_failed_close_is_retried_not_acknowledged(tmp_path): rt, lifecycle, changeset_id, pub, gh = _setup(tmp_path) pub.run_once() _collect(rt) - gh.prs[42]["queued"] = True - gh.fail.add("undo") - rt.outbox.transition(lambda: rt.ledger.bump_generation(lifecycle.repo, pause=True, reason="drill"), - public_repos={lifecycle.repo}) - merge.pause_open_prs(rt.ledger, rt.outbox, lifecycle.repo, "drill") + gh.fail.add("close") + _issue(rt, lifecycle, changeset_id, "close", pr=42, reason="superseded") assert pub.run_once()["failed"] == 1 assert not list((tmp_path / "state" / "inbox" / "acks").glob("*.json")) # nothing answered - assert pub.run_once()["performed"] == 1 and gh.prs[42]["isDraft"] is True - _collect(rt) - assert rt.ledger.changeset(changeset_id)["status"] == "paused" - - + gh.fail.discard("close") + assert pub.run_once()["performed"] == 1 and gh.prs[42]["state"] == "CLOSED" @pytest.mark.parametrize("control", [{"payload": {"issued_at": "invalid"}}, [], {"payload": []}, "x"]) def test_malformed_control_records_and_items_never_stop_the_publisher(tmp_path, control): rt, _lifecycle, _changeset_id, pub, gh = _setup(tmp_path) @@ -514,16 +480,12 @@ def test_malformed_control_records_and_items_never_stop_the_publisher(tmp_path, assert summary["skipped"] == 1 and summary["performed"] == 1 -def test_a_verdict_is_not_posted_while_the_hold_label_cannot_be_removed(tmp_path): - rt, lifecycle, changeset_id, pub, gh = _setup(tmp_path) - pub.run_once() - head = gh.prs[42]["headRefOid"] - gh.prs[42]["labels"].append("kb:hold") - gh.fail.add("remove-label") - _issue(rt, lifecycle, changeset_id, "post_verdict", pr=42, head_sha=head, comment="verdict") - assert pub.run_once()["failed"] == 1 and gh.comments == [] - - +def test_a_draft_pr_is_never_readied_or_merged(tmp_path): + rt, lifecycle, changeset_id, pub, gh, head = _merge_ready(tmp_path) + gh.prs[42]["isDraft"] = True + assert pub.run_once()["failed"] == 1 + assert gh.prs[42]["state"] == "OPEN" and gh.prs[42]["isDraft"] is True + assert not any(call[:2] in (["pr", "merge"], ["pr", "ready"]) for call in gh.calls) def test_transport_failures_keep_the_daemon_alive_and_resend_later(tmp_path): rt, _lifecycle, changeset_id, pub, gh = _setup(tmp_path) real = pub.transport diff --git a/test/test_kb_service_core.py b/test/test_kb_service_core.py index ff73cdab..2af124b4 100644 --- a/test/test_kb_service_core.py +++ b/test/test_kb_service_core.py @@ -15,7 +15,7 @@ ) from infermatrix_copilot.kb_service.ledger import Ledger, LeaseError from infermatrix_copilot.kb_service.outbox import ( - CONTROL_MAX_AGE, Outbox, OutboxError, check_item, verify_holds, + CONTROL_MAX_AGE, Outbox, OutboxError, check_item, ) from infermatrix_copilot.knowledge_service.signing import ( SignatureError, generate_private_key, load_private_key, load_public_key, @@ -171,20 +171,20 @@ def _check(world, item, **kw): def test_publisher_accepts_a_fresh_item(world): _clock, _ledger, outbox, _public, _state = world - item = outbox.issue("demo", "enqueue", {"pr": 5, "head": "b" * 40}) + item = outbox.issue("demo", "merge", {"pr": 5, "head": "b" * 40}) outbox.refresh_control() assert _check(world, item).body["pr"] == 5 def test_generation_bump_voids_everything_issued_before_it(world): _clock, ledger, outbox, _public, _state = world - enqueue = outbox.issue("demo", "enqueue", {"pr": 5}) - pause = outbox.issue("demo", "pause", {"pr": 5}) + merge = outbox.issue("demo", "merge", {"pr": 5}) + close = outbox.issue("demo", "close", {"pr": 5}) ledger.bump_generation("demo", pause=False, reason="") # rollback / shadow switch outbox.refresh_control() with pytest.raises(OutboxError, match="stale generation"): - _check(world, enqueue) - assert _check(world, pause).kind == "pause" # stopping is always allowed + _check(world, merge) + assert _check(world, close).kind == "close" # stopping is always allowed global_old = outbox.issue("demo", "open_pr", {}) ledger.bump_generation("*", pause=False, reason="") outbox.refresh_control() @@ -202,10 +202,10 @@ def test_generation_bump_voids_everything_issued_before_it(world): def test_publisher_refusals(world, case, message): clock, ledger, outbox, _public, _state = world kw = {} - item = outbox.issue("demo", "enqueue", {"pr": 5}) + item = outbox.issue("demo", "merge", {"pr": 5}) if case == "paused": ledger.bump_generation("demo", pause=True, reason="x") - item = outbox.issue("demo", "enqueue", {"pr": 5}) + item = outbox.issue("demo", "merge", {"pr": 5}) outbox.refresh_control() if case == "expired": clock.now += 31 * 60 @@ -232,19 +232,6 @@ def test_forged_item_is_refused(world): _check(world, item) -def test_hold_list_names_paused_repos_and_prs_and_expires(world): - clock, ledger, outbox, public, state = world - cs = ledger.create_changeset("demo", "intake") - ledger.update_changeset(cs, status="paused", pr_number=42) - ledger.bump_generation("demo", pause=True, reason="breaker") - outbox.publish_holds(sequence=1, public_repos={"demo"}) - holds = verify_holds(_read(state / "public" / "holds.json"), public, now=clock.now) - assert holds["repos"] == ["demo"] and holds["prs"] == [42] and not holds["global"] - clock.now += 11 * 60 - with pytest.raises(OutboxError, match="stale"): - verify_holds(_read(state / "public" / "holds.json"), public, now=clock.now) - - def test_acks_must_be_signed_by_the_publisher(world, tmp_path): _clock, ledger, outbox, _public, state = world publisher = generate_private_key(tmp_path / "publisher.pem") @@ -278,8 +265,6 @@ def test_kb_cli_pause_resume_refreshes_signed_control(tmp_path, monkeypatch, cap assert cli_main(["kb", "pause", "--repo", "vllm-omni", "--reason", "drill"]) == 0 control = verify("kb-control", _read(tmp_path / "state" / "outbox" / "control.json"), public) assert control["repos"]["vllm-omni"]["paused"] is True - holds = verify("kb-holds", _read(tmp_path / "state" / "public" / "holds.json"), public) - assert holds["repos"] == ["vllm-omni"] assert cli_main(["kb", "resume", "--repo", "vllm-omni"]) == 0 control = verify("kb-control", _read(tmp_path / "state" / "outbox" / "control.json"), public) assert control["repos"]["vllm-omni"]["paused"] is False @@ -293,26 +278,26 @@ def test_kb_cli_pause_resume_refreshes_signed_control(tmp_path, monkeypatch, cap def test_mode_round_trip_voids_items_issued_before_it(world): _clock, ledger, outbox, _public, _state = world - enqueue = outbox.issue("demo", "enqueue", {"pr": 5}) + merge = outbox.issue("demo", "merge", {"pr": 5}) assert ledger.ensure_repo("demo", "shadow") is True assert ledger.ensure_repo("demo", "shadow") is False # no change, no bump assert ledger.ensure_repo("demo", "auto_merge") is True outbox.refresh_control() with pytest.raises(OutboxError, match="stale generation"): - _check(world, enqueue) + _check(world, merge) @pytest.mark.parametrize("mode", ["disabled", "shadow"]) -@pytest.mark.parametrize("kind", ["open_pr", "open_companion_pr", "update_branch", "enqueue", "post_verdict"]) +@pytest.mark.parametrize("kind", ["open_pr", "open_companion_pr", "merge", "post_findings"]) def test_only_auto_merge_repos_get_non_stop_writes(world, mode, kind): _clock, ledger, outbox, _public, _state = world ledger.ensure_repo("demo", mode) item = outbox.issue("demo", kind, {}) - stop = outbox.issue("demo", "pause", {}) + stop = outbox.issue("demo", "close", {}) outbox.refresh_control() with pytest.raises(OutboxError, match="not in auto_merge"): _check(world, item) - assert _check(world, stop).kind == "pause" + assert _check(world, stop).kind == "close" def test_overlapping_refresh_cannot_undo_a_completed_pause(world, tmp_path): @@ -321,7 +306,7 @@ def test_overlapping_refresh_cannot_undo_a_completed_pause(world, tmp_path): import threading clock, _ledger, outbox, public, state = world - stale_enqueue = outbox.issue("demo", "enqueue", {"pr": 5}) + stale_merge = outbox.issue("demo", "merge", {"pr": 5}) key = outbox._key read_done, may_write = threading.Event(), threading.Event() errors: list[BaseException] = [] @@ -346,8 +331,7 @@ def pauser(): try: ledger = Ledger(state / "kb.db", clock=clock) Outbox(state, key, ledger, clock=clock).transition( - lambda: ledger.bump_generation("demo", pause=True, reason="breaker"), - public_repos={"demo"}) + lambda: ledger.bump_generation("demo", pause=True, reason="breaker")) except BaseException as exc: # pragma: no cover errors.append(exc) @@ -365,33 +349,4 @@ def pauser(): control = verify("kb-control", _read(state / "outbox" / "control.json"), public) assert control["repos"]["demo"]["paused"] is True with pytest.raises(OutboxError): - _check(world, stale_enqueue) - - -def test_private_repos_never_appear_in_the_public_hold_list(tmp_path, monkeypatch, capsys): - import shutil - - from infermatrix_copilot.cli.entry import main as cli_main - - adapters = tmp_path / "adapters" - shutil.copytree(ROOT / "adapters", adapters) - secret = adapters / "secret_repo" - secret.mkdir() - (secret / "manifest.yaml").write_text(yaml.safe_dump({ - "name": "secret_repo", - "repo": {"full_name": "org/secret-repo", "path": "/x"}, - "knowledge": {"repo_subdir": "repos/secret-repo"}, - "knowledge_lifecycle": {"enabled": True, "mode": "shadow", "upstream_visibility": "private"}, - }), encoding="utf-8") - key_path = tmp_path / "service.pem" - assert cli_main(["kb", "keygen", "--out", str(key_path)]) == 0 - public = load_public_key(capsys.readouterr().out.strip()) - monkeypatch.setenv("KB_SIGNING_KEY", str(key_path)) - monkeypatch.setenv("KB_STATE_DIR", str(tmp_path / "state")) - monkeypatch.setenv("ADAPTERS_DIR", str(adapters)) - assert cli_main(["kb", "pause", "--repo", "secret-repo", "--reason", "drill"]) == 0 - assert cli_main(["kb", "pause", "--repo", "vllm-omni", "--reason", "drill"]) == 0 - raw = (tmp_path / "state" / "public" / "holds.json").read_text(encoding="utf-8") - assert "secret" not in raw - holds = verify("kb-holds", json.loads(raw), public) - assert holds["repos"] == ["vllm-omni"] + _check(world, stale_merge) diff --git a/tools/build_kb_gate_bundle.py b/tools/build_kb_gate_bundle.py deleted file mode 100644 index 71dfb73a..00000000 --- a/tools/build_kb_gate_bundle.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -"""Generate (or check) the pinned kb-gate verifier bundle in ``.github/kb-gate/``. - -The required check ``kb-gate`` runs ONLY from this bundle, never from ``src/``, -``tools/`` or ``knowledge/tools/``: weakening validation logic in a code PR does -not weaken the gate, and changing the bundle is a ``.github/**`` change that -CODEOWNERS must approve. The bundle is a verbatim copy of the verifier modules -with a generated-file header, plus ``requirements.lock`` (dependencies pinned by -hash) and ``MANIFEST.sha256`` (every bundle file's hash). - - python tools/build_kb_gate_bundle.py # regenerate the module copies + manifest - python tools/build_kb_gate_bundle.py --check # exit 1 when the bundle is stale - python tools/build_kb_gate_bundle.py --lock # re-pin requirements.lock from PyPI (network) - -A stale bundle keeps the gate at its previous strictness; ``--check`` in CI only -reminds that the source moved on. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import sys -import urllib.request -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -SOURCE = REPO / "src" / "infermatrix_copilot" / "knowledge_service" -BUNDLE = REPO / ".github" / "kb-gate" -# bundle file -> source module (the verifier imports its siblings by flat name) -MODULES = { - "kb_gate.py": "gate_verifier.py", - "l1.py": "l1.py", - "lifecycle.py": "lifecycle.py", - "ops.py": "ops.py", - "signing.py": "signing.py", - "verdict.py": "verdict.py", -} -# the pinned runtime dependencies: signing needs cryptography (-> cffi -> -# pycparser), l1/lifecycle/ops need PyYAML -PINS = {"cryptography": "50.0.1", "cffi": "2.1.1", "pycparser": "3.0", "PyYAML": "6.0.3"} -STATIC = ("README.md", "config.json", "requirements.lock") -HEADER = "# GENERATED by tools/build_kb_gate_bundle.py from {src} -- do not edit here.\n" - - -def module_text(bundle_name: str) -> str: - src = SOURCE / MODULES[bundle_name] - return HEADER.format(src=src.relative_to(REPO).as_posix()) + src.read_text(encoding="utf-8") - - -def manifest_text(files: dict[str, str]) -> str: - return "".join(f"{hashlib.sha256(files[name].encode()).hexdigest()} {name}\n" for name in sorted(files)) - - -def expected_bundle() -> dict[str, str]: - files = {name: module_text(name) for name in MODULES} - for name in STATIC: - path = BUNDLE / name - if not path.is_file(): - raise SystemExit(f"{path.relative_to(REPO)} is missing") - files[name] = path.read_text(encoding="utf-8") - files["MANIFEST.sha256"] = manifest_text(files) - return files - - -def lock_text() -> str: - lines = ["# GENERATED by tools/build_kb_gate_bundle.py --lock. Install with:", - "# pip install --require-hashes --no-deps -r .github/kb-gate/requirements.lock"] - for name, version in PINS.items(): - with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{version}/json", timeout=30) as resp: - data = json.load(resp) - hashes = sorted({item["digests"]["sha256"] for item in data["urls"]}) - lines.append(f"{name}=={version} \\") - lines.extend(f" --hash=sha256:{digest} \\" for digest in hashes[:-1]) - lines.append(f" --hash=sha256:{hashes[-1]}") - return "\n".join(lines) + "\n" - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--check", action="store_true") - parser.add_argument("--lock", action="store_true") - args = parser.parse_args(argv) - if args.lock: - (BUNDLE / "requirements.lock").write_text(lock_text(), encoding="utf-8") - expected = expected_bundle() - actual = {p.name: p.read_text(encoding="utf-8") for p in BUNDLE.iterdir() if p.is_file()} \ - if BUNDLE.is_dir() else {} - stale = sorted(name for name in set(expected) | set(actual) if expected.get(name) != actual.get(name)) - if args.check: - for name in stale: - print(f"kb-gate bundle is stale: .github/kb-gate/{name}") - if stale: - print("regenerate with: python tools/build_kb_gate_bundle.py (a .github/** change: CODEOWNERS review)") - return 1 if stale else 0 - BUNDLE.mkdir(parents=True, exist_ok=True) - for name, text in expected.items(): - (BUNDLE / name).write_text(text, encoding="utf-8") - for name in set(actual) - set(expected): - (BUNDLE / name).unlink() - print(f"kb-gate bundle: {len(expected)} files" + (f", updated {', '.join(stale)}" if stale else ", unchanged")) - return 0 - - -if __name__ == "__main__": - sys.exit(main())