kbforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kbforge/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """kbforge — agent-first knowledge bases, forged from your systems of record.
2
+
3
+ The production half of the Open Knowledge Format: connectors, canonicalization,
4
+ diff, provenance, and publish. See docs/architecture.md.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["__version__"]
kbforge/__main__.py ADDED
@@ -0,0 +1,125 @@
1
+ """`python -m kbforge ...` — the walking-skeleton entry point.
2
+
3
+ Connector selection and config are fully generic: the connector is resolved by
4
+ name from the registry (built-in or entry-point-discovered), and its config comes
5
+ from repeatable `--set KEY=VALUE` pairs. Nothing here knows a connector's config
6
+ shape, so a third-party plugin is usable with no change to this file."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from typing import cast
12
+
13
+ import pluggy
14
+ import yaml
15
+
16
+ from kbforge.pipeline import (
17
+ Aborted,
18
+ ConfigError,
19
+ ConnectorProtocol,
20
+ NoOp,
21
+ Published,
22
+ PublisherProtocol,
23
+ run,
24
+ )
25
+ from kbforge.registry import build_registry
26
+
27
+
28
+ def _connectors(pm: pluggy.PluginManager) -> dict[str, ConnectorProtocol]:
29
+ """name -> connector instance (a connector implements kbforge_fetch)."""
30
+ return {
31
+ p.kbforge_connector_info().name: cast(ConnectorProtocol, p)
32
+ for p in pm.get_plugins()
33
+ if hasattr(p, "kbforge_fetch")
34
+ }
35
+
36
+
37
+ def _publisher(pm: pluggy.PluginManager) -> PublisherProtocol:
38
+ for p in pm.get_plugins():
39
+ if hasattr(p, "kbforge_publish"):
40
+ return cast(PublisherProtocol, p)
41
+ raise SystemExit("no publisher registered")
42
+
43
+
44
+ def _parse_settings(pairs: list[str]) -> dict:
45
+ """`KEY=VALUE` pairs into a config dict; VALUE is YAML-typed so `max_commits=5`
46
+ is an int, `ref=HEAD` a str, and `ignore_globs=[a, b]` a list."""
47
+ config: dict = {}
48
+ for pair in pairs:
49
+ key, sep, raw = pair.partition("=")
50
+ if not sep:
51
+ raise ValueError(f"--set expects KEY=VALUE, got {pair!r}")
52
+ config[key] = yaml.safe_load(raw)
53
+ return config
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = argparse.ArgumentParser(prog="kbforge")
58
+ sub = parser.add_subparsers(dest="cmd", required=True)
59
+
60
+ sub.add_parser("list", help="list available connectors")
61
+
62
+ r = sub.add_parser("run", help="run the pipeline once")
63
+ r.add_argument("--connector", required=True)
64
+ r.add_argument(
65
+ "--set",
66
+ action="append",
67
+ default=[],
68
+ dest="settings",
69
+ metavar="KEY=VALUE",
70
+ help="connector config (repeatable); values are YAML-typed",
71
+ )
72
+ r.add_argument("--mirror", required=True)
73
+ r.add_argument("--out", required=True)
74
+ r.add_argument("--state", required=True)
75
+ args = parser.parse_args(argv)
76
+
77
+ pm = build_registry()
78
+ connectors = _connectors(pm)
79
+
80
+ if args.cmd == "list":
81
+ for name in sorted(connectors):
82
+ info = connectors[name].kbforge_connector_info()
83
+ print(f"{name}\t{info.source_system}")
84
+ return 0
85
+
86
+ if args.connector not in connectors:
87
+ available = ", ".join(sorted(connectors)) or "(none)"
88
+ print(f"unknown connector {args.connector!r}; available: {available}")
89
+ return 2
90
+
91
+ try:
92
+ config = _parse_settings(args.settings)
93
+ except ValueError as exc:
94
+ print(str(exc))
95
+ return 2
96
+
97
+ try:
98
+ result = run(
99
+ connectors[args.connector],
100
+ _publisher(pm),
101
+ config=config,
102
+ mirror=args.mirror,
103
+ state_dir=args.state,
104
+ publish_config={"out_dir": args.out},
105
+ )
106
+ except ConfigError as exc:
107
+ print(str(exc))
108
+ return 2
109
+
110
+ if isinstance(result, Published):
111
+ print(f"Published: {result.url}")
112
+ return 0
113
+ if isinstance(result, NoOp):
114
+ print("NoOp: no change detected; no MR opened.")
115
+ return 0
116
+ if isinstance(result, Aborted):
117
+ print(f"Aborted: {len(result.failures)} validation failure(s):")
118
+ for f in result.failures:
119
+ print(f" [{f.law}] {f.concept_path}: {f.message}")
120
+ return 1
121
+ return 2
122
+
123
+
124
+ if __name__ == "__main__": # pragma: no cover
125
+ raise SystemExit(main())
kbforge/canonical.py ADDED
@@ -0,0 +1,49 @@
1
+ """Canonicalization: stable content hashing and the §4.3 law-1 stability check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Callable, Sequence
8
+
9
+ from kbforge.models import CanonicalDocument, RawRecord
10
+
11
+
12
+ class StabilityError(RuntimeError):
13
+ """normalize() produced different canonical content for identical input."""
14
+
15
+
16
+ def content_hash(doc: CanonicalDocument) -> str:
17
+ """SHA-256 over the canonical CONTENT — everything the diff must react to.
18
+ The anchor is excluded: `retrieved_at` is volatile (§4.3 law 2) and the
19
+ anchor's own content_hash would be circular."""
20
+ payload = {
21
+ "doc_id": doc.doc_id,
22
+ "title": doc.title,
23
+ "text": doc.text,
24
+ "structured": doc.structured,
25
+ "relations": sorted(doc.relations),
26
+ "deleted": doc.deleted,
27
+ }
28
+ # default=str keeps determinism while tolerating YAML-parsed date/datetime
29
+ # values (PyYAML turns bare `2024-05-01` into a date, which json can't dump).
30
+ blob = json.dumps(
31
+ payload,
32
+ sort_keys=True,
33
+ ensure_ascii=True,
34
+ separators=(",", ":"),
35
+ default=str,
36
+ )
37
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
38
+
39
+
40
+ def assert_stability(
41
+ normalize: Callable[[Sequence[RawRecord]], list[CanonicalDocument]],
42
+ records: Sequence[RawRecord],
43
+ ) -> None:
44
+ """§4.3 law 1: normalize twice over identical input, require identical content
45
+ hashes. A connector that fails is not deterministic and must be rejected."""
46
+ first = [content_hash(d) for d in normalize(records)]
47
+ second = [content_hash(d) for d in normalize(records)]
48
+ if first != second:
49
+ raise StabilityError("normalize() is not deterministic over identical input")
File without changes
@@ -0,0 +1,150 @@
1
+ """A git-history connector: each commit reachable from a ref becomes one canonical
2
+ document. No credentials, no network — reads a local repository with `git log`.
3
+
4
+ Unlike the feed-less local_files connector, this one uses the cursor for real: the
5
+ watermark is the last-synced commit SHA, so an incremental fetch returns only
6
+ `<last_sha>..<ref>` — the first live exercise of the pipeline's incremental path.
7
+ A commit is immutable, so its content hash never changes and a re-seen commit is a
8
+ clean no-op."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from collections.abc import Sequence
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from kbforge.canonical import content_hash
18
+ from kbforge.hookspecs import hookimpl
19
+ from kbforge.models import (
20
+ CanonicalDocument,
21
+ ConnectorInfo,
22
+ Cursor,
23
+ FetchResult,
24
+ RawRecord,
25
+ ResourceAnchor,
26
+ )
27
+
28
+ _SYSTEM = "git_commits"
29
+ _UNIT = "\x1f" # field separator inside one commit's formatted record
30
+ # sha, author name, author email, author date (ISO), committer date (ISO), subject,
31
+ # body — body is last because it may contain newlines (records are NUL-delimited).
32
+ _FORMAT = _UNIT.join(["%H", "%an", "%ae", "%aI", "%cI", "%s", "%b"])
33
+ _FIELDS = 7
34
+
35
+
36
+ def _git(repo: Path, *args: str, check: bool = True) -> str:
37
+ result = subprocess.run(
38
+ ["git", *args],
39
+ cwd=repo,
40
+ capture_output=True,
41
+ text=True,
42
+ check=check,
43
+ )
44
+ return result.stdout
45
+
46
+
47
+ class GitCommitsConnector:
48
+ @hookimpl
49
+ def kbforge_connector_info(self) -> ConnectorInfo:
50
+ return ConnectorInfo(
51
+ name=_SYSTEM,
52
+ version="0.1.0",
53
+ source_system="git history (local repository)",
54
+ info_types=["commit"],
55
+ )
56
+
57
+ @hookimpl
58
+ def kbforge_validate_config(self, config: dict) -> list[str]:
59
+ problems: list[str] = []
60
+ repo = config.get("repo")
61
+ if not repo or not Path(repo).is_dir():
62
+ problems.append(f"config 'repo' is not a readable directory: {repo!r}")
63
+ elif not _git(Path(repo), "rev-parse", "--git-dir", check=False).strip():
64
+ problems.append(f"config 'repo' is not a git repository: {repo!r}")
65
+ max_commits = config.get("max_commits")
66
+ if max_commits is not None and (
67
+ not isinstance(max_commits, int) or isinstance(max_commits, bool)
68
+ ):
69
+ problems.append(f"config 'max_commits' must be an int: {max_commits!r}")
70
+ return problems
71
+
72
+ @hookimpl
73
+ def kbforge_fetch(self, config: dict, cursor: Cursor | None) -> FetchResult:
74
+ repo = Path(config["repo"])
75
+ ref = config.get("ref", "HEAD")
76
+ tip = _git(repo, "rev-parse", ref, check=False).strip()
77
+ if not tip:
78
+ # Empty repo / unknown ref: nothing to sync, watermark stays put.
79
+ return FetchResult(
80
+ records=[], cursor=Cursor(connector=_SYSTEM, payload={"ref": ref})
81
+ )
82
+
83
+ # cursor=None → backfill everything reachable from ref (bounded by
84
+ # max_commits); an existing watermark → only commits since it (`last..ref`).
85
+ last_sha = cursor.payload.get("last_sha") if cursor else None
86
+ rev_range = f"{last_sha}..{ref}" if last_sha else ref
87
+ log_args = ["log", rev_range, f"--format={_FORMAT}", "-z"]
88
+ max_commits = config.get("max_commits")
89
+ if not last_sha and max_commits is not None:
90
+ log_args.append(f"--max-count={max_commits}")
91
+
92
+ raw = _git(repo, *log_args)
93
+ records: list[RawRecord] = []
94
+ for chunk in raw.split("\x00"):
95
+ if not chunk.strip():
96
+ continue
97
+ parts = chunk.split(_UNIT)
98
+ if len(parts) < _FIELDS:
99
+ continue
100
+ sha, author, email, adate, cdate, subject, body = parts[:_FIELDS]
101
+ records.append(
102
+ RawRecord(
103
+ anchor_hint={
104
+ "native_id": sha,
105
+ "url": None,
106
+ "retrieved_at": cdate,
107
+ "author": author,
108
+ "author_email": email,
109
+ "author_date": adate,
110
+ "subject": subject,
111
+ },
112
+ media_type="text/x-git-commit",
113
+ payload=body.encode("utf-8"),
114
+ )
115
+ )
116
+ return FetchResult(
117
+ records=records,
118
+ cursor=Cursor(connector=_SYSTEM, payload={"last_sha": tip, "ref": ref}),
119
+ )
120
+
121
+ @hookimpl
122
+ def kbforge_normalize(
123
+ self, records: Sequence[RawRecord]
124
+ ) -> list[CanonicalDocument]:
125
+ docs: list[CanonicalDocument] = []
126
+ for rec in records:
127
+ hint = rec.anchor_hint
128
+ sha = hint["native_id"]
129
+ anchor = ResourceAnchor(
130
+ system=_SYSTEM,
131
+ native_id=sha,
132
+ url=hint.get("url"),
133
+ retrieved_at=datetime.fromisoformat(hint["retrieved_at"]),
134
+ content_hash="",
135
+ )
136
+ doc = CanonicalDocument(
137
+ anchor=anchor,
138
+ doc_id=f"{_SYSTEM}:{sha}",
139
+ title=hint.get("subject") or sha[:12],
140
+ text=rec.payload.decode("utf-8").strip(),
141
+ structured={
142
+ "author": hint.get("author"),
143
+ "author_email": hint.get("author_email"),
144
+ "author_date": hint.get("author_date"),
145
+ },
146
+ relations=[],
147
+ )
148
+ doc.anchor.content_hash = content_hash(doc)
149
+ docs.append(doc)
150
+ return docs
@@ -0,0 +1,175 @@
1
+ """A fixture connector: a folder of markdown-with-frontmatter → canonical docs.
2
+ No credentials, no network — the deterministic source for the walking skeleton."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Sequence
7
+ from datetime import UTC, datetime
8
+ from fnmatch import fnmatch
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from kbforge.canonical import content_hash
14
+ from kbforge.hookspecs import hookimpl
15
+ from kbforge.models import (
16
+ CanonicalDocument,
17
+ ConnectorInfo,
18
+ Cursor,
19
+ FetchResult,
20
+ RawRecord,
21
+ ResourceAnchor,
22
+ )
23
+
24
+ _SYSTEM = "local_files"
25
+ # Keys handled structurally, so they never leak into `structured` (hence facets):
26
+ # title → the concept title; relations → cross-links; type is dropped here because
27
+ # the OKF type comes from synthesis taxonomy (the stub emits "concept"); description,
28
+ # timestamp, resource, and links are emit-side OKF fields the synthesizer owns — a
29
+ # source key of the same name must not collide with them in the rendered frontmatter.
30
+ _RESERVED_KEYS = frozenset(
31
+ {"type", "title", "relations", "description", "timestamp", "resource", "links"}
32
+ )
33
+ # Dependency, VCS, and tool-cache directories that a blind rglob would sweep into
34
+ # the KB (a real live test pulled 98 vendored `.venv` docs of 132 total). These
35
+ # always apply; a source's `ignore_globs` config ADDS to them, never replaces them —
36
+ # so adding one custom pattern can't silently re-enable `.venv`.
37
+ _DEFAULT_IGNORES = frozenset(
38
+ {
39
+ ".git",
40
+ ".venv",
41
+ "venv",
42
+ "node_modules",
43
+ ".pytest_cache",
44
+ "__pycache__",
45
+ ".mypy_cache",
46
+ ".ruff_cache",
47
+ ".ipynb_checkpoints",
48
+ }
49
+ )
50
+
51
+
52
+ def _is_ignored(rel: str, patterns: frozenset[str]) -> bool:
53
+ """True if the posix-relative path is excluded. A pattern matches either any
54
+ single path segment (a bare dir name like `.venv` skips the whole subtree) or
55
+ the full relative path (a glob like `drafts/*` or `_draft*`)."""
56
+ segments = rel.split("/")
57
+ return any(
58
+ fnmatch(rel, pat) or any(fnmatch(seg, pat) for seg in segments)
59
+ for pat in patterns
60
+ )
61
+
62
+
63
+ def _split_frontmatter(text: str) -> tuple[dict, str]:
64
+ if not text.startswith("---"):
65
+ return {}, text
66
+ _, _, rest = text.partition("---")
67
+ front_raw, sep, body = rest.partition("\n---")
68
+ if not sep:
69
+ return {}, text
70
+ try:
71
+ data = yaml.safe_load(front_raw) or {}
72
+ except yaml.YAMLError:
73
+ # Malformed frontmatter (e.g. an unquoted colon) must not crash the whole
74
+ # sync: drop the unparseable frontmatter, keep the clean body.
75
+ data = {}
76
+ front = data if isinstance(data, dict) else {}
77
+ return front, body.lstrip("\n")
78
+
79
+
80
+ class LocalFilesConnector:
81
+ @hookimpl
82
+ def kbforge_connector_info(self) -> ConnectorInfo:
83
+ return ConnectorInfo(
84
+ name=_SYSTEM,
85
+ version="0.1.0",
86
+ source_system="local filesystem (fixture)",
87
+ info_types=["fixture"],
88
+ )
89
+
90
+ @hookimpl
91
+ def kbforge_validate_config(self, config: dict) -> list[str]:
92
+ problems: list[str] = []
93
+ path = config.get("path")
94
+ if not path or not Path(path).is_dir():
95
+ problems.append(f"config 'path' is not a readable directory: {path!r}")
96
+ ignore = config.get("ignore_globs")
97
+ if ignore is not None and (
98
+ not isinstance(ignore, list) or not all(isinstance(g, str) for g in ignore)
99
+ ):
100
+ problems.append(
101
+ f"config 'ignore_globs' must be a list of strings: {ignore!r}"
102
+ )
103
+ return problems
104
+
105
+ @hookimpl
106
+ def kbforge_fetch(self, config: dict, cursor: Cursor | None) -> FetchResult:
107
+ # cursor is unused: this feed-less source always re-scans. The mirror diff
108
+ # detects adds and modifies; it does NOT derive deletions — a full-scan
109
+ # source can't tell a deleted file from an absent one, so a removed file
110
+ # leaves a stale concept until a tombstone / `complete`-aware diff lands (a
111
+ # later increment; `FetchResult.complete` is defined but not yet consumed).
112
+ # retrieved_at is stamped here (fetch may use a clock; normalize may not)
113
+ # from file mtime, keeping runs reproducible.
114
+ root = Path(config["path"])
115
+ ignores = _DEFAULT_IGNORES | frozenset(config.get("ignore_globs") or [])
116
+ records: list[RawRecord] = []
117
+ for path in sorted(root.rglob("*.md")):
118
+ rel = path.relative_to(root).as_posix()
119
+ if _is_ignored(rel, ignores):
120
+ continue
121
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
122
+ records.append(
123
+ RawRecord(
124
+ anchor_hint={
125
+ "native_id": rel,
126
+ "url": None,
127
+ "retrieved_at": mtime.isoformat(),
128
+ },
129
+ media_type="text/markdown",
130
+ payload=path.read_bytes(),
131
+ )
132
+ )
133
+ return FetchResult(records=records, cursor=Cursor(connector=_SYSTEM))
134
+
135
+ @hookimpl
136
+ def kbforge_normalize(
137
+ self, records: Sequence[RawRecord]
138
+ ) -> list[CanonicalDocument]:
139
+ docs: list[CanonicalDocument] = []
140
+ for rec in records:
141
+ # utf-8-sig strips a BOM (Windows editors) that would otherwise defeat
142
+ # the `startswith("---")` check; line-ending normalization keeps CRLF
143
+ # and LF copies of the same content hashing identically (§4.3 law 1).
144
+ text = (
145
+ rec.payload.decode("utf-8-sig")
146
+ .replace("\r\n", "\n")
147
+ .replace("\r", "\n")
148
+ )
149
+ front, body = _split_frontmatter(text)
150
+ native_id = rec.anchor_hint["native_id"]
151
+ doc_id = f"{_SYSTEM}:{native_id}"
152
+ relations = sorted(
153
+ f"{_SYSTEM}:{r}"
154
+ for r in front.get("relations", [])
155
+ if isinstance(r, str)
156
+ )
157
+ structured = {k: v for k, v in front.items() if k not in _RESERVED_KEYS}
158
+ anchor = ResourceAnchor(
159
+ system=_SYSTEM,
160
+ native_id=native_id,
161
+ url=rec.anchor_hint.get("url"),
162
+ retrieved_at=datetime.fromisoformat(rec.anchor_hint["retrieved_at"]),
163
+ content_hash="",
164
+ )
165
+ doc = CanonicalDocument(
166
+ anchor=anchor,
167
+ doc_id=doc_id,
168
+ title=str(front.get("title") or native_id),
169
+ text=body.strip(),
170
+ structured=structured,
171
+ relations=relations,
172
+ )
173
+ doc.anchor.content_hash = content_hash(doc)
174
+ docs.append(doc)
175
+ return docs
kbforge/hookspecs.py ADDED
@@ -0,0 +1,68 @@
1
+ """Pluggy hookspecs. The connector and publisher interfaces ARE the product (§5).
2
+ Kept minimal for the walking skeleton: one connector family, one publisher family."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Sequence
8
+
9
+ import pluggy
10
+
11
+ from kbforge.models import (
12
+ CanonicalDocument,
13
+ ConnectorInfo,
14
+ Cursor,
15
+ FetchResult,
16
+ ProposedChange,
17
+ RawRecord,
18
+ )
19
+
20
+ PROJECT = "kbforge"
21
+ hookspec = pluggy.HookspecMarker(PROJECT)
22
+ hookimpl = pluggy.HookimplMarker(PROJECT)
23
+
24
+ # Entry-point groups a third-party distribution advertises to be discovered. Kept
25
+ # separate so a package declares intent: a connector plugin vs a publisher plugin.
26
+ CONNECTOR_ENTRYPOINTS = "kbforge.connectors"
27
+ PUBLISHER_ENTRYPOINTS = "kbforge.publishers"
28
+
29
+
30
+ class ConnectorSpec(ABC):
31
+ """One plugin object per system of record. Connectors never see the bundle,
32
+ never call the LLM, never touch git (§4.1)."""
33
+
34
+ @hookspec
35
+ @abstractmethod
36
+ def kbforge_connector_info(self) -> ConnectorInfo:
37
+ """Static self-description."""
38
+
39
+ @hookspec
40
+ @abstractmethod
41
+ def kbforge_validate_config(self, config: dict) -> list[str]:
42
+ """Return human-readable problems ([] = ok). No network I/O."""
43
+
44
+ @hookspec
45
+ @abstractmethod
46
+ def kbforge_fetch(self, config: dict, cursor: Cursor | None) -> FetchResult:
47
+ """Pull raw records (cursor=None = full backfill / bootstrap)."""
48
+
49
+ @hookspec
50
+ @abstractmethod
51
+ def kbforge_normalize(
52
+ self, records: Sequence[RawRecord]
53
+ ) -> list[CanonicalDocument]:
54
+ """Deterministic, volatile-free, clock-free (§4.3)."""
55
+
56
+
57
+ class PublisherSpec(ABC):
58
+ """Where proposals go. MUST NOT merge (§5.2)."""
59
+
60
+ @hookspec
61
+ @abstractmethod
62
+ def kbforge_publisher_info(self) -> ConnectorInfo:
63
+ """Static self-description."""
64
+
65
+ @hookspec
66
+ @abstractmethod
67
+ def kbforge_publish(self, change: ProposedChange, config: dict) -> str:
68
+ """Open a review request; return its URL/path. Never merges."""
kbforge/mirror.py ADDED
@@ -0,0 +1,60 @@
1
+ """The canonical mirror and the read-only diff (architecture §7's mirror_and_diff,
2
+ split into a pure `diff` and a success-only `commit`)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ from pathlib import Path
8
+
9
+ from kbforge.models import CanonicalDocument, ChangeSet
10
+
11
+
12
+ def _slot(mirror: Path, doc_id: str) -> Path:
13
+ key = hashlib.sha256(doc_id.encode("utf-8")).hexdigest()
14
+ return mirror / f"{key}.json"
15
+
16
+
17
+ def _load(mirror: Path, doc_id: str) -> CanonicalDocument | None:
18
+ slot = _slot(mirror, doc_id)
19
+ if not slot.exists():
20
+ return None
21
+ return CanonicalDocument.model_validate_json(slot.read_text("utf-8"))
22
+
23
+
24
+ def diff(mirror: Path, docs: list[CanonicalDocument]) -> ChangeSet:
25
+ """Read-only comparison against the mirror. Deletions are explicit tombstones
26
+ (`deleted=True`); absence never implies one (§4.2). Never mutates the mirror."""
27
+ added: list[str] = []
28
+ modified: list[str] = []
29
+ removed: list[str] = []
30
+ unchanged = 0
31
+ for doc in docs:
32
+ prev = _load(mirror, doc.doc_id)
33
+ if doc.deleted:
34
+ if prev is not None:
35
+ removed.append(doc.doc_id)
36
+ continue
37
+ if prev is None:
38
+ added.append(doc.doc_id)
39
+ elif prev.anchor.content_hash != doc.anchor.content_hash:
40
+ modified.append(doc.doc_id)
41
+ else:
42
+ unchanged += 1
43
+ return ChangeSet(
44
+ added=sorted(added),
45
+ modified=sorted(modified),
46
+ removed=sorted(removed),
47
+ unchanged_count=unchanged,
48
+ )
49
+
50
+
51
+ def commit(mirror: Path, docs: list[CanonicalDocument]) -> None:
52
+ """Advance the mirror to the fetched state. Called only after a run fully
53
+ succeeds, so a failed publish never leaves the mirror ahead of the bundle."""
54
+ mirror.mkdir(parents=True, exist_ok=True)
55
+ for doc in docs:
56
+ slot = _slot(mirror, doc.doc_id)
57
+ if doc.deleted:
58
+ slot.unlink(missing_ok=True)
59
+ else:
60
+ slot.write_text(doc.model_dump_json(), "utf-8")